Download static/script.js from horiyouta/quad: direct link, hf CLI and curl.
- Browser
- Download file 24.6 kB
-
https://huggingface.co/spaces/horiyouta/quad/resolve/main/static/script.js
- Command line
-
hf download hf://spaces/horiyouta/quad/static/script.js
-
curl -L -o script.js https://huggingface.co/spaces/horiyouta/quad/resolve/main/static/script.js
24.6 kB
| // ==== 音声管理 ==== | |
| 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 ? `<br>${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); | |
| } | |
| } |