柴っちGO!に続き第2弾、HIDDEN ECHO タイル消去のGAMEコード公開です。

HIDDEN ECHOはブラウザゲームで一般的に同じ色のタイルをドラッグ&ドロップして消去していく内容です。プログラムコードに付いては、HTML・CSS・JSで作成しており、下記のゲームコードを参考にしてください。また、ゲームスコア等の保存については、スタート後、カウントダウン・クリア終了方式を採用しており、端末のローカルストレージやサーバーなどのコード記述は致しておりません。

尚、デモサイト(このページの一番下付近にリンク)でPLAYできますが、フリーゲームサイト等などに公式に一般公開していません。タイル消去の動作確認が本来の目的なのでご了承ください。

以下にこのゲームの概要を説明します。

ゲームの流れと仕様
1.ドラッグまたはタッチ
同じ色が2つ以上、上下左右のいずれかが隣り合わせ(斜めを除く)しているか、複数並んでいる場所で移動可能
2.ドロップ
同じ色が単独または1つ以上の上下左右のいずれかに隣り合わせ(斜めを除く)しているか、または複数並んでいる場所でドロップ可能
3.ドロップ
A:タイル内に隠された数字が一瞬表示、+1から+5 0 -1から-5のいづれかを表示。+はタイル数を消去、-はタイル数を表示
B:1つのタイル内に上記の数字が1つ表示されて、タイルの隣り合わせの数字が揃うと+-の条件で表示/消去する
例: タイルの同色が5列の数字内容(-5 -5 +2 +3 +3)だとした場合は、その隣接したペアは一つの数値とみなして合計は(-5+(+3))で-2となり、盤面内の2つが消去される。同時にこの条件はドラッグ元も同じように適用される。
C:消去する際はキラキラ星のアニメーション演出
4.ゲーム終盤場面
ドラッグ元で同じ色が2つ以上隣り合わせになければゲーム終了
盤面の全てのタイルを消去した場合も演出アニメ表示後に終了表示
5.ゲーム時間
スタートからカウントダウン方式で10分 タイムUPの場合は強制的にゲーム終了
1分以内でアラーム音 赤に点滅
6.ゲーム中の案内表示
盤面内、上部中央に各操作後の内容メッセージを表示(警告や取得数字など)
ゲーム最上部はタイム表示 取得回数 数字内容 取得可能なタイル数 現在の消去タイル数など各数値をリアルタイムに表示
7.ボタン類
案内表示はクリックまたはタッチで消去
スタートボタンで開始、同時にカウントダウン開始
ヒントボタンは現在ドラッグ&ドロップできるタイルを全表示 ※表示は3回まで
中止ボタンは強制終了(初期化、起動画面に戻る)
動作環境
2026年時点での主要ブラウザで動作可能 画像の消去、再表示を繰り返すため処理速度やメモリ等のスペックが少ない端末は多少動作がぎこちなくなるかも知れません。
動作環境はウインドウズ10以上のPCが推奨です。
ファイル階層
hidden_pictures(任意の名称)
 | ※全てのファイル名変更可(その場合はコード変更)
 |------index.html
 |------game.js
 |------style.css
 |--img
   |
   |--background.jpg
   |--win.png
参考コードについて
下記のコードの使用は、全て修正・追加・削除・UIのデザイン等の変更が可能です。
コードのバグ、動作不具合等は一応確認済みですが、万が一不具合が発生した場合は開発者様で修正をお願いします。
※コードの大部分が横並びの書き方になっていて多少読みずらいかも知れませんが、行数節約のためです。
画像について
バックグランド画像に付いてはAIが作成した画像です
勝利演出のwin.png画像はMOMO企画が作成した画像です。なので、仮にwin.png画像をWEBで一般公開した場合は、MOMO企画のクレジット表記が必要です。
WEB公開について
このページの最下部にMOMO企画の実際のデモサイトをリンクしています。なので、個人使用以外で丸ごとコピペしてWEB上に一般公開する事は許可いたしません。仮にWEB公開する場合は、全面的にUIの変更をお願いします。※コードのコピペはOKです。
HTML
<!DOCTYPE html>
<html lang="ja">
<head>

<meta charset="UTF-8">
<meta name="viewport"content="width=device-width, initial-scale=1.0">

<title>HIDDEN ECHO</title>
<link rel="stylesheet" href="style.css">
</head>

<body>

<main class="wrap">

    <header class="hud">

        <div>
            <h1>HIDDEN ECHO</h1>
            <p>同じ色をつなぎ、隠された数字を呼び出せ。</p>
        </div>

    <div class="stats"> 

        <div> <span>TIME</span> <strong id="timer">10:00</strong> </div> 
        <div> <span>MOVES</span> <strong id="moveCount">0</strong></div> 
        <div> <span>HIT</span> <strong id="hitCount">0</strong> </div>
        <div><span>READY</span><strong id="readyCount">0</strong></div>
        <div> <span>CLEAR</span> <strong id="clearCount">0 / 300</strong> </div>
    </div>

    </header>

    <section class="game-area">

    <div id="message" class="message">STARTを押してください</div>

    <div id="gameCanvas" class="game-canvas"></div>

    <div id="gameGuide" class="game-guide">

        <div class="game-guide-title">Clear all Colors Within the Time limit.</div>

        <div class="game-guide-text">

            ・タッチ・ドラック(移動元)&ドロップ(移動先)操作で色を移動<br>
            ・移動元は、上下左右に同じ色が1つ以上あるタイル<br>
            ・移動先は同じ色の両隣(斜除く)や同色並びで交換/消去が可能<br>
            ・交換後は双方のグループ内に同じ数字が隣り合うと表示や消去<br>
            ・盤面上部中央のメッセージNEXT MOVEで次の操作が可能<br>
            <div class="inf">【消去や表示の条件】</div>
            ・数字のプラスが隣り合わせで消去、同じくマイナスで表示<br>
            ・数字の隣り合わせが複数の場合は+-の合計数で表示や消去<br>
            <div class="inf" >【タイル(色)消去や表示の例】</div>
            ・-10と+5で-5 タイルが5枚表示<br>
            ・+10と-5で+5 タイルが5枚消去 0は表示/消去なし<br>
            <div class="inf">【消去や表示の数字内容】</div>
            ・ +1 5 +2 10 +3 15 +4 20 +5 50(消去枚数)<br>
            ・ -1 1 -2 2 -3 3 -4 4 -5 5(表示枚数)
            
        </div>

        <div class="game-guide-start">PRESS START</div>

    </div>

    <div id="gameEnd" class="game-end">GAME OVER</div>
    <div id="gameClear" class="game-clear">WONDERFUL</div>

    </section>

    <div class="controls">
        <button id="startButton">START</button>
        <button id="hintButton">HINT</button>
        <button id="stopButton" disabled>STOP</button>
   </div>

   <div id="clearImageEffect" class="clear-image-effect">
    <img src="images/win.png" alt="">
   </div>

</main>
<script src="game.js"></script>

</body>
</html>
JS
const TILE = 42; /*タイルサイズ*/
const COLS = 20; /*横列*/
const ROWS = 15; /*行数*/
const TOTAL = COLS * ROWS;
const CANVAS_WIDTH = COLS * TILE;
const CANVAS_HEIGHT = ROWS * TILE;

const GAME_SECONDS = 60 * 10; /*カウントダウン*/
const clearCountEl = document.getElementById("clearCount");
const gameEndEl = document.getElementById("gameEnd");
const gameClearEl = document.getElementById("gameClear");
const hintButton = document.getElementById("hintButton");
const gameGuide = document.getElementById("gameGuide");
const stopButton = document.getElementById("stopButton");
const readyCountEl = document.getElementById("readyCount");

let currentDropTarget = null;

let dragStartX = 0;
let dragStartY = 0;
let processing = false;

hintButton.disabled = true;

/* ======COLOR SETTINGS5色 × 60個============== */
const COLORS = [

    { name: "red",className: "color-red",color: "#e94b4b"},

    { name: "blue",className: "color-blue",color: "#4b8de9"},

    { name: "green",className: "color-green",color: "#45c979"},

    { name: "yellow",className: "color-yellow",color: "#e5c94a"},

    { name: "purple",className: "color-purple",color: "#9b61db"}

];

/* ====DOM====================== */
const canvas = document.getElementById("gameCanvas");
const startButton = document.getElementById("startButton");
const timerEl = document.getElementById("timer");
const moveCountEl = document.getElementById("moveCount");
const hitCountEl = document.getElementById("hitCount");
const messageEl = document.getElementById("message");

/* ====GAME STATE=============== */
let tiles = [];
let running = false;
let finished = false;
let secondsLeft = GAME_SECONDS;
let timerId = null;
let moveCount = 0;
let hitCount = 0;

const MAX_HINTS = 3;

let hintCount = 0;

/* =======DRAG STATE============= */
let dragTile = null;

/* ======AUDIO=================== */
let audioCtx = null;

/* =====RANDOM=================== */
function shuffle(array) {

    const result = [...array];

    for (let i = result.length - 1; i > 0; i--) {

        const j = Math.floor(Math.random() * (i + 1));

        [result[i], result[j]] = [result[j], result[i]];
    }

    return result;
}

/* =====VALUE-5 ~ +5============ */
function randomValue() {

    return Math.floor(Math.random() * 11) - 5;
}

/* ==CREATE COLOR LIST5色 × 60個 = 300===== */
function createColorList() {

    const list = [];

    COLORS.forEach(color => {

        for (let i = 0; i < 100; i++) {

            list.push(color.name);
        }

    });

    return shuffle(list);
}

/* =====GET TILE=================== */
function getTile(row, col) {

    if (
        row < 0 ||
        row >= ROWS ||
        col < 0 ||
        col >= COLS
    ) {
        return null;
    }

    const index = row * COLS + col;

    return tiles[index];
}

/* =====GET POSITION================ */
function getPosition(tile) {

    return {

        row:
            Math.floor(tile.index / COLS),

        col:
            tile.index % COLS

    };
}

/* ====GET ORTHOGONAL NEIGHBORS====== */
function getNeighbors(tile) {

    const pos = getPosition(tile);

    const candidates = [

        getTile(pos.row - 1, pos.col),

        getTile(pos.row + 1, pos.col),

        getTile(pos.row, pos.col - 1),

        getTile(pos.row, pos.col + 1)

    ];

    return candidates.filter(Boolean);
}

/* ==CAN DRAG上下左右に同色が1個以上ある場合だけ可能== */
function canDrag(tile) {

    if (!tile) {
        return false;
    }

    // 消去済みはドラッグ不可
    if (tile.cleared) {
        return false;
    }

    /*上下左右に同じ色が1個以上あればドラッグ可能*/
    return getNeighbors(tile).some(
        neighbor => {

            return (
                !neighbor.cleared &&
                neighbor.color === tile.color
            );

        }
    );
}

/* =======USE HINT====================*/
function useHint() {

    if (
        !running ||
        finished
    ) {
        return;
    }

    if (
        hintCount >= MAX_HINTS
    ) {
        messageEl.textContent = "HINTは使い切りました";
        return;
    }
    /*現在ドラッグ可能なタイルを取得*/
    const candidates = tiles.filter(tile => canDrag(tile));

    if (
        candidates.length === 0
    ) {

        messageEl.textContent = "ドラッグ可能なタイルがありません";

        return;
    }
    hintCount++;

    /*HINTボタン表示更新*/
    hintButton.textContent = `HINT ${MAX_HINTS - hintCount}`;

    /*ドラッグ可能タイルを点滅*/
    candidates.forEach(
        tile => {

            tile.el.classList.add("hint");

        }
    );

    messageEl.textContent = `ドラッグ可能なタイルを表示 / 残りHINT ${MAX_HINTS - hintCount}`;
    /*1.5秒後に解除*/
    setTimeout(() => {

        candidates.forEach(
            tile => {

             tile.el.classList.remove("hint");
            }
        );

    }, 3000);

    /* HINTを使い切った*/
    if (
        hintCount >= MAX_HINTS
    ) {

        hintButton.disabled = true;
    }
}

/*現在ドラッグ可能なタイルを取得*/
function updateReadyCount() {

    const candidates = tiles.filter(tile => canDrag(tile));

    readyCountEl.textContent = candidates.length;

}

function isSingleTile(tile) {

    if (!tile || tile.cleared) {
        return true;
    }

    return !getNeighbors(tile).some(neighbor => {

        return (
            !neighbor.cleared &&
            neighbor.color === tile.color
        );

    });
}

/* ========CAN DROP================ */
function canDrop(source, target) {

    if (!source || !target) {
        return false;
    }

    // 自分自身
    if (source === target) {
        return false;
    }

    // 背景が見える消去済み場所
    if (source.cleared || target.cleared) {
        return false;
    }

    /* ドロップ先が同色ならOK */
    if (source.color === target.color) {
        return true;
    }

    /*ドロップ先の上下左右にドラッグ元と同色があればOK= 同色グループの端に接続できる*/
    return getNeighbors(target).some(neighbor => {

        return (
            !neighbor.cleared &&
            neighbor.color === source.color
        );

    });
}

/* ===========UPDATE DRAG STATUS============== */
function updateDragStatus() {

    tiles.forEach(tile => {

        tile.el.classList.remove("can-drag");

        if (
            running &&
            !finished &&
            !processing &&
            canDrag(tile)
        ) {
            tile.el.classList.add("can-drag");
        }

    });

    updateReadyCount();
}

/* =======CREATE BOARD=================== */
function createBoard() {

    canvas.innerHTML = "";

    tiles = [];

    const colorList = createColorList();

    for (
        let i = 0;
        i < TOTAL;
        i++
    ) {

    const el = document.createElement("div");
    el.className = "tile";
    const colorPanel = document.createElement("div");
    colorPanel.className = "tile-color";
    const number = document.createElement("div");
    number.className =  "number";
    el.append(colorPanel,number);
    canvas.appendChild(el);

    const tile = {
    el,
    colorPanel,
    number,
    index: i,
    color: colorList[i],
    value: randomValue(),
    cleared: false
};

    tiles.push(tile);

/* Pointer Down */
el.addEventListener(

    "pointerdown",

    event => {

        event.preventDefault();

        startDrag(
            event,
            tile
        );

    },

    {
        passive: false
    }

);

    }

    updateAllTileColors();
    updateDragStatus();
}

/* ===CONTEXT MENU DISABLE===========*/
canvas.addEventListener(
    "contextmenu",
    event => {
        event.preventDefault();
        event.stopPropagation();
    }
);

function activeTiles() {
    return tiles.filter(tile => !tile.cleared);
}

/* ======UPDATE TILE COLOR================*/
function updateTileColor(tile) {

    const colorData =
        COLORS.find(
            color =>
                color.name === tile.color
        );

    if (!colorData) {
        return;
    }
    /*CSS変数に色を設定 実際の背景表現はCSS側の .tile-color が担当*/
    tile.colorPanel.style.setProperty("--tile-color",colorData.color);
}

/* =======UPDATE ALL COLORS============= */
function updateAllTileColors() {

    tiles.forEach(
        updateTileColor
    );
}

/* =========START DRAG================== */
function startDrag(event, tile) {

    event.preventDefault();
    event.stopPropagation();

    if (!running || finished || processing) {return;}

    if (!canDrag(tile)) {
        messageEl.textContent = "このマスは単独のため移動できません";
        return;
    }

    dragTile = tile;

     /*ドラッグ開始時のポインター座標を記録*/
    dragStartX = event.clientX;
    dragStartY = event.clientY;

    tile.el.classList.add("dragging");

    /*このポインター操作をドラッグ元のタイルが最後まで受け取る*/
    try {

        tile.el.setPointerCapture(
            event.pointerId
        );

    } catch (error) {

        console.warn(
            "Pointer Capture failed",
            error
        );

    }

    messageEl.textContent = "移動先のマスへドラッグしてください";

    /*documentではなくドラッグ元タイル自身で追跡*/
    tile.el.addEventListener("pointermove",handleDragMove,{passive: false});
    tile.el.addEventListener("pointerup",handleDragEnd,{once: true,passive: false});
    tile.el.addEventListener("pointercancel",handleDragCancel,{once: true});
}

/* ======HANDLE DRAG MOVE================= */
function handleDragMove(event) {

    if (!dragTile) {return;}

    event.preventDefault();
    event.stopPropagation();

    /*ドラッグ開始位置から現在位置までの移動量*/
    const moveX = event.clientX - dragStartX;
    const moveY = event.clientY - dragStartY;

    /*ドラッグしているタイル自身をポインターの移動に追従させる*/
    dragTile.el.style.transform = `translate(${moveX}px, ${moveY}px)`;

    /*従来のドロップ先判定はそのまま維持*/
    updateDropTarget(event);
}

function handleDragEnd(event) {

    event.preventDefault();
    event.stopPropagation();


    if (!dragTile) {
        return;
    }

    const source = dragTile;

    /* 指・マウスを離した座標からドロップ先を取得 */
    const target = getTileFromPoint(
        event.clientX,
        event.clientY
    );

    /*Pointer Capture解除*/
    try {

        if (
            source.el.hasPointerCapture(event.pointerId)
        ) {
            source.el.releasePointerCapture(event.pointerId);
        }

    } catch (error) {
        console.warn("Pointer release failed",error);
    }
    source.el.classList.remove("dragging"
);

/*追従表示を元に戻す*/
source.el.style.transform = "";

/*pointermoveを解除*/
source.el.removeEventListener("pointermove",handleDragMove);

clearDropTargets();

dragTile = null;

    /* タイルがない場所 */
    if (!target) {
        messageEl.textContent = "移動先がないため元に戻りました";
        return;
    }
    /* 自分自身 */
    if (source === target) {
        messageEl.textContent = "同じ場所のため移動しません";
        return;
    }
    /* ドロップ条件 */
    if (!canDrop(source, target)) {

        if (target.cleared) {
            messageEl.textContent = "背景が見える場所には置けません";
        }
        else if (source.color !== target.color) {
            messageEl.textContent = "同じ色のグループにつながる場所へ置いてください";
        }
        else {
            messageEl.textContent = "そこには移動できません";}
        return;
    }
    /* 条件OK */
    swapTiles(source,target);
}

function handleDragCancel(event) {

    if (!dragTile) {return;}

    const source = dragTile;

    console.log(
        "pointercancel",
        event.pointerType
    );

    source.el.classList.remove("dragging");
    /*追従位置を元に戻す*/
    source.el.style.transform = "";

    /*pointermove監視を解除*/
    source.el.removeEventListener("pointermove",handleDragMove);

    clearDropTargets();
    dragTile = null;
    /*タブレットではpointercancelが発生する場合がある*/
    messageEl.textContent ="操作が中断されました";

}

function getTileFromPoint(clientX, clientY) {

    const rect = canvas.getBoundingClientRect();

    // キャンバス外
    if (
        clientX < rect.left ||
        clientX > rect.right ||
        clientY < rect.top ||
        clientY > rect.bottom
    ) {
        return null;
    }

    // CSS transform: scale() の補正
    const scaleX = rect.width / CANVAS_WIDTH;
    const scaleY = rect.height / CANVAS_HEIGHT;
    const x = (clientX - rect.left) / scaleX;
    const y = (clientY - rect.top) / scaleY;
    const col = Math.floor(x / TILE);
    const row = Math.floor(y / TILE);

    if (
        col < 0 ||
        col >= COLS ||
        row < 0 ||
        row >= ROWS
    ) {
        return null;
    }

    const index = row * COLS + col;

    return tiles[index] || null;
}

/* =====UPDATE DROP TARGET================= */
function updateDropTarget(event) {

    if (!dragTile) {
        return;
    }

    /*ポインター位置からタイルを取得*/
    const target = getTileFromPoint(event.clientX,event.clientY);

    /*前回と同じタイルなら何もしないこれが非常に重要*/
    if (target === currentDropTarget) {return;}

    /*前の表示だけ解除*/
    if (currentDropTarget) {
        currentDropTarget.el.classList.remove("drop-target","invalid-target");
    }

    currentDropTarget = target;

    /*タイルがない場所*/
    if (!target) {return;}

    /*自分自身*/
    if (target === dragTile) {return;}

    /*ドロップ可能判定*/
    if (canDrop(dragTile, target)) {

        target.el.classList.add("drop-target");

    } else {
        target.el.classList.add("invalid-target");
    }
}

/* ======CLEAR DROP TARGET================ */
function clearDropTargets() {

    if (!currentDropTarget) {return;}
    currentDropTarget.el.classList.remove("drop-target","invalid-target");
    currentDropTarget = null;
}

/* ================SWAP TILE============ */
function swapTiles(a, b) {

    // Aの情報を保存
    const tempColor = a.color;
    const tempValue = a.value;

    // B → A
    a.color = b.color;
    a.value = b.value;

    // 保存していたA → B
    b.color = tempColor;
    b.value = tempValue;

    // 色を再描画
    updateTileColor(a);
    updateTileColor(b);

    // 移動回数
    moveCount++;

    moveCountEl.textContent = moveCount;

    // アニメーション
    a.el.classList.add("swap-source");
    b.el.classList.add("swap-target");

    setTimeout(() => {

        a.el.classList.remove("swap-source");
        b.el.classList.remove("swap-target");

    }, 400);
    messageEl.textContent = "判定中…";

    // 交換後に両方の位置を判定
    setTimeout(() => { checkAfterSwap(a, b);}, 250);
}

/* ======GET SAME COLOR GROUP=========== */
function getSameColorGroup(startTile) {

    if (startTile.cleared) {
        return [];
    }

    const color = startTile.color;
    const group = [];
    const visited = new Set();
    const stack = [startTile];

    while (stack.length > 0) {

        const current = stack.pop();

        if (visited.has(current.index)) {continue;}

        visited.add(current.index);

        if (current.cleared) {continue;}

        if (current.color !== color) {continue;}

        group.push(current);

        getNeighbors(current).forEach(neighbor => {

            if (
                !visited.has(neighbor.index) &&
                !neighbor.cleared &&
                neighbor.color === color
            ) {
                stack.push(neighbor);
            }

        });

    }

    return group;
}

/* =========タイル消去================= */
function clearTile(tile) {

    if (tile.cleared) {return;}

    /*タイルが消える瞬間に星を飛び散らせる*/
    createStarBurst(tile);

    // 先にゲーム上の状態を確定
    tile.cleared = true;
    tile.el.classList.remove("restoring");
    tile.el.classList.add("clearing");

    updateClearCount();

    setTimeout(() => {

    tile.el.classList.remove("clearing");
    tile.el.classList.add("cleared");

    }, 450);

}

/* ==========消去したタイルを復活する===============*/
function restoreTile(tile,updateCount = true) {

    if (!tile.cleared) {
        return;
    }
    /*cleared を外した瞬間に 一瞬通常表示になるのを防ぐ*/
    tile.el.classList.add("restoring");
    tile.el.classList.remove("cleared","clearing");

    // ゲーム上の状態を復活
    tile.cleared = false;

    /*通常の復活時だけクリア数を更新*/
    if (updateCount) {
        updateClearCount();
    }
    setTimeout(() => {
        tile.el.classList.remove(
            "restoring"
        );

    }, 500);
}

/* ======プラス効果:ランダムに消去============== */
function applyPositiveEffect(
    amount,
    preferredTiles = []
    ) {

    const targets = [];

    // 条件成立した場所を優先
    const preferred = preferredTiles.filter(tile => !tile.cleared);

    shuffle(preferred).forEach(tile => {

        if (targets.length >= amount) {
            return;
        }

        if (!targets.includes(tile)) {

            targets.push(tile);

        }

    });

    // 足りない分だけ他の場所
    const others = activeTiles().filter(tile => !targets.includes(tile));

    shuffle(others).forEach(tile => {

        if (targets.length >= amount) {
            return;
        }

        targets.push(tile);
    });
    // 消去
    targets.forEach(tile => {clearTile(tile);});

    return targets.length;
}

/* ====マイナス効果:ランダムに復活=============== */
function applyNegativeEffect(
    amount,
    preferredTiles = []
    ) {

    const targets = [];

    // 成立場所の中に消去済みがあれば優先
    const preferred = preferredTiles.filter(
        tile => tile.cleared
    );

    shuffle(preferred).forEach(tile => {

        if (targets.length >= amount) {
            return;
        }

        if (!targets.includes(tile)) {

            targets.push(tile);
        }

    });

    // 足りない分を他の消去済み場所から
    const others = clearedTiles().filter(tile => !targets.includes(tile));

    shuffle(others).forEach(tile => {

        if (targets.length >= amount) {
            return;
        }

        targets.push(tile);

    });

    // 復活
    targets.forEach(tile => {

        restoreTile(tile);

    });

    return targets.length;
}

/* ======残っているマスの数値をシャッフル========== */
function shuffleRemainingValues() {

    const active = activeTiles();

    // 現在残っている数値だけ取得
    const values = active.map(tile => tile.value);

    // Fisher-Yatesシャッフル
    for (let i = values.length - 1; i > 0; i--) {

        const j = Math.floor(Math.random() * (i + 1));

        [values[i], values[j]] = [values[j], values[i]];
    }
    // シャッフルした数値を再配置
    active.forEach((tile, index) => {

        tile.value = values[index];

    });
}

/* ====CHECK SAME VALUE PAIRS========== */
function getValuePairs(group) {

    const groupIndexes = new Set(group.map(tile => tile.index));
    const pairs = [];
    const checked = new Set();

    for (const tile of group) {

        const neighbors = getNeighbors(tile);

        for (const neighbor of neighbors) {

            // 同じ色グループ外は無視
            if (!groupIndexes.has(neighbor.index)) {continue;}
            // 同じ数値でなければ無視
            if (tile.value !== neighbor.value) {continue;}
            /*同じペアを二重に数えないA-BB-Aを1回だけにする*/
            const key = [
                Math.min(tile.index, neighbor.index),
                Math.max(tile.index, neighbor.index)
            ].join("-");

            if (checked.has(key)) {continue;}
            checked.add(key);
            pairs.push({value: tile.value,tiles: [tile, neighbor]});
        }
    }

    return pairs;
}

/* ====ペアの効果値を合計================= */
function getEffectValue(pairs) {

    return pairs.reduce((total, pair) => {

        return total + pair.value;

    }, 0);
}

/* ====数値表示するタイルをまとめる========== */
function getPairTiles(pairs) {

    const map = new Map();

    pairs.forEach(pair => {

        pair.tiles.forEach(tile => {

            map.set(tile.index, tile);

        });

    });

    return [...map.values()];
}

/* ===POSITIVE CLEAR AMOUNT=========== */
function getPositiveClearAmount(effect) {

    const clearTable = {1: 5, 2: 10, 3: 15, 4: 20, 5: 50};

    /*+5を超える効果はひとまず +5と同じ15マス*/
    const value = Math.min(5,Math.max(1,effect));

    return clearTable[value];

}

/* =====HITカウント======= */
function updateHitCount(effect) {

    if (!hitCountEl) {return;}

    if (effect > 0) {hitCountEl.textContent = `+${effect}`;}

    else if (effect < 0) {hitCountEl.textContent =`${effect}`;}

    else {hitCountEl.textContent ="±0";}
}

/* =====CHECK AFTER SWAP ドラッグ元とドロップ先を両方判定======= */
function checkAfterSwap(source, target) {

    if (processing) {return;}

    processing = true;

    const groups = [getSameColorGroup(source),getSameColorGroup(target)];

    /*同色グループの数値を表示*/
    const checkTiles = [...new Set(groups.flat())];

    checkTiles.forEach(tile => {

        showNumber(tile);

    });

    const checkedGroups = new Set();
    const allPairs = [];

    for (const group of groups) {

        if (group.length < 2) {
            continue;
        }

        const groupKey =
            group
                .map(tile => tile.index)
                .sort((a, b) => a - b)
                .join(",");

        if (checkedGroups.has(groupKey)) {continue;}

        checkedGroups.add(groupKey);

        const pairs = getValuePairs(group);

        allPairs.push(...pairs);

    }

/* =====条件不成立============== */
if (allPairs.length === 0) {

    updateHitCount(0);

    messageEl.textContent = "条件なし";

    checkTiles.forEach(tile => {

        showNumber(tile);

    });

    /*1.5秒間、「なぜ条件なしなのか」を確認*/
    setTimeout(() => {

        // 残っているマスの数値をシャッフル
        shuffleRemainingValues();

        /*次の操作まで数値を隠す*/
        checkTiles.forEach(tile => {

            hideNumber(tile);
        });

        /*シャッフル後の盤面で有効な移動がなければ終了*/
        if (
            running && !finished
        ) {
            checkNoMoves();
        }

        /*GAME OVERでなければドラッグ状態を更新*/
        if (
            running &&
            !finished
        ) {
            processing = false;
            updateDragStatus();
            messageEl.textContent = "NEXT MOVE";
        }

    }, 1500);

    return;

}
    /* ====条件成立したタイルを取得============= */
    const pairTiles = getPairTiles(allPairs);

    // 数値を一瞬表示
    pairTiles.forEach(tile => {

        showNumber(tile);
        tile.el.classList.add("hit");

    });
    
    /*効果値を計算-1ペア → -1+5ペア → +5合計 +4*/
    const effect = getEffectValue(allPairs);

    /*ドロップ成立直後に増減値をHIT表示*/
    updateHitCount(effect);

    /* ====数値を見せた後、効果発動========== */
    setTimeout(() => {

        pairTiles.forEach(tile => {tile.el.classList.remove("hit");});

        let changed = 0;

        /* ======プラス → 消去=============== */
        if (effect > 0) {

            const clearAmount = getPositiveClearAmount(effect);

            changed = applyPositiveEffect(clearAmount,pairTiles);

            messageEl.textContent = `+${effect} / ${changed}マス消去`;

        }

        /* ===マイナス → 復活=========== */
        else if (effect < 0) {

            changed = applyNegativeEffect(Math.abs(effect),pairTiles);

            messageEl.textContent = `${effect} / ${changed}マス復活`;
        }

        /* ==0 → 相殺======= */
        else {messageEl.textContent ="±0 / 相殺された";}

        /* ===エフェクト完了後===== */
        setTimeout(() => {

            // 残っているマスだけ数値シャッフル
            shuffleRemainingValues();

            // 消去数表示を更新
            updateClearCount();

            /* ① 先にCLEAR判定全消去なら、ここでゲーム終了*/
            checkClear();

            /*② CLEARしていない場合だけ詰み判定*/
            if (
                running && !finished
            ) {

                checkNoMoves();

            }
            /*③ まだゲームが続いている場合だけドラッグ可能状態を更新*/
            if (
                running &&
                !finished
            ) {
                processing = false;
                updateDragStatus();
                messageEl.textContent = "NEXT MOVE";
            }

        }, 500);

    }, 1500);

}

/* ===NO MOVES GAME OVER有効なドラッグ&ドロップが1つもなくなった場合======= */
function noMovesGameOver() {

    // 二重実行防止
    if (finished) {return;}

    running = false;
    finished = true;
    const m = Math.floor(secondsLeft / 60);
    const s = secondsLeft % 60;
    const timeText = `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")} END`;

    timerEl.textContent = timeText;
    // タイマー停止
    clearInterval(timerId);
    
    /*ボタン状態START → 有効HINT  → 無効STOP  → 無効*/
    startButton.disabled = false;
    hintButton.disabled = true;
    stopButton.disabled = true;

    /*ドラッグ無効化*/
    updateDragStatus();

    messageEl.textContent = "置ける場所がなくなりました";

    /*3秒間、プレイヤーが盤面を確認その後 GAME OVER*/
    setTimeout(() => {

        // 消去済みタイルをすべて表示
        showAllTiles();

        // GAME OVER表示
        gameEndEl.classList.add("show");}, 5000);
}

/* ==SHOW ALL TILES================= */
function showAllTiles() {

    tiles.forEach(tile => {

        if (tile.cleared) {

            /*GAME OVER時の全表示なのでクリア数は更新しない*/
            restoreTile(
                tile,
                false
            );

        }

    });

}

/* ==CHECK NO MOVESドラッグ可能なタイルから実際にドロップ可能な場所が1つもなければ終了==== */
function checkNoMoves() {

    if (!running || finished) {
        return false;
    }

    const active = activeTiles();

    for (const source of active) {

        // ドラッグ元として有効か
        if (!canDrag(source)) {
            continue;
        }

        for (const target of active) {

            // 実際に置ける場所が1つでもあれば続行
            if (canDrop(source, target)) {
                return false;
            }

        }

    }

    noMovesGameOver();
    return true;
}

/* ============SHOW NUMBER================ */
function showNumber(tile) {

    const v = tile.value;

    if (v > 0) {

        tile.number.textContent = `+${v}`;
        tile.number.className = "number plus show";

    } else if (v < 0) {

        tile.number.textContent = `${v}`;
        tile.number.className = "number minus show";

    } else {

        tile.number.textContent = "0";
        tile.number.className = "number zero show";

    }

    setTimeout(() => {tile.number.classList.remove("show");}, 1500);

}

function hideNumber(tile) {

    if (!tile || !tile.number) {return;}

    tile.number.classList.remove("show");
}

/* ========TIMER======================= */
function updateTimer() {

    const m = Math.floor(secondsLeft / 60);
    const s = secondsLeft % 60;


    const timeText = `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;

    if (running) {
        timerEl.textContent = `${timeText} LEFT`;
    } else {
        timerEl.textContent = timeText;
    }
    if (
        running &&
        secondsLeft <= 60 &&
        secondsLeft > 0
    ) {
        timerEl.classList.add( "danger");
        timerEl.classList.add("blink");

        setTimeout(() => {

            timerEl.classList.remove(
                "blink"
            );

        }, 150);

    } else {
        timerEl.classList.remove("danger","blink");
    }

}
/* ========TIME UP====================== */
function timeUp() {

    if (finished) {
        return;
    }

    /*初期化される前に 現在の取得数を保存*/
    const finalClearCount =
        tiles.filter(
            tile => tile.cleared
        ).length;

    // ゲーム終了
    finished = true;
    running = false;

    clearInterval(timerId);

    timerId = null;

    // タイムアップ音
    playTone(120, 0.7);

    /*初期盤面を再生成この時点で盤面上の cleared はすべて false になる*/
    resetGame();

    finished = true;
    running = false;

    /*GAME OVERに保存した取得数を表示*/
    gameEndEl.textContent = `GAME OVER\n${finalClearCount} / ${TOTAL} GET`;
    gameEndEl.classList.add("show");
    messageEl.textContent = "TIME UP";
    
    // STARTボタンを再度使用可能にする
    startButton.disabled = false;
}

/* =========START GAME================ */
function startGame() {

    initAudio();

    /* 盤面・カウンターを初期化*/
    resetGame();

    /*GAME GUIDEをフェードアウト*/
    if (gameGuide) {

        gameGuide.classList.add("hide");
        setTimeout(() => {
            gameGuide.classList.add("hidden");
        }, 500);
    }

    /*GAME OVER / CLEAR を消す*/
    gameEndEl.classList.remove("show");
    gameClearEl.classList.remove("show");
    /*HINTを有効化*/
    hintButton.disabled =false;
    running = true;
    finished = false;
    startButton.disabled = true;
    stopButton.disabled = false;
    messageEl.textContent = "START — 同じ色を探せ";
    updateDragStatus();

    /*TIMER START*/
    timerId =
        setInterval(() => {

            secondsLeft--;
            updateTimer();

            /*残り1分*/
            if (
                secondsLeft === 60
            ) {

                messageEl.textContent = "残り1分";
                playAlarm();
            }
            /*IME UP*/
            if (
                secondsLeft <= 0
            ) {

                timeUp();
            }

        }, 1000);

}

/* ============CLEAR判定=================== */
function checkClear() {

    if (clearedTiles().length !== TOTAL) {return;}

    finished = true;
    running = false;
    const m = Math.floor(secondsLeft / 60);
    const s = secondsLeft % 60;
    const timeText = `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")} END`;

    timerEl.textContent = timeText;
    startButton.disabled = false;
    hintButton.disabled = true;
    stopButton.disabled = true;
    clearInterval(timerId);
    timerId = null;
    messageEl.textContent ="CLEAR — HIDDEN ECHO";

    gameEndEl.classList.remove("show");
    /*WONDERFULは最初は消す*/
    gameClearEl.classList.remove("show");
    /*win画像を表示*/
    clearImageEffect.classList.add("show");

    playTone(880,0.4);

    /*win画像の演出完了後、5秒間そのまま表示2.5秒アニメーション+ 5秒停止= 7.5秒*/
    setTimeout(() => {

        /*win画像を消す*/
        clearImageEffect.classList.remove("show");
        /* WONDERFULだけ表示*/
        gameClearEl.classList.add("show");
    }, 7500);

}

/* ==========RESET GAME=================== */
function resetGame() {

    clearInterval(timerId);

    timerId = null;
    secondsLeft = GAME_SECONDS;
    moveCount = 0;
    hitCount = 0;
    hintCount = 0;
    running = false;
    finished = false;
    processing = false;
    dragTile = null;
    clearImageEffect.classList.remove("show");

    gameClearEl.classList.remove("show");
    moveCountEl.textContent = "0";
    hitCountEl.textContent = "0";
    hintButton.disabled = false;
    hintButton.textContent = `HINT ${MAX_HINTS}`;
    startButton.disabled = false;
    stopButton.disabled = true;
    
    updateTimer();
    createBoard();
    updateClearCount();
}

/* === STOP GAMEゲーム中止 → 初期状態へ戻す======== */
function stopGame() {

    /*ゲーム中以外は何もしない*/
    if (!running) {return;}
    /*タイマー停止*/
    clearInterval(timerId);

    timerId = null;

    /*ゲーム状態停止*/
    running = false;
    finished = false;

    /*ドラッグ状態解除*/
    if (dragTile) {

        dragTile.el.classList.remove("dragging");
    }

    dragTile = null;

    clearDropTargets();

    /*GAME OVER / CLEAR を消す*/
    gameEndEl.classList.remove("show");
    gameClearEl.classList.remove("show");

    /*初期状態に戻す*/
    resetGame();

    /*ボタン状態*/
    startButton.disabled = false;
    stopButton.disabled = true;
    hintButton.disabled = true;

    /*案内を再表示*/
    if (gameGuide) {

        gameGuide.classList.remove("hide","hidden");
    }

    messageEl.textContent = "ゲームを中止しました";

}

/* ===========AUDIO===================== */
function initAudio() {

    if (!audioCtx) {

        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    }

    if (audioCtx.state === "suspended"
    ) {audioCtx.resume();
    }
}

/* =========PLAY TONE===================== */
function playTone(

    freq,
    duration
    ) {

    initAudio();

    const oscillator = audioCtx.createOscillator();
    const gain = audioCtx.createGain();

    oscillator.frequency.value = freq;

    gain.gain.setValueAtTime(0.0001,audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.2,audioCtx.currentTime + 0.02);
    gain.gain.exponentialRampToValueAtTime(0.0001,audioCtx.currentTime + duration);

    oscillator.connect(gain);
    gain.connect(audioCtx.destination);
    oscillator.start();
    oscillator.stop(audioCtx.currentTime +duration);

}

/* ============ALARM================== */
function playAlarm() {

    [0, 350, 700]
        .forEach(
            (delay, index) => {

                setTimeout(() => {

                    playTone(
                        index % 2
                            ? 660
                            : 880,
                        0.28
                    );

                }, delay);

            }
        );
}

/* =========ESIZE========== */
function resize() {

    const maxWidth = Math.min(innerWidth - 24,CANVAS_WIDTH);
    const scale = maxWidth / CANVAS_WIDTH;
    canvas.style.transform = `scale(${scale})`;
    document.querySelector(".game-area").style.height = `${CANVAS_HEIGHT * scale}px`;

}

/* =========消去の演出========== */
function createStarBurst(tile) {

    const count = 3;

    for (let i = 0; i < count; i++) {

        const star = document.createElement("div");

        star.className = "star-particle";

        const stars = ["✦","✧","★"];

        star.textContent = stars[Math.floor(Math.random() * stars.length)];

        const angle = Math.random() * Math.PI * 2;
        const distance = 15+ Math.random() * 30;
        const x = Math.cos(angle) * distance;
        const y = Math.sin(angle) * distance;

        star.style.setProperty("--x",`${x}px`);

        star.style.setProperty("--y",`${y}px`);

        star.style.setProperty("--rotate",`${Math.random() * 360}deg`);

        star.style.setProperty("--duration",`${400 + Math.random() * 300}ms`);

        tile.el.appendChild(star);

        setTimeout(() => {star.remove();}, 800);
    }
}

/* ===========EVENT=== */
startButton.addEventListener("click",startGame);

window.addEventListener("resize",resize);

/* ====CONTEXT MENU BLOCKタブレットの長押しメニュー対策======== */
document.addEventListener(

    "contextmenu",

    event => {

        if (
            event.target.closest("#gameCanvas")
        ) {
            event.preventDefault();
            event.stopImmediatePropagation();
        }
    },

    true
);

/* ====TEXT SELECTION BLOCK=========== */
document.addEventListener(

    "selectstart",

    event => {

        if (
            event.target.closest("#gameCanvas")
        ) {
            event.preventDefault();
        }
    },
    true
);

/* =====GAME GUIDE CLOSE PCクリック / スマホ・タブレットタッチ======= */
document.addEventListener(

    "pointerdown",

    event => {

        const guide = event.target.closest("#gameGuide");

        if (!guide) {
            return;
        }

        event.preventDefault();
        guide.classList.add("hidden");

    },

    true
);

/* ========INITIALIZE======= */
resetGame();
resize();

function clearedTiles() {
return tiles.filter(tile => tile.cleared);}

function updateClearCount() {

const cleared = tiles.filter(tile => tile.cleared).length;
    clearCountEl.textContent = `${cleared} / ${TOTAL}`;
}
stopButton.addEventListener(
    "click",
    stopGame
);
hintButton.addEventListener("click",useHint);
CSS
* {box-sizing: border-box;}
html,body {margin: 0; min-height: 100%;background: #090b10;color: #f4f4f4; font-family:"Segoe UI","Yu Gothic",sans-serif;}
body {display: flex; justify-content: center; padding: 24px 12px;}
.wrap {width: min(840px, 100%);}
.hud {display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-bottom: 14px;}
h1 {margin: 0;font-size: 24px;letter-spacing: 0.18em;}
.hud p {margin: 5px 0 0;color: #62ff32;font-size: 13px;}
.stats {display: flex;gap: 18px; text-align: center;}
.stats div {min-width: 72px;}
.stats span {display: block;color: #65f5ff; font-size: 10px; letter-spacing: 0.14em;}
.stats strong {display: block; margin-top: 3px; font-size: 20px;}
.game-area {position: relative; width: 840px; height: 630px; max-width: 100%; margin: 0 auto;}
.game-canvas {position: absolute; inset: 0; width: 840px; height: 630px; display: grid; grid-template-columns: repeat(20, 42px); grid-template-rows: repeat(15, 42px);
overflow: hidden; border: 2px solid #242a34; background-image:url("images/background.jpg"); background-repeat: no-repeat; background-size: 840px 840px;
touch-action: none; transform-origin: top left;}
.tile {position: relative; width: 42px; height: 42px;cursor: default; touch-action: none;}
.tile.can-drag {cursor: grab;}
.tile.can-drag:active {cursor: grabbing;}
.tile-color {position: absolute;inset: 2px; border-radius: 7px; background:linear-gradient(145deg,rgba(255, 255, 255, 0.38) 0%,
rgba(255, 255, 255, 0.12) 25%,rgba(255, 255, 255, 0.02) 45%,rgba(0, 0, 0, 0.28) 100%),var(--tile-color);border:1px solid rgba(255, 255, 255, 0.30);
box-shadow:inset 1px 1px 2px rgba(255, 255, 255, 0.55),inset -2px -2px 3px rgba(0, 0, 0, 0.28),2px 3px 5px rgba(0, 0, 0, 0.40); opacity: 1;
visibility: visible; transition:transform 0.18s ease,filter 0.18s ease,box-shadow 0.18s ease,opacity 0.18s ease;}
.tile.can-drag:not(.cleared):not(.clearing):not(.restoring):hover
.tile-color {transform:translateY(-2px)scale(1.04);
filter:brightness(1.12); box-shadow:inset 1px 1px 2px rgba(255, 255, 255, 0.60),inset -2px -2px 3px rgba(0, 0, 0, 0.25),3px 6px 10px rgba(0, 0, 0, 0.45);}
.tile.dragging {z-index: 1000;}
.tile.dragging .tile-color {transform:translate(var(--drag-x, 0px),var(--drag-y, 0px)) scale(1.15) rotate(2deg);
filter:brightness(1.18) saturate(1.15); box-shadow: inset 1px 1px 3px rgba(255, 255, 255, 0.70),inset -3px -3px 5px rgba(0, 0, 0, 0.30),0 10px 18px rgba(0, 0, 0, 0.55);transition: none;}
.tile.drop-target .tile-color {transform:scale(1.08); filter:brightness(1.30); box-shadow:0 0 0 2px rgba(255, 255, 255, 0.85),0 0 14px
rgba(255, 255, 255, 0.70),inset 1px 1px 3px rgba(255, 255, 255, 0.70),inset -2px -2px 4px rgba(0, 0, 0, 0.25);}
.tile.invalid-target .tile-color {filter:grayscale(0.5)brightness(0.8);opacity: 0.75;}
.number {position: absolute; z-index: 10;inset: 0; display: grid;place-items: center; font-size: 15px; font-weight: 900; text-shadow:0 2px 5px #000,
0 0 2px #000; opacity: 0; pointer-events: none;transition: opacity 0.15s ease;}
.number.show {opacity: 1;}
.number.plus {color: #00ff66;}
.number.minus {color: #fdb2fd;}
.number.zero {color: #ffffff;}
.tile.hit .tile-color {animation: hitPulse 0.65s ease;}
@keyframes hitPulse {0% {transform: scale(1);filter: brightness(1);}40% {transform: scale(1.16);filter: brightness(1.7);}100% {transform: scale(1);filter: brightness(1);}}
.tile.swap-source .tile-color {animation: sourceMove 0.35s ease;}
.tile.swap-target .tile-color {animation: targetMove 0.35s ease;}
@keyframes sourceMove {0% {transform: scale(1);}50% {transform: scale(0.80);}100% {transform: scale(1);}}
@keyframes targetMove {0% {transform: scale(1);}50% {transform: scale(1.12);}100% {transform: scale(1);}}
.tile.clearing {pointer-events: none;}
.tile.clearing .tile-color {animation:tileDisappear 0.45s cubic-bezier(.4, 0, .2, 1)forwards;}
.tile.cleared {pointer-events: none;}
.tile.cleared .tile-color {opacity: 0;visibility: hidden;transform:scale(0.55)rotate(-8deg);filter:brightness(1);animation: none;box-shadow: none;}
.tile.cleared .number {opacity: 0;visibility: hidden;}
@keyframes tileDisappear {0% {opacity: 1; transform:scale(1)rotate(0deg); filter:brightness(1);}40% {opacity: 1; transform:scale(1.12)rotate(0deg); filter:brightness(1.7);}
100% {opacity: 0; transform:scale(0.55)rotate(-8deg);filter:brightness(2);}}
.tile.restoring {pointer-events: none;}
.tile.restoring .tile-color {visibility: visible;opacity: 1; animation: tileRestore 0.5s cubic-bezier(.2, .8, .3, 1.15)forwards;}
.tile.restoring .number {visibility: visible;}
@keyframes tileRestore {0% {opacity: 0; transform:scale(0.35)rotate(8deg); filter:brightness(2);}60% {opacity: 1; transform:scale(1.13)rotate(-2deg);
filter:brightness(1.35);}100% {opacity: 1; transform:scale(1)rotate(0deg); filter:brightness(1);}}
.message {position: absolute; z-index: 50;top: 10px; left: 50%; transform: translateX(-50%); padding: 7px 14px; border-radius: 999px; background: rgba(49, 49, 65, 0.493);
border:1px solid#2c3440; color: #ecebeb; font-size: 14px; font-weight: bold; text-shadow: 1px 1px 1px #141414;pointer-events: none; white-space: nowrap;}
.controls {display: flex;justify-content: center;gap: 10px; margin-top: 16px;}
button {border: 1px solid #343d49;background: #171b22;color: #f4f4f4; padding: 10px 24px; border-radius: 8px; font-weight: 700; letter-spacing: 0.08em; cursor: pointer;}
button:hover:not(:disabled) {background: #222936;}
button:disabled {opacity: 0.35;cursor: not-allowed;}
@media (max-width: 820px) {.hud {align-items: start; flex-direction: column;}
.stats {width: 100%; justify-content: space-between; text-align: left;}}
.game-end {position: absolute; z-index: 1000;top: 50%;left: 50%;transform:translate(-50%, -50%)scale(0.8); opacity: 0;pointer-events: none; padding: 18px 12px;
border-radius: 12px; background:rgba(0, 0, 0, 0.82); border:2px solid rgba(255, 255, 255, 0.75);color: #ffffff;font-size: 20px; font-weight: 900;
letter-spacing: 0.18em; text-shadow:0 0 10px rgba(255, 255, 255, .8),0 0 25px rgba(255, 255, 255, .45); transition:opacity 0.4s ease,transform 0.4s ease;}
.game-end.show {opacity: 1; transform:translate(-50%, -50%)scale(1);}
#timer.danger {color: #ff3b3b; text-shadow:0 0 5px rgba(255, 60, 60, 0.7),0 0 14px rgba(255, 30, 30, 0.45); transition:opacity 0.08s linear;}
#timer.danger.blink {opacity: 0.2;}
.game-clear {position: absolute;z-index: 1000;top: 50%;left: 50%; transform:translate(-50%, -50%)scale(0.8); opacity: 0; pointer-events: none;
padding: 18px 36px; border-radius: 12px; background:rgba(32, 13, 136, 0.26); border:2px solid rgba(255, 255, 255, 0.75); color: #ffffff; font-size: 42px;
font-weight: 900; letter-spacing: 0.18em; text-shadow:0 0 10px rgba(255,255,255,.8),0 0 25px rgba(255,255,255,.45); transition:opacity 0.4s ease,transform 0.4s ease;}
.game-clear.show {opacity: 1; transform:translate(-50%, -50%)scale(1);}
.tile.hint {z-index: 20;}
.tile.hint .tile-color {animation:hintBlink 1.8s ease-in-out infinite;border: 4px solid rgba(56, 255, 245, 0.911)}
@keyframes hintBlink {0%,100% {transform:scale(1); filter:brightness(1); box-shadow:inset 1px 1px 2px rgba(255, 255, 255, 0.55),inset -2px -2px 3px
rgba(0, 0, 0, 0.28),2px 3px 5px rgba(0, 0, 0, 0.40);}50% {transform:scale(1.08); filter:brightness(1.25)saturate(1.1); box-shadow:0 0 0 1px
rgba(255, 255, 255, 0.7),0 0 10px rgba(255, 255, 255, 0.45),inset 1px 1px 2px rgba(255, 255, 255, 0.6);}}
.game-guide {position: absolute; z-index: 80; top: 50%; left: 50%; width: min(500px, 86%); transform: translate(-50%, -50%); padding: 10px 14px; border-radius: 18px;
background: rgba(70, 2, 52, 0.856); border: 8px solid rgba(3, 3, 3, 0.856); box-shadow: 0 0 14px rgb(1, 5, 26); text-align: left; font-weight: bold; pointer-events: auto;
transition: opacity 0.5s ease,transform 0.5s ease; cursor: pointer; touch-action: manipulation;}
.game-guide-text{color: #c5c5c5; font-size: 14px;}
.game-guide-title,.game-guide-start{text-align: center; color: #dfdb00;}
.game-guide.hide {opacity: 0; transform:translate(-50%, -50%)scale(0.96);}
.game-guide.hidden {opacity: 0; visibility: hidden; pointer-events: none;}
.game-canvas,.game-canvas * {touch-action: none !important; -webkit-user-select: none !important; user-select: none !important; -webkit-touch-callout: none !important;
-webkit-tap-highlight-color: transparent; -webkit-user-drag: none; -webkit-context-menu: none;}
.tile {-webkit-user-drag: none; user-drag: none;}
.tile.dragging,.tile.dragging * {touch-action: none; -webkit-user-select: none; user-select: none;}
.star-particle {position: absolute;left: 50%;top: 50%; z-index: 200;pointer-events: none; font-size: 14px; line-height: 1; transform:translate(-50%,-50%);
animation: starBurst var(--duration) ease-out forwards;}
@keyframes starBurst {0% {opacity: 1; transform:translate(-50%,-50%)scale(0.3)rotate(0deg);}
70% {opacity: 1;} 100% {opacity: 0;transform:translate(calc(-50% + var(--x)),calc(-50% + var(--y)))scale(1.2)rotate(var(--rotate));}}
.inf{color: #0affad;}
.clear-image-effect {position: absolute; z-index: 900; left: 50%; top: 50%; width: 446px; height: 500px; max-width: 80%;
max-height: 80%; pointer-events: none; opacity: 0; transform: translate(-50%, -50%) scale(0)rotate(-360deg);}
.clear-image-effect img { display: block; width: 100%; height: 100%; object-fit: contain;}
.clear-image-effect.show { animation: clearWinAppear 2.5s cubic-bezier(.15, .8, .25, 1) forwards;}
@keyframes clearWinAppear { 0% { opacity: 0; transform: translate(-50%, -50%) scale(0)rotate(-360deg);} 15% {opacity: 1;} 100% { opacity: 1;
transform: translate(-50%, -50%) scale(1) rotate(0deg);}}

≪≪動作確認用のHIDDEN ECHOデモゲーム≫≫

ダウンロード圧縮ファイルについて、全てのファイル(画像を含む)が圧縮ファイル(HIDDEN ECHO.zip)に入っています。圧縮ファイル内のwin.png画像をWEBで公開した場合は、MOMOのクレジット表記が必要です。また、コード開発用として試験的にwin.png画像を使用される場合は除きます。

圧縮ファイルの内容(HIDDEN ECHO.zip)

index.html style.css game.js imagesフォルダー内(background.jpg win.png)

HIDDEN ECHOのダウンロード