【JavaScript】遊戯王のカードゲームを作ってみました

こんにちは、miraと申します。
ブラウザで動く遊戯王のシミュレーターを作成したので、動かし方などを共有したいと思います。
ゲームを作っている方の参考になれば幸いです。

こんな感じのものを作りました！
![Screenshot_2025-03-28-20-18-15-63_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/0d9e2c82-0501-4c80-a3dc-9eebdf078532.jpeg)

下のリンクで遊べます。

https://mirayugioh.github.io/yugiohSolitaireTool/

リポジトリはこちらです。
https://github.com/mirayugioh/yugiohSolitaireTool/tree/main

使用方法は動画があるので見てみてください。

https://youtu.be/0PhUobbBtoQ?feature=shared

## 経緯
遊戯王マスターデュエルが好きで、展開系をよく回しています。
（展開系はたくさんのカードがわちゃわちゃ動くデッキタイプです）
でも、新しい動きやギミックを思いついた時にどう伝えるか悩むわけです。
実物のカードを触ってレコーディングするのは大変。そもそも持ってない。
かといって文章に書くのは取っ付きづらいし、海外プレイヤーに伝わるか分からない。

そこで一人回しツールが欲しいと。
javascriptだったらプログラミング初心者でも齧りやすいと思ったのでやってみることに。
実は2年前から細々と作っていて、たくさんのフィードバックや反響を頂いた上でのリメイクとなりました。ありがとうございます😊

## ギミック紹介

### ゾーンを生成する
![Screenshot_2025-03-28-20-19-28-99_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/2652515e-be64-478e-93b8-7138f841a75e.jpeg)
遊戯王のゾーンは7×7=49マスです。
pxを指定せず、使用者の画面に合わせて7等分します。
```js
const zoneElement = document.createElement('div');
zoneElement.classList.add('zone');
zoneElement.id = `zone${i}`;
zoneElement.addEventListener('click', (event) => {
  handleZoneClick(event);
}); 
zoneElement.style.width = `${zoneBoard.offsetWidth / 7.1}px`; 
//一部の端末ではゾーンが綺麗に7×7=49マスになりません。÷7.1を÷7.2や÷7.4にしてください。
zoneElement.style.height = `${zoneBoard.offsetHeight / 7.1}px`;
zoneBoard.appendChild(zoneElement);
```
```css
#zoneBoard {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  aspect-ratio: 1/1;
  width: 100%;
  opacity: 0.6;
  margin-left: auto;
  margin-right: auto;
  background-size: cover;
  background-position: center;
}
.zone {
  background-size: cover;
  background-position: center;
  border: 0.3px solid #77787B;
  border-radius: 0.01em;
  box-sizing: border-box;
  opacity: 1;
  display: flex;
  justify-content: center;
  align-items: center;
}
```

### 初期表示のカードを生成する
![Screenshot_2025-03-28-20-20-54-42_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/e5d234de-2bb0-47d4-a908-6d3768d9d7a4.jpeg)

img要素をそのままカードとして使っています。
createCardElement関数とcreateThumbnailCardElement関数を定義して、createThemeCards関数から呼び出しています。入れ子構造です。
```js
const createCardElement = (nameOfTheme, objectId, typeOfCard, nameOfCard, imageUrlOfCard) => {
  const imgElement = document.createElement('img');
  const someZone = document.querySelector('.zone');
  const someZoneHeight = window.getComputedStyle(someZone).height;
  imgElement.height = parseInt(someZoneHeight) * 0.95;
  imgElement.width = imgElement.height * cardAspectRatio;
  //console.log(`カードを作りました。横幅は${imgElement.width}px、カードの高さは${imgElement.height}pxです。`);
  imgElement.classList.add('card');
  imgElement.classList.add('onCardBoard');
  imgElement.dataset.themeName = nameOfTheme;
  imgElement.id = objectId;
  imgElement.dataset.cardType = typeOfCard;
  imgElement.dataset.cardName = nameOfCard;
  imgElement.dataset.faceUpSrc = imageUrlOfCard;
  imgElement.src = imgElement.dataset.faceUpSrc;
  insertIntoBestPosition(imgElement);
  imgElement.addEventListener('click', handleCardClick);
  imgElement.style.display = 'none';
}
```
```js
const createThumbnailCardElement = (nameOfTheme, urlOfThumbnailImage) => {
  const favoriteCardElement = document.createElement('img');
  favoriteCardElement.src = urlOfThumbnailImage;
  const someZone = document.querySelector('.zone');
  const someZoneHeight = window.getComputedStyle(someZone).height;
  favoriteCardElement.dataset.themeName = nameOfTheme;
  favoriteCardElement.height = parseInt(someZoneHeight) * 0.95;
  favoriteCardElement.width = favoriteCardElement.height * cardAspectRatio;
  cropAndResize(favoriteCardElement);  favoriteCardElement.classList.add('closedThumbnail');
  cardBoard.appendChild(favoriteCardElement);
  //テーマのサムネ画像をクリックした時の処理
  favoriteCardElement.addEventListener('click', () => {
    const themeCards = Array.from(cardBoard.querySelectorAll('.card'))
      .filter(card => card.dataset.themeName === favoriteCardElement.dataset.themeName);
    if (favoriteCardElement.classList.contains('closedThumbnail')) {
      for (const themeCard of themeCards) {
        themeCard.style.display = 'inline-block';
        themeCard.src = themeCard.dataset.faceUpSrc;
        themeCard.classList.add('cardOpenAnimation');
        setTimeout(() => {
          themeCard.classList.remove('cardOpenAnimation');
        }, 400);
      }
      favoriteCardElement.classList.remove('closedThumbnail');
    } else {
      for (const themeCard of themeCards) {
        themeCard.style.display = 'none';
      }
      favoriteCardElement.classList.add('closedThumbnail');
    }
  })
}
```
```js
const createThemeCards = (nameOfTheme, urlOfFavoriteCardImage, cardDataSets) => {
  createThumbnailCardElement(nameOfTheme, urlOfFavoriteCardImage);
  let counter = 1;
  for (const cardDataSet of cardDataSets) {
    createCardElement(nameOfTheme, `${nameOfTheme}${counter}`, cardDataSet.type, cardDataSet.name, cardDataSet.url);
    counter++;
  }
} 
```
```js
//サンプルテーマ
createThemeCards(
  'sample', //テーマの名前
  'https://sample.com/sample.jpg', //代表カードの画像URL
  [
    {type: 'ef', name: '羽根箒', url: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRn96m8Sis8usMR4RwIzVDcXfcduFQJRn7WHpzPgYrzuiBqoTXWMrTpfvs&s=10'}
    ,{type: 'tr', name: '拮抗', url: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSTIYXQVITTaDubkX-zagiu5oRlCFb6iv9qvamhzsKyTUCnVoqFntoiJD0&s=10'}
  ]
);
//御巫
createThemeCards(
  'mikanko',
 'https://mirayugioh.github.io/yugiohSolitaireTool/card/mikanko/オオヒメ.jpg',
  [
    {type: 'ef', name: 'アショカ', url: 'https://mirayugioh.github.io/yugiohSolitaireTool/card/mikanko/アショカ.jpg'}
    ,{type: 'ef', name: 'ハレ', url: 'https://mirayugioh.github.io/yugiohSolitaireTool/card/mikanko/ハレ.jpg'}
    ,{type: 'sp', name: '迷わし', url: 'https://mirayugioh.github.io/yugiohSolitaireTool/card/mikanko/迷わし.jpg'}
    ,{type: 'sp', name: '祓舞', url: 'https://mirayugioh.github.io/yugiohSolitaireTool/card/mikanko/祓舞.jpg'}
  ]
);
```

### テーマのサムネイル画像を動的に生成する
![Screenshot_2025-03-28-20-21-48-62_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/da67eb49-209a-4488-a028-0c56b1517577.jpeg)

元のカード画像からclipPathで自動的に切り取ります。自分で画像をコラージュしなくていいのがメリット。
```js
const cardAspectRatio = 59 / 86; //遊戯王カードの横÷縦
const cropAndResize = (image) => {
  const beforeWidth = image.width;
  const beforeHeight = image.height;
  //console.log(`切り抜き前の横幅は${beforeWidth}px、高さは${beforeHeight}pxです。`)
  const croppedPartHeightRatio = 52; //くり抜きたい部分の縦幅の長さは実際のカードで測定。元のカードの高さの48%〜52%
  const topPaddingRatio = 18; //カードの上辺からイラストの上枠までの長さも実際のカードで測定。元のカードのの高さの18%
  // もとの縦幅をh,もとの横幅をwとする。縦横比の関係から h = w / cardAspectRatio が成り立つ。
  // 切り抜きたい部分の縦幅は croppedPartHeightRatio = 0.52 * h と分かったので、
  // 切り抜きたい部分の横幅は croppedPartWidthRatio = croppedPartHeightRatio * cardAspectRatio = 0.52 * h * cardAspectRatio となる。
  // さきほどの式を代入して croppedPartWidthRatio = 0.52 * w / cardAspectRatio * cardAspectRatio = 0.52 * w
  const croppedPartWidthRatio = 52; //くり抜きたい部分の横幅の長さは計算した通り。元のカードの横幅の52%
  const leftPaddingRatio = (100 - croppedPartWidthRatio) / 2; //カードの左辺からイラスト左枠までの長さ。
  //イラスト枠を正方形にくり抜く場合は 'polygon(10% 18%, 90% 18%, 90% 70%, 10% 70%)' となる
  image.style.clipPath = `polygon(
    ${leftPaddingRatio}% ${topPaddingRatio}%
    , ${leftPaddingRatio + croppedPartWidthRatio}% ${topPaddingRatio}%
    , ${leftPaddingRatio + croppedPartWidthRatio}% ${topPaddingRatio + croppedPartHeightRatio}%
    , ${leftPaddingRatio}% ${topPaddingRatio + croppedPartHeightRatio}%
  )`;
  const croppedPartHeight = 0.52 * beforeHeight;
  const zoomRatio = beforeHeight / croppedPartHeight //1.923倍となる
  //console.log(`テーマのサムネイル画像からイラストを切り抜きました。${zoomRatio}倍に拡大します。`);
  image.style.transform = `scale(${zoomRatio})`;
  //console.log(`拡大後の横幅を${image.width}px、高さを${image.height}pxに戻しました。`);
}
```

### カードの種類ごとに並び替える
![Screenshot_2025-03-28-20-22-43-47_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/890d2950-3d45-463c-96fe-80603f1b368c.jpeg)

初期表示でカードの種類がバラバラだと見づらいため、typeOrderを定義して整列させています。
```js
const typeOrder = {
  no: 1,
  //normal通常モンスター
  ef: 2,
  //effect効果モンスター
  ri: 3,
  //ritual儀式モンスター
  pe: 4,
  //pendulumペンデュラムモンスター
  fu: 5,
  //fusion融合モンスター
  sy: 6,
  //synchroシンクロモンスター
  xy: 7,
  //xyzエクシーズモンスター
  li: 8,
  //linkリンクモンスター
  sp: 9,
  //spell魔法カード
  tr: 10,
  //trapトラップカード
  to: 11,
  //tokenトークン
  ot: 12,
  //otherその他。他の種類も追加可能
};
const insertIntoBestPosition = (card) => {
  //console.log(`🥺テーマ名${card.dataset.themeName}、名前${card.dataset.cardName}、種類${card.dataset.cardType}、カードID${card.id}のカードを適切な位置に挿入します。`)
  const sameThemeElements = cardBoard.querySelectorAll(`[data-theme-name='${card.dataset.themeName}']`);
  if (sameThemeElements.length === 0) {
    cardBoard.appendChild(card);
    //console.log('同じテーマ名のカードがなかったため普通に一番末尾に挿入しました');
    return;
  }
  const sameThemeSameImageUrlElement = Array.from(sameThemeElements).find(el => el.dataset.faceUpSrc === card.dataset.faceUpSrc);
  if (sameThemeSameImageUrlElement) {
    cardBoard.insertBefore(card, sameThemeSameImageUrlElement);
    //console.log('対象カードと同じテーマで同じ画像URLの要素が既にあったため、その直前に追加しました');
    return;
  } 
  const sameThemeSameTypeElement = Array.from(sameThemeElements).find(el => el.dataset.cardType === card.dataset.cardType);
  if (sameThemeSameTypeElement) {
    cardBoard.insertBefore(card, sameThemeSameTypeElement);
    //console.log('同じ種類のカードがあったためその直前に追加しました');
    return;
  } 
  const typeKeys = Object.keys(typeOrder);
  if (!typeOrder.hasOwnProperty(card.dataset.cardType)) {
    cardBoard.appendChild(card);
    //console.warn(`未定義のカードタイプ: ${card.dataset.cardType}`);
    return;
  }
  const currentIndex = typeKeys.indexOf(card.dataset.cardType);
  const nextTypeElements = Array.from(sameThemeElements).filter(el => typeOrder[el.dataset.cardType] > currentIndex);
  if (nextTypeElements.length >= 1) {
    const theSmallestTypeElement = nextTypeElements.sort(
      (a, b) => typeOrder[a.dataset.cardType] - typeOrder[b.dataset.cardType]
    )[0];
    cardBoard.insertBefore(card, theSmallestTypeElement);
    //console.log('次の種類のカードがあったためその直前に置きました');
    return;
  } 
  cardBoard.appendChild(card);
  //console.log('次の種類のカードがなかったため一番末尾に置きました');
}
```

余談
このinsertIntoBestPosition関数はreturn処理を多用しています。
下のようなifの書き方もありますがネストが深くなって悩ましい。
```js
const insertIntoBestPosition = (card) => {
  //console.log(`テーマ名${card.dataset.themeName}、名前${card.dataset.cardName}、種類${card.dataset.cardType}、カードID${card.id}のカードを適切な位置に挿入します。`)
  //対象のカードと同じテーマ名のカードがあるか判定
  const sameThemeElement = cardBoard.querySelector(`[data-theme-name='${card.dataset.themeName}']`);
  if (!sameThemeElement) {
    cardBoard.appendChild(card);
    //console.log('同じテーマ名のカードがなかったため普通に一番末尾に挿入しました');
  } else {
    //対象カードと同じ種類（efやxy）が既にあるか判定
    const sameThemeSameTypeElement = sameThemeElement.querySelector(`[data-card-type='${card.dataset.cardType}']`);
    if (sameThemeSameTypeElement) {
      cardBoard.insertBefore(card, sameThemeSameTypeElement);
      //console.log('同じ種類のカードがあったためその先頭に追加しました');
    } else {
      //対象のカードの次の種類のカードがあるか判定
      const typeKeys = Object.keys(typeOrder);
      if (!typeOrder.hasOwnProperty(card.dataset.cardType)) {
        console.warn(`未定義のカードタイプ: ${card.dataset.cardType}`);
        return;
      }
      const currentIndex = typeKeys.indexOf(card.dataset.cardType);
      //const cardElements = cardBoard.querySelectorAll('.card');
      const sameThemeElements = cardBoard.querySelectorAll(`[data-theme-name='${card.dataset.themeName}']`);
      const nextTypeElements = Array.from(sameThemeElements).filter(el => typeOrder[el.dataset.cardType] > currentIndex);
      if (nextTypeElements.length <= 0) {
        cardBoard.appendChild(card);
        //console.log('次の種類のカードがなかったため一番末尾に置きました');
      } else {
        let theSmallestTypeElement = nextTypeElements[0];
        for (let i = 1; i < nextTypeElements.length; i++) {
          if (typeOrder[nextTypeElements[i].dataset.cardType] < typeOrder[theSmallestTypeElement.dataset.cardType]) {
            theSmallestTypeElement = nextTypeElements[i];
          }
        }
        cardBoard.insertBefore(card, theSmallestTypeElement);
        //console.log('次の種類のカードがあったためその直前に置きました');
      }
    }
  }
}
insertIntoBestPosition(imgElement);
```

### カードを移動する（純粋な移動処理）
移動先の座標、leftとtopを求めてtransitionで移動しています。
```js
const moveCardToZone = (card, targetZone) => {
  window.scrollTo(0, 0);
  targetZone.style.position = 'relative';
  card.style.position = 'absolute';
  card.style.zIndex = getNewZIndex();
  const targetZoneRect = targetZone.getBoundingClientRect();
  const targetZoneLeft = targetZoneRect.left;
  const targetZoneTop = targetZoneRect.top;
  const targetZoneWidth = targetZoneRect.width;
  const targetZoneHeight = targetZoneRect.height;
  const cardWidth = card.width;
  const cardHeight = card.height;
  card.style.left = `${targetZoneLeft + (targetZoneWidth - cardWidth) / 2}px`;
  card.style.top = `${targetZoneTop + (targetZoneHeight - cardHeight) / 2}px`;
  card.style.transition = 'top 0.7s ease-in-out, left 0.7s ease-in-out';
  card.style.borderWidth = originalBorderWidth;
}
```

### カードを移動する（最も手前に表示させる）
手前、奥の情報はzIndexプロパティで設定しています。
画面上のすべてのカード（.cardクラス）から最大のzIndexを取得し、1加算します。
```js
let maxZIndex = 0;
const getNewZIndex = () => {
  maxZIndex = Array.from(document.querySelectorAll('.card'))
    .map(obj => parseInt(obj.style.zIndex) || 0)
    .reduce((first, second) => Math.max(first, second), maxZIndex);
  return ++maxZIndex;
};
card.style.zIndex = getNewZIndex();
```

### カードを移動する（表裏の向きを決定する）
img要素それぞれのdata属性に表側の画像URLと裏側の画像URLをしまっておき、分岐に応じてsrc属性に反映させます。
```js
const setCardFace = (card, flipSide) => {
  switch (flipSide) {
    case 'back':
      card.src = card.dataset.faceDownSrc;
      card.classList.add('isBackSide');
      break;
    case 'front':
      card.src = card.dataset.faceUpSrc;
      card.classList.remove('isBackSide');
      break;
    default:
      console.warn('Unknown flipSide:', flipSide);
      break;
  }
}
```

### カードを移動する（守備表示などの角度を決定する）
```js
const setCardRotation = (card, rotation) => {
  card.style.transform = `rotate(${rotation}deg)`;
}
```

### 攻撃する処理
モンスターAがモンスターBに攻撃するとき、AをBに向けます。
角度を動的に求めます。
```js
const getAttackRotationAngle = (card, targetZone) => {
  //console.log(`${card.id}を${targetZone.id}に向けます。`);
  const cardRect = card.getBoundingClientRect();
  const cardX = cardRect.left + cardRect.width / 2;
  const cardY = cardRect.top + cardRect.height / 2;
  const zoneRect = targetZone.getBoundingClientRect();
  const zoneX = zoneRect.left + zoneRect.width / 2;
  const zoneY = zoneRect.top + zoneRect.height / 2;
  const distanceX = zoneX - cardX;
  const distanceY = -(zoneY - cardY); //javascriptは画面左上が座標基準となるためY軸の距離は反転する
  //console.log(`カードを原点としたX座標の距離は${distanceX}, Y座標の距離は${distanceY}です。`);
  const angleRad = Math.atan2(distanceX, distanceY);
  const angleDeg = angleRad * (180 / Math.PI);
  //console.log(`アークタンジェントから求められた角度は${angleRad}ラジアンになりました。角度に戻すと${angleDeg}です。`);
  return angleDeg;
}
```
```js
const beforeRotation = clickedCard.style.transform; //元の角度を記録しておく。（180度回転されている等）
clickedCard.style.transform = 'rotate(0deg)'; //わかりやすくするため正位置に戻してからgetAttackRotationAngle関数を実行する）
setCardRotation(clickedCard, getAttackRotationAngle(clickedCard, targetZone));
moveCardToZone(clickedCard, targetZone);
setTimeout(() => {
  clickedCard.style.transform = beforeRotation;
  moveCardToZone(clickedCard, beforeZone);
}, 700);
```

### カードを移動する（総括）
ゾーンが押されたとき、それまでに押されたカードとアクションボタンを.clickedCardと.clickedActionButtonで取得します。
```js
const clickedCard = document.querySelector('.clickedCard');
const clickedActionButton = document.querySelector('.clickedActionButton');
setCardFace(clickedCard, clickedActionButton.dataset.flipSide);
setCardRotation(clickedCard, -Number(clickedActionButton.dataset.rotation));
moveCardToZone(clickedCard, targetZone);
applyCardMoveAnimation(clickedCard, clickedActionButton.dataset.moveType);
```

### 重なっているカードの中から選ぶ
ゾーン上のカードを押したとき、カードが2枚以上重なっている場合の処理です。
専用のダイアログを作成して一覧表示します。
```js
const showDialogBox = () => {
  const existingContainer = document.querySelector('.dialogBox');
  if (existingContainer) {
    existingContainer.remove(); //既存のリストを削除
  }
  const dialogBox = document.createElement('div');
  document.body.appendChild(dialogBox);
  dialogBox.classList.add('dialogBox');
  dialogBox.style.zIndex = getNewZIndex();
  const infoBox = document.createElement('div');
  dialogBox.appendChild(infoBox);
  infoBox.classList.add('infoBox');
  const dialogCloseButtonBox = document.createElement('div');
  dialogBox.appendChild(dialogCloseButtonBox);
  dialogCloseButtonBox.classList.add('dialogCloseButtonBox');
  const dialogCloseButton = document.createElement('div');
  dialogCloseButtonBox.appendChild(dialogCloseButton);
  dialogCloseButton.classList.add('dialogCloseButton');
  dialogCloseButton.textContent = '✕';
  dialogCloseButton.addEventListener('click', () => {
    dialogBox.remove();
  });
}
```
```js
const showOverLappingCardsList = (overlappingCards) => {
  showDialogBox();
  const infoBox = document.querySelector('.infoBox');
  for (const overlappingCard of overlappingCards) {
    const tempCard = document.createElement('img');
    const someZone = document.querySelector('.zone');
    const someZoneHeight = window.getComputedStyle(someZone).height;
    tempCard.height = parseInt(someZoneHeight) * 0.95 * 2;
    tempCard.width = tempCard.height * cardAspectRatio;
    tempCard.style.transform = 'scale(0.5)';
    tempCard.classList.add('card');
    tempCard.classList.add('tempCard');
    tempCard.style.transform = overlappingCard.style.transform;
    tempCard.dataset.faceUpSrc = overlappingCard.dataset.faceUpSrc;
    tempCard.dataset.faceDownSrc = overlappingCard.dataset.faceDownSrc;
    if (overlappingCard.classList.contains('isBackSide')) {
      tempCard.src = tempCard.dataset.faceDownSrc;
      tempCard.classList.add('isBackSide');
    } else {
      tempCard.src = tempCard.dataset.faceUpSrc;
    }
    tempCard.src = overlappingCard.src;
    tempCard.dataset.uniqueId = overlappingCard.id;
    tempCard.addEventListener('click', () => {
      // revealFrontSide(tempCard);
      //console.log(`選択したカード: ${tempCard.dataset.uniqueId}`);
      resetClickedCard();
      const selectedTempCard = Array.from(document.querySelectorAll('.card')).find(card => card.id === tempCard.dataset.uniqueId);
      selectedTempCard.classList.add('clickedCard');
      selectedTempCard.style.zIndex = getNewZIndex();
      document.querySelector('.dialogBox').remove();
    });
    infoBox.appendChild(tempCard);
  }
};
const buffer = 15; //カードのズレの許容範囲。基本的にカードはゾーン内に綺麗に収まるが保険として設定しておく
const getRect = (element) => {
  const rect = element.getBoundingClientRect();
  return {
    top: window.scrollY + rect.top,
    bottom: window.scrollY + rect.bottom,
    left: window.scrollX + rect.left,
    right: window.scrollX + rect.right
  };
};
const isInside = (rect1, rect2) =>
  rect1.top >= rect2.top - buffer &&
  rect1.bottom <= rect2.bottom + buffer &&
  rect1.left >= rect2.left - buffer &&
  rect1.right <= rect2.right + buffer;
const allCards = document.querySelectorAll('.card');
const overlappingCards = Array.from(allCards)
  .filter(card => isInside(getRect(card), getRect(targetZone)))
  .sort((first, second) => parseInt(second.style.zIndex) - parseInt(first.style.zIndex));
showOverLappingCardsList(overlappingCards);
```

### Googleカスタム検索APIでカード生成する
![Screenshot_2025-03-28-20-24-34-24_e3c1f266f17b29c7b40472751f031275.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/61083d93-0c7b-415b-9126-68e8c5754c30.jpeg)

1人回しの途中に「このカード欲しい！」と思ったとき生成できるようにしました。
```js
async function fetchData() {
  const apiUrl = `https://www.googleapis.com/customsearch/v1?key=${apiKey}&cx=${searchEngineId}&q=${encodeURIComponent(inputTextBox.value)}&searchType=image`;
  console.log("apiUrl:", apiUrl);
  try {
    const searchResultCardBoard = document.createElement('div');
    searchBoard.appendChild(searchResultCardBoard);
    searchResultCardBoard.id = 'searchResultCardBoard';
    const response = await fetch(apiUrl);
    const data = await response.json();
    for (let i = 0; i < 9; i++) {
      const apiSearchElement = document.createElement('img');
      const allApiGeneratedCards = document.querySelectorAll('.apiGenerated');
      apiSearchElement.id = `api${allApiGeneratedCards.length + 1}`;
      apiSearchElement.classList.add('card', 'apiGenerated');
      const someZone = document.querySelector('.zone');
      const someZoneHeight = window.getComputedStyle(someZone).height;
      apiSearchElement.height = parseInt(someZoneHeight) * 0.95;
      apiSearchElement.width = apiSearchElement.height * cardAspectRatio;
      apiSearchElement.dataset.faceUpSrc = data.items[i].link;
      apiSearchElement.dataset.cardName = inputTextBox.value;
      apiSearchElement.src = apiSearchElement.dataset.faceUpSrc;
      apiSearchElement.dataset.cardType = 'ot';
      searchResultCardBoard.appendChild(apiSearchElement);
      apiSearchElement.addEventListener('click', (event) => {
        const selectedApiSearchElement = event.target; // クリックされた画像要素を取得
        selectedApiSearchElement.classList.add('onCardBoard');
        cardBoard.insertBefore(selectedApiSearchElement, cardBoard.children[0]);
        selectedApiSearchElement.addEventListener('click', handleCardClick);
        searchResultCardBoard.remove();
      });
    }
  } catch (error) {
    alert('カード生成ができませんでした。以下の点をご確認ください。\n○ 正しいAPIキーが登録されているか\n○ 正しい検索エンジンIDが登録されているか\n○ 検索結果が1件以上あるか');
    searchResultCardBoard.remove();
  }
}
searchButton.addEventListener('click', () => {
  fetchData();
});
```
余談。この仕組みは遊戯王じゃないので、他のゲームにも応用できるかも。
![Screenshot_2025-03-28-20-24-49-37_e3c1f266f17b29c7b40472751f031275.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/d8b3dfe9-07c8-4300-848f-ce3d404438b9.jpeg)
検索ワード次第ではこういうことも可能なので

### コメント欄を更新する
![Screenshot_2025-03-28-20-27-14-37_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/49c63721-ce52-408f-9b3b-762837bcc015.jpeg)
```js
document.addEventListener('click', (event) => {
  if (replayingFlag === true) return;
  if (event.target === tempCommentBox) return;
  const lastTempComment = logs
    .filter(log => log.actionType === 'tempComment')
    .at(-1);
  const lastTempCommentValue = lastTempComment ? lastTempComment.value : "";
  if (tempCommentBox.value === lastTempCommentValue) return;
  addLog('tempComment', {
    value: tempCommentBox.value
  });
});
```

### 操作ログを残す
![Screenshot_2025-03-28-20-27-52-17_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/b80747c1-5e06-4756-9c4e-be2c9b50a13c.jpeg)

```js
const addLog = (actionType, details = {}) => {
  const log = {
    actionType,
    ...details
  };
  logs.push(log);
}
```
```js
addLog('tempComment', {
  value: tempCommentBox.value
});
addLog('opponentLifePoint', { 
  value: inputValue
});
addLog(clickedActionButton.dataset.moveType, {
  actionType: clickedActionButton.dataset.moveType,
  cardId: clonedCard.id,
  zoneId: targetZone.id,
  flipSide: clickedActionButton.dataset.flipSide,
  rotation: shapedTransformValue,
  zIndex: clonedCard.style.zIndex //zIndex重なり順の情報はリプレイ時にはいらないが一手戻す際に欲しい
});
```

### やり直す（一手戻す）
ログの情報をもとに動作します。
```js
undoButton.addEventListener('click', () => {
  if (logs.length === 0) return; // ログが空なら何もしない
  const lastLog = logs.at(-1); // 最後のログを取得
  const lastLogActionType = lastLog.actionType;
  switch (lastLogActionType) {
    case 'move':
    case 'summon':
    case 'attack':
    case 'activate': {
      const sameCardLogs = logs.filter(log => log.cardId === lastLog.cardId);
      if (sameCardLogs.length >= 2) {
        const secondLastLog = sameCardLogs[sameCardLogs.length - 2];
        //console.log("secondLastLog", secondLastLog);
        if (secondLastLog.actionType === 'attack') {
          //何もしない
        } else {
          const undoCard = document.getElementById(secondLastLog.cardId);
          const undoZone = document.getElementById(secondLastLog.zoneId);
          setCardFace(undoCard, secondLastLog.flipSide);
          setCardRotation(undoCard, -Number(secondLastLog.rotation));
          moveCardToZone(undoCard, undoZone);
          applyCardMoveAnimation(undoCard, secondLastLog.moveType);
          undoCard.style.zIndex = secondLastLog.zIndex; //zIndexを更新するのではなく元に戻したい
        }
      } else {
        const undoCard = document.getElementById(lastLog.cardId);
        undoCard.remove(); //そのカードの存在を消す
      }
      break;
    }
    case 'tempComment': {
      const sameActionTypeLogs = logs.filter(log => log.actionType === lastLog.actionType);
      if (sameActionTypeLogs.length >= 2) {
        const secondLastLog = sameActionTypeLogs[sameActionTypeLogs.length - 2];
        tempCommentBox.value = secondLastLog.value;
      } else {
        tempCommentBox.value = '';
      }
      break;
    }
    case 'foreverComment': {
      const sameActionTypeLogs = logs.filter(log => log.actionType === lastLog.actionType);
      if (sameActionTypeLogs.length >= 2) {
        const secondLastLog = sameActionTypeLogs[sameActionTypeLogs.length - 2];
        foreverCommentBox.value = secondLastLog.value;
      } else {
        foreverCommentBox.value = '';
      }
      break;
    }
    case 'myLifePoint': {
      const sameActionTypeLogs = logs.filter(log => log.actionType === lastLog.actionType);
      if (sameActionTypeLogs.length >= 2) {
        const secondLastLog = sameActionTypeLogs[sameActionTypeLogs.length - 2];
        myLifePointBox.textContent = secondLastLog.value;
      } else {
        myLifePointBox.textContent = initialMyLifePoint;
      }
      break;
    }
    case 'opponentLifePoint': {
      const sameActionTypeLogs = logs.filter(log => log.actionType === lastLog.actionType);
      if (sameActionTypeLogs.length >= 2) {
        const secondLastLog = sameActionTypeLogs[sameActionTypeLogs.length - 2];
        opponentLifePointBox.textContent = secondLastLog.value;
      } else {
        opponentLifePointBox.textContent = initialOpponentLifePoint;
      }
      break;
    }
    default:
      console.warn(`未対応のアクションタイプ: ${lastLogActionType}`);
      break;
  }
  logs.length--; //ログの最後の要素を削除
  displayJson(logs);
});
```

### 裏側カードの表面を確認する
![Screenshot_2025-03-28-20-38-10-15_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/c25e61e8-cff9-4d69-aeb8-e89a5b67c89c.jpeg)
![Screenshot_2025-03-28-20-38-04-87_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/3c7e5551-39fe-4dbe-aec0-ac5956cdde0f.jpeg)

```js
let isBackFlag = true;
const revealFrontSide = () => {
  const allCards = document.querySelectorAll('.card');
  const backCards = Array.from(allCards).filter(card => card.classList.contains('isBackSide'));
  if (backCards.length === 0) return; // 裏面のカードがなければ何もしない
  for (const backCard of backCards) {
    if (isBackFlag) {
      backCard.src = backCard.dataset.faceUpSrc;
      //backCard.style.background = "linear-gradient(45deg, #362ae0 0%, #3b79cc 50%, #42d3ed 100%)"; // 背景のグラデーション
      backCard.classList.add("backSideAnimation");
      setTimeout(() => {
        backCard.classList.remove("backSideAnimation");
        if (!backCard.classList.contains('isBackSide')) {
          //console.log('表側表示定期プログラム中に手動で移動して表側になったカードのため、裏側に戻しません');
        } else {
          backCard.src = backCard.dataset.faceDownSrc;
        }
      }, 1500);
    } else {
      backCard.src = backCard.dataset.faceDownSrc;
    }
  };
  isBackFlag = !isBackFlag; // フラグを反転
}
setInterval(revealFrontSide,
  3000);
``` 


### ライフポイントを編集する
![Screenshot_2025-03-28-20-29-37-72_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/1c19e976-94c5-45f7-9296-194b71df573b.jpeg)

```js
const initialMyLifePoint = 8000;
const myLifePointBox = document.getElementById('myLifePointBox');
myLifePointBox.textContent = initialMyLifePoint;
myLifePointBox.addEventListener('click', () => {
  const getUserInputMyLifePoint = () => {
    const currentMyLifePointValue = myLifePointBox.textContent;
    const userInput = window.prompt("ライフポイントを入力してください。例：8000、4500、100", currentMyLifePointValue);
    return userInput;
  };
  const checkValidation = (inputValue) => {
    if (inputValue === null) {
      return;
    } else {
      const regex = /^\d+$/; //半角で整数の数字のみを検証する正規表現
      if (!inputValue.match(regex)) {
        alert("入力できるのは半角の整数のみです。（7000、4000、100など）");
        checkValidation(getUserInputMyLifePoint());
      } else {
        myLifePointBox.classList.add('lifeChangeAnimation');
        setTimeout(() => {
          myLifePointBox.classList.remove('lifeChangeAnimation');
        }, 500);
        myLifePointBox.textContent = inputValue;
        addLog('myLifePoint', {
          value: inputValue
        });
      }
    }
  }
  //初回実行
  checkValidation(getUserInputMyLifePoint());
});
```

### 効果音を非同期で再生する
```js
const soundErrorUrl = 'https://mirayugioh.github.io/yugiohSolitaireTool/sound/error.wav';
async function playSound(url) {
  try {
    const response = await fetch(url);
    //console.log(response);
    const data = await response.arrayBuffer();
    //console.log(data);
    const buffer = await audioContext.decodeAudioData(data);
    //console.log(buffer);
    const source = audioContext.createBufferSource();
    //console.log(source);
    source.buffer = buffer;
    source.connect(audioContext.destination);
    source.start();
  } catch (error) {
    console.error('音声ファイルのプリロード中にエラーが発生しました: ', error);
  }
}
playSound(soundErrorUrl);
```

### ログを元にリプレイ再生する
![Screenshot_2025-03-28-20-30-17-77_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/54044970-a040-4739-8fb3-3d5ef62b9976.jpeg)

```js
replayButton.addEventListener('click', () => {
  const sampleMilliSecondValues = [800, 1000, 1200, 1400, 1600];
  const examples = [];
  for (const sampleMilliSecondValue of sampleMilliSecondValues) {
    const expectedTime = logs.length * sampleMilliSecondValue / 1000;
    const expectedMinute = Math.floor(expectedTime / 60);
    const expectedSecond = Math.trunc(expectedTime % 60);
    const message = `${sampleMilliSecondValue/1000}秒（再生時間 約${expectedMinute}分${expectedSecond}秒）`;
    examples.push(message);
  }
  const checkInputValue = () => {
    const inputValue = window.prompt("リプレイを再生します。間隔を秒数で入力してください。例→\n" + examples.join('\n') + "\n0.8は速め、1.6はゆっくりめです。", "1.2");
    if (!inputValue) {
      return null;
    }
    if (!/^\d+(\.\d+)?$/.test(inputValue)) {
      alert("無効な入力です。半角数字のみ入力してください。（1.2や1など）");
      return checkInputValue();
    }
    console.log(`${inputValue}秒間隔が指定されました。`);
    return inputValue * 1000;
  };
  replayLog(checkInputValue());
});
let replayingFlag = false;
const replayLog = (milliSecond) => {
  if (milliSecond === null) return;
  replayingFlag = true;
  const resetCondition = () => {
    window.scrollTo(0, 0);
    const allCards = document.querySelectorAll('.card');
    const allCardsOnZoneBoard = Array.from(allCards).filter(obj => isInside(getRect(obj), getRect(zoneBoard)));
    for (const cardOnZoneBoard of allCardsOnZoneBoard) {
      cardOnZoneBoard.remove();
    }
    myLifePointBox.textContent = initialMyLifePoint;
    opponentLifePointBox.textContent = initialOpponentLifePoint;
    tempCommentBox.value = '';
    foreverCommentBox.value = '';
  }
  resetCondition();
  for (let i = 0; i < logs.length; i++) {
    setTimeout(() => {
      if (i >= 3 && logs.length >= 4) {
        const latestLog = logs[i];
        const secondLatestLog = logs[i-1];
        const thirdLatestLog = logs[i-2];
        const fourthLatestLog = logs[i-3];
        const hasNoSaveComment =
          latestLog.actionType !== "tempComment" &&
          secondLatestLog.actionType !== "tempComment" &&
          thirdLatestLog.actionType !== "tempComment" &&
          fourthLatestLog.actionType !== "tempComment";
        if (hasNoSaveComment) {
            tempCommentBox.value = '';
            //console.log('一時コメントが無駄に長期間表示されていたのでけしました');
        }
      }
      const currentLog = logs[i];
      const currentLogActionType = currentLog.actionType;
      const currentLogZone = document.getElementById(currentLog.zoneId);
      const currentLogCard = document.getElementById(currentLog.cardId);
      console.log(`${logs.length}個中${i+1}番目のログを再生します。ログの種類は${currentLogActionType}です。`)
      switch (currentLogActionType) {
        case 'move':
        case 'summon':
        case 'activate':
          if (!currentLogCard) {
            //ログに記載されたカードIDのカードがあるかどうか判定。なければ新しく作る
            const originalCardId = currentLog.cardId.replace(/-\d+$/, ''); //ログにはid="infernoble24-1"のような記録が残されているので、id="infernoble24"を特定する。
            //console.log(`${currentLog.cardId}のカードがなかったため${originalCardId}を複製しました。`);
            const originalCard = document.getElementById(originalCardId);
            const clonedCard = createClonedCard(originalCard)
            clonedCard.addEventListener('click', handleCardClick);
            setCardFace(clonedCard, currentLog.flipSide);
            setCardRotation(clonedCard, -Number(currentLog.rotation));
            moveCardToZone(clonedCard, currentLogZone);
            applyCardMoveAnimation(clonedCard, currentLogActionType);
          } else {
            setCardFace(currentLogCard, currentLog.flipSide);
            setCardRotation(currentLogCard, -Number(currentLog.rotation));
            moveCardToZone(currentLogCard, currentLogZone);
            applyCardMoveAnimation(currentLogCard, currentLog.actionType);
          }
          break;
        case 'attack':
          const allZones = document.querySelectorAll('.zone');
          const currentLogCardRect = getRect(currentLogCard);
          const beforeZone = Array.from(allZones).find(z => isInside(currentLogCardRect, getRect(z)));
          setCardFace(currentLogCard, currentLog.flipSide);
          const beforeRotation = currentLogCard.style.transform;
          currentLogCard.style.transform = 'rotate(0deg)';
          setCardRotation(currentLogCard, getAttackRotationAngle(currentLogCard, currentLogZone));
          moveCardToZone(currentLogCard, currentLogZone);
          setTimeout(() => {
            currentLogCard.style.transform = beforeRotation;
            moveCardToZone(currentLogCard, beforeZone);
          }, 700);
          applyCardMoveAnimation(currentLogCard, 'attack');
          break;
        case 'tempComment':
          tempCommentBox.value = currentLog.value;
          break;
        case 'foreverComment':
          foreverCommentBox.value = currentLog.value;
          break;
        case 'myLifePoint':
          myLifePointBox.textContent = currentLog.value;
          myLifePointBox.classList.add('lifeChangeAnimation');
          setTimeout(() => {
            myLifePointBox.classList.remove('lifeChangeAnimation');
          }, 500);
          break;
        case 'opponentLifePoint':
          //console.log(`ライフポイントを${currentLog.value}にします`);
          opponentLifePointBox.textContent = currentLog.value;
          opponentLifePointBox.classList.add('lifeChangeAnimation');
          setTimeout(() => {
            opponentLifePointBox.classList.remove('lifeChangeAnimation');
          }, 500);
          break;
        default:
          console.warn('Unknown actionType:', currentLogActionType);
          break;
      }
      //最後のログが処理されたらdisplayUsedCardsを呼び出す
      if (i === logs.length - 1) {
        setTimeout(() => {
          displayUsedCards();
        }, milliSecond * 1.5);
        replayingFlag = false;
      }
    }, i * milliSecond); // `i * milliSecond` と書くことで順番に実行される
  }
};
```

### リプレイ再生後に使用カード一覧を表示する
![Screenshot_2025-03-28-20-33-11-29_e4424258c8b8649f6e67d283a50a2cbc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/4dd40106-1759-41ef-a8d9-8e7fc6b72511.jpeg)

typeOrder配列を使い回します。
通常モンスター、効果モンスター、儀式、ペンデュラムはグループ1。
融合、シンクロ、エクシーズ、リンクはグループ2。
魔法と罠はグループ3。
トークン等その他はグループ4として並び替えます。
```js
const displayUsedCards = () => {
  const warning = 'リプレイが終了しました。7秒後、リプレイに使用したカードを並べます。';
  alert(warning);
  setTimeout(() => {
    const message = 'リプレイに使用したカードを並べてよろしいですか？';
    const confirmed = window.confirm(message);
    if (!confirmed) return;
    const allCards = document.querySelectorAll('.card');
    const allCardsOnZoneBoard = Array.from(allCards).filter(obj => isInside(getRect(obj), getRect(zoneBoard)));
    const sortedAllCardsOnZoneBoard = allCardsOnZoneBoard.sort((first, second) => typeOrder[first.dataset.cardType] - typeOrder[second.dataset.cardType]);
    for (const sortedCardOnZoneBoard of sortedAllCardsOnZoneBoard) {
      setCardFace(sortedCardOnZoneBoard, 'front');
      setCardRotation(sortedCardOnZoneBoard, '0');
    }
    window.scrollTo(0, 0);
    // グループの作成
    const cardGroup1 = sortedAllCardsOnZoneBoard.filter(card => ['no', 'ef', 'ri', 'pe'].includes(card.dataset.cardType));
    const cardGroup2 = sortedAllCardsOnZoneBoard.filter(card => ['fu', 'sy', 'xy', 'li'].includes(card.dataset.cardType));
    const cardGroup3 = sortedAllCardsOnZoneBoard.filter(card => ['sp', 'tr'].includes(card.dataset.cardType));
    const cardGroup4 = sortedAllCardsOnZoneBoard.filter(card => ['to', 'ot'].includes(card.dataset.cardType));
    // 定数設定
    const boardLineLength = 7; // ゾーンの1行のマス目の数
    const lastZone = document.getElementById('zone49'); // 最後のゾーン
    // グループの移動処理を行う関数
    const moveGroupCardsToZones = (cardGroup, startZoneIndex) => {
      for (let i = 0; i < cardGroup.length; i++) {
        const targetedCard = cardGroup[i];
        const targetedZoneIndex = startZoneIndex + i;
        const targetedZone = (targetedZoneIndex <= 49) ?
                              document.getElementById(`zone${targetedZoneIndex}`) :
                              lastZone;
        moveCardToZone(targetedCard, targetedZone);
      }
    };
    // グループ1の移動
    moveGroupCardsToZones(cardGroup1, 1);
    // グループ2の移動
    const FirstPlaceOfCardGroup2 = boardLineLength * Math.ceil(cardGroup1.length / boardLineLength) + 1;
    moveGroupCardsToZones(cardGroup2, FirstPlaceOfCardGroup2);
    // グループ3の移動
    const FirstPlaceOfCardGroup3 = boardLineLength * (Math.ceil(cardGroup1.length / boardLineLength) + Math.ceil(cardGroup2.length / boardLineLength)) + 1;
    moveGroupCardsToZones(cardGroup3, FirstPlaceOfCardGroup3);
    // グループ4の移動
    const FirstPlaceOfCardGroup4 = boardLineLength * (Math.ceil(cardGroup1.length / boardLineLength) + Math.ceil(cardGroup2.length / boardLineLength) + Math.ceil(cardGroup3.length / boardLineLength)) + 1;
    moveGroupCardsToZones(cardGroup4, FirstPlaceOfCardGroup4);
  }, 7000);
};
```

## 関連リンク

note記事
https://note.com/mirayugioh/n/n1081ba18fabe

開発日記（マストドン）
https://mstdn.jp/@mirayugioh/112758991267236155

YouTubeリンク
https://m.youtube.com/@mira-vz9js/videos

別件で作った手札誘発完全網羅リストもぜひ！灰流うららや増殖するGからトークンコレクター、森の精霊エーコまであらゆる誘発を網羅しました。
![images.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4047665/f469a280-59ee-4f60-848f-bcdeee7f5228.jpeg)
https://mirayugioh.github.io/allHandTrapList/

## おわりに
閲覧いただきありがとうございました😊
ご指摘や質問があればYouTubeのコメント欄などに書いていただけると幸いです。