tictactoe/script.js
Parley Hatch 450d46c55d Actually fix no-scroll: fixed-centered card scaled to fit + cache busting
My previous 'no scrolling' commit didn't actually work — two real bugs:

1. Stale assets: nginx cached css/js for a week (max-age=604800), so after
   a redeploy returning visitors kept the OLD stylesheet/script and never saw
   the fit logic. Switched assets to Cache-Control:no-cache (tiny files, 304s)
   and added ?v= query strings to bust the existing week-long cache.

2. Bad centering geometry: grid-centering pins an over-tall card to the top,
   and .winner-card re-declared position:relative, overriding the fix. Now the
   card is position:fixed at 50%/50% and fitActiveScreen() folds the fit scale
   into one transform: translate(-50%,-50%) scale(fit). Removed the winner-card
   position override and switched the screen fade to opacity-only (a transform
   on .screen would capture the fixed card and break centering).

Verified in real Chromium against the container — setup, game, AND winner all
show fits:true / scrolls:false at 1366x640 (short laptop), 375x667, and
360x640 (small phone). No scrolling on any screen at any size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:00:32 -06:00

556 lines
21 KiB
JavaScript

/* =========================================================================
ULTIMATE TIC-TAC-TOE — game engine
- Per-player CAMPS: each player picks any avatar; their color/sound/FX
follow that avatar's camp (sparkle or shred). Players can mix freely.
- Global STAGE: sparkle / shred / mixed → backdrop, fonts, winner vibe.
- Hot-seat two-player, accessible, sound + particle FX, persistence.
========================================================================= */
'use strict';
/* ---------- Avatar roster (each tagged with its camp) ---------- */
const AVATARS = [
// Sparkle camp
{ emoji: '⭐', camp: 'sparkle' },
{ emoji: '🦄', camp: 'sparkle' },
{ emoji: '🌸', camp: 'sparkle' },
{ emoji: '🎀', camp: 'sparkle' },
{ emoji: '🦋', camp: 'sparkle' },
{ emoji: '🌈', camp: 'sparkle' },
{ emoji: '🎨', camp: 'sparkle' },
{ emoji: '🍭', camp: 'sparkle' },
// Shred camp
{ emoji: '💀', camp: 'shred' },
{ emoji: '☠️', camp: 'shred' },
{ emoji: '🎸', camp: 'shred' },
{ emoji: '🤘', camp: 'shred' },
{ emoji: '🔥', camp: 'shred' },
{ emoji: '⚡', camp: 'shred' },
{ emoji: '🏴‍☠️', camp: 'shred' },
{ emoji: '🐉', camp: 'shred' },
];
const campOf = (emoji) => (AVATARS.find((a) => a.emoji === emoji) || AVATARS[0]).camp;
/* ---------- Per-camp flavor (color is in CSS; here: sound + particles) ---------- */
const CAMPS = {
sparkle: {
particles: ['✨', '💫', '⭐', '🌟'],
sound: { wave: 'sine', move: 587.33, gain: 0.26, powerChord: false }, // D5 chime
win: [523.25, 659.25, 783.99, 1046.5], // C E G C — bright arpeggio
},
shred: {
particles: ['🔥', '💥', '⚡', '💀'],
sound: { wave: 'sawtooth', move: 98.0, gain: 0.2, powerChord: true }, // G2 chug
win: [82.41, 110.0, 164.81, 220.0], // E A E A — power-chord climb
},
};
/* ---------- Stages (global vibe) ---------- */
const STAGES = {
sparkle: {
startLabel: 'Start Playing!',
winnerTitle: '🎉 Winner! 🎉',
winnerSub: 'A magical victory!',
drawText: "It's a Draw! 🤝",
celebrate: ['🎉', '✨', '🌟', '💫', '🎊', '⭐', '🌈', '🦄'],
},
shred: {
startLabel: 'Start Battle!',
winnerTitle: '🤘 WINNER 🤘',
winnerSub: 'TOTAL DOMINATION!',
drawText: 'STALEMATE! ⚔️',
celebrate: ['🔥', '💀', '⚡', '🤘', '💥', '🎸', '☠️', '🐉'],
},
mixed: {
startLabel: 'Start Battle!',
winnerTitle: '🏆 WINNER 🏆',
winnerSub: 'Champion of the arena!',
drawText: 'DRAW! 🤝',
celebrate: ['🎉', '🔥', '✨', '⚡', '🌟', '💥', '🌈', '💀', '🦄', '🤘'],
},
};
/* ---------- Persistence ---------- */
const STORE_KEY = 'ttt_state_v2';
function loadState() {
try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; }
catch (_) { return {}; }
}
function saveState() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify({
stage: currentStage,
p1Name: player1Input.value,
p2Name: player2Input.value,
p1Avatar: player1Avatar,
p2Avatar: player2Avatar,
scores: { p1: player1Score, p2: player2Score },
soundOn,
}));
} catch (_) { /* storage unavailable */ }
}
const persisted = loadState();
/* ---------- Game state ---------- */
let currentStage = STAGES[persisted.stage] ? persisted.stage : 'mixed';
let currentPlayer = 'X';
let gameBoard = ['', '', '', '', '', '', '', '', ''];
let gameActive = true;
let player1Name = '';
let player2Name = '';
let player1Avatar = AVATARS.some((a) => a.emoji === persisted.p1Avatar) ? persisted.p1Avatar : '⭐';
let player2Avatar = AVATARS.some((a) => a.emoji === persisted.p2Avatar) ? persisted.p2Avatar : '💀';
let player1Score = persisted.scores ? persisted.scores.p1 : 0;
let player2Score = persisted.scores ? persisted.scores.p2 : 0;
let soundOn = persisted.soundOn !== false;
/* ---------- DOM ---------- */
const root = document.documentElement;
const nameScreen = document.getElementById('nameScreen');
const gameScreen = document.getElementById('gameScreen');
const winnerScreen = document.getElementById('winnerScreen');
const player1Input = document.getElementById('player1Name');
const player2Input = document.getElementById('player2Name');
const startBtn = document.getElementById('startBtn');
const startLabel = startBtn.querySelector('.btn-label');
const player1Display = document.getElementById('player1Display');
const player2Display = document.getElementById('player2Display');
const player1DisplayBox = document.querySelector('.player1-display');
const player2DisplayBox = document.querySelector('.player2-display');
const gameStatus = document.getElementById('gameStatus');
const gameBoardElement = document.getElementById('gameBoard');
const cells = Array.from(document.querySelectorAll('[data-cell]'));
const resetBtn = document.getElementById('resetBtn');
const changeNamesBtn = document.getElementById('changeNamesBtn');
const changeNamesBtn2 = document.getElementById('changeNamesBtn2');
const playAgainBtn = document.getElementById('playAgainBtn');
const resetScoresBtn = document.getElementById('resetScoresBtn');
const muteBtn = document.getElementById('muteBtn');
const winnerName = document.getElementById('winnerName');
const winnerAvatar = document.getElementById('winnerAvatar');
const winnerTitle = document.getElementById('winnerTitle');
const winnerSub = document.getElementById('winnerSub');
const winnerCard = winnerScreen.querySelector('.winner-card');
const celebration = document.getElementById('celebration');
const player1AvatarOptions = document.getElementById('player1Avatars');
const player2AvatarOptions = document.getElementById('player2Avatars');
const score1Element = document.getElementById('score1');
const score2Element = document.getElementById('score2');
const score1Name = document.getElementById('score1Name');
const score2Name = document.getElementById('score2Name');
const score1Avatar = document.getElementById('score1Avatar');
const score2Avatar = document.getElementById('score2Avatar');
const score1Item = document.querySelector('.player1-score');
const score2Item = document.querySelector('.player2-score');
const player1SymbolElement = document.getElementById('player1Symbol');
const player2SymbolElement = document.getElementById('player2Symbol');
const stageOptions = Array.from(document.querySelectorAll('.stage-option'));
const winningCombinations = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
/* ---------- Audio ---------- */
let audioCtx = null;
function getAudioCtx() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (_) { audioCtx = null; }
}
return audioCtx;
}
function tone(freq, startOffset, duration, wave, gain) {
const ctx = getAudioCtx();
if (!ctx) return;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.connect(g);
g.connect(ctx.destination);
osc.type = wave;
osc.frequency.value = freq;
const t0 = ctx.currentTime + startOffset;
const t1 = t0 + duration;
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.001, t1);
osc.start(t0);
osc.stop(t1);
}
function playMoveSound(camp) {
if (!soundOn) return;
const s = CAMPS[camp].sound;
tone(s.move, 0, s.powerChord ? 0.18 : 0.1, s.wave, s.gain);
if (s.powerChord) tone(s.move * 1.5, 0, 0.18, s.wave, s.gain * 0.6); // fifth
}
function playWinSound(camp) {
if (!soundOn) return;
const c = CAMPS[camp];
const step = camp === 'shred' ? 0.12 : 0.15;
c.win.forEach((freq, i) => {
tone(freq, i * step, 0.3, c.sound.wave, c.sound.gain);
if (c.sound.powerChord) tone(freq * 1.5, i * step, 0.3, c.sound.wave, c.sound.gain * 0.5);
});
}
function playDrawSound() {
if (!soundOn) return;
[440, 392, 349.23].forEach((f, i) => tone(f, i * 0.16, 0.25, 'sine', 0.16));
}
/* ---------- Setup rendering ---------- */
function renderAvatarGrid(container, selectedAvatar) {
container.innerHTML = '';
AVATARS.forEach(({ emoji, camp }) => {
const opt = document.createElement('button');
opt.type = 'button';
const selected = emoji === selectedAvatar;
opt.className = `avatar-option camp-${camp}${selected ? ' selected' : ''}`;
opt.dataset.avatar = emoji;
opt.setAttribute('role', 'radio');
opt.setAttribute('aria-checked', selected ? 'true' : 'false');
opt.setAttribute('aria-label', `Avatar ${emoji} (${camp})`);
opt.textContent = emoji;
container.appendChild(opt);
});
}
function applyStage(stage, { persist = true } = {}) {
currentStage = stage;
root.setAttribute('data-stage', stage);
stageOptions.forEach((opt) =>
opt.setAttribute('aria-checked', opt.dataset.stage === stage ? 'true' : 'false'));
startLabel.textContent = STAGES[stage].startLabel;
if (persist) saveState();
scheduleFit(); // font swap (e.g. Metal Mania) changes height → refit
}
/* ---------- Setup interactions ---------- */
stageOptions.forEach((opt) => {
opt.addEventListener('click', () => applyStage(opt.dataset.stage));
});
function wireAvatarGroup(container, isP1) {
container.addEventListener('click', (e) => {
const opt = e.target.closest('.avatar-option');
if (!opt) return;
const picked = opt.dataset.avatar;
const other = isP1 ? player2Avatar : player1Avatar;
if (picked === other) {
// Both players can't share the exact same avatar — nudge, don't select.
opt.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' },
{ transform: 'translateX(6px)' }, { transform: 'translateX(0)' }],
{ duration: 250 }
);
return;
}
container.querySelectorAll('.avatar-option').forEach((o) => {
o.classList.remove('selected');
o.setAttribute('aria-checked', 'false');
});
opt.classList.add('selected');
opt.setAttribute('aria-checked', 'true');
if (isP1) player1Avatar = picked; else player2Avatar = picked;
saveState();
});
}
wireAvatarGroup(player1AvatarOptions, true);
wireAvatarGroup(player2AvatarOptions, false);
startBtn.addEventListener('click', startGame);
resetBtn.addEventListener('click', resetGame);
playAgainBtn.addEventListener('click', resetGame);
changeNamesBtn.addEventListener('click', () => showScreen(nameScreen));
changeNamesBtn2.addEventListener('click', () => showScreen(nameScreen));
resetScoresBtn.addEventListener('click', () => {
player1Score = 0; player2Score = 0;
score1Element.textContent = '0';
score2Element.textContent = '0';
saveState();
});
player1Input.addEventListener('keypress', (e) => { if (e.key === 'Enter') player2Input.focus(); });
player2Input.addEventListener('keypress', (e) => { if (e.key === 'Enter') startGame(); });
player1Input.addEventListener('input', saveState);
player2Input.addEventListener('input', saveState);
muteBtn.addEventListener('click', () => {
soundOn = !soundOn;
muteBtn.setAttribute('aria-pressed', soundOn ? 'false' : 'true');
if (soundOn) { getAudioCtx(); playMoveSound(campOf(player1Avatar)); }
saveState();
});
/* ---------- Board interactions (mouse + keyboard) ---------- */
cells.forEach((cell) => {
cell.addEventListener('click', handleCellClick);
cell.addEventListener('keydown', handleCellKeydown);
});
function handleCellKeydown(e) {
const idx = Number(e.currentTarget.dataset.index);
let target = null;
switch (e.key) {
case 'ArrowRight': target = idx % 3 === 2 ? idx - 2 : idx + 1; break;
case 'ArrowLeft': target = idx % 3 === 0 ? idx + 2 : idx - 1; break;
case 'ArrowDown': target = (idx + 3) % 9; break;
case 'ArrowUp': target = (idx + 6) % 9; break;
default: return;
}
e.preventDefault();
cells[target].focus();
}
function handleCellClick(e) {
const cell = e.currentTarget;
const index = Number(cell.dataset.index);
if (gameBoard[index] !== '' || !gameActive) return;
getAudioCtx();
makeMove(cell, index);
checkResult();
}
function activeAvatar() { return currentPlayer === 'X' ? player1Avatar : player2Avatar; }
function activeName() { return currentPlayer === 'X' ? player1Name : player2Name; }
function makeMove(cell, index) {
gameBoard[index] = currentPlayer;
const avatar = activeAvatar();
const camp = campOf(avatar);
cell.textContent = avatar;
cell.classList.add('filled', `camp-${camp}`);
cell.setAttribute('aria-label', `Cell ${index + 1}, ${activeName()}`);
createParticleBurst(cell, camp, avatar);
playMoveSound(camp);
if (camp === 'shred') {
gameBoardElement.classList.remove('shake');
void gameBoardElement.offsetWidth;
gameBoardElement.classList.add('shake');
}
}
function checkResult() {
let winningCombo = null;
for (const combo of winningCombinations) {
const [a, b, c] = combo;
if (gameBoard[a] && gameBoard[a] === gameBoard[b] && gameBoard[a] === gameBoard[c]) {
winningCombo = combo;
break;
}
}
if (winningCombo) {
gameActive = false;
const winnerIsP1 = currentPlayer === 'X';
const camp = campOf(winnerIsP1 ? player1Avatar : player2Avatar);
winningCombo.forEach((i) => {
cells[i].classList.remove('camp-sparkle', 'camp-shred');
cells[i].classList.add('winning', `camp-${camp}`);
});
const name = winnerIsP1 ? player1Name : player2Name;
const avatar = winnerIsP1 ? player1Avatar : player2Avatar;
if (winnerIsP1) { player1Score++; bumpScore(score1Element, player1Score); }
else { player2Score++; bumpScore(score2Element, player2Score); }
saveState();
gameStatus.textContent = `${name} wins!`;
setTimeout(() => {
const stage = STAGES[currentStage];
winnerTitle.textContent = stage.winnerTitle;
winnerName.textContent = name;
winnerAvatar.textContent = avatar;
winnerSub.textContent = stage.winnerSub;
winnerCard.classList.remove('camp-sparkle', 'camp-shred');
winnerCard.classList.add(`camp-${camp}`);
renderCelebration(camp);
showScreen(winnerScreen);
playWinSound(camp);
}, 1100);
return;
}
if (!gameBoard.includes('')) {
gameActive = false;
gameStatus.textContent = STAGES[currentStage].drawText;
gameStatus.classList.add('draw');
playDrawSound();
return;
}
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
updateStatus();
updatePlayerHighlight();
}
function bumpScore(el, value) {
el.textContent = value;
el.classList.add('score-update');
setTimeout(() => el.classList.remove('score-update'), 500);
}
function updateStatus() {
gameStatus.classList.remove('draw');
gameStatus.textContent = `${activeName()}'s Turn`;
}
function updatePlayerHighlight() {
player1DisplayBox.classList.toggle('active', currentPlayer === 'X');
player2DisplayBox.classList.toggle('active', currentPlayer === 'O');
}
/* ---------- Apply per-player camps to game UI ---------- */
function applyPlayerCamps() {
const c1 = campOf(player1Avatar);
const c2 = campOf(player2Avatar);
player1DisplayBox.classList.remove('camp-sparkle', 'camp-shred');
player2DisplayBox.classList.remove('camp-sparkle', 'camp-shred');
score1Item.classList.remove('camp-sparkle', 'camp-shred');
score2Item.classList.remove('camp-sparkle', 'camp-shred');
player1DisplayBox.classList.add(`camp-${c1}`);
player2DisplayBox.classList.add(`camp-${c2}`);
score1Item.classList.add(`camp-${c1}`);
score2Item.classList.add(`camp-${c2}`);
}
/* ---------- Screens ---------- */
function showScreen(screen) {
[nameScreen, gameScreen, winnerScreen].forEach((s) => s.classList.remove('active'));
screen.classList.add('active');
scheduleFit();
}
/* ---------- Fit-to-viewport ----------
Scale the active card (via the --fit custom prop) so the whole screen
always fits — it's tic-tac-toe, no scrolling, ever. */
let fitRAF = 0;
function scheduleFit() {
if (fitRAF) cancelAnimationFrame(fitRAF);
fitRAF = requestAnimationFrame(() => { fitRAF = 0; fitActiveScreen(); });
}
function fitActiveScreen() {
const screen = document.querySelector('.screen.active');
if (!screen) return;
const card = screen.querySelector('.card');
if (!card) return;
// offsetWidth/Height ignore transforms, so we always read the natural size.
// offsetWidth/Height are layout sizes (they ignore transforms), so we
// always measure the card's natural size even after a prior scale.
const w = card.offsetWidth;
const h = card.offsetHeight;
if (!w || !h) return;
const margin = 16; // small breathing room around the card
const scale = Math.min(1, (window.innerWidth - margin) / w, (window.innerHeight - margin) / h);
// Fold the fit scale into the centering translate. The card is position:fixed
// at top/left 50%, so translate(-50%,-50%) centers it on the viewport and the
// scale shrinks it to fit — together, no scrolling at any size.
card.style.transform = `translate(-50%, -50%) scale(${scale})`;
}
window.addEventListener('resize', scheduleFit);
window.addEventListener('orientationchange', scheduleFit);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(scheduleFit);
/* ---------- Lifecycle ---------- */
function startGame() {
player1Name = player1Input.value.trim() || 'Player 1';
player2Name = player2Input.value.trim() || 'Player 2';
player1Display.textContent = player1Name;
player2Display.textContent = player2Name;
player1SymbolElement.textContent = player1Avatar;
player2SymbolElement.textContent = player2Avatar;
score1Name.textContent = player1Name;
score2Name.textContent = player2Name;
score1Avatar.textContent = player1Avatar;
score2Avatar.textContent = player2Avatar;
score1Element.textContent = player1Score;
score2Element.textContent = player2Score;
applyPlayerCamps();
saveState();
resetBoardState();
showScreen(gameScreen);
}
function resetBoardState() {
currentPlayer = Math.random() < 0.5 ? 'X' : 'O';
gameBoard = ['', '', '', '', '', '', '', '', ''];
gameActive = true;
cells.forEach((cell, i) => {
cell.textContent = '';
cell.classList.remove('winning', 'filled', 'camp-sparkle', 'camp-shred');
cell.setAttribute('aria-label', `Cell ${i + 1}, empty`);
});
gameStatus.classList.remove('draw');
updateStatus();
updatePlayerHighlight();
}
function resetGame() {
resetBoardState();
showScreen(gameScreen);
}
/* ---------- Effects ---------- */
function createParticleBurst(cell, camp, emoji) {
const rect = cell.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const set = CAMPS[camp].particles.concat(emoji);
const count = 8;
for (let i = 0; i < count; i++) {
const p = document.createElement('div');
p.className = 'particle';
p.textContent = set[Math.floor(Math.random() * set.length)];
const angle = (Math.PI * 2 * i) / count;
const dist = 80 + Math.random() * 40;
p.style.left = cx + 'px';
p.style.top = cy + 'px';
p.style.setProperty('--tx', Math.cos(angle) * dist + 'px');
p.style.setProperty('--ty', Math.sin(angle) * dist + 'px');
document.body.appendChild(p);
setTimeout(() => p.remove(), 800);
}
}
function renderCelebration(winnerCamp) {
celebration.innerHTML = '';
// Blend the stage's celebration set with the winner's camp particles.
const set = STAGES[currentStage].celebrate.concat(CAMPS[winnerCamp].particles);
const count = 30;
for (let i = 0; i < count; i++) {
const piece = document.createElement('span');
piece.className = 'confetti-piece';
piece.textContent = set[Math.floor(Math.random() * set.length)];
piece.style.left = Math.random() * 100 + '%';
piece.style.animationDuration = 2.5 + Math.random() * 2.5 + 's';
piece.style.animationDelay = Math.random() * 2 + 's';
piece.style.fontSize = 1.2 + Math.random() * 1.4 + 'em';
celebration.appendChild(piece);
}
}
/* ---------- Init ---------- */
function init() {
if (persisted.p1Name) player1Input.value = persisted.p1Name;
if (persisted.p2Name) player2Input.value = persisted.p2Name;
muteBtn.setAttribute('aria-pressed', soundOn ? 'false' : 'true');
score1Element.textContent = player1Score;
score2Element.textContent = player2Score;
renderAvatarGrid(player1AvatarOptions, player1Avatar);
renderAvatarGrid(player2AvatarOptions, player2Avatar);
applyStage(currentStage, { persist: false });
applyPlayerCamps();
updatePlayerHighlight();
scheduleFit();
}
init();