やってみたかった案件が失注しちゃった。。。
受注できたらワクワクするような案件、失注しちゃうことエンジニアあるあると思います。
私の場合、たまたまカードゲームの一人回し(カードゲームの自主練みたいなもの)を実装する案件をあえなく失注してしまいました。
やりたかっただけにちょっぴり残念でしたが、まともにカードゲームのドローのロジックとかどうしてるんだろ?とか考えるきっかけになったので、遊びついでに実装をしてみようかな?と思いちょっとやってみたのでなんかに残しとこうと思って執筆しました。
同じくカードゲーム案件を失注してしまった同氏のお役に立てればと思います
何やるのか
雑に方向性だけ箇条書き
・PHPとJavaScriptとCSSのみで実装
・ドロー機能
・シャッフル機能
・手札のカードを墓地、フィールドに置く
・頑張らない
・ChatGPTを信じる
各実装ごとに説明を書いて、最後にソースのっけて終わりにします!
PHPとJavaScriptとCSSのみで実装
実装はPHPとjsとCSSのみ
簡単に3ファイルのみで構成されています
画像ファイルは勝手にMTGの公式サイトから連番のURLから勝手に引っ張ってきています
※今回の失注案件とは全く持って関係ありません!
※勝手に引っ張ってきているので怒られたら記事消します!w
ドロー機能
要件
・デッキは60枚
・ドローをしたら山札から消える
・シャッフルやドローをしない限り、山札の順番は保持される
・カードの保持の仕様は以下
#カード配列の持ち方このように持つ(山札、手札、墓地、フィールドそれぞれの領域)
myHand[0]=cardImg0
myHand[1]=cardImg1
myHand[2]=cardImg4
myHand[3]=cardImg35
#PHPで画像リストを準備(60枚分)
$images = [];
for ($i = 681471; $i <= 681530; $i++) {
$images[] = "https://mtg-jp.com//img_sys/cardImages/J25/$i/cardimage.png?r=1";};
#PHPで取得した配列をjsの配列に格納
let storedDeck = <?php echo json_encode($images); ?>;
#jsで最初に手札とフィールドと墓地領域をjsで宣言
let myHand = []; // 手札
let myField = []; // フィールド
let trash = []; // 墓地
#手札に加えたいカード画像を選択、手札の番号を+1してmyHand配列に格納
const cardToAdd = imgElement.src; // サムネイル画像のURLを取得
const cardNumber = myHand.length + 1; // 手札の番号
myHand.push({ number: cardNumber, image: cardToAdd }); // 手札に追加(番号をつけて格納)
#山札からそのカードを削除するため storedDeckから選択したカードを削除
const cardToAdd = imgElement.src; // サムネイル画像のURLを取得
const deckIndex = storedDeck.indexOf(cardToAdd); //山札の該当カードを宣言
if (deckIndex > -1) {
storedDeck.splice(deckIndex, 1); // 選択したカードをstoredDeckから削除
}
シャッフル機能
・ランダムで山札をシャッフルする
・デッキからドローされたカードは含めない(ドロー機能で実装しているので割愛)
#Fisher-Yatesアルゴリズムを仕様して配列をランダムに入れ替え
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
// 0〜iのランダムなインデックスを取得
const j = Math.floor(Math.random() * (i + 1));
// 要素を入れ替え
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
【Fisher-Yatesアルゴリズムとは】
(Math.random()をかけることによってかけられた整数以下の数字が限りなくランダムに近く取得できる。
その数値と現在選択されているiが選択された数値を入れ替えられるため、ランダムな入れ替えが可能
最大値より上の数値は出ないの?
→ (最大値+1)*0~1より下の数字なのでありえない
同じ数字は選択されないの?
→ 最大値が徐々に小さくなっていくので同じ数字が入れ替わることはない
ロジックの理解が結構むずいのでFisher-Yatesアルゴリズムで検索してみてください
手札のカードを墓地、フィールドに置く
・各フィールド領域をHTML上に配置
・手札のカードの通し番号を削除し、別配列の通し番号に紐づけて格納
・カードを手札に入れるとき、上記のロジックのバインドをカードのクリックイベントに追加
#手札からフィールドにカードを送る
/**
* カードをフィールドに送る
* @module MoveCardToField
* @param {string} cardId カードID
* @return {void}
*/
function moveCardToField(cardId) {
const cardElement = document.getElementById(cardId);
const fieldContainer = document.getElementById('field');
// 対応する手札データを削除
const cardIndex = myHand.findIndex(card => `handCard${card.number}` === cardId);
if (cardIndex !== -1) {
const cardData = myHand.splice(cardIndex, 1)[0]; // 手札から削除
myField.push(cardData); // フィールドに追加
}
// フィールド領域にカードを表示
const fieldCardElement = document.createElement('img');
fieldCardElement.src = cardElement.src;
fieldCardElement.alt = `フィールド${myField.length}`; // フィールドのaltを変更
fieldCardElement.id = `field${myField.length}`; // フィールドのidを変更
fieldCardElement.classList.add('field-card');
fieldContainer.appendChild(fieldCardElement);
// 手札領域から選択されたカードを削除
cardElement.remove();
}
全部説明するのが面倒なので、ソース乗っけときます
##PHP
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>カード選択</title>
<link rel="stylesheet" href="./choice_card.css">
<script src="./choice_card.js"></script>
</head>
<body>
<?php
// PHPで画像リストを準備(60枚分)
$images = [];
for ($i = 681471; $i <= 681530; $i++) {
$images[] = "https://mtg-jp.com//img_sys/cardImages/J25/$i/cardimage.png?r=1"; // 例として cardimage1.png ~ cardimage60.png
};
?>
<script>
// phpで取得した配列をjsの配列に格納
let storedDeck = <?php echo json_encode($images); ?>;
</script>
<!-- 元の画面 -->
<h1>カード選択</h1>
<div id="toast" class="toast"></div>
<button id="openModal">デッキから手札に移動</button>
<button id="shuffleDeck">デッキをシャッフル</button>
<button id="handOut">手札を5枚配る</button>
<button id="addCard">カードを1枚加える</button>
<img id="selectedImage" src="" alt="選択した画像" style="display:none;">
<!-- カードの動作選択用モーダル -->
<div id="moveCardModal" class="moveCardModal">
<div class="modal-content">
<h2>カードの動作を選択</h2>
<div class="button-container">
<button id="moveToField">フィールドに移動</button>
<button id="moveToTrash">墓地に移動</button>
<button id="moveToHand">手札に戻す</button>
</div>
<button id="closeModal" class="close-btn">閉じる</button>
</div>
</div>
<!-- フィールドを表示するエリア -->
<div class="field-container">
<h3>フィールド</h3> <!-- ラベル -->
<div id="field" class="field">
<!-- フィールドの画像がここに動的に表示されます -->
</div>
</div>
<!-- 手札を表示するエリア -->
<div class="field-container">
<h3>手札</h3> <!-- ラベル -->
<div id="hand" class="field">
<!-- 手札の画像がここに動的に表示されます -->
</div>
</div>
<!-- 墓地を表示するエリア -->
<div class="field-container">
<h3>墓地</h3> <!-- ラベル -->
<div id="trash" class="field">
<!-- 手札の画像がここに動的に表示されます -->
</div>
</div>
<!-- モーダル -->
<div id="myModal" class="modal">
<div class="modal-content">
<h2>カードを選択してください</h2>
<div class="thumbnail-grid" id="thumbnailGrid">
<!-- JavaScriptで画像が追加される -->
</div>
</div>
</div>
<!-- カード操作メニュー -->
<div id="cardMenu" style="display: none; position: absolute;">
<button id="moveToField">フィールドに送る</button>
<button id="moveToTrash">墓地に送る</button>
<button id="moveToHand">手札に戻す</button>
</div>
</body>
</html>
#JavaScript
document.addEventListener('DOMContentLoaded', function () {
let myHand = []; // 手札
let myField = []; // フィールド
let trash = []; // 墓地
// モーダル関連の要素を取得
const modal = document.getElementById('moveCardModal');
const closeModalButton = document.getElementById('closeModal');
// 閉じるボタンがクリックされたときにモーダルを非表示
closeModalButton.addEventListener('click', function () {
modal.style.display = 'none';
});
// 外側がクリックされた場合にもモーダルを非表示にする
window.addEventListener('click', function (event) {
if (event.target === modal) {
modal.style.display = 'none';
}
});
// showCardMenu 関数内でモーダルを表示
function showCardMenu(cardId) {
const modal = document.getElementById('moveCardModal');
if (!modal) {
console.error('モーダルが見つかりません');
return;
}
// モーダル内のボタンにカードIDをセット
document.getElementById('moveToField').dataset.cardId = cardId;
document.getElementById('moveToTrash').dataset.cardId = cardId;
document.getElementById('moveToHand').dataset.cardId = cardId;
// モーダルを表示
modal.style.display = 'flex';
}
/**
* 手札カードがクリックされたときにモーダルを表示
* @module ShowCardMenu
* @param {string} cardId クリックされたカードのID
* @return {void}
*/
function showCardMenu(cardId) {
const modal = document.getElementById('moveCardModal');
if (!modal) {
console.error('モーダルが見つかりません');
return;
}
const cardElement = document.getElementById(cardId);
// モーダル内のボタンにカードIDをセット
document.getElementById('moveToField').dataset.cardId = cardId;
document.getElementById('moveToTrash').dataset.cardId = cardId;
document.getElementById('moveToHand').dataset.cardId = cardId;
// モーダルを表示
modal.style.display = 'flex';
}
/**
* カードをフィールドに送る
* @module MoveCardToField
* @param {string} cardId カードID
* @return {void}
*/
function moveCardToField(cardId) {
const cardElement = document.getElementById(cardId);
const fieldContainer = document.getElementById('field');
// 対応する手札データを削除
const cardIndex = myHand.findIndex(card => `handCard${card.number}` === cardId);
if (cardIndex !== -1) {
const cardData = myHand.splice(cardIndex, 1)[0]; // 手札から削除
myField.push(cardData); // フィールドに追加
}
// フィールド領域にカードを表示
const fieldCardElement = document.createElement('img');
fieldCardElement.src = cardElement.src;
fieldCardElement.alt = `フィールド${myField.length}`; // フィールドのaltを変更
fieldCardElement.id = `field${myField.length}`; // フィールドのidを変更
fieldCardElement.classList.add('field-card');
fieldContainer.appendChild(fieldCardElement);
// 手札領域から選択されたカードを削除
cardElement.remove();
}
/**
* カードを墓地に送る
* @module MoveCardToTrash
* @param {string} cardId カードID
* @return {void}
*/
function moveCardToTrash(cardId) {
const cardElement = document.getElementById(cardId);
const trashContainer = document.getElementById('trash');
// 対応する手札データを削除
const cardIndex = myHand.findIndex(card => `handCard${card.number}` === cardId);
if (cardIndex !== -1) {
const cardData = myHand.splice(cardIndex, 1)[0]; // 手札から削除
trash.push(cardData); // 墓地に追加
}
// 墓地領域にカードを表示
const trashCardElement = document.createElement('img');
trashCardElement.src = cardElement.src;
trashCardElement.alt = `墓地${trash.length}`; // 墓地のaltを変更
trashCardElement.id = `trash${trash.length}`; // 墓地のidを変更
trashCardElement.classList.add('field-card');
trashContainer.appendChild(trashCardElement);
// 手札領域から選択されたカードを削除
cardElement.remove();
}
/**
* カードを手札に戻す
* @module MoveCardToHand
* @param {string} cardId カードID
* @return {void}
*/
function moveCardToHand(cardId) {
const cardElement = document.getElementById(cardId);
const handContainer = document.getElementById('hand');
// 対応するフィールドデータを削除
const cardIndex = myField.findIndex(card => `fieldCard${card.number}` === cardId);
if (cardIndex !== -1) {
const cardData = myField.splice(cardIndex, 1)[0]; // フィールドから削除
myHand.push(cardData); // 手札に戻す
}
// 手札領域にカードを表示
const handCardElement = document.createElement('img');
handCardElement.src = cardElement.src;
handCardElement.alt = `手札${myHand.length}`; // 手札のaltを変更
handCardElement.id = `handCard${myHand.length}`; // 手札のidを変更
handCardElement.classList.add('field-card');
handContainer.appendChild(handCardElement);
// フィールド領域から選択されたカードを削除
cardElement.remove();
}
/**
* モーダルの移動ボタンにクリックイベントを設定
* @module BindModalButtonEvents
* @return {void}
*/
function bindModalButtonEvents() {
document.getElementById('moveToField').addEventListener('click', function () {
const cardId = this.dataset.cardId;
moveCardToField(cardId);
closeModal();
});
document.getElementById('moveToTrash').addEventListener('click', function () {
const cardId = this.dataset.cardId;
moveCardToTrash(cardId);
closeModal();
});
document.getElementById('moveToHand').addEventListener('click', function () {
const cardId = this.dataset.cardId;
moveCardToHand(cardId);
closeModal();
});
}
/**
* モーダルを閉じる
* @module CloseModal
* @return {void}
*/
function closeModal() {
const modal = document.getElementById('moveCardModal');
modal.style.display = 'none';
}
/**
* 手札カードのクリックイベントを設定
* @module BindHandCardEvents
* @param {void}
* @return {void}
*/
function bindHandCardEvents() {
myHand.forEach(card => {
const cardId = `handCard${card.number}`;
const cardElement = document.getElementById(cardId);
if (cardElement) {
cardElement.addEventListener('click', function () {
showCardMenu(cardId);
});
}
});
}
// モーダルボタンのイベントをバインド
bindModalButtonEvents();
/**
* 手札にカードを一枚追加する処理
* @return {void}
*/
document.getElementById('addCard').addEventListener('click', function () {
const handContainer = document.getElementById('hand');
if (storedDeck.length > 0) {
// storedDeckの一番上のカードを手札に追加
const cardToAdd = storedDeck[0]; // 最初のカードを取得
const cardNumber = myHand.length + 1; // 手札の番号
myHand.push({ number: cardNumber, image: cardToAdd }); // 手札に追加(番号をつけて格納)
// 手札を表示
const imgElement = document.createElement('img');
imgElement.src = cardToAdd;
imgElement.alt = `手札${cardNumber}`; // 手札の番号を表示
imgElement.id = `handCard${cardNumber}`; // 手札のidを表示
imgElement.classList.add('field-card');
handContainer.appendChild(imgElement);
// storedDeckからそのカードを削除
storedDeck.splice(0, 1); // 最初の1枚を削除
// イベントをバインド
bindHandCardEvents();
} else {
console.log('デッキにカードが足りません');
}
});
/**
* 手札をリセットしてカードを5枚引く処理
* @return {void}
*/
document.getElementById('handOut').addEventListener('click', function () {
// 手札がある場合、現在の手札をデッキに戻す
if (myHand.length > 0) {
myHand.forEach(function(card) {
storedDeck.push(card.image); // 画像のURLをデッキに戻す
});
// 手札をクリア
myHand = [];
}
// デッキをシャッフル
storedDeck = shuffleArray(storedDeck);
// 手札に最初の5枚を表示
const handContainer = document.getElementById('hand');
handContainer.innerHTML = ''; // 手札エリアをクリア
if (storedDeck && storedDeck.length > 0) {
// 最初の5枚を手札として表示
for (let i = 0; i < 5; i++) {
if (storedDeck[i]) {
// 手札にカードを追加(番号をつけて格納)
myHand.push({ number: i + 1, image: storedDeck[i] });
// 手札を表示
const imgElement = document.createElement('img');
imgElement.src = storedDeck[i];
imgElement.alt = `手札${i + 1}`;
imgElement.id = `handCard${myHand.length}`; // 手札のidを設定
imgElement.classList.add('field-card');
handContainer.appendChild(imgElement);
}
}
// 手札に表示後、storedDeckから削除
storedDeck.splice(0, 5); // 先頭から5枚を削除
} else {
console.error('デッキの画像がセッションに存在しません。');
}
// イベントをバインド
bindHandCardEvents();
});
/**
* Fisher-Yatesアルゴリズムを使用して配列をシャッフル
* @param {Array} array - シャッフルする配列
* @return {Array} シャッフルされた配列
*/
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
// 0〜iのランダムなインデックスを取得
const j = Math.floor(Math.random() * (i + 1));
// 要素を入れ替え
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
/**
* モーダルを表示
* @return {void}
*/
document.getElementById('openModal').addEventListener('click', function () {
updateModalThumbnails(); // サムネイルを更新
document.getElementById('myModal').style.display = 'flex';
});
/**
* モーダルを非表示
* @return {void}
*/
document.getElementById('closeModal').addEventListener('click', function () {
document.getElementById('myModal').style.display = 'none';
});
/**
* モーダルを非表示(画面外クリック時)
* @param {Event} event - クリックイベント
* @return {void}
*/
window.addEventListener('click', function (event) {
const modal = document.getElementById('myModal');
if (event.target === modal) {
modal.style.display = 'none';
}
});
/**
* モーダルからデッキ内の好きなカードを手札に追加する
* @return {void}
*/
const thumbnails = document.querySelectorAll('.thumbnail');
if (storedDeck && storedDeck.length > 0) {
// モーダルが開かれたときに、画像サムネイルを動的に生成して表示
updateModalThumbnails(); // 初回表示時にサムネイルを更新
}
/**
* モーダルのサムネイルを更新
* @return {void}
*/
function updateModalThumbnails() {
const thumbnailGrid = document.getElementById('thumbnailGrid');
thumbnailGrid.innerHTML = ''; // 前回の内容をクリア(リセット)
storedDeck.forEach(function (imageSrc) {
const imgElement = document.createElement('img');
imgElement.src = imageSrc;
imgElement.alt = 'サムネイル';
imgElement.classList.add('thumbnail');
imgElement.addEventListener('click', function () {
const handContainer = document.getElementById('hand');
const cardToAdd = imgElement.src; // サムネイル画像のURLを取得
const cardNumber = myHand.length + 1; // 手札の番号
myHand.push({ number: cardNumber, image: cardToAdd }); // 手札に追加(番号をつけて格納)
// 手札を表示
const handCardElement = document.createElement('img');
handCardElement.src = cardToAdd;
handCardElement.alt = `手札${cardNumber}`; // 手札の番号を表示
handCardElement.id = `handCard${cardNumber}`; // 手札のidを表示
handCardElement.classList.add('field-card');
handContainer.appendChild(handCardElement);
// イベントをバインド
bindHandCardEvents();
// storedDeckからそのカードを削除
const deckIndex = storedDeck.indexOf(cardToAdd);
if (deckIndex > -1) {
storedDeck.splice(deckIndex, 1); // 選択したカードをデッキから削除
}
// モーダルを閉じる
document.getElementById('myModal').style.display = 'none';
// モーダルのサムネイルを更新
updateModalThumbnails();
});
thumbnailGrid.appendChild(imgElement);
});
}
});
# CSS
/* モーダルの基本スタイル */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 5px;
width: 90%; /* モーダルの幅を広げる */
max-height: 80%;
overflow-y: auto; /* コンテンツが多い場合スクロール可能に */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
position: relative;
}
.modal-close {
cursor: pointer;
color: red;
font-weight: bold;
position: absolute;
top: 10px;
right: 20px;
font-size: 24px;
}
.thumbnail-grid {
display: grid;
grid-template-columns: repeat(5, 1fr); /* 横に5枚固定で並べる */
gap: 10px;
justify-items: center;
}
.thumbnail {
width: 100%; /* 各セルの幅に合わせる */
aspect-ratio: 265 / 370; /* 元のアスペクト比を維持 */
cursor: pointer;
object-fit: cover; /* 縦横比を崩さない */
transition: transform 0.3s;
}
.thumbnail:hover {
transform: scale(1.1);
}
#selectedImage {
display: block;
margin: 20px auto;
max-width: 300px;
height: auto;
}
/* トースト通知のスタイル */
.toast {
position: fixed;
top: 20px; /* 上部に表示 */
left: 50%; /* 左端から50%の位置 */
transform: translateX(-50%); /* 50%だけ左に移動して、中央に配置 */
background-color: #333;
color: #fff;
padding: 10px 20px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.5s, transform 0.5s;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0); /* 画面中央にトーストを表示 */
}
/* 手札領域 */
.field {
display: grid;
grid-template-columns: repeat(5, 1fr); /* 1行に5枚表示 */
gap: 10px; /* カード間の隙間 */
width: 70%; /* 手札領域の幅を画面幅の70%に設定 */
margin: 0 auto; /* 中央寄せ */
padding: 10px; /* 手札領域内の余白を少なく */
border: 2px solid #000; /* 枠線 */
border-radius: 10px; /* 角を丸くする */
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); /* 手札領域に影をつける */
background-color: #fff; /* 背景色 */
}
/* 手札のカード */
.field-card {
width: 100%; /* カードが親要素の幅に合わせて拡大 */
height: auto;
border-radius: 5px; /* カードの角を丸くする */
cursor: pointer;
transition: transform 0.2s ease-in-out; /* ホバー時にカードが少し拡大する */
}
/* カードホバー時 */
.field-card:hover {
transform: scale(1.1);
}
.field-container {
text-align: center; /* 中央揃え */
}
/* 手札ラベル */
.field-container h3 {
font-size: 1.5rem;
margin-bottom: 5px; /* ラベルと手札領域の間の余白を小さく */
color: #333;
}
/* モーダルのスタイル */
.moveCardModal {
display: none; /* 初期状態では非表示 */
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6); /* 背景を半透明に */
z-index: 1000; /* 最前面に表示 */
justify-content: center;
align-items: center;
padding: 10px;
}
/* モーダルのコンテンツ */
.moveCardModal .modal-content {
background-color: white;
padding: 20px;
border-radius: 10px;
max-width: 500px;
width: 100%;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
text-align: center;
animation: fadeIn 0.3s ease-out;
}
/* モーダルの見出し */
.moveCardModal .modal-content h2 {
font-size: 20px;
margin-bottom: 20px;
font-family: 'Arial', sans-serif;
color: #333;
}
/* ボタンのコンテナ */
.moveCardModal .button-container {
display: flex;
justify-content: space-between;
gap: 15px;
margin-bottom: 20px;
}
/* 各ボタンのスタイル */
.moveCardModal button {
padding: 12px 20px;
font-size: 16px;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
background-color: #4CAF50; /* 初期のボタン色 */
color: white;
width: 30%; /* 横並びにするため、ボタンの幅を設定 */
}
/* ボタンにホバー時の効果 */
.moveCardModal button:hover {
background-color: #45a049;
transform: scale(1.05);
}
/* 閉じるボタン */
.moveCardModal .close-btn {
background-color: #f44336;
margin-top: 20px;
padding: 10px 20px;
font-size: 16px;
border-radius: 5px;
color: white;
cursor: pointer;
transition: background-color 0.3s;
}
/* 閉じるボタンにホバー時の効果 */
.moveCardModal .close-btn:hover {
background-color: #e53935;
}
/* モーダルのフェードインアニメーション */
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}