前置き

こんにちは、遊戯王プレイヤーのmira(@aBhs1hpE9cVWYSW)と申します😀

遊戯王でカードの動きをシェアするため、ソリティアツールを作りました。

詳しい使い方は下記noteをご覧ください。

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

この記事では、様々なギミックをどんなコードで実装したか、ちょっとしたメモを共有します。

「JavaScriptでカードゲームを作りたい」「トランプゲームを作りたい」等と考えている人にとって参考になれば嬉しいです🥺

内容に誤りがあればご指摘いただけると助かります。


ギミック説明

🎮カードをゾーンに動かす

動かす動作自体はこのコードで実装しています。

JavaScript

clickedCard.style.transition = 'top 0.8s ease-in-out, left 0.8s ease-in-out';

対象の要素のstyle属性のtransitionプロパティを変更しています。下記の形式で書くようです。

要素.style.transition = 'CSSプロパティ トランジジョン時間 アニメーションの進行速度 アニメーション開始までの遅延時間';

CSSプロパティはtop(上辺)、left(左辺)、opacity(透明度)など。

トランジジョン時間は「0.8s」や「1000ms」など。

アニメーションの進行速度はease、linear、ease-in、ease-out、ease-in-outなど。

ease-in-outにすることで、アニメが遅く始まって中間部分で早くなって最後にまた遅くなります。

アニメーション開始までの遅延時間は「0.3s」や「500ms」など。

clickedCardって?topとleftはどこで決めてるの?となると思うので、もう少し詳細を。

まずカードクリックの処理についてです。

document全体にクリックイベントを追加します。

画面上の適当な場所を押して、それがカードだった場合処理を実行します。

カードじゃなければ処理をしません。

JavaScript

document.addEventListener('click', (event) => { const card = event.target.closest('.cards'); if (!card) return; //カードじゃなければ処理をしない //以下略 })

カードを押した直前に何を押していたかで処理が分かれます。

何も押していない状態だったら、押されたカードに「clickedCard」というクラスを付与します。

JavaScript

card.classList.add('clickedCard');

このclickedCardは単なる目印用のクラスです。

CSS

.clickedCard { /* 目印用のクラス */ }

次にゾーンをクリックしていきます。

ゾーンはこのコードで生成しました。

JavaScript

for (let i = 1; i <= 49; i++) { const zoneElement = document.createElement('div'); zoneElement.classList.add('zones'); zoneElement.id = `zone${i}`; zoneBoard.appendChild(zoneElement); };

7✕7=49個のマス目を作る感じですね。

ゾーン1つ1つに、ゾーンを押したときのクリックイベントを追加します。

「zones」というクラスを持つ要素をzones変数に配列として代入します。

zones配列の0番目の要素は「zone1」、1番目の要素は「zone2」、(省略)、48番目の要素は「zone49」になります。

したがってforループ文ではlet i = 0; i < zones.length; i++という記載になります。

JavaScript

const zones = document.querySelectorAll('.zones'); for (let i = 0; i < zones.length; i++) { const zone = zones[i]; zone.addEventListener('click', () => { //略 }) }

ゾーンを押したとき、直前に何が押されていたかで処理を分岐させます。

カードが押されていた場合は「clickedZone」というクラスを付与します。

JavaScript

zone.classList.add('clickedZone');

このclickedZoneも、clickedZone同様に単なる目印用のクラスです。

CSS

.clickedZone { /* 目印用のクラス */ }

カードとゾーンを両方押した状態で「移動」ボタンを押すと、さっき目印のクラスをつけた要素を取得します。

カードがゾーンの上に動きます。

JavaScript

const clickedCard = document.querySelector('.clickedCard'); const clickedZone = document.querySelector('.clickedZone'); clickedCard.src = imageUrl; clickedCard.style.transform = `rotate(${rotation}deg)`; clickedCard.style.position = 'absolute'; clickedZone.style.position = 'relative'; const zoneRect = clickedZone.getBoundingClientRect(); const zoneTop = zoneRect.top + 2; //2は微調整した結果の数値 const zoneLeft = zoneRect.left + 22; //22も微調整した結果の数値 clickedCard.style.top = `${zoneTop}px`; clickedCard.style.left = `${zoneLeft}px`; clickedCard.style.transition = 'top 0.8s ease-in-out, left 0.8s ease-in-out';

getBoundingClientRectはオブジェクト.getBoundingClientRect()の形で使い、オブジェクトがブラウザ画面の中でどこにあるか突き止めます。

戻り値としてbottom, left, right, top, width, height, x, yプロパティを返します。

今回はtopプロパティとleftプロパティを使いました。対象ゾーンの左端と上端の位置がわかれば十分なので。

✨カードを光らせる

カードの効果を発動したとき、分かりやすくするためカードを光らせていきます。

下記のコードで実装しました。

CSS

.cardGlowAnimation { animation: cardGlowKeyFrames 1s; } @keyframes cardGlowKeyFrames { 0% { filter: brightness(100%); } 50% { filter: brightness(200%); } 100% { filter: brightness(100%); } }

JavaScript

setTimeout(() => { clickedCard.classList.add('cardGlowAnimation'); setTimeout(() => { clickedCard.classList.remove('cardGlowAnimation'); }, 1000); }, 150);

クラスを一瞬だけ付与して、1000ミリ秒後(1秒後)にsetTimeOutで剥がす感じです。

transitionはアニメーションの始めと終わりのみ設定できますが、keyframesだと開始・中間・終了を細かく指定してくれます。

🎴カードを裏側にする

下記コードで実装しました。

JavaScript

let reversedCardUrl = null;
document.addEventListener('DOMContentLoaded', () => { const selectedProtectorUrl = sessionStorage.getItem('selectedProtectorUrl'); if (selectedProtectorUrl) { reversedCardUrl = selectedProtectorUrl; } else { reversedCardUrl = defaultProtectorUrl; } });
const actionMove = (imageUrl, rotation) => { if (document.querySelector('.clickedCard') && document.querySelector('.clickedZone')) { const clickedCard = document.querySelector('.clickedCard'); clickedCard.src = imageUrl; } };
document.getElementById('action0FacedownMove').addEventListener('click', () => { actionMove(reversedCardUrl, 0); });

🎴カードを表側にする

カードの表側はdatasetを使っています。

カードを裏側にして表に戻すとき、表のカード画像URLをどこから取得するか悩みました...。

変数に入れる方法だと書き方が複雑になってしまうため、オブジェクトに固有のURLを持たせたい。

そんなときにdatasetがあるよと。data属性はHTML5から導入され、カスタムデータ属性と呼ばれているようです。

data-〇〇という形で独自の名前を入れることができます。今回はdata-srcでURLソース名をdata属性として格納しています。

JavaScript

document.getElementById('action0FaceupMove').addEventListener('click', () => { const clickedCard = document.querySelector('.clickedCard'); actionMove(clickedCard.dataset.src, 0); });

👆カードを重ねる

上下の位置関係はZIndexを使って表しました。

zIndexは要素を重ねる順序を指定するプロパティです。値が大きいほど上に(手前に)表示されます。

グローバル変数としてmaxZIndexを定義しました。

カードを移動するたびにその値を1ずつ増やしていきます。最大のzIndexを常に更新し続ける感じです。

JavaScript

let maxZIndex = 0; //maxZIndexを初期化
let maxZIndex = Array.from(document.querySelectorAll('.cards')) .map(obj => parseInt(obj.style.zIndex) .reduce((first, second) => Math.max(first, second)); card.style.zIndex = ++maxZIndex;

何をやっているのか順番に説明します。

  1. document.querySelectorAll('.cards')で「cards」というクラス名を持つ全てのオブジェクトをNodeListオブジェクトとして取得します。

※NodeListは「DOMノードのコレクションやリストを表すオブジェクト」という意味のようです。

  1. Array.fromでそのNodeListオブジェクトから配列を作ります。

  2. map(obj => parseInt(obj.style.zIndex)で、配列内のオブジェクトからstyle属性のzIndexプロパティを取得して配列として返します。

※parseIntはparseInt(文字列, 基数)の形で書かれ、文字列を(基本)10進数に変換します。

※map()は配列.map(処理)の形で書かれ、配列にある全ての要素に対して同じ処理を実行し、結果を配列として返します。

下記のように使われます。

JavaScript

const testarray = [1, 4, 9, 16]; const testmap = testarray.map((x) => x * 2); console.log(testmap); //結果:[2, 8, 18, 32]
  1. reduce((first, second) => Math.max(first, second)); でzIndex値の配列の中から一番大きな値を取得します。

reduceは配列.reduce( (最初の値, 次の値) => 処理 )の形で使われ、配列内の要素に対して処理を繰り返します。

Math.max(first, second)の処理は2つの値を比較して大きい方を残す(返す)ようにしています。

イメージ的には最初のプレイヤーと次のプレイヤーで戦って強い方が残り、その人と3番目のプレイヤーが戦って強い方が残り、最終的に一番強い人が生き残る感じです。

  1. そうしてできたmaxZIndexを++maxZIndexで1増やして、cardのstyle属性のzIndexプロパティにします。

なお、以前はスプレッド構文の...を使っていました。一応共有します。

let maxZIndex = Math.max(...Array.from(document.querySelectorAll('.cards')).map(obj => obj.style.zIndex));

Math.max関数はMath.max(引数)の形で書かれ、引数として与えられた0個以上の数値のうち最大の数を返します。

Math.max(100, 200);と書かれたら200を返します。

じゃあ[100, 200]という配列を引数にすることはできるのかと言うと、できないらしいです。

const array = [100, 200]; const max = Math.max(array); //エラーになる

そこで配列にスプレッド構文の...を使い、配列の中身を取得する必要があります。

const array = [100, 200]; const max = Math.max(...array); //maxは200となる

コード内でMath.max(...Array.fromと書かれているのはそのためです。

このスプレッド構文、数が多すぎるとコンピュータが処理しきれなくてエラーになるようなので断念しました。

👆ゾーン上のカードを一覧表示する

さきほど

ゾーンを押したとき、直前に何が押されていたかで処理を分岐させます。 カードが押されていた場合は「clickedZone」というクラスを付与します。

と述べました。じゃあ何も押されていない状態でゾーンを押した場合はどうなるか。

そのゾーン上にある全てのカードを一覧表示させます。

//画面上にあるすべてのカードを取得する const allObjects = document.querySelectorAll('.cards'); //その中で対象ゾーンの上に重なっているものを抽出する const overlappingObjects = Array.from(allObjects).filter((object) => { const objectRect = object.getBoundingClientRect(); const zoneRect = zone.getBoundingClientRect(); return objectRect.top > zoneRect.top - 15 && objectRect.bottom < zoneRect.bottom + 15 && objectRect.left > zoneRect.left - 15 && objectRect.right < zoneRect.right + 15; }); //クリックしたゾーンの上にあるカードが2枚以上なら一覧表示処理する if (overlappingObjects.length >= 2) { window.scrollTo(0, 0); zone.classList.add('clickedZone'); const zoneRect = zone.getBoundingClientRect(); const cardHeight = 127; //カードの高さ const cardWidth = 100; //カードの横幅 const spacing = 10; //カード間のスペース const howManyCardsInOneRow = 6; //1列の中で表示したいカードの数 let topOffset = zoneRect.top + 10; //微調整 let leftOffset = zoneRect.left + 22; //微調整 let howManyCardsInOneRowCounter = 0; let howManyRowsCounter = 1; overlappingObjects.sort((first, second) => first.style.zIndex - second.style.zIndex) if (zoneRect.left > 460) { //対象ゾーンが右側にあるとき一覧表示は左下方向に広がる for (const object of overlappingObjects) { let maxZIndex = Math.max(...Array.from(document.querySelectorAll('.cards')).map(obj => obj.style.zIndex)); Object.assign(object.style, { position: 'absolute', left: `${leftOffset}px`, top: `${topOffset}px`, zIndex: ++maxZIndex, }); object.classList.add('splittedObjects'); howManyCardsInOneRowCounter++; if (howManyCardsInOneRowCounter === howManyCardsInOneRow) { topOffset = zoneRect.top + spacing; leftOffset = zoneRect.left - cardWidth * howManyRowsCounter - spacing * 3; //守備表示のカードを横に並べるとき微調整 howManyRowsCounter++; howManyCardsInOneRowCounter = 0; } else { topOffset = zoneRect.top + cardHeight * howManyCardsInOneRowCounter + spacing; } } } else if (zoneRect.left <= 460) { //対象ゾーンが左側の状態から一覧表示するときは右下方向に広がる for (const object of overlappingObjects) { let maxZIndex = Math.max(...Array.from(document.querySelectorAll('.cards')).map(obj => obj.style.zIndex)); Object.assign(object.style, { position: 'absolute', left: `${leftOffset}px`, top: `${topOffset}px`, zIndex: ++maxZIndex, // 重なっているカードを他のゾーンのカードより上に表示する }); object.classList.add('splittedObjects'); howManyCardsInOneRowCounter++; if (howManyCardsInOneRowCounter === howManyCardsInOneRow) { topOffset = zoneRect.top + spacing; leftOffset = zoneRect.left + cardWidth * howManyRowsCounter + spacing * 3; // 守備表示のカードを横に並べるとき微調整 howManyRowsCounter++; howManyCardsInOneRowCounter = 0; } else { topOffset = zoneRect.top + cardHeight * howManyCardsInOneRowCounter + spacing; } } } }

♫効果音を再生する

音ズレがないよう、非同期処理を採用しました。

JavaScript

const audioContext = new(window.AudioContext || window.webkitAudioContext)(); const soundMoveUrl = 'https://mirayugioh.github.io/yugiohTool/move.wav'; async function playSound(url) { try { const response = await fetch(url); const data = await response.arrayBuffer(); const buffer = await audioContext.decodeAudioData(data); const source = audioContext.createBufferSource(); source.buffer = buffer; source.connect(audioContext.destination); source.start(); } catch (error) { console.error('音声ファイルのプリロード中にエラーが発生しました: ', error); } }
playSound(soundMoveUrl);

順番に説明します。chatGPT等の受け売りなのはご了承ください...。

  1. const audioContext = new(window.AudioContext || window.webkitAudioContext)();

windowオブジェクトのプロパティとしてAudioContextまたはwebkitAudioContextを取得し、そのオブジェクトをインスタンス化します。

Web Audio APIを使用して音声を操作するための準備です。

  1. const soundMoveUrl = 'https://mirayugioh.github.io/yugiohTool/move.wav';

soundMoveUrlという変数を宣言し、URLを文字列として代入します。

  1. async function playSound(url)

非同期関数playSoundを宣言します。引数にURLをとります。

  1. const response = await fetch(url);

fetch関数でHTTPリクエストを作ってサーバーに投げます。結果をresponseという変数に代入します。

await演算子を使っているので、この処理が終わるまで次の処理は実行されません。

responseの中身はこんな感じです。

bodyUsed: true headers: Headers {} ok: true redirected: false status: 200 statusText: "" type: "cors" url: "https://mirayugioh.github.io/yugiohTool/activate.wav" [[Prototype]] arrayBuffer: ƒ arrayBuffer() blob: ƒ blob() clone: ƒ clone() formData: ƒ formData() json: ƒ json() text: ƒ text()
  1. const data = await response.arrayBuffer();

response変数にはurlとかtypeとか色々ありますが、その中でarrayBufferオブジェクトをdata変数に代入します。

data変数の中身はこんな感じです。

byteLength: 0 detached: true maxByteLength: 0 resizable: false
  1. const buffer = await audioContext.decodeAudioData(data);

AudioContextオブジェクトのdecodeAudioDataメソッドを使い、ArrayBuffer形式の音声データをデコードします。それをbuffer変数に代入します。

buffer変数の中身はこんな感じです。

duration: 2.9275 length: 140520 numberOfChannels: 2 sampleRate: 48000
  1. const source = audioContext.createBufferSource();

AudioContextオブジェクトのcreateBufferSourceメソッドを使い、新しいAudioBufferSourceNodeオブジェクトを生成します。

これは音声ファイルを再生するためのノードです。source変数に代入します。

source変数の中身はこんな感じです。

buffer: AudioBuffer { length: 140520, duration: 2.9275, sampleRate: 48000, numberOfChannels: 2 } channelCount: 2 channelCountMode: "max" channelInterpretation: "speakers" context: AudioContext { baseLatency: 0.005333333333333333, outputLatency: 0.016, sinkId: '', onsinkchange: null, destination: AudioDestinationNode, … } detune: AudioParam { value: 0, automationRate: 'k-rate', defaultValue: 0, minValue: -3.4028234663852886e+38, maxValue: 3.4028234663852886e+38 } loop: false loopEnd: 0 loopStart: 0 numberOfInputs: 0 numberOfOutputs: 1 onended: null playbackRate: AudioParam { value: 1, automationRate: 'k-rate', defaultValue: 1, minValue: -3.4028234663852886e+38, maxValue: 3.4028234663852886e+38 } [[Prototype]]: AudioBufferSourceNode
  1. source.buffer = buffer;

source変数のbufferプロパティは、先ほど宣言したbuffer変数とします。

  1. source.connect(audioContext.destination);

AudioNodeオブジェクトのconnectメソッドを使い、sourceをAudioContextのdestinationプロパティに接続します。

  1. source.start();

AudioNodeオブジェクトのstartメソッドを使い、sourceを再生します。

🌍カードをGoogle検索APIで出現させる

Googleカスタム検索APIを使って、ソリティア作成中に好きなカードを呼び出せるようにしました。

JavaScript

const apiKey = 'AbCdEfGhIjKlMnOpQrStUv'; //sample const searchEngineId = '123qwe34ty56ui7'; //sample
const inputTextBox = document.getElementById('inputTextBox'); const cardBoard = document.getElementById('cardBoard'); const searchButton = document.getElementById('searchButton'); async function fetchData() { const apiUrl = `https://www.googleapis.com/customsearch/v1?key=${apiKey}&cx=${searchEngineId}&q=${encodeURIComponent(inputTextBox.value)}&searchType=image`; try { const response = await fetch(apiUrl); if (!response.ok) { throw new Error('エラーが発生しました'); } const data = await response.json(); const imgElement = document.createElement('img'); imgElement.classList.add('cards') const newCardId = `card${cardBoard.children.length + 1}`; imgElement.id = newCardId; imgElement.dataset.src = data.items[0].link; imgElement.src = data.items[0].link; cardBoard.appendChild(imgElement); const cardData = { id: newCardId, imageUrl: data.items[0].link }; sessionStorage.setItem(newCardId, JSON.stringify(cardData)); } catch (error) { alert('カード生成ができませんでした'); } } searchButton.addEventListener('click', () => { fetchData(); });

わかりづらいかもしれないので具体例で。「darkMagician」で検索したとしましょう。

https://www.googleapis.com/customsearch/v1?key=AbCdEfGhIjKlMnOpQrStUv&cx=123qwe34ty56ui7&q=darkMagician&searchType=image

このようなapiUrlが作られます。

const response = await fetch(apiUrl);

fetch関数でHTTPリクエストを作成してサーバーに投げます。結果をresponseという変数に代入します。

const data = await response.json();

このコードでdataという変数を宣言し、さきほどのresponse変数からJSON形式のデータを取得して代入します。

data.items[0].link;

このコードでdata変数にあるJSONデータからitemsという配列の0番目の要素を対象にし、そのlinkプロパティを取得します。

data変数の中身が気になったので見てみます。

console.log(data);

このコードで確認。以下はGoogle検索APIページの受け売りです。

https://developers.google.com/custom-search/v1/reference/rest/v1/Search

dataには大きく分けて7つのオブジェクトが入っています。

kind オブジェクトを表す識別子。customsearch#searchという文字列が表示される。 url Google検索APIのテンプレートを定義するOpenSearch URL要素。 queries 検索語句。 context クエリが使われた検索エンジンの名前。 searchInformation この検索に関するメタデータ。検索時間や検索結果の合計など。 spelling 検索語句を修正してカプセル化するもの。 Items 検索した結果のセット。

この中の「Items」にはさらに0番目〜9番目のオブジェクトが入っています。0番目は一番最初の検索結果ということです。

実際こんな感じです。↓

0:Object kind:customsearch#result title:Dark Magician | Yu-Gi-Oh! Wiki | Fandom htmlTitle:<b>Dark Magician</b> | <b>Yu-Gi-Oh</b>! Wiki | Fandom link:https://static.wikia.nocookie.net/yugioh/images/b/bf/DarkMagician-HAC1-EN-DUPR-1E.png/revision/latest?cb=20220311231945 displayLink:yugioh.fandom.com snippet:Dark Magician | Yu-Gi-Oh! Wiki | Fandom htmlSnippet:<b>Dark Magician</b> | <b>Yu-Gi-Oh</b>! Wiki | Fandom mime:image/ fileFormat:image/ image:Object contextLink:https://yugioh.fandom.com/wiki/Dark_Magician height:688 width:470 byteSize:840570 thumbnailLink:https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR_Iy6_GW8DxXfX0ksGEEIbGHEhJySOMannvAswsr5xImBAbNc0OWAJ2Rs&s thumbnailHeight:139 thumbnailWidth:95

この中のlinkプロパティ(=画像URL)を取得しているわけです。

やろうと思えばlinkだけでなくheight(画像の縦幅)thumbnailLink(サムネイルの画像URL)も取得できるかも。

👀テーマカード内で種類ごとに並び替える

HTML

<label for='themeSelect'>使いたいテーマを選ぶ: </label> <select id='themeSelect'> <option value='none'>(選択)</option> <option value='default'>デフォルト</option> </select>

JavaScript

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 createCardElement = (id, imageUrl, type) => { const imgElement = document.createElement('img'); imgElement.classList.add('cards'); imgElement.id = id; imgElement.dataset.src = imageUrl; imgElement.src = imageUrl; imgElement.dataset.type = type; const sameImageUrlElement = cardBoard.querySelector(`img[data-src="${imageUrl}"]`); //すでに同じカード画像がある場合 if (sameImageUrlElement) { cardBoard.insertBefore(imgElement, sameImageUrlElement); } else { const sameTypeElement = cardBoard.querySelector(`[data-type='${type}']`); //すでに同じ種類のカードがある場合 if (sameTypeElement) { cardBoard.insertBefore(imgElement, sameTypeElement); } else { const typeKeys = Object.keys(typeOrder); const currentIndex = typeKeys.indexOf(type); const cardElements = cardBoard.querySelectorAll('.cards'); const nextTypeElements = Array.from(cardElements).filter(cardElement => typeOrder[cardElement.dataset.type] > currentIndex); //次の種類のカードがある場合 if (nextTypeElements.length > 0) { let theSmallestTypeElement = nextTypeElements[0]; for (let i = 1; i < nextTypeElements.length; i = i + 1) { if (typeOrder[nextTypeElements[i].dataset.type] < typeOrder[theSmallestTypeElement.dataset.type]) { theSmallestTypeElement = nextTypeElements[i]; } } cardBoard.insertBefore(imgElement, theSmallestTypeElement); //次の種類のカードすらない場合 } else { cardBoard.appendChild(imgElement); } } } sessionStorage.setItem(id, JSON.stringify(cardData)); };
switch (selectedOption) { //デフォルト case 'default': { sessionStorage.clear(); createCardElement('card1', 'https://sample.jpg', 'no'); const optionalProtectorUrl = 'https://sample.jpg'; sessionStorage.setItem('selectedProtectorUrl', optionalProtectorUrl); const optionalBackgroundUrl = 'https://sample.jpg'; sessionStorage.setItem('selectedBackgroundUrl', optionalBackgroundUrl); location.reload(); } break; } });

💻ログを残す

let logs = [];
const log = { actionType: 'moveAndGlowCard', cardId: clickedCard.id, zoneId: clickedZone.id, zIndex: clickedCard.style.zIndex, cardImageUrl: clickedCard.src, animationType: 'cardGlowAnimation', transform: updatedTransform }; logs.push(log); saveTextBox.value = JSON.stringify(logs, null, 2);

💻操作を取り消す(1手戻す)

やり直しボタンを押したとき、最後のログが何であるかによって分岐します。 配列は0番目から数えるので、logs[logs.length - 1]が最後のログになります。。

if (logs[logs.length - 1].actionType === 'moveCard' || logs[logs.length - 1].actionType === 'moveAndZoomCard' || logs[logs.length - 1].actionType === 'moveAndGlowCard') { const lastCardId = logs[logs.length - 1].cardId; const filteredLogs = logs.filter(log => log.cardId === lastCardId && (log.actionType === 'moveCard' || log.actionType === 'moveAndZoomCard' || log.actionType === 'moveAndGlowCard')); //対象カードの移動履歴がないとき if (filteredLogs.length === 1) { window.scrollTo(0, 0); const undoZoneId = 'zone25'; //対象カードを真ん中のゾーンに置く const undoZone = document.getElementById(undoZoneId); const undoZoneRect = undoZone.getBoundingClientRect(); const undoZoneTop = undoZoneRect.top + 2; const undoZoneLeft = undoZoneRect.left + 22; let maxZIndex = Math.max(...Array.from(document.querySelectorAll('.cards')).map(obj => obj.style.zIndex)); const undoZIndex = ++maxZIndex; const undoCardImageUrl = filteredLogs[filteredLogs.length - 1].cardImageUrl; const undoTransform = filteredLogs[filteredLogs.length - 1].transform; const undoCard = document.getElementById(lastCardId); undoCard.style.opacity = '0'; undoCard.src = undoCardImageUrl; undoCard.style.transform = undoTransform; undoCard.style.position = 'absolute'; undoZone.style.position = 'relative'; undoCard.style.top = `${undoZoneTop}px`; undoCard.style.left = `${undoZoneLeft}px`; undoCard.style.transition = 'top 0.8s ease-in-out, left 0.8s ease-in-out'; undoCard.style.zIndex = undoZIndex; setTimeout(() => { undoCard.style.opacity = '1'; undoZone.style.opacity = '0.35'; }, 500); //対象カードの移動履歴があるときは一番最近の場所に戻す } else if (filteredLogs.length >= 2) { window.scrollTo(0, 0); const undoZoneId = filteredLogs[filteredLogs.length - 2].zoneId; const undoZone = document.getElementById(undoZoneId); const undoZoneRect = undoZone.getBoundingClientRect(); const undoZoneTop = undoZoneRect.top + 2; const undoZoneLeft = undoZoneRect.left + 22; const undoZIndex = filteredLogs[filteredLogs.length - 2].zIndex; const undoCardImageUrl = filteredLogs[filteredLogs.length - 2].cardImageUrl; const undoTransform = filteredLogs[filteredLogs.length - 2].transform; const undoCard = document.getElementById(lastCardId); undoCard.style.opacity = '0'; undoCard.src = undoCardImageUrl; undoCard.style.transform = undoTransform; undoCard.style.position = 'absolute'; undoZone.style.position = 'relative'; undoCard.style.top = `${undoZoneTop}px`; undoCard.style.left = `${undoZoneLeft}px`; undoCard.style.transition = 'top 0.8s ease-in-out, left 0.8s ease-in-out'; undoCard.style.zIndex = undoZIndex; setTimeout(() => { undoCard.style.opacity = '1'; undoZone.style.opacity = '0.35'; }, 500); } logs.pop(); saveTextBox.value = JSON.stringify(logs, null, 2); }

💻ログを再生する

loadLogButton.addEventListener('click', () => { const secValue = parseInt(secList.value); const loadTextBox = document.getElementById('loadTextBox'); const logText = loadTextBox.value; logs = JSON.parse(logText); const expectedTime = logs.length * secValue / 1000; const expectedMinute = Math.floor(expectedTime / 60); const expectedSecond = Math.trunc(expectedTime % 60); const message = `リプレイ時間は、約${expectedMinute}分${expectedSecond}秒です。よろしいですか?`; const confirmed = window.confirm(message); if (!confirmed) return; window.scrollTo(0, 0); for (const [index, log] of logs.entries()) { //指定した時間間隔でログ再生する setTimeout(() => { const { actionType, cardId, zoneId, zIndex, cardImageUrl, animationType, transform, text } = log; if (actionType === 'moveCard') { //省略 } else if (actionType === 'moveAndZoomCard') { //省略 } else if (actionType === 'moveAndGlowCard') { //省略 } else if (actionType === 'glowCard') { //省略 } else if (actionType === 'saveComment') { //省略 } else if (actionType === 'attentionComment') { //省略 } else if (actionType === 'displayMyLifePoint') { //省略 } else if (actionType === 'displayOpponentLifePoint') { //省略 } }, index * secValue); } });

振り返り

いい感じに実装できたんじゃないかなと思います。

あとは複数選択のやり方を身に付けたいです。。。javascriptだと長押しに対応していないみたいで。

ご指摘や感想があればTwitter等で教えていただけると助かります。