Spaces:
Running
Running
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>カラーリンクパズル (AI 自動プレイ版)</title> | |
| <style> | |
| :root { | |
| --grid-size:6; /* 6×6 のグリッド */ | |
| --tile-size:80px; /* 1 タイルの横幅・高さ */ | |
| --transition-duration:0.15s; /* 移動アニメーション */ | |
| --remove-duration:0.35s; /* 消去アニメーションの長さ */ | |
| /* ---------- Light theme ---------- */ | |
| --bg-color:#f0f0f0; /* 背景 */ | |
| --text-color:#333; /* 基本テキスト */ | |
| --tile-bg:#ddd; /* タイル(未選択)背景 */ | |
| --tile-text:#fff; /* タイル文字色 (タイル上の文字は使われていませんが残しておく) */ | |
| } | |
| /* Dark theme override – body に .dark がついた時に適用される */ | |
| body.dark { | |
| --bg-color:#181a1b; /* 背景(暗め) */ | |
| --text-color:#e0e0e0; /* テキスト */ | |
| --tile-bg:#444; /* タイル背景 */ | |
| } | |
| body{ | |
| font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; | |
| background: var(--bg-color); | |
| color: var(--text-color); | |
| margin:0;padding:0; | |
| display:flex; | |
| flex-direction:column; | |
| align-items:center; | |
| justify-content:center; /* 縦方向センタリング */ | |
| height:100vh; | |
| } | |
| h1{margin:20px 0 5px; font-size:2rem; text-align:center;} | |
| #controls{ | |
| margin:10px 0; | |
| display:flex; | |
| flex-wrap:wrap; | |
| justify-content:center; | |
| gap:8px; | |
| } | |
| #score,#moves{margin:0 6px; font-weight:bold;} | |
| #board{ | |
| display:grid; | |
| grid-template-columns:repeat(var(--grid-size),var(--tile-size)); | |
| grid-auto-rows:var(--tile-size); | |
| gap:4px; | |
| margin:20px; | |
| user-select:none; | |
| } | |
| .tile{ | |
| width:var(--tile-size); | |
| height:var(--tile-size); | |
| border-radius:8px; | |
| background:var(--tile-bg); | |
| display:flex; | |
| justify-content:center; | |
| align-items:center; | |
| font-size:2rem; | |
| color: var(--tile-text); | |
| cursor:pointer; | |
| transition: | |
| transform var(--transition-duration) ease; | |
| transform:scale(1); | |
| } | |
| .tile:hover{ transform:scale(1.07); } | |
| .empty{background:transparent; cursor:default; } | |
| .disabled{pointer-events:none;} | |
| /* 消去エフェクト */ | |
| @keyframes tile-fade-out{ | |
| 0% { opacity:1; transform:scale(1); } | |
| 100% { opacity:0; transform:scale(0.2); } | |
| } | |
| /* アニメーション中に付けるクラス */ | |
| .tile.removing{ | |
| animation: tile-fade-out var(--remove-duration) ease-out forwards; | |
| } | |
| #message{margin-top:1rem; font-size:1.2rem; text-align:center;} | |
| /* Media query for better mobile support */ | |
| @media (max-width:600px){ | |
| #board{ | |
| grid-template-columns:repeat(var(--grid-size),1fr); /* Make tiles responsive */ | |
| } | |
| .tile{ | |
| --tile-size:60px; /* Adjust tile size for smaller screens */ | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>カラーリンクパズル</h1> | |
| <div id="controls"> | |
| <button id="newGameBtn">新規ゲーム</button> | |
| <button id="autoPlayBtn">自動プレイ</button> | |
| <!-- 速度スライダー --> | |
| <label style="margin-left:20px;"> | |
| 速度: | |
| <input type="range" id="speedSlider" min="20" max="2000" step="10" | |
| value="600" style="vertical-align: middle; width:120px;"> | |
| <span id="speedValue">600</span> ms | |
| </label> | |
| <!-- ダーク / ライト 切替ボタン --> | |
| <button id="themeBtn">暗いモード</button> | |
| <span id="score">スコア: 0</span> | |
| <span id="moves">手数: 0</span> | |
| </div> | |
| <div id="board"></div> | |
| <div id="message"></div> | |
| <script> | |
| /* ------------------------------------------------- | |
| Ⅰ. グローバル変数 & 初期設定 | |
| ------------------------------------------------- */ | |
| const boardEl = document.getElementById('board'); | |
| const boardSize = parseInt(getComputedStyle(document.documentElement) | |
| .getPropertyValue('--grid-size')); | |
| const colors = ['#e74c3c','#3498db','#f1c40f','#2ecc71', | |
| '#9b59b6','#ff9800']; // 6 色 | |
| let board = []; // 2‑D 格子(色コード) | |
| let score = 0; | |
| let moves = 0; | |
| let isAnimating = false; | |
| let autoPlayTimer = null; | |
| // スライダー関連の変数を追加 | |
| const speedSlider = document.getElementById('speedSlider'); | |
| const speedValue = document.getElementById('speedValue'); | |
| let playDelay = parseInt(speedSlider.value); // 速さ(ms) | |
| const scoreEl = document.getElementById('score'); | |
| const movesEl = document.getElementById('moves'); | |
| const messageEl = document.getElementById('message'); | |
| const newGameBtn = document.getElementById('newGameBtn'); | |
| const autoPlayBtn = document.getElementById('autoPlayBtn'); | |
| const themeBtn = document.getElementById('themeBtn'); | |
| /* ------------------------------------------------- | |
| テーマ切替 | |
| ------------------------------------------------- */ | |
| function setTheme(dark) { | |
| const body = document.body; | |
| if (dark) { | |
| body.classList.add('dark'); | |
| themeBtn.textContent = '明るいモード'; | |
| localStorage.setItem('theme','dark'); | |
| } else { | |
| body.classList.remove('dark'); | |
| themeBtn.textContent = '暗いモード'; | |
| localStorage.setItem('theme','light'); | |
| } | |
| } | |
| function toggleTheme() { | |
| const isDark = document.body.classList.contains('dark'); | |
| setTheme(!isDark); | |
| } | |
| themeBtn.addEventListener('click', toggleTheme); | |
| /* 起動時に前回の設定を復元 */ | |
| (() => { | |
| const saved = localStorage.getItem('theme'); | |
| if (saved === 'dark') setTheme(true); | |
| })(); | |
| /* ------------------------------------------------- | |
| スライダーの値変更時処理 | |
| ------------------------------------------------- */ | |
| speedSlider.addEventListener('input', () => { | |
| playDelay = parseInt(speedSlider.value); | |
| speedValue.textContent = playDelay; | |
| if (autoPlayBtn.dataset.state === 'running') { | |
| stopAutoPlay(); | |
| startAutoPlay(); | |
| } | |
| }); | |
| /* ------------------------------------------------- | |
| Ⅱ. ボード生成・描画 | |
| ------------------------------------------------- */ | |
| function initBoard() { | |
| board = []; | |
| for (let r = 0; r < boardSize; r++) { | |
| board[r] = []; | |
| for (let c = 0; c < boardSize; c++) { | |
| board[r][c] = colors[Math.floor(Math.random()*colors.length)]; | |
| } | |
| } | |
| renderBoard(); | |
| score = 0; moves = 0; updateScore(); | |
| messageEl.textContent = ''; | |
| isAnimating = false; | |
| stopAutoPlay(); | |
| } | |
| function renderBoard() { | |
| boardEl.textContent = ''; | |
| for (let r = 0; r < boardSize; r++) { | |
| for (let c = 0; c < boardSize; c++) { | |
| const idx = r * boardSize + c; | |
| const div = document.createElement('div'); | |
| div.className = 'tile'; | |
| div.dataset.idx = idx; | |
| const col = board[r][c]; | |
| if (col === null) { | |
| div.classList.add('empty'); | |
| } else { | |
| div.style.background = col; | |
| } | |
| boardEl.appendChild(div); | |
| } | |
| } | |
| } | |
| /* ------------------------------------------------- | |
| Ⅲ. ユーザー操作(ドラッグ + スワップ) | |
| ------------------------------------------------- */ | |
| let dragStart = null; | |
| boardEl.addEventListener('mousedown', e => { | |
| if (isAnimating) return; | |
| const tile = e.target.closest('.tile'); | |
| if (!tile) return; | |
| const idx = Number(tile.dataset.idx); | |
| const [r,c] = indexToRC(idx); | |
| if (board[r][c] === null) return; | |
| dragStart = {r,c, elem: tile}; | |
| tile.style.opacity = 0.6; | |
| }); | |
| boardEl.addEventListener('mouseup', e => { | |
| if (!dragStart) return; | |
| dragStart.elem.style.opacity = 1; | |
| const target = e.target.closest('.tile'); | |
| if (!target) { dragStart = null; return; } | |
| const idx = Number(target.dataset.idx); | |
| const [r2,c2] = indexToRC(idx); | |
| const {r:r1,c:c1} = dragStart; | |
| if (Math.abs(r1-r2)+Math.abs(c1-c2) === 1) { | |
| swapTiles(r1,c1,r2,c2); | |
| checkAndClearMatches(); | |
| } | |
| dragStart = null; | |
| }); | |
| /* ------------------------------------------------- | |
| Ⅳ. スワップ & マッチ判定 | |
| ------------------------------------------------- */ | |
| function swapTiles(r1,c1,r2,c2){ | |
| [board[r1][c1], board[r2][c2]] = [board[r2][c2], board[r1][c1]]; | |
| renderBoard(); | |
| } | |
| /* 同色ライン(4 以上)検出 */ | |
| function findMatches() { | |
| const toClear = new Set(); | |
| const dirs = [[0,1],[1,0],[1,1],[1,-1]]; // 右・下・右下・左下 | |
| for (let r = 0; r < boardSize; r++) { | |
| for (let c = 0; c < boardSize; c++) { | |
| const col = board[r][c]; | |
| if (!col) continue; | |
| for (const [dr,dc] of dirs) { | |
| const line = []; | |
| let rr = r, cc = c; | |
| while (inside(rr,cc) && board[rr][cc] === col) { | |
| line.push([rr,cc]); | |
| rr += dr; cc += dc; | |
| } | |
| if (line.length >= 4) { | |
| line.forEach(([sr,sc])=>toClear.add(`${sr}_${sc}`)); | |
| } | |
| } | |
| } | |
| } | |
| return Array.from(toClear); | |
| } | |
| function inside(r,c){ return r>=0 && c>=0 && r<boardSize && c<boardSize; } | |
| function rcToIndex(r,c){ return r*boardSize + c; } | |
| function indexToRC(i){ return [Math.floor(i/boardSize), i%boardSize]; } | |
| function removeTiles(cells) { | |
| cells.forEach(key => { | |
| const [r,c] = key.split('_').map(Number); | |
| board[r][c] = null; | |
| }); | |
| } | |
| /* 落下と補充 */ | |
| function applyGravityAndFill() { | |
| for (let c = 0; c < boardSize; c++) { | |
| const column = []; | |
| for (let r = boardSize-1; r >= 0; r--) { | |
| if (board[r][c] !== null) column.push(board[r][c]); | |
| } | |
| let r = boardSize-1; | |
| for (const col of column) { | |
| board[r][c] = col; | |
| r--; | |
| } | |
| while (r >= 0) { | |
| board[r][c] = colors[Math.floor(Math.random()*colors.length)]; | |
| r--; | |
| } | |
| } | |
| } | |
| /* 消去 & チェーン判定 */ | |
| function checkAndClearMatches() { | |
| if (isAnimating) return; | |
| const matches = findMatches(); | |
| // 消えるものが無い → 手数だけ増やす | |
| if (matches.length === 0) { | |
| moves++; updateScore(); | |
| if (isFinished()) { | |
| messageEl.textContent = `クリア! 手数 ${moves}、スコア ${score}`; | |
| } | |
| return; | |
| } | |
| /* ① エフェクト付与 */ | |
| matches.forEach(key => { | |
| const [r,c] = key.split('_').map(Number); | |
| const idx = rcToIndex(r,c); | |
| const tile = boardEl.querySelector(`.tile[data-idx='${idx}']`); | |
| if (tile) tile.classList.add('removing'); | |
| }); | |
| /* ② アニメーション後に実際の削除・落下 */ | |
| isAnimating = true; | |
| const ANIM_MS = parseFloat(getComputedStyle(document.documentElement) | |
| .getPropertyValue('--remove-duration')) * 1000; | |
| setTimeout(() => { | |
| removeTiles(matches); | |
| applyGravityAndFill(); | |
| const added = matches.length * 5; // 1 個=5 pt | |
| score += added; | |
| moves++; // 1 手としてカウント | |
| renderBoard(); | |
| isAnimating = false; | |
| checkAndClearMatches(); // 連鎖 | |
| updateScore(); | |
| }, ANIM_MS); | |
| } | |
| /* フィールドがすべて空か判定 */ | |
| function isFinished() { | |
| for (let r = 0; r < boardSize; r++) { | |
| for (let c = 0; c < boardSize; c++) { | |
| if (board[r][c] !== null) return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /* ------------------------------------------------- | |
| V. AI 自動プレイロジック | |
| ------------------------------------------------- */ | |
| function findSwapThatCreatesMatch() { | |
| const clone = () => board.map(row=>[...row]); | |
| for (let r = 0; r < boardSize; r++) { | |
| for (let c = 0; c < boardSize; c++) { | |
| if (!board[r][c]) continue; | |
| const dirs = [[0,1],[1,0],[0,-1],[-1,0]]; | |
| for (const [dr,dc] of dirs) { | |
| const nr=r+dr, nc=c+dc; | |
| if (!inside(nr,nc) || !board[nr][nc]) continue; | |
| const tmp = clone(); | |
| [tmp[r][c], tmp[nr][nc]] = [tmp[nr][nc], tmp[r][c]]; | |
| if (hasMatch(tmp)) return {r1:r,c1:c,r2:nr,c2:nc}; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function hasMatch(arr) { | |
| const dirs = [[0,1],[1,0],[1,1],[1,-1]]; | |
| for (let r = 0; r < boardSize; r++) { | |
| for (let c = 0; c < boardSize; c++) { | |
| const col = arr[r][c]; | |
| if (!col) continue; | |
| for (const [dr,dc] of dirs) { | |
| let len = 0, rr=r, cc=c; | |
| while (inside(rr,cc) && arr[rr][cc] === col) { | |
| len++; rr+=dr; cc+=dc; | |
| } | |
| if (len >= 4) return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| /* AI の 1 手 */ | |
| function aiPlayOneMove() { | |
| if (isAnimating || isFinished()) return; | |
| const move = findSwapThatCreatesMatch(); | |
| if (move) { | |
| swapTiles(move.r1,move.c1,move.r2,move.c2); | |
| checkAndClearMatches(); | |
| } else { | |
| // ランダムに隣接ペアをスワップ(マッチが作れないなら) | |
| const r = Math.floor(Math.random()*boardSize); | |
| const c = Math.floor(Math.random()*boardSize); | |
| const dirs = [[0,1],[1,0],[0,-1],[-1,0]]; | |
| const cand = dirs.map(([dr,dc])=>[r+dr,c+dc]) | |
| .filter(([nr,nc])=>inside(nr,nc) && board[nr][nc]!=null); | |
| if (cand.length) { | |
| const [nr,nc] = cand[Math.floor(Math.random()*cand.length)]; | |
| swapTiles(r,c,nr,nc); | |
| checkAndClearMatches(); | |
| } else { | |
| moves++; updateScore(); | |
| } | |
| } | |
| } | |
| /* 自動プレイの開始 / 停止 */ | |
| function startAutoPlay() { | |
| autoPlayBtn.textContent = '停止'; | |
| autoPlayBtn.dataset.state = 'running'; | |
| if (autoPlayTimer) clearInterval(autoPlayTimer); | |
| autoPlayTimer = setInterval(() => { | |
| if (!isFinished()) aiPlayOneMove(); | |
| else stopAutoPlay(); | |
| }, playDelay); | |
| } | |
| function stopAutoPlay() { | |
| if (autoPlayTimer) clearInterval(autoPlayTimer); | |
| autoPlayTimer = null; | |
| autoPlayBtn.textContent = '自動プレイ'; | |
| autoPlayBtn.dataset.state = 'stopped'; | |
| } | |
| /* ------------------------------------------------- | |
| VI. UI 更新・ボタン処理 | |
| ------------------------------------------------- */ | |
| function updateScore() { | |
| scoreEl.textContent = `スコア: ${score}`; | |
| movesEl.textContent = `手数: ${moves}`; | |
| } | |
| /* ボタン処理 */ | |
| newGameBtn.addEventListener('click', initBoard); | |
| autoPlayBtn.addEventListener('click', () => { | |
| if (autoPlayBtn.dataset.state === 'running') stopAutoPlay(); | |
| else startAutoPlay(); | |
| }); | |
| /* 初回起動 */ | |
| initBoard(); | |
| </script> | |
| </body> | |
| </html> |