Each player picks ANY avatar from a combined 16-emoji roster (8 sparkle + 8 shred); their color, move sound, and particle FX follow that avatar's CAMP, so one nibling can rock unicorns/rainbows while the other brings skulls/metal. A 3-way STAGE selector (Sparkle / Shred / Mixed) sets the global backdrop, fonts, and winner vibe. Camps and stages are orthogonal, driven by CSS custom properties (.camp-* and [data-stage]). - Combined avatar grids for both players with a same-avatar guard - Per-camp Web Audio SFX (sine chimes vs sawtooth power chords) + mute toggle - Per-player glow on displays, scoreboard, and board pieces; winner screen adopts the winner's camp; screen shake on shred moves; themed celebration - Persist names/avatars/stage/scores/mute via localStorage; reset-scores button - Fix nested-scroll / clipped-top bug (center with margin:auto, not flex + max-height); no horizontal overflow 320px-desktop; SVG favicon - Keyboard-navigable board, ARIA roles, live status, prefers-reduced-motion - Containerize for OrbStack: Dockerfile (nginx:alpine), nginx.conf (gzip + cache/security headers), docker-compose.yml — one command on :8080 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
524 lines
19 KiB
JavaScript
524 lines
19 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();
|
|
}
|
|
|
|
/* ---------- 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');
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
/* ---------- 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();
|
|
}
|
|
init();
|