// ==== 音声管理 ==== let soundEnabled = false; let titleBgm, bgm, sfxPut, sfxMatch, sfxGameover, sfxRotate, sfxWin, sfxDamage; let isPlayingGame = false; function createAudio(src) { const a = new Audio(src); a.preload = 'auto'; return a; } function ensureAudioCreated() { if (!bgm) { titleBgm = createAudio('static/title.mp3'); titleBgm.loop = true; bgm = createAudio('static/bgm.mp3'); bgm.loop = true; sfxPut = createAudio('static/put.mp3'); sfxMatch = createAudio('static/match.mp3'); sfxGameover = createAudio('static/gameover.mp3'); sfxRotate = createAudio('static/rotate.mp3'); sfxWin = createAudio('static/win.mp3'); sfxDamage = createAudio('static/damage.mp3'); } } function playSfx(audio, vol = 1.0) { if (!soundEnabled || !audio) return; const c = audio.cloneNode(); c.volume = vol; c.play().catch(() => { }); } function bgmControl(state) { if (!soundEnabled || !titleBgm || !bgm) return; titleBgm.pause(); bgm.pause(); if (state === 'title') { titleBgm.currentTime = 0; titleBgm.volume = 0.4; titleBgm.play().catch(() => { }); } else if (state === 'game') { bgm.currentTime = 0; bgm.volume = 0.4; bgm.play().catch(() => { }); } } document.getElementById('sound-toggle').addEventListener('click', () => { ensureAudioCreated(); soundEnabled = !soundEnabled; document.getElementById('sound-toggle').textContent = soundEnabled ? '🔊' : '🔇'; if (soundEnabled) { if (isPlayingGame && (!p1 || !p1.isGameOver)) bgmControl('game'); else if (!isPlayingGame) bgmControl('title'); } else { bgmControl('stop'); } }); // ==== ゲーム設定 ==== const COLS = 8, ROWS = 8, COLORS = 4, MIN_MATCH = 3; const tileImages = ['static/tile1.png', 'static/tile2.png', 'static/tile3.png', 'static/tile4.png', 'static/tile5.png']; const tileImg = (i) => { const img = document.createElement('img'); img.src = tileImages[i]; img.draggable = false; return img; }; const sleep = ms => new Promise(r => setTimeout(r, ms)); let p1, p2; let gameMode = '1p'; // '1p', 'vs-real', 'vs-turn' let currentTurn = 1; // 1: Player, 2: AI (ターン制用) let isDemoActive = true; class QuadGame { constructor(rootEl, isAI, isDemo = false) { this.root = rootEl; this.isAI = isAI; this.isDemo = isDemo; this.boardEl = rootEl.querySelector('.board'); this.handEl = rootEl.querySelector('.hand-area'); this.scoreEl = rootEl.querySelector('.score-display'); this.msgEl = rootEl.querySelector('.message-pop'); this.garbageUi = rootEl.querySelector('.garbage-ui'); this.garbageBar = rootEl.querySelector('.garbage-bar'); this.garbageText = rootEl.querySelector('.garbage-text'); this.board = []; this.hand = []; this.score = 0; this.combo = 0; this.isProcessing = false; this.isGameOver = false; this.aiFetching = false; this.opponent = null; this.pendingGarbage = 0; this.garbageTimerMax = 6000; this.garbageTimer = 0; this.dragging = null; this.dragEl = null; this.ghostCells = []; } init() { this.board = Array.from({ length: ROWS }, () => Array(COLS).fill(-1)); this.hand = [this.genPiece(), this.genPiece(), this.genPiece()]; this.score = 0; this.combo = 0; this.pendingGarbage = 0; this.isGameOver = false; this.isProcessing = false; this.boardEl.innerHTML = ''; for (let i = 0; i < 64; i++) { const c = document.createElement('div'); c.className = 'cell'; this.boardEl.appendChild(c); } this.updateHandUI(); if (this.scoreEl) this.scoreEl.textContent = '0'; if (this.garbageUi) this.garbageUi.style.display = 'none'; } genPiece() { let p; while (true) { p = [[Math.floor(Math.random() * COLORS), Math.floor(Math.random() * COLORS)], [Math.floor(Math.random() * COLORS), Math.floor(Math.random() * COLORS)]]; if (!this.hasMatchSync(p)) return p; } } hasMatchSync(p) { for (let col = 0; col < COLORS; col++) { let count = 0; for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++) if (p[r][c] === col) count++; if (count >= 3) return true; } return false; } getCellEl(r, c) { return this.boardEl.children[r * COLS + c]; } renderBoard() { for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { const cell = this.getCellEl(r, c); const ex = cell.querySelector('.block'); if (ex) ex.remove(); if (this.board[r][c] >= 0) { const blk = document.createElement('div'); blk.className = 'block'; blk.appendChild(tileImg(this.board[r][c])); cell.appendChild(blk); } } } updateHandUI() { this.handEl.innerHTML = ''; this.hand.forEach((p, idx) => { const el = document.createElement('div'); el.className = 'hand-piece' + (!this.isAI ? ' interactive' : ''); if (!p) { el.style.visibility = 'hidden'; this.handEl.appendChild(el); return; } [[0, 0], [0, 1], [1, 0], [1, 1]].forEach(([r, c]) => { const w = document.createElement('div'); w.className = 'hand-cell-wrapper'; const cl = document.createElement('div'); cl.className = 'hand-cell'; cl.style.left = '0'; cl.style.top = '0'; cl.appendChild(tileImg(p[r][c])); w.appendChild(cl); el.appendChild(w); }); if (!this.isAI) { el.addEventListener('mousedown', e => this.onHandDown(e, idx)); el.addEventListener('touchstart', e => this.onHandDown(e, idx), { passive: false }); } this.handEl.appendChild(el); }); this.checkValidMoves(); } rotateCW(idx) { const p = this.hand[idx]; this.hand[idx] = [[p[1][0], p[0][0]], [p[1][1], p[0][1]]]; } async animateRotation(idx) { if (!this.isDemo) playSfx(sfxRotate, 0.4); this.rotateCW(idx); this.updateHandUI(); } canPlace(piece, sr, sc) { if (!piece) return false; for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++) { const br = sr + r, bc = sc + c; if (br < 0 || br >= ROWS || bc < 0 || bc >= COLS || this.board[br][bc] >= 0) return false; } return true; } checkValidMoves() { if (this.isGameOver) return; let any = false; const els = this.handEl.querySelectorAll('.hand-piece'); // ターン制での自分以外のターンの場合はすべて無効化 const isMyTurn = (gameMode !== 'vs-turn') || (this.isAI ? currentTurn === 2 : currentTurn === 1); this.hand.forEach((p, i) => { if (!p) return; let ok = false; if (isMyTurn) { for (let r = 0; r < 7; r++) for (let c = 0; c < 7; c++) if (this.canPlace(p, r, c)) ok = true; } if (ok) any = true; if (els[i]) els[i].classList.toggle('disabled', !ok); }); // 置ける手がない(本当に盤面が埋まっているか手札がない)場合のゲームオーバー判定 let trueAny = false; this.hand.forEach(p => { if (!p) return; for (let r = 0; r < 7; r++) for (let c = 0; c < 7; c++) if (this.canPlace(p, r, c)) trueAny = true; }); if (!trueAny && this.hand.some(p => p !== null) && !this.isProcessing) { this.triggerGameOver(); } } onHandDown(e, idx) { if (this.isProcessing || this.isGameOver || this.isDemo) return; if (gameMode === 'vs-turn' && currentTurn !== 1) return; // 自分のターン以外操作不可 const t = e.touches ? e.touches[0] : e; let startX = t.clientX, startY = t.clientY, moved = false; const onMove = ev => { const tt = ev.touches ? ev.touches[0] : ev; if (!moved && Math.hypot(tt.clientX - startX, tt.clientY - startY) > 8) { moved = true; this.boardRect = this.boardEl.getBoundingClientRect(); this.cellSize = this.boardRect.width / COLS; this.beginDrag(idx, tt.clientX, tt.clientY); } if (moved) this.moveDrag(tt.clientX, tt.clientY); }; const onUp = ev => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp); if (!moved) { this.animateRotation(idx); } else { const tt = ev.changedTouches ? ev.changedTouches[0] : ev; this.finishDrag(tt.clientX, tt.clientY); } }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp); } beginDrag(idx, x, y) { this.dragging = { idx, piece: this.hand[idx] }; this.dragEl = document.createElement('div'); this.dragEl.className = 'dragging-piece'; for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++) { const cl = document.createElement('div'); cl.className = 'drag-cell'; cl.appendChild(tileImg(this.hand[idx][r][c])); this.dragEl.appendChild(cl); } document.body.appendChild(this.dragEl); this.handEl.children[idx].style.opacity = '0.3'; this.moveDrag(x, y); } moveDrag(x, y) { if (!this.dragEl) return; this.dragEl.style.left = (x - 45) + 'px'; this.dragEl.style.top = (y - 45) + 'px'; const relX = x - this.boardRect.left, relY = y - this.boardRect.top; const c = Math.round(relX / this.cellSize - 1), r = Math.round(relY / this.cellSize - 1); this.ghostCells.forEach(el => { el.classList.remove('highlight'); const g = el.querySelector('.ghost'); if (g) g.remove(); }); this.ghostCells = []; if (this.canPlace(this.dragging.piece, r, c)) { for (let ir = 0; ir < 2; ir++) for (let ic = 0; ic < 2; ic++) { const el = this.getCellEl(r + ir, c + ic); el.classList.add('highlight'); const g = document.createElement('div'); g.className = 'ghost'; g.appendChild(tileImg(this.dragging.piece[ir][ic])); el.appendChild(g); this.ghostCells.push(el); } } } finishDrag(x, y) { const relX = x - this.boardRect.left, relY = y - this.boardRect.top; const c = Math.round(relX / this.cellSize - 1), r = Math.round(relY / this.cellSize - 1); if (this.canPlace(this.dragging.piece, r, c)) { this.placePiece(this.dragging.idx, r, c); } else { this.handEl.children[this.dragging.idx].style.opacity = '1'; } this.ghostCells.forEach(el => { el.classList.remove('highlight'); const g = el.querySelector('.ghost'); if (g) g.remove(); }); this.ghostCells = []; if (this.dragEl) { this.dragEl.remove(); this.dragEl = null; } this.dragging = null; } placePiece(idx, r, c) { if (!this.isDemo) playSfx(sfxPut, 0.6); const p = this.hand[idx]; for (let ir = 0; ir < 2; ir++) for (let ic = 0; ic < 2; ic++) this.board[r + ir][c + ic] = p[ir][ic]; this.hand[idx] = this.genPiece(); this.combo = 0; this.isProcessing = true; this.renderBoard(); this.updateHandUI(); setTimeout(() => this.processMatches(), 150); } processMatches() { const vis = Array.from({ length: ROWS }, () => Array(COLS).fill(false)); const toRemove = new Set(); for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { if (this.board[r][c] >= 0 && this.board[r][c] <= 3 && !vis[r][c]) { const color = this.board[r][c], grp = [], q = [[r, c]]; vis[r][c] = true; while (q.length > 0) { const [cr, cc] = q.shift(); grp.push([cr, cc]); [[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => { const nr = cr + dr, nc = cc + dc; if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && !vis[nr][nc] && this.board[nr][nc] === color) { vis[nr][nc] = true; q.push([nr, nc]); } }); } if (grp.length >= MIN_MATCH) grp.forEach(([gr, gc]) => toRemove.add(`${gr},${gc}`)); } } if (toRemove.size > 0) { if (!this.isDemo) playSfx(sfxMatch, 0.7); this.combo++; const garbageSet = new Set(); toRemove.forEach(k => { const [r, c] = k.split(',').map(Number); [[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => { const nr = r + dr, nc = c + dc; if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && this.board[nr][nc] === 4) garbageSet.add(`${nr},${nc}`); }); }); garbageSet.forEach(k => toRemove.add(k)); const count = toRemove.size; this.score += count * 10 * this.combo; if (this.scoreEl) this.scoreEl.textContent = this.score; this.showPop(`${count} BLOCKS!` + (this.combo > 1 ? `
${this.combo} COMBO!` : '')); if (this.opponent && (this.combo >= 2 || count >= 4)) { const atk = Math.max(1, count - 3) + (this.combo - 1) * 2; this.sendGarbage(atk); } toRemove.forEach(k => { const [r, c] = k.split(',').map(Number); const el = this.getCellEl(r, c).querySelector('.block'); if (el) el.classList.add('clearing'); }); setTimeout(() => { toRemove.forEach(k => { const [r, c] = k.split(',').map(Number); this.board[r][c] = -1; }); this.renderBoard(); setTimeout(() => this.processMatches(), 200); }, 400); } else { this.isProcessing = false; this.checkValidMoves(); // 処理終了後のアクション if (gameMode === 'vs-turn' && !this.isGameOver) { // ターン切り替え switchTurn(); } else if (this.isAI && !this.isGameOver && gameMode !== 'vs-turn') { // リアルタイムAIの連続操作 setTimeout(() => requestAI(this), 300); } } } showPop(txt) { if (!this.msgEl) return; this.msgEl.innerHTML = txt; this.msgEl.style.transition = 'none'; this.msgEl.style.opacity = '1'; this.msgEl.style.transform = 'translate(-50%,-50%) scale(0.5)'; requestAnimationFrame(() => { this.msgEl.style.transition = 'transform 0.3s cubic-bezier(0.175,0.885,0.32,1.275), opacity 0.5s'; this.msgEl.style.transform = 'translate(-50%,-50%) scale(1)'; setTimeout(() => this.msgEl.style.opacity = '0', 800); }); } sendGarbage(amount) { if (this.pendingGarbage > 0) { if (this.pendingGarbage >= amount) { this.pendingGarbage -= amount; amount = 0; } else { amount -= this.pendingGarbage; this.pendingGarbage = 0; } this.garbageTimer = this.garbageTimerMax; this.updateGarbageUI(); } if (amount > 0 && this.opponent) { if (gameMode === 'vs-turn') { // ターン制の場合は即時落下させるために相手側に保留させずそのまま落とす this.opponent.receiveGarbageInstant(amount); } else { this.opponent.receiveGarbage(amount); } } } receiveGarbage(amount) { this.pendingGarbage += amount; this.garbageTimer = this.garbageTimerMax; this.updateGarbageUI(); } receiveGarbageInstant(amount) { this.pendingGarbage += amount; this.dropGarbage(); } updateGarbageUI() { if (!this.garbageUi) return; if (gameMode === 'vs-turn') { this.garbageUi.style.display = 'none'; return; } // ターン制はUI不要 if (this.pendingGarbage > 0) { this.garbageUi.style.display = 'block'; this.garbageText.textContent = `+${this.pendingGarbage}`; this.garbageBar.style.transform = `scaleX(${Math.max(0, this.garbageTimer / this.garbageTimerMax)})`; } else { this.garbageUi.style.display = 'none'; } } tick(dt) { if (this.isGameOver || this.isProcessing || gameMode === 'vs-turn') return; if (this.pendingGarbage > 0) { this.garbageTimer -= dt; this.updateGarbageUI(); if (this.garbageTimer <= 0) { this.dropGarbage(); } } } dropGarbage() { if (!this.isDemo) playSfx(sfxDamage, 0.8); let empty = []; for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (this.board[r][c] === -1) empty.push({ r, c }); for (let i = empty.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [empty[i], empty[j]] = [empty[j], empty[i]]; } const drop = Math.min(this.pendingGarbage, empty.length); for (let i = 0; i < drop; i++) this.board[empty[i].r][empty[i].c] = 4; this.pendingGarbage -= drop; if (this.pendingGarbage < 0) this.pendingGarbage = 0; this.updateGarbageUI(); this.renderBoard(); this.checkValidMoves(); } triggerGameOver() { this.isGameOver = true; if (this.isDemo) { setTimeout(() => { if (!isPlayingGame && isDemoActive) { this.init(); requestAI(this); } }, 2000); return; } checkGlobalGameOver(); } } // ==== グローバル制御 ==== let lastTime = 0; let demoGame = null; window.onload = () => { demoGame = new QuadGame(document.getElementById('demo-area'), true, true); demoGame.init(); requestAI(demoGame); // デモ用AI起動 }; document.getElementById('btn-1p').onclick = () => startGame('1p'); document.getElementById('btn-vs-real').onclick = () => startGame('vs-real'); document.getElementById('btn-vs-turn').onclick = () => startGame('vs-turn'); function startGame(mode) { isDemoActive = false; isPlayingGame = true; gameMode = mode; currentTurn = 1; ensureAudioCreated(); bgmControl('game'); document.getElementById('title-screen').classList.add('hidden'); document.getElementById('main-wrapper').classList.add('show'); p1 = new QuadGame(document.getElementById('p1-area'), false); p1.init(); const indicator = document.getElementById('turn-indicator'); if (mode === 'vs-real' || mode === 'vs-turn') { document.getElementById('p2-area').style.display = 'flex'; p2 = new QuadGame(document.getElementById('p2-area'), true); p2.init(); p1.opponent = p2; p2.opponent = p1; if (mode === 'vs-turn') { indicator.style.display = 'block'; updateTurnUI(); } else { indicator.style.display = 'none'; setTimeout(() => requestAI(p2), 1000); } } else { document.getElementById('p2-area').style.display = 'none'; indicator.style.display = 'none'; } lastTime = performance.now(); requestAnimationFrame(gameLoop); } function gameLoop(time) { const dt = time - lastTime; lastTime = time; if (p1 && !p1.isGameOver) p1.tick(dt); if (p2 && !p2.isGameOver) p2.tick(dt); requestAnimationFrame(gameLoop); } function switchTurn() { if (p1.isGameOver || p2.isGameOver) return; currentTurn = currentTurn === 1 ? 2 : 1; updateTurnUI(); // それぞれの手札の活性/非活性を更新 p1.checkValidMoves(); p2.checkValidMoves(); if (currentTurn === 2) { setTimeout(() => requestAI(p2), 800); // ターン交代のゆとり } } function updateTurnUI() { const ind = document.getElementById('turn-indicator'); const p1a = document.getElementById('p1-area'); const p2a = document.getElementById('p2-area'); if (currentTurn === 1) { ind.textContent = "PLAYER'S TURN"; ind.style.color = "#4d96ff"; p1a.classList.remove('waiting'); p2a.classList.add('waiting'); } else { ind.textContent = "AI'S TURN"; ind.style.color = "#ff6b6b"; p1a.classList.add('waiting'); p2a.classList.remove('waiting'); } } function checkGlobalGameOver() { if (gameMode !== '1p') { if (p1.isGameOver || p2.isGameOver) { bgmControl('stop'); if (p1.isGameOver) { document.getElementById('result-title').textContent = "YOU LOSE..."; document.getElementById('result-title').style.color = "#ff6b6b"; playSfx(sfxGameover, 0.8); } else { document.getElementById('result-title').textContent = "YOU WIN!!"; document.getElementById('result-title').style.color = "#6bcb77"; playSfx(sfxWin, 0.8); } document.getElementById('game-over-overlay').classList.add('show'); } } else { if (p1.isGameOver) { bgmControl('stop'); playSfx(sfxGameover, 0.8); document.getElementById('game-over-overlay').classList.add('show'); } } } async function requestAI(gameObj) { if (gameObj.isGameOver || gameObj.isProcessing || gameObj.aiFetching) return; if (!gameObj.hand.some(p => p !== null)) return; if (gameObj.isDemo && !isDemoActive) return; // 難易度(温度)設定 // 0: デモ用(最強)、 25: ターン制(少し手加減)、 35: リアルタイム(隙を見せる) let temp = 0.0; if (!gameObj.isDemo) { temp = gameMode === 'vs-turn' ? 20.0 : 35.0; } gameObj.aiFetching = true; try { const res = await fetch('/api/ai_move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ board: gameObj.board, hand: gameObj.hand, temperature: temp }) }); const move = await res.json(); gameObj.aiFetching = false; if (gameObj.isDemo && !isDemoActive) return; if (move && move.piece_idx !== undefined && !gameObj.isGameOver && !gameObj.isProcessing) { // 人間らしい遅延の設定 // リアルタイム対戦の場合、思考にわざと時間をかける let thinkTime = gameObj.isDemo ? 300 : (gameMode === 'vs-real' ? 1200 + Math.random() * 800 : 600); await sleep(thinkTime); if (gameObj.isGameOver || gameObj.isProcessing || (gameObj.isDemo && !isDemoActive)) return; for (let i = 0; i < move.rotations; i++) { gameObj.animateRotation(move.piece_idx); await sleep(gameObj.isDemo ? 100 : 250); // 回転のディレイ } if (gameObj.canPlace(gameObj.hand[move.piece_idx], move.r, move.c)) { gameObj.placePiece(move.piece_idx, move.r, move.c); } } } catch (e) { gameObj.aiFetching = false; setTimeout(() => { if (!gameObj.isGameOver && !gameObj.isProcessing && (!gameObj.isDemo || isDemoActive)) requestAI(gameObj); }, 2000); } }