quad / static /script.js
horiyouta's picture
2603271026
350facf
// ==== 音声管理 ====
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);
}
}