Make tic-tac-toe epic: per-player camps, 3 stages, containerized
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>
This commit is contained in:
parent
4b7d42dc13
commit
0fc1d32099
9 changed files with 1231 additions and 992 deletions
15
.dockerignore
Normal file
15
.dockerignore
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Keep the build context tiny — only the static assets + configs are needed.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
|
test/
|
||||||
|
.playwright-mcp/
|
||||||
|
screenshots/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -7,4 +7,11 @@ Thumbs.db
|
||||||
|
|
||||||
# Test / tooling artifacts
|
# Test / tooling artifacts
|
||||||
/screenshots/
|
/screenshots/
|
||||||
|
.playwright-mcp/
|
||||||
|
node_modules/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Local Claude config + tooling artifacts
|
||||||
|
.claude/
|
||||||
|
*.png
|
||||||
|
*.jpeg
|
||||||
|
|
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Ultimate Tic-Tac-Toe — static site served by nginx.
|
||||||
|
# No build step: copy the static assets into the nginx web root.
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
# Tuned server config (gzip, cache + security headers, SPA-safe fallback).
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Static assets.
|
||||||
|
COPY index.html style.css script.js /usr/share/nginx/html/
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Liveness probe for `docker ps` / orchestration.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q -O /dev/null http://localhost/ || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
67
README.md
67
README.md
|
|
@ -1,7 +1,68 @@
|
||||||
# Kara's ultimate tic tac toe
|
# Ultimate Tic-Tac-Toe
|
||||||
|
|
||||||
## Objective
|
A two-player, hot-seat tic-tac-toe game for the browser where **each player brings their own flavor**.
|
||||||
|
|
||||||
Design an ultimate browser based tic tac toe game with the following features:
|
Every player picks **any** avatar from a combined roster — sparkle *or* shred — and that choice sets their personal color, move sound, and particle effects. One player can rock 🦄 unicorns and rainbows while the other brings 💀 skulls and metal. A separate **stage** selector sets the global vibe.
|
||||||
|
|
||||||
|
## Camps & stages
|
||||||
|
|
||||||
|
**Camps** (per player, set by the chosen avatar):
|
||||||
|
|
||||||
|
- **✨ Sparkle** — ⭐🦄🌸🎀🦋🌈🎨🍭 · pink glow, bright sine chimes, sparkle particles.
|
||||||
|
- **💀 Shred** — 💀☠️🎸🤘🔥⚡🏴☠️🐉 · red glow, sawtooth power-chords, fire/lightning particles, board shake.
|
||||||
|
|
||||||
|
**Stages** (global, pick on the setup screen):
|
||||||
|
|
||||||
|
- **✨ Sparkle** — pastel gradient, glassmorphism cards, twinkling stars.
|
||||||
|
- **💀 Shred** — black & red, rising embers, lightning flashes, metal lettering.
|
||||||
|
- **⚔️ Mixed** — neutral dark arena where both camps' colors pop; both backdrops play. Great when the two players don't agree.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Free-for-all avatars** — each player's grid shows all 16; a guard stops both choosing the *same* one.
|
||||||
|
- **Per-player identity** carries through the whole game: side panels, scoreboard, and each player's pieces on the board glow in their camp color, with camp-specific move sounds and particle bursts.
|
||||||
|
- **Three stages** driven entirely by CSS custom properties (`[data-stage]`); the winner screen adopts the winner's camp.
|
||||||
|
- **Web Audio** SFX (no asset files); mute toggle, top-right.
|
||||||
|
- **Persistent** names, avatars, stage, scores, and mute via `localStorage`.
|
||||||
|
- **Accessible** — keyboard-navigable board (arrow keys + Enter/Space), ARIA roles and live status, visible focus rings, `prefers-reduced-motion`.
|
||||||
|
- **Responsive & scroll-safe** — no horizontal overflow or clipped content from 320 px to desktop (the old nested-scroll / clipped-top bug is fixed: the layout now centers with `margin:auto` instead of fragile flex centering + `max-height`).
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
Static site, no build step.
|
||||||
|
|
||||||
|
**Local web server** (so `localStorage` + audio behave like production)
|
||||||
|
```bash
|
||||||
|
python3 -m http.server 8000
|
||||||
|
# → http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker / OrbStack** (nginx, production-style headers)
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
# → http://localhost:8080
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
End-to-end tests drive real Chromium against the running app via `playwright-core` and print a plain-text PASS/FAIL report (avatar roster, mixed-camp play, stage switching, win/draw flows, persistence, responsive overflow at five widths, keyboard a11y, console cleanliness). Screenshots land in `screenshots/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# with a server (or the container) running on :8080
|
||||||
|
PW_CORE="$(node -e "console.log(require.resolve('playwright-core'))")" \
|
||||||
|
node test/e2e.mjs http://localhost:8080
|
||||||
|
# optional: PW_CHROMIUM=/path/to/Chromium to pin a browser build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
index.html markup; stage selector + three screens (setup / game / winner)
|
||||||
|
style.css stages ([data-stage]) + per-player camps (.camp-sparkle/.camp-shred)
|
||||||
|
script.js engine: avatar roster, camps, stages, audio, FX, persistence, a11y
|
||||||
|
Dockerfile nginx:alpine static image
|
||||||
|
nginx.conf gzip, cache + security headers
|
||||||
|
docker-compose.yml one-command run on :8080 via OrbStack
|
||||||
|
test/e2e.mjs Playwright end-to-end suite
|
||||||
|
```
|
||||||
|
|
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Run the game locally with OrbStack's Docker engine:
|
||||||
|
# docker compose up -d --build
|
||||||
|
# open http://localhost:8080
|
||||||
|
# docker compose down
|
||||||
|
services:
|
||||||
|
tictactoe:
|
||||||
|
build: .
|
||||||
|
image: tictactoe:latest
|
||||||
|
container_name: tictactoe
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: unless-stopped
|
||||||
169
index.html
169
index.html
|
|
@ -1,130 +1,149 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-stage="mixed">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>✨ Ultimate Tic-Tac-Toe ✨</title>
|
<title>Ultimate Tic-Tac-Toe</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%23%EF%B8%8F%E2%83%A3%3C/text%3E%3C/svg%3E">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Fredoka:wght@500;600;700&family=Metal+Mania&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Animated backdrop: stars (sparkle) + embers/lightning (shred); both show on mixed -->
|
||||||
|
<div class="backdrop" aria-hidden="true">
|
||||||
<div class="stars"></div>
|
<div class="stars"></div>
|
||||||
|
<div class="embers"></div>
|
||||||
|
<div class="lightning"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="muteBtn" class="icon-btn" type="button" aria-pressed="false" title="Toggle sound" aria-label="Toggle sound">
|
||||||
|
<span class="icon-on">🔊</span><span class="icon-off">🔇</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Player Name Input Screen -->
|
<!-- Player Setup Screen -->
|
||||||
<div id="nameScreen" class="screen active">
|
<div id="nameScreen" class="screen active">
|
||||||
<div class="name-card">
|
<div class="card name-card">
|
||||||
<h1 class="title">✨ Ultimate Tic-Tac-Toe ✨</h1>
|
<h1 class="title" id="setupTitle">Ultimate Tic-Tac-Toe</h1>
|
||||||
<div class="sparkle-divider"></div>
|
<div class="sparkle-divider"></div>
|
||||||
|
|
||||||
|
<!-- Stage (global vibe) selector -->
|
||||||
|
<div class="stage-selector">
|
||||||
|
<span class="stage-label">Choose your arena</span>
|
||||||
|
<div class="stage-options" role="radiogroup" aria-label="Choose arena / stage">
|
||||||
|
<button class="stage-option" data-stage="sparkle" role="radio" aria-checked="false" type="button">
|
||||||
|
<span class="stage-emoji">✨</span>
|
||||||
|
<span class="stage-name">Sparkle</span>
|
||||||
|
<span class="stage-desc">Rainbows & unicorns</span>
|
||||||
|
</button>
|
||||||
|
<button class="stage-option" data-stage="mixed" role="radio" aria-checked="true" type="button">
|
||||||
|
<span class="stage-emoji">⚔️</span>
|
||||||
|
<span class="stage-name">Mixed</span>
|
||||||
|
<span class="stage-desc">Anything goes</span>
|
||||||
|
</button>
|
||||||
|
<button class="stage-option" data-stage="shred" role="radio" aria-checked="false" type="button">
|
||||||
|
<span class="stage-emoji">💀</span>
|
||||||
|
<span class="stage-name">Shred</span>
|
||||||
|
<span class="stage-desc">Skulls & metal</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="setup-hint">Each player picks <strong>any</strong> avatar — sparkle or shred, your call. 🌈🤘</p>
|
||||||
|
|
||||||
|
<div class="players-setup">
|
||||||
|
<div class="player-setup" id="p1Setup">
|
||||||
<div class="name-input-group">
|
<div class="name-input-group">
|
||||||
<label for="player1Name">
|
<label for="player1Name">
|
||||||
<span class="player-icon">🌟</span>
|
<span class="player-icon p1-icon">🌟</span>
|
||||||
Player 1 Name
|
Player 1
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="player1Name" placeholder="Enter your name..." maxlength="15">
|
<input type="text" id="player1Name" placeholder="Enter name..." maxlength="15" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="player-avatar-group">
|
||||||
|
<span class="avatar-label">Pick an avatar</span>
|
||||||
|
<div class="avatar-options" id="player1Avatars" role="radiogroup" aria-label="Player 1 avatar"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="player-setup" id="p2Setup">
|
||||||
<div class="name-input-group">
|
<div class="name-input-group">
|
||||||
<label for="player2Name">
|
<label for="player2Name">
|
||||||
<span class="player-icon">💫</span>
|
<span class="player-icon p2-icon">💫</span>
|
||||||
Player 2 Name
|
Player 2
|
||||||
</label>
|
</label>
|
||||||
<input type="text" id="player2Name" placeholder="Enter your name..." maxlength="15">
|
<input type="text" id="player2Name" placeholder="Enter name..." maxlength="15" autocomplete="off">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="avatar-section">
|
|
||||||
<h3 class="avatar-title">Choose Your Avatars!</h3>
|
|
||||||
<div class="avatar-selection">
|
|
||||||
<div class="player-avatar-group">
|
|
||||||
<label>Player 1</label>
|
|
||||||
<div class="avatar-options" id="player1Avatars">
|
|
||||||
<div class="avatar-option selected" data-avatar="⭐">⭐</div>
|
|
||||||
<div class="avatar-option" data-avatar="🦄">🦄</div>
|
|
||||||
<div class="avatar-option" data-avatar="🌸">🌸</div>
|
|
||||||
<div class="avatar-option" data-avatar="🎀">🎀</div>
|
|
||||||
<div class="avatar-option" data-avatar="🦋">🦋</div>
|
|
||||||
<div class="avatar-option" data-avatar="🌈">🌈</div>
|
|
||||||
<div class="avatar-option" data-avatar="🎨">🎨</div>
|
|
||||||
<div class="avatar-option" data-avatar="🎪">🎪</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="player-avatar-group">
|
<div class="player-avatar-group">
|
||||||
<label>Player 2</label>
|
<span class="avatar-label">Pick an avatar</span>
|
||||||
<div class="avatar-options" id="player2Avatars">
|
<div class="avatar-options" id="player2Avatars" role="radiogroup" aria-label="Player 2 avatar"></div>
|
||||||
<div class="avatar-option" data-avatar="⭐">⭐</div>
|
|
||||||
<div class="avatar-option" data-avatar="🦄">🦄</div>
|
|
||||||
<div class="avatar-option selected" data-avatar="🌸">🌸</div>
|
|
||||||
<div class="avatar-option" data-avatar="🎀">🎀</div>
|
|
||||||
<div class="avatar-option" data-avatar="🦋">🦋</div>
|
|
||||||
<div class="avatar-option" data-avatar="🌈">🌈</div>
|
|
||||||
<div class="avatar-option" data-avatar="🎨">🎨</div>
|
|
||||||
<div class="avatar-option" data-avatar="🎪">🎪</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="startBtn" class="start-btn">
|
<button id="startBtn" class="btn btn-primary" type="button">
|
||||||
Start Playing! 🎮
|
<span class="btn-label">Start Battle!</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Game Screen -->
|
<!-- Game Screen -->
|
||||||
<div id="gameScreen" class="screen">
|
<div id="gameScreen" class="screen">
|
||||||
<div class="game-card">
|
<div class="card game-card">
|
||||||
<h1 class="game-title">✨ Tic-Tac-Toe ✨</h1>
|
<h1 class="game-title" id="gameTitle">Tic-Tac-Toe</h1>
|
||||||
|
|
||||||
<div class="game-layout">
|
<div class="game-layout">
|
||||||
<!-- Left Side: Game Area -->
|
|
||||||
<div class="game-area">
|
<div class="game-area">
|
||||||
<div class="player-info">
|
<div class="player-info">
|
||||||
<div class="player-display player1-display active">
|
<div class="player-display player1-display active">
|
||||||
<span class="player-symbol" id="player1Symbol">X</span>
|
<span class="player-symbol" id="player1Symbol">⭐</span>
|
||||||
<span class="player-name" id="player1Display"></span>
|
<span class="player-name" id="player1Display">Player 1</span>
|
||||||
<div class="player-glow"></div>
|
<div class="player-glow"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vs">VS</div>
|
<div class="vs" id="vsLabel">VS</div>
|
||||||
<div class="player-display player2-display">
|
<div class="player-display player2-display">
|
||||||
<span class="player-symbol" id="player2Symbol">O</span>
|
<span class="player-symbol" id="player2Symbol">💀</span>
|
||||||
<span class="player-name" id="player2Display"></span>
|
<span class="player-name" id="player2Display">Player 2</span>
|
||||||
<div class="player-glow"></div>
|
<div class="player-glow"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="game-board" id="gameBoard">
|
<div class="game-status" id="gameStatus" aria-live="polite"></div>
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
<div class="cell" data-cell></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="game-status" id="gameStatus"></div>
|
<div class="game-board" id="gameBoard" role="grid" aria-label="Tic-tac-toe board">
|
||||||
|
<button class="cell" data-cell data-index="0" role="gridcell" aria-label="Cell 1, empty"></button>
|
||||||
|
<button class="cell" data-cell data-index="1" role="gridcell" aria-label="Cell 2, empty"></button>
|
||||||
|
<button class="cell" data-cell data-index="2" role="gridcell" aria-label="Cell 3, empty"></button>
|
||||||
|
<button class="cell" data-cell data-index="3" role="gridcell" aria-label="Cell 4, empty"></button>
|
||||||
|
<button class="cell" data-cell data-index="4" role="gridcell" aria-label="Cell 5, empty"></button>
|
||||||
|
<button class="cell" data-cell data-index="5" role="gridcell" aria-label="Cell 6, empty"></button>
|
||||||
|
<button class="cell" data-cell data-index="6" role="gridcell" aria-label="Cell 7, empty"></button>
|
||||||
|
<button class="cell" data-cell data-index="7" role="gridcell" aria-label="Cell 8, empty"></button>
|
||||||
|
<button class="cell" data-cell data-index="8" role="gridcell" aria-label="Cell 9, empty"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button id="resetBtn" class="game-btn">🔄 New Game</button>
|
<button id="resetBtn" class="btn btn-ghost" type="button">🔄 New Round</button>
|
||||||
<button id="changeNamesBtn" class="game-btn">👥 Change Names</button>
|
<button id="changeNamesBtn" class="btn btn-ghost" type="button">👥 Change Players</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Side: Score Board -->
|
|
||||||
<div class="score-sidebar">
|
<div class="score-sidebar">
|
||||||
<h2 class="score-title">Scoreboard</h2>
|
<h2 class="score-title" id="scoreTitle">Scoreboard</h2>
|
||||||
<div class="score-item player1-score">
|
<div class="score-item player1-score">
|
||||||
<span class="score-avatar" id="score1Avatar">⭐</span>
|
<span class="score-avatar" id="score1Avatar">⭐</span>
|
||||||
<span class="score-name" id="score1Name">Player 1</span>
|
<span class="score-name" id="score1Name">Player 1</span>
|
||||||
<span class="score-value" id="score1">0</span>
|
<span class="score-value" id="score1">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-divider">🏆</div>
|
<div class="score-divider" id="scoreDivider">🏆</div>
|
||||||
<div class="score-item player2-score">
|
<div class="score-item player2-score">
|
||||||
<span class="score-avatar" id="score2Avatar">🌸</span>
|
<span class="score-avatar" id="score2Avatar">💀</span>
|
||||||
<span class="score-name" id="score2Name">Player 2</span>
|
<span class="score-name" id="score2Name">Player 2</span>
|
||||||
<span class="score-value" id="score2">0</span>
|
<span class="score-value" id="score2">0</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="resetScoresBtn" class="btn btn-tiny" type="button">Reset Scores</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,18 +151,16 @@
|
||||||
|
|
||||||
<!-- Winner Screen -->
|
<!-- Winner Screen -->
|
||||||
<div id="winnerScreen" class="screen">
|
<div id="winnerScreen" class="screen">
|
||||||
<div class="winner-card">
|
<div class="card winner-card">
|
||||||
<div class="confetti"></div>
|
<div class="celebration" id="celebration" aria-hidden="true"></div>
|
||||||
<h1 class="winner-title">🎉 Winner! 🎉</h1>
|
<h1 class="winner-title" id="winnerTitle">Winner!</h1>
|
||||||
<div class="trophy" id="winnerAvatar">🏆</div>
|
<div class="trophy" id="winnerAvatar">🏆</div>
|
||||||
<p class="winner-name" id="winnerName"></p>
|
<p class="winner-name" id="winnerName"></p>
|
||||||
<div class="fireworks">
|
<p class="winner-sub" id="winnerSub"></p>
|
||||||
<div class="firework"></div>
|
<div class="winner-buttons">
|
||||||
<div class="firework"></div>
|
<button id="playAgainBtn" class="btn btn-primary" type="button">Play Again!</button>
|
||||||
<div class="firework"></div>
|
<button id="changeNamesBtn2" class="btn btn-ghost" type="button">Change Players</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="playAgainBtn" class="start-btn">Play Again! 🎮</button>
|
|
||||||
<button id="changeNamesBtn2" class="game-btn">Change Names</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
27
nginx.conf
Normal file
27
nginx.conf
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript image/svg+xml;
|
||||||
|
gzip_min_length 256;
|
||||||
|
|
||||||
|
# Static assets: cache for a week (filenames are stable for this app).
|
||||||
|
location ~* \.(css|js|svg|png|ico)$ {
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTML entry point: always revalidate so redeploys show up immediately.
|
||||||
|
location / {
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
730
script.js
730
script.js
|
|
@ -1,15 +1,112 @@
|
||||||
// Game State
|
/* =========================================================================
|
||||||
|
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 currentPlayer = 'X';
|
||||||
let gameBoard = ['', '', '', '', '', '', '', '', ''];
|
let gameBoard = ['', '', '', '', '', '', '', '', ''];
|
||||||
let gameActive = true;
|
let gameActive = true;
|
||||||
let player1Name = '';
|
let player1Name = '';
|
||||||
let player2Name = '';
|
let player2Name = '';
|
||||||
let player1Avatar = '⭐';
|
let player1Avatar = AVATARS.some((a) => a.emoji === persisted.p1Avatar) ? persisted.p1Avatar : '⭐';
|
||||||
let player2Avatar = '🌸';
|
let player2Avatar = AVATARS.some((a) => a.emoji === persisted.p2Avatar) ? persisted.p2Avatar : '💀';
|
||||||
let player1Score = 0;
|
let player1Score = persisted.scores ? persisted.scores.p1 : 0;
|
||||||
let player2Score = 0;
|
let player2Score = persisted.scores ? persisted.scores.p2 : 0;
|
||||||
|
let soundOn = persisted.soundOn !== false;
|
||||||
|
|
||||||
// DOM Elements
|
/* ---------- DOM ---------- */
|
||||||
|
const root = document.documentElement;
|
||||||
const nameScreen = document.getElementById('nameScreen');
|
const nameScreen = document.getElementById('nameScreen');
|
||||||
const gameScreen = document.getElementById('gameScreen');
|
const gameScreen = document.getElementById('gameScreen');
|
||||||
const winnerScreen = document.getElementById('winnerScreen');
|
const winnerScreen = document.getElementById('winnerScreen');
|
||||||
|
|
@ -17,112 +114,329 @@ const winnerScreen = document.getElementById('winnerScreen');
|
||||||
const player1Input = document.getElementById('player1Name');
|
const player1Input = document.getElementById('player1Name');
|
||||||
const player2Input = document.getElementById('player2Name');
|
const player2Input = document.getElementById('player2Name');
|
||||||
const startBtn = document.getElementById('startBtn');
|
const startBtn = document.getElementById('startBtn');
|
||||||
|
const startLabel = startBtn.querySelector('.btn-label');
|
||||||
|
|
||||||
const player1Display = document.getElementById('player1Display');
|
const player1Display = document.getElementById('player1Display');
|
||||||
const player2Display = document.getElementById('player2Display');
|
const player2Display = document.getElementById('player2Display');
|
||||||
|
const player1DisplayBox = document.querySelector('.player1-display');
|
||||||
|
const player2DisplayBox = document.querySelector('.player2-display');
|
||||||
const gameStatus = document.getElementById('gameStatus');
|
const gameStatus = document.getElementById('gameStatus');
|
||||||
const gameBoardElement = document.getElementById('gameBoard');
|
const gameBoardElement = document.getElementById('gameBoard');
|
||||||
const cells = document.querySelectorAll('[data-cell]');
|
const cells = Array.from(document.querySelectorAll('[data-cell]'));
|
||||||
|
|
||||||
const resetBtn = document.getElementById('resetBtn');
|
const resetBtn = document.getElementById('resetBtn');
|
||||||
const changeNamesBtn = document.getElementById('changeNamesBtn');
|
const changeNamesBtn = document.getElementById('changeNamesBtn');
|
||||||
const changeNamesBtn2 = document.getElementById('changeNamesBtn2');
|
const changeNamesBtn2 = document.getElementById('changeNamesBtn2');
|
||||||
const playAgainBtn = document.getElementById('playAgainBtn');
|
const playAgainBtn = document.getElementById('playAgainBtn');
|
||||||
|
const resetScoresBtn = document.getElementById('resetScoresBtn');
|
||||||
|
const muteBtn = document.getElementById('muteBtn');
|
||||||
|
|
||||||
const winnerName = document.getElementById('winnerName');
|
const winnerName = document.getElementById('winnerName');
|
||||||
const winnerAvatar = document.getElementById('winnerAvatar');
|
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');
|
||||||
|
|
||||||
// Avatar elements
|
|
||||||
const player1AvatarOptions = document.getElementById('player1Avatars');
|
const player1AvatarOptions = document.getElementById('player1Avatars');
|
||||||
const player2AvatarOptions = document.getElementById('player2Avatars');
|
const player2AvatarOptions = document.getElementById('player2Avatars');
|
||||||
|
|
||||||
// Score elements
|
|
||||||
const score1Element = document.getElementById('score1');
|
const score1Element = document.getElementById('score1');
|
||||||
const score2Element = document.getElementById('score2');
|
const score2Element = document.getElementById('score2');
|
||||||
const score1Name = document.getElementById('score1Name');
|
const score1Name = document.getElementById('score1Name');
|
||||||
const score2Name = document.getElementById('score2Name');
|
const score2Name = document.getElementById('score2Name');
|
||||||
const score1Avatar = document.getElementById('score1Avatar');
|
const score1Avatar = document.getElementById('score1Avatar');
|
||||||
const score2Avatar = document.getElementById('score2Avatar');
|
const score2Avatar = document.getElementById('score2Avatar');
|
||||||
|
const score1Item = document.querySelector('.player1-score');
|
||||||
|
const score2Item = document.querySelector('.player2-score');
|
||||||
|
|
||||||
// Symbol elements
|
|
||||||
const player1SymbolElement = document.getElementById('player1Symbol');
|
const player1SymbolElement = document.getElementById('player1Symbol');
|
||||||
const player2SymbolElement = document.getElementById('player2Symbol');
|
const player2SymbolElement = document.getElementById('player2Symbol');
|
||||||
|
|
||||||
// Winning Combinations
|
const stageOptions = Array.from(document.querySelectorAll('.stage-option'));
|
||||||
|
|
||||||
const winningCombinations = [
|
const winningCombinations = [
|
||||||
[0, 1, 2],
|
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||||
[3, 4, 5],
|
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||||
[6, 7, 8],
|
[0, 4, 8], [2, 4, 6],
|
||||||
[0, 3, 6],
|
|
||||||
[1, 4, 7],
|
|
||||||
[2, 5, 8],
|
|
||||||
[0, 4, 8],
|
|
||||||
[2, 4, 6]
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Event Listeners
|
/* ---------- 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);
|
startBtn.addEventListener('click', startGame);
|
||||||
resetBtn.addEventListener('click', resetGame);
|
resetBtn.addEventListener('click', resetGame);
|
||||||
changeNamesBtn.addEventListener('click', showNameScreen);
|
|
||||||
changeNamesBtn2.addEventListener('click', showNameScreen);
|
|
||||||
playAgainBtn.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();
|
||||||
|
});
|
||||||
|
|
||||||
cells.forEach(cell => {
|
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('click', handleCellClick);
|
||||||
|
cell.addEventListener('keydown', handleCellKeydown);
|
||||||
});
|
});
|
||||||
|
function handleCellKeydown(e) {
|
||||||
// Avatar selection listeners
|
const idx = Number(e.currentTarget.dataset.index);
|
||||||
player1AvatarOptions.addEventListener('click', (e) => {
|
let target = null;
|
||||||
if (e.target.classList.contains('avatar-option')) {
|
switch (e.key) {
|
||||||
player1AvatarOptions.querySelectorAll('.avatar-option').forEach(opt => {
|
case 'ArrowRight': target = idx % 3 === 2 ? idx - 2 : idx + 1; break;
|
||||||
opt.classList.remove('selected');
|
case 'ArrowLeft': target = idx % 3 === 0 ? idx + 2 : idx - 1; break;
|
||||||
});
|
case 'ArrowDown': target = (idx + 3) % 9; break;
|
||||||
e.target.classList.add('selected');
|
case 'ArrowUp': target = (idx + 6) % 9; break;
|
||||||
player1Avatar = e.target.dataset.avatar;
|
default: return;
|
||||||
}
|
}
|
||||||
});
|
e.preventDefault();
|
||||||
|
cells[target].focus();
|
||||||
player2AvatarOptions.addEventListener('click', (e) => {
|
|
||||||
if (e.target.classList.contains('avatar-option')) {
|
|
||||||
player2AvatarOptions.querySelectorAll('.avatar-option').forEach(opt => {
|
|
||||||
opt.classList.remove('selected');
|
|
||||||
});
|
|
||||||
e.target.classList.add('selected');
|
|
||||||
player2Avatar = e.target.dataset.avatar;
|
|
||||||
}
|
}
|
||||||
});
|
function handleCellClick(e) {
|
||||||
|
const cell = e.currentTarget;
|
||||||
// Allow Enter key to start game
|
const index = Number(cell.dataset.index);
|
||||||
player1Input.addEventListener('keypress', (e) => {
|
if (gameBoard[index] !== '' || !gameActive) return;
|
||||||
if (e.key === 'Enter') {
|
getAudioCtx();
|
||||||
player2Input.focus();
|
makeMove(cell, index);
|
||||||
|
checkResult();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
player2Input.addEventListener('keypress', (e) => {
|
function activeAvatar() { return currentPlayer === 'X' ? player1Avatar : player2Avatar; }
|
||||||
if (e.key === 'Enter') {
|
function activeName() { return currentPlayer === 'X' ? player1Name : player2Name; }
|
||||||
startGame();
|
|
||||||
|
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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Functions
|
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() {
|
function startGame() {
|
||||||
// Get player names or use defaults
|
|
||||||
player1Name = player1Input.value.trim() || 'Player 1';
|
player1Name = player1Input.value.trim() || 'Player 1';
|
||||||
player2Name = player2Input.value.trim() || 'Player 2';
|
player2Name = player2Input.value.trim() || 'Player 2';
|
||||||
|
|
||||||
// Randomly choose starting player
|
|
||||||
currentPlayer = Math.random() < 0.5 ? 'X' : 'O';
|
|
||||||
|
|
||||||
// Update displays
|
|
||||||
player1Display.textContent = player1Name;
|
player1Display.textContent = player1Name;
|
||||||
player2Display.textContent = player2Name;
|
player2Display.textContent = player2Name;
|
||||||
|
|
||||||
// Update symbols with avatars
|
|
||||||
player1SymbolElement.textContent = player1Avatar;
|
player1SymbolElement.textContent = player1Avatar;
|
||||||
player2SymbolElement.textContent = player2Avatar;
|
player2SymbolElement.textContent = player2Avatar;
|
||||||
|
|
||||||
// Update score board
|
|
||||||
score1Name.textContent = player1Name;
|
score1Name.textContent = player1Name;
|
||||||
score2Name.textContent = player2Name;
|
score2Name.textContent = player2Name;
|
||||||
score1Avatar.textContent = player1Avatar;
|
score1Avatar.textContent = player1Avatar;
|
||||||
|
|
@ -130,257 +444,81 @@ function startGame() {
|
||||||
score1Element.textContent = player1Score;
|
score1Element.textContent = player1Score;
|
||||||
score2Element.textContent = player2Score;
|
score2Element.textContent = player2Score;
|
||||||
|
|
||||||
// Switch to game screen
|
applyPlayerCamps();
|
||||||
showGameScreen();
|
saveState();
|
||||||
updateStatus();
|
resetBoardState();
|
||||||
updatePlayerHighlight();
|
showScreen(gameScreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNameScreen() {
|
function resetBoardState() {
|
||||||
nameScreen.classList.add('active');
|
|
||||||
gameScreen.classList.remove('active');
|
|
||||||
winnerScreen.classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showGameScreen() {
|
|
||||||
nameScreen.classList.remove('active');
|
|
||||||
gameScreen.classList.add('active');
|
|
||||||
winnerScreen.classList.remove('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showWinnerScreen() {
|
|
||||||
nameScreen.classList.remove('active');
|
|
||||||
gameScreen.classList.remove('active');
|
|
||||||
winnerScreen.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCellClick(e) {
|
|
||||||
const cell = e.target;
|
|
||||||
const cellIndex = Array.from(cells).indexOf(cell);
|
|
||||||
|
|
||||||
// Check if cell is already filled or game is not active
|
|
||||||
if (gameBoard[cellIndex] !== '' || !gameActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make move
|
|
||||||
makeMove(cell, cellIndex);
|
|
||||||
|
|
||||||
// Check for win or draw
|
|
||||||
checkResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMove(cell, index) {
|
|
||||||
gameBoard[index] = currentPlayer;
|
|
||||||
|
|
||||||
// Use avatar instead of X/O
|
|
||||||
const avatar = currentPlayer === 'X' ? player1Avatar : player2Avatar;
|
|
||||||
cell.textContent = avatar;
|
|
||||||
cell.classList.add(currentPlayer.toLowerCase());
|
|
||||||
|
|
||||||
// Create particle burst effect
|
|
||||||
createParticleBurst(cell, avatar);
|
|
||||||
|
|
||||||
// Play move sound
|
|
||||||
playMoveSound(currentPlayer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkResult() {
|
|
||||||
let roundWon = false;
|
|
||||||
let winningCombo = null;
|
|
||||||
|
|
||||||
// Check all winning combinations
|
|
||||||
for (let i = 0; i < winningCombinations.length; i++) {
|
|
||||||
const [a, b, c] = winningCombinations[i];
|
|
||||||
if (gameBoard[a] && gameBoard[a] === gameBoard[b] && gameBoard[a] === gameBoard[c]) {
|
|
||||||
roundWon = true;
|
|
||||||
winningCombo = [a, b, c];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roundWon) {
|
|
||||||
// Highlight winning cells
|
|
||||||
winningCombo.forEach(index => {
|
|
||||||
cells[index].classList.add('winning');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine winner
|
|
||||||
const winner = currentPlayer === 'X' ? player1Name : player2Name;
|
|
||||||
const winnerAvatarSymbol = currentPlayer === 'X' ? player1Avatar : player2Avatar;
|
|
||||||
gameActive = false;
|
|
||||||
|
|
||||||
// Update score
|
|
||||||
if (currentPlayer === 'X') {
|
|
||||||
player1Score++;
|
|
||||||
score1Element.textContent = player1Score;
|
|
||||||
score1Element.classList.add('score-update');
|
|
||||||
setTimeout(() => score1Element.classList.remove('score-update'), 500);
|
|
||||||
} else {
|
|
||||||
player2Score++;
|
|
||||||
score2Element.textContent = player2Score;
|
|
||||||
score2Element.classList.add('score-update');
|
|
||||||
setTimeout(() => score2Element.classList.remove('score-update'), 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show winner screen after animation
|
|
||||||
setTimeout(() => {
|
|
||||||
winnerName.textContent = winner;
|
|
||||||
winnerAvatar.textContent = winnerAvatarSymbol;
|
|
||||||
showWinnerScreen();
|
|
||||||
playWinSound();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for draw
|
|
||||||
if (!gameBoard.includes('')) {
|
|
||||||
gameActive = false;
|
|
||||||
gameStatus.textContent = "It's a Draw! 🤝";
|
|
||||||
setTimeout(() => {
|
|
||||||
resetGame();
|
|
||||||
}, 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch player
|
|
||||||
currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
|
|
||||||
updateStatus();
|
|
||||||
updatePlayerHighlight();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus() {
|
|
||||||
const currentPlayerName = currentPlayer === 'X' ? player1Name : player2Name;
|
|
||||||
gameStatus.textContent = `${currentPlayerName}'s Turn (${currentPlayer})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePlayerHighlight() {
|
|
||||||
const player1Div = document.querySelector('.player1-display');
|
|
||||||
const player2Div = document.querySelector('.player2-display');
|
|
||||||
|
|
||||||
if (currentPlayer === 'X') {
|
|
||||||
player1Div.classList.add('active');
|
|
||||||
player2Div.classList.remove('active');
|
|
||||||
} else {
|
|
||||||
player1Div.classList.remove('active');
|
|
||||||
player2Div.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetGame() {
|
|
||||||
// Reset game state
|
|
||||||
currentPlayer = Math.random() < 0.5 ? 'X' : 'O';
|
currentPlayer = Math.random() < 0.5 ? 'X' : 'O';
|
||||||
gameBoard = ['', '', '', '', '', '', '', '', ''];
|
gameBoard = ['', '', '', '', '', '', '', '', ''];
|
||||||
gameActive = true;
|
gameActive = true;
|
||||||
|
cells.forEach((cell, i) => {
|
||||||
// Clear cells
|
|
||||||
cells.forEach(cell => {
|
|
||||||
cell.textContent = '';
|
cell.textContent = '';
|
||||||
cell.classList.remove('x', 'o', 'winning');
|
cell.classList.remove('winning', 'filled', 'camp-sparkle', 'camp-shred');
|
||||||
|
cell.setAttribute('aria-label', `Cell ${i + 1}, empty`);
|
||||||
});
|
});
|
||||||
|
gameStatus.classList.remove('draw');
|
||||||
// Update display
|
|
||||||
updateStatus();
|
updateStatus();
|
||||||
updatePlayerHighlight();
|
updatePlayerHighlight();
|
||||||
|
|
||||||
// Show game screen
|
|
||||||
showGameScreen();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function playWinSound() {
|
function resetGame() {
|
||||||
// Create a simple celebratory sound using Web Audio API
|
resetBoardState();
|
||||||
try {
|
showScreen(gameScreen);
|
||||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
||||||
|
|
||||||
// Play a series of ascending notes
|
|
||||||
const notes = [523.25, 587.33, 659.25, 783.99]; // C, D, E, G
|
|
||||||
|
|
||||||
notes.forEach((frequency, index) => {
|
|
||||||
const oscillator = audioContext.createOscillator();
|
|
||||||
const gainNode = audioContext.createGain();
|
|
||||||
|
|
||||||
oscillator.connect(gainNode);
|
|
||||||
gainNode.connect(audioContext.destination);
|
|
||||||
|
|
||||||
oscillator.frequency.value = frequency;
|
|
||||||
oscillator.type = 'sine';
|
|
||||||
|
|
||||||
const startTime = audioContext.currentTime + (index * 0.15);
|
|
||||||
const stopTime = startTime + 0.3;
|
|
||||||
|
|
||||||
gainNode.gain.setValueAtTime(0.3, startTime);
|
|
||||||
gainNode.gain.exponentialRampToValueAtTime(0.01, stopTime);
|
|
||||||
|
|
||||||
oscillator.start(startTime);
|
|
||||||
oscillator.stop(stopTime);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Audio not supported');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function playMoveSound(player) {
|
/* ---------- Effects ---------- */
|
||||||
// Create different sound for each player
|
function createParticleBurst(cell, camp, emoji) {
|
||||||
try {
|
|
||||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
||||||
const oscillator = audioContext.createOscillator();
|
|
||||||
const gainNode = audioContext.createGain();
|
|
||||||
|
|
||||||
oscillator.connect(gainNode);
|
|
||||||
gainNode.connect(audioContext.destination);
|
|
||||||
|
|
||||||
// Different frequencies for different players
|
|
||||||
oscillator.frequency.value = player === 'X' ? 523.25 : 659.25; // C or E
|
|
||||||
oscillator.type = 'sine';
|
|
||||||
|
|
||||||
const startTime = audioContext.currentTime;
|
|
||||||
const stopTime = startTime + 0.1;
|
|
||||||
|
|
||||||
gainNode.gain.setValueAtTime(0.2, startTime);
|
|
||||||
gainNode.gain.exponentialRampToValueAtTime(0.01, stopTime);
|
|
||||||
|
|
||||||
oscillator.start(startTime);
|
|
||||||
oscillator.stop(stopTime);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Audio not supported');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createParticleBurst(cell, emoji) {
|
|
||||||
// Get cell position
|
|
||||||
const rect = cell.getBoundingClientRect();
|
const rect = cell.getBoundingClientRect();
|
||||||
const centerX = rect.left + rect.width / 2;
|
const cx = rect.left + rect.width / 2;
|
||||||
const centerY = rect.top + rect.height / 2;
|
const cy = rect.top + rect.height / 2;
|
||||||
|
const set = CAMPS[camp].particles.concat(emoji);
|
||||||
// Create particles
|
const count = 8;
|
||||||
const particleCount = 8;
|
for (let i = 0; i < count; i++) {
|
||||||
const particles = ['✨', '💫', '⭐', '🌟', emoji];
|
const p = document.createElement('div');
|
||||||
|
p.className = 'particle';
|
||||||
for (let i = 0; i < particleCount; i++) {
|
p.textContent = set[Math.floor(Math.random() * set.length)];
|
||||||
const particle = document.createElement('div');
|
const angle = (Math.PI * 2 * i) / count;
|
||||||
particle.className = 'particle';
|
const dist = 80 + Math.random() * 40;
|
||||||
particle.textContent = particles[Math.floor(Math.random() * particles.length)];
|
p.style.left = cx + 'px';
|
||||||
|
p.style.top = cy + 'px';
|
||||||
// Random direction
|
p.style.setProperty('--tx', Math.cos(angle) * dist + 'px');
|
||||||
const angle = (Math.PI * 2 * i) / particleCount;
|
p.style.setProperty('--ty', Math.sin(angle) * dist + 'px');
|
||||||
const distance = 80 + Math.random() * 40;
|
document.body.appendChild(p);
|
||||||
const tx = Math.cos(angle) * distance;
|
setTimeout(() => p.remove(), 800);
|
||||||
const ty = Math.sin(angle) * distance;
|
|
||||||
|
|
||||||
particle.style.left = centerX + 'px';
|
|
||||||
particle.style.top = centerY + 'px';
|
|
||||||
particle.style.setProperty('--tx', tx + 'px');
|
|
||||||
particle.style.setProperty('--ty', ty + 'px');
|
|
||||||
|
|
||||||
document.body.appendChild(particle);
|
|
||||||
|
|
||||||
// Remove particle after animation
|
|
||||||
setTimeout(() => {
|
|
||||||
particle.remove();
|
|
||||||
}, 800);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize player highlight
|
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();
|
updatePlayerHighlight();
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue