Initial commit: game development

This commit is contained in:
robi committed 2026-09-23 16:31:05 +03:00
commit 176d7cc300
5 files changed
+1084

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
# Tic-Tac-Toe vs CPU — Implementation Plan
A polished, mobile-friendly tic-tac-toe game (X vs O) playable in the browser against a
random-move CPU opponent. Pure HTML/CSS/JS — no build step, no dependencies.
Open `index.html` directly in a browser to play.
## 1. Project Structure
```
game1/
├── index.html # Page structure (board, scoreboard, status, overlay)
├── style.css # All styling: glassmorphism dark theme, animations
├── game.js # Game logic: state, win detection, CPU, scoring, FX
└── docs/
├── PLAN.md # This document
└── mockup.svg # UI mockup (open in a browser to view)
```
## 2. Visual Design
Research (Dribbble/Figma/CodePen examples) shows the current popular style for
tic-tac-toe: **dark glassmorphism with neon accents**, frosted panels, soft glows,
and confetti celebrations on wins.
- **Background**: deep gradient (dark navy → violet) with two soft blurred
colored blobs (cyan + magenta) for depth.
- **Panels**: frosted glass cards — `background: rgba(255,255,255,0.06)`,
`backdrop-filter: blur(14px)`, 1px translucent white border, soft shadow.
(Blur kept modest for mobile performance.)
- **Colors**:
- X (player) — neon cyan `#22d3ee`
- O (CPU) — neon pink/magenta `#f472b6`
- Text: white / muted gray
- **Typography**: system font stack, bold title, tabular numerals for scores.
- **Mockup**: see `docs/mockup.svg` (open in a browser) for the envisioned
look — dark glass panels, neon cyan X / pink O with glow, gradient title.
- **Layout (top → bottom)**:
1. Title: "Tic-Tac-Toe" with subtle gradient text
2. Scoreboard: three glass chips — You (X) · CPU (O) · Draws
3. Status bar: "Your turn" / "CPU is thinking…" / result text
4. Board: 3×3 grid of glass cells
5. Buttons: "New Game" (primary) and "Reset Scores" (ghost)
## 3. Mobile-Friendliness
- `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`
- Board width: `min(88vw, 340px)`; cells are large touch targets (≥ 72px on phones).
- `touch-action: manipulation` on buttons/cells to kill double-tap zoom delay.
- No hover-only interaction: hover glow is a desktop enhancement only; taps work
everywhere. Use `:active` press feedback.
- Layout uses flex column, centered, max-width 420px — looks like a mobile app
on desktop too.
- `user-select: none` on the board to avoid text selection while tapping fast.
- Test: 360px-wide viewport, fast repeated taps, orientation change.
## 4. Game Rules
- Standard 3×3 tic-tac-toe. Player is **X**, CPU is **O**.
- Player always starts the round with X (keeps it simple and predictable).
- Click/tap an empty cell to place your mark.
- CPU responds on a random empty cell after a **600 ms "thinking" delay**
(feels natural, shows the "CPU is thinking…" status).
- Win: 3 in a row (8 winning lines). Draw: board full with no winner.
- After a round ends, a result overlay appears; "Play Again" starts a fresh round
(board clears, scores persist).
## 5. Scoring
- Track **You**, **CPU**, and **Draws** across rounds.
- Persisted in `localStorage` (key `ttt-scores`) so scores survive reloads.
- "Reset Scores" button zeroes them (with a short confirm-less toast feedback).
- Score chips animate a quick pop when the value changes.
## 6. Game-Over Reactions
- **Winning line**: an SVG line is drawn across the three winning cells with a
stroke-dashoffset animation (draws itself in ~400 ms).
- **Result overlay**: frosted glass modal fades/scales in with:
- Win → "You Win! 🎉" + confetti burst (DOM particles, ~40 pieces, random
colors/velocities, fall + fade over ~1.8 s, then removed).
- Loss → "CPU Wins" + a subtle red-tinted shake of the board.
- Draw → "It's a Draw" + neutral pulse.
- Overlay has a single big "Play Again" button.
- Small micro-feedback during play:
- Marks pop in with a scale+fade animation (150 ms).
- Empty cells glow faintly on hover (desktop) and brighten on `:active`.
- Status bar text cross-fades on turn change; "CPU is thinking…" pulses.
## 7. JavaScript Architecture (game.js)
Single file, plain functions + one state object:
```
state = {
board: Array(9).fill(null), // null | 'X' | 'O'
turn: 'X', // 'X' = player, 'O' = CPU
gameOver: false,
scores: { you: 0, cpu: 0, draws: 0 }
}
```
Modules (sections in the file):
1. **DOM refs** — cells, status, score chips, overlay, buttons.
2. **Win detection** — `WIN_LINES` = 8 combos; `checkWinner(board)` returns
`{winner, line}` or `{winner: 'draw'}` or `null`.
3. **Player move** — `onCellTap(i)`: ignore if occupied/game over/CPU turn;
place X, render, check end; else schedule CPU.
4. **CPU move** — `cpuMove()`: after 600 ms, pick a random empty index,
place O, render, check end.
5. **Render** — `renderMark(i, mark)` (pop animation), `renderStatus()`,
`renderScores()`, `drawWinLine(line)` (SVG overlay on the board).
6. **Round end** — `endRound(result)`: update + persist scores, show overlay
with appropriate FX (confetti / shake / pulse).
7. **Confetti** — `burstConfetti()`: create N absolutely-positioned divs with
random transforms, animate via CSS transitions, remove after finish.
8. **Reset** — `newRound()` (clear board, player first), `resetScores()`.
9. **Init** — load scores from localStorage, build board DOM, bind events.
## 8. Implementation Steps
1. `index.html` — semantic structure, viewport meta, SVG defs for win line.
2. `style.css` — theme tokens (CSS custom properties), background, glass panels,
board grid, marks (X/O drawn with CSS or inline SVG for crispness),
animations (pop, pulse, shake, confetti fall), overlay, responsive tweaks.
3. `game.js` — implement sections 1–9 above.
4. Manual test pass:
- Win all 8 line types (play through scenarios).
- Draw scenario.
- Rapid tapping (no double placement, no CPU overlap).
- Scores persist across reload; reset works.
- 360px viewport: no overflow, big touch targets, no zoom on tap.
5. Polish: check animation smoothness on a mid-range phone if available.
## 9. Hosting the App
The app is three static files, so any static file server works. The goal is a
one-line start, no config files, no build step.
**Primary option — Python (already installed, zero setup):**
```bash
python3 -m http.server 8000
```
Run from the `game1/` folder. It binds to all network interfaces by default,
so the game is immediately reachable:
- **Same machine**: http://localhost:8000
- **Same network (LAN)**: http://\<machine-ip\>:8000
(find the IP with `hostname -I` or `ip addr`)
- **Internet**: forward port 8000 in the router/firewall, or put the machine
behind a reverse proxy. For public exposure, prefer caddy (see below) and
keep the raw port closed otherwise.
**Alternative — Caddy (nicest for internet exposure, auto-TLS with a domain):**
```bash
caddy file-server --root . --listen :8000
```
**Alternative — Node (if Python is unavailable):**
```bash
npx serve -l 8000
```
Notes:
- These dev servers are fine for LAN / home use. For a public internet
endpoint, use caddy behind a domain (it handles TLS automatically) and do
not expose the raw port to the internet with a bare dev server.
- No deploy step: the folder *is* the app — copy it anywhere and serve it.
## 10. Non-Goals (kept simple on purpose)
- No minimax / unbeatable AI (CPU is random, as requested).
- No sound effects, no multi-language support, no user-vs-user mode.
- No framework, no bundler — the game is three files.
+92
View File
@@ -0,0 +1,92 @@
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="780" viewBox="0 0 420 780" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
<defs>
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0d0f24"/>
<stop offset="1" stop-color="#1c1140"/>
</linearGradient>
<linearGradient id="titleGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<linearGradient id="btnGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<filter id="blob" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="55"/>
</filter>
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- background -->
<rect width="420" height="780" fill="url(#bgGrad)"/>
<circle cx="70" cy="170" r="130" fill="#22d3ee" opacity="0.22" filter="url(#blob)"/>
<circle cx="350" cy="580" r="150" fill="#f472b6" opacity="0.18" filter="url(#blob)"/>
<!-- title -->
<text x="210" y="66" text-anchor="middle" font-size="30" font-weight="700" fill="url(#titleGrad)">Tic-Tac-Toe</text>
<!-- scoreboard -->
<g>
<rect x="18" y="96" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
<text x="78" y="120" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">YOU (X)</text>
<text x="78" y="148" text-anchor="middle" font-size="22" font-weight="700" fill="#22d3ee">2</text>
<rect x="150" y="96" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
<text x="210" y="120" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">CPU (O)</text>
<text x="210" y="148" text-anchor="middle" font-size="22" font-weight="700" fill="#f472b6">1</text>
<rect x="282" y="96" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
<text x="342" y="120" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">DRAWS</text>
<text x="342" y="148" text-anchor="middle" font-size="22" font-weight="700" fill="#ffffff">1</text>
</g>
<!-- status -->
<text x="210" y="200" text-anchor="middle" font-size="14" fill="#ffffff" opacity="0.65">CPU is thinking...</text>
<!-- board panel -->
<rect x="40" y="220" width="340" height="340" rx="24" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.12"/>
<!-- cells -->
<g fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.10">
<rect x="54" y="234" width="96" height="96" rx="16"/>
<rect x="162" y="234" width="96" height="96" rx="16"/>
<rect x="270" y="234" width="96" height="96" rx="16"/>
<rect x="54" y="342" width="96" height="96" rx="16"/>
<rect x="162" y="342" width="96" height="96" rx="16"/>
<rect x="270" y="342" width="96" height="96" rx="16"/>
<rect x="54" y="450" width="96" height="96" rx="16"/>
<rect x="162" y="450" width="96" height="96" rx="16"/>
<rect x="270" y="450" width="96" height="96" rx="16"/>
</g>
<!-- marks -->
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
<line x1="80" y1="260" x2="124" y2="304"/>
<line x1="124" y1="260" x2="80" y2="304"/>
</g>
<circle cx="210" cy="282" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
<circle cx="102" cy="390" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
<line x1="188" y1="368" x2="232" y2="412"/>
<line x1="232" y1="368" x2="188" y2="412"/>
</g>
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
<line x1="296" y1="368" x2="340" y2="412"/>
<line x1="340" y1="368" x2="296" y2="412"/>
</g>
<circle cx="210" cy="498" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
<!-- buttons -->
<rect x="60" y="600" width="300" height="54" rx="27" fill="url(#btnGrad)"/>
<text x="210" y="633" text-anchor="middle" font-size="16" font-weight="700" fill="#0d1025">New Game</text>
<rect x="60" y="668" width="300" height="48" rx="24" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.18"/>
<text x="210" y="697" text-anchor="middle" font-size="14" fill="#ffffff" opacity="0.7">Reset Scores</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+315
View File
@@ -0,0 +1,315 @@
'use strict';
/* ---------- 1. DOM refs ---------- */
const boardEl = document.getElementById('board');
const statusEl = document.getElementById('status');
const statusTextEl = document.getElementById('status-text');
const overlayEl = document.getElementById('overlay');
const overlayTitleEl = document.getElementById('overlay-title');
const winLineEl = document.getElementById('win-line-el');
const confettiLayer = document.getElementById('confetti-layer');
const toastEl = document.getElementById('toast');
const scoreEls = {
you: document.getElementById('score-you'),
cpu: document.getElementById('score-cpu'),
draws: document.getElementById('score-draws'),
};
const cells = [];
/* ---------- state ---------- */
const state = {
board: Array(9).fill(null),
turn: 'X',
gameOver: false,
scores: { you: 0, cpu: 0, draws: 0 },
};
let cpuTimer = 0;
let toastTimer = 0;
const WIN_LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
const SCORES_KEY = 'ttt-scores';
const CPU_DELAY = 600;
const X_SVG =
'<svg viewBox="0 0 100 100" class="mark mark-x" aria-hidden="true">' +
'<line x1="24" y1="24" x2="76" y2="76"></line>' +
'<line x1="76" y1="24" x2="24" y2="76"></line>' +
'</svg>';
const O_SVG =
'<svg viewBox="0 0 100 100" class="mark mark-o" aria-hidden="true">' +
'<circle cx="50" cy="50" r="30"></circle>' +
'</svg>';
/* ---------- 2. Win detection ---------- */
function checkWinner(board) {
for (const line of WIN_LINES) {
const [a, b, c] = line;
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return { winner: board[a], line };
}
}
if (board.every((v) => v !== null)) return { winner: 'draw' };
return null;
}
/* ---------- 3. Player move ---------- */
function onCellTap(i) {
if (state.gameOver || state.turn !== 'X' || state.board[i]) return;
placeMark(i, 'X');
const result = checkWinner(state.board);
if (result) {
endRound(result);
return;
}
state.turn = 'O';
renderStatus('CPU is thinking…', 'thinking');
cpuTimer = setTimeout(cpuMove, CPU_DELAY);
}
/* ---------- 4. CPU move ---------- */
function cpuMove() {
if (state.gameOver) return;
const empty = [];
state.board.forEach((v, i) => {
if (v === null) empty.push(i);
});
if (!empty.length) return;
const i = empty[Math.floor(Math.random() * empty.length)];
placeMark(i, 'O');
const result = checkWinner(state.board);
if (result) {
endRound(result);
return;
}
state.turn = 'X';
renderStatus('Your turn', 'normal');
}
function placeMark(i, mark) {
state.board[i] = mark;
renderMark(i, mark);
}
/* ---------- 5. Render ---------- */
function renderMark(i, mark) {
const cell = cells[i];
cell.classList.remove('empty');
cell.classList.add(mark === 'X' ? 'has-x' : 'has-o');
cell.setAttribute('aria-label', mark === 'X' ? 'X' : 'O');
cell.innerHTML = mark === 'X' ? X_SVG : O_SVG;
}
function renderStatus(text, mode) {
mode = mode || 'normal';
statusEl.classList.toggle('thinking', mode === 'thinking');
if (statusTextEl.textContent === text) return;
statusEl.classList.add('fading');
setTimeout(() => {
statusTextEl.textContent = text;
statusEl.classList.remove('fading');
}, 150);
}
function renderScores(changed) {
for (const k of ['you', 'cpu', 'draws']) {
scoreEls[k].textContent = state.scores[k];
if (k === changed) {
scoreEls[k].classList.remove('pop');
void scoreEls[k].offsetWidth;
scoreEls[k].classList.add('pop');
}
}
}
function cellCenter(i, boardRect) {
const r = cells[i].getBoundingClientRect();
return {
x: r.left + r.width / 2 - boardRect.left,
y: r.top + r.height / 2 - boardRect.top,
};
}
function drawWinLine(line) {
const boardRect = boardEl.getBoundingClientRect();
const p1 = cellCenter(line[0], boardRect);
const p2 = cellCenter(line[2], boardRect);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy) || 1;
const ext = 16;
const ux = dx / len;
const uy = dy / len;
const x1 = p1.x - ux * ext;
const y1 = p1.y - uy * ext;
const x2 = p2.x + ux * ext;
const y2 = p2.y + uy * ext;
winLineEl.setAttribute('x1', x1);
winLineEl.setAttribute('y1', y1);
winLineEl.setAttribute('x2', x2);
winLineEl.setAttribute('y2', y2);
const total = Math.hypot(x2 - x1, y2 - y1);
winLineEl.style.transition = 'none';
winLineEl.style.strokeDasharray = total;
winLineEl.style.strokeDashoffset = total;
winLineEl.style.opacity = '1';
void winLineEl.getBoundingClientRect();
winLineEl.style.transition = 'stroke-dashoffset 400ms ease';
winLineEl.style.strokeDashoffset = '0';
}
function clearWinLine() {
winLineEl.style.transition = 'none';
winLineEl.style.opacity = '0';
winLineEl.style.strokeDashoffset = winLineEl.style.strokeDasharray;
}
/* ---------- 6. Round end ---------- */
function endRound(result) {
state.gameOver = true;
let title;
let key;
if (result.winner === 'X') {
key = 'you';
title = 'You Win! 🎉';
renderStatus('You win! 🎉', 'normal');
} else if (result.winner === 'O') {
key = 'cpu';
title = 'CPU Wins';
renderStatus('CPU wins', 'normal');
boardEl.classList.add('shake');
} else {
key = 'draws';
title = "It's a Draw";
renderStatus("It's a draw", 'normal');
boardEl.classList.add('pulse');
}
state.scores[key]++;
persistScores();
renderScores(key);
if (result.line) drawWinLine(result.line);
setTimeout(() => {
overlayTitleEl.textContent = title;
overlayEl.classList.add('show');
if (result.winner === 'X') burstConfetti();
}, result.winner === 'draw' ? 400 : 600);
}
/* ---------- 7. Confetti ---------- */
const CONFETTI_COLORS = ['#22d3ee', '#f472b6', '#a78bfa', '#fbbf24', '#34d399', '#ffffff'];
function burstConfetti() {
const pieces = [];
for (let i = 0; i < 40; i++) {
const el = document.createElement('div');
el.className = 'confetti';
const size = 6 + Math.random() * 8;
el.style.width = size + 'px';
el.style.height = size * 1.4 + 'px';
el.style.left = Math.random() * 100 + 'vw';
el.style.background = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
el.style.setProperty('--drift', (Math.random() * 160 - 80) + 'px');
el.style.setProperty('--spin', (360 + Math.random() * 720) + 'deg');
el.style.animationDuration = 1.3 + Math.random() * 0.5 + 's';
el.style.animationDelay = Math.random() * 0.25 + 's';
confettiLayer.appendChild(el);
pieces.push(el);
}
setTimeout(() => pieces.forEach((p) => p.remove()), 2200);
}
/* ---------- 8. Reset ---------- */
function newRound() {
clearTimeout(cpuTimer);
state.board = Array(9).fill(null);
state.turn = 'X';
state.gameOver = false;
cells.forEach((cell) => {
cell.innerHTML = '';
cell.classList.add('empty');
cell.classList.remove('has-x', 'has-o');
cell.setAttribute('aria-label', 'Empty cell');
});
clearWinLine();
boardEl.classList.remove('shake', 'pulse');
overlayEl.classList.remove('show');
confettiLayer.innerHTML = '';
renderStatus('Your turn', 'normal');
}
function resetScores() {
state.scores = { you: 0, cpu: 0, draws: 0 };
persistScores();
renderScores();
showToast('Scores reset');
}
function persistScores() {
try {
localStorage.setItem(SCORES_KEY, JSON.stringify(state.scores));
} catch (e) {
/* storage unavailable */
}
}
function loadScores() {
try {
const raw = localStorage.getItem(SCORES_KEY);
if (raw) {
const s = JSON.parse(raw);
state.scores = {
you: Number(s.you) || 0,
cpu: Number(s.cpu) || 0,
draws: Number(s.draws) || 0,
};
}
} catch (e) {
/* corrupted storage — keep defaults */
}
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600);
}
/* ---------- 9. Init ---------- */
function buildBoard() {
for (let i = 0; i < 9; i++) {
const cell = document.createElement('button');
cell.type = 'button';
cell.className = 'cell empty';
cell.dataset.index = String(i);
cell.setAttribute('aria-label', 'Empty cell');
cell.addEventListener('click', () => onCellTap(i));
boardEl.appendChild(cell);
cells.push(cell);
}
}
function init() {
buildBoard();
loadScores();
renderScores();
document.getElementById('btn-new').addEventListener('click', newRound);
document.getElementById('btn-reset').addEventListener('click', resetScores);
document.getElementById('btn-again').addEventListener('click', newRound);
renderStatus('Your turn', 'normal');
}
init();
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0f24">
<title>Tic-Tac-Toe</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="bg-blob blob-cyan" aria-hidden="true"></div>
<div class="bg-blob blob-magenta" aria-hidden="true"></div>
<main class="app">
<h1 class="title">Tic-Tac-Toe</h1>
<div class="scoreboard">
<div class="chip" id="chip-you">
<span class="chip-label">You (X)</span>
<span class="chip-value chip-x" id="score-you">0</span>
</div>
<div class="chip" id="chip-cpu">
<span class="chip-label">CPU (O)</span>
<span class="chip-value chip-o" id="score-cpu">0</span>
</div>
<div class="chip" id="chip-draws">
<span class="chip-label">Draws</span>
<span class="chip-value" id="score-draws">0</span>
</div>
</div>
<div class="status" id="status">
<span class="status-text" id="status-text">Your turn</span>
</div>
<div class="board-wrap">
<div class="board" id="board" aria-label="Tic-tac-toe board"></div>
<svg class="win-line" id="win-line" aria-hidden="true">
<defs>
<linearGradient id="win-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#22d3ee"></stop>
<stop offset="1" stop-color="#f472b6"></stop>
</linearGradient>
</defs>
<line id="win-line-el" stroke="url(#win-grad)" stroke-width="6" stroke-linecap="round" opacity="0"></line>
</svg>
</div>
<div class="buttons">
<button class="btn primary" id="btn-new" type="button">New Game</button>
<button class="btn ghost" id="btn-reset" type="button">Reset Scores</button>
</div>
</main>
<div class="overlay" id="overlay" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-title" id="overlay-title">You Win! 🎉</div>
<button class="btn primary" id="btn-again" type="button">Play Again</button>
</div>
</div>
<div id="confetti-layer" aria-hidden="true"></div>
<div class="toast" id="toast" aria-live="polite"></div>
<script src="game.js"></script>
</body>
</html>
+431
View File
@@ -0,0 +1,431 @@
:root {
--bg-1: #0d0f24;
--bg-2: #1c1140;
--cyan: #22d3ee;
--pink: #f472b6;
--glass: rgba(255, 255, 255, 0.06);
--glass-strong: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.14);
--text: #ffffff;
--muted: rgba(255, 255, 255, 0.55);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%);
background-attachment: fixed;
color: var(--text);
min-height: 100dvh;
display: flex;
justify-content: center;
overflow-x: hidden;
}
/* ---------- background blobs ---------- */
.bg-blob {
position: fixed;
border-radius: 50%;
filter: blur(70px);
pointer-events: none;
z-index: 0;
}
.blob-cyan {
width: 340px;
height: 340px;
left: -120px;
top: 8%;
background: var(--cyan);
opacity: 0.2;
}
.blob-magenta {
width: 380px;
height: 380px;
right: -140px;
bottom: 4%;
background: var(--pink);
opacity: 0.16;
}
/* ---------- layout ---------- */
.app {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding:
calc(28px + env(safe-area-inset-top))
16px
calc(28px + env(safe-area-inset-bottom));
}
.title {
font-size: clamp(28px, 8vw, 34px);
font-weight: 800;
letter-spacing: 0.5px;
background: linear-gradient(90deg, var(--cyan), var(--pink));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ---------- scoreboard ---------- */
.scoreboard {
display: flex;
gap: 10px;
width: 100%;
}
.chip {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 16px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.chip-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--muted);
}
.chip-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.chip-x { color: var(--cyan); }
.chip-o { color: var(--pink); }
.chip-value.pop {
animation: score-pop 320ms ease;
}
@keyframes score-pop {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
/* ---------- status ---------- */
.status {
height: 20px;
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
transition: opacity 150ms ease;
}
.status.fading { opacity: 0; }
.status.thinking .status-text {
animation: status-pulse 900ms ease-in-out infinite;
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ---------- board ---------- */
.board-wrap {
position: relative;
}
.board {
width: min(88vw, 340px);
aspect-ratio: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 10px;
padding: 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 24px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
user-select: none;
-webkit-user-select: none;
}
.board.shake {
animation: shake 450ms ease;
border-color: rgba(244, 63, 94, 0.5);
box-shadow: 0 0 34px rgba(244, 63, 94, 0.3);
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-7px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
.board.pulse {
animation: board-pulse 600ms ease;
}
@keyframes board-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
.cell {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
}
.cell.empty:active {
background: var(--glass-strong);
border-color: rgba(255, 255, 255, 0.25);
}
@media (hover: hover) {
.cell.empty:hover {
background: var(--glass-strong);
border-color: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 18px rgba(34, 211, 238, 0.15);
}
}
/* ---------- marks ---------- */
.mark {
width: 58%;
height: 58%;
animation: mark-pop 150ms ease-out;
}
@keyframes mark-pop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.mark-x {
filter: drop-shadow(0 0 7px rgba(34, 211, 238, 0.65));
}
.mark-o {
filter: drop-shadow(0 0 7px rgba(244, 114, 182, 0.65));
}
.mark line {
stroke: var(--cyan);
stroke-width: 9;
stroke-linecap: round;
}
.mark circle {
fill: none;
stroke: var(--pink);
stroke-width: 9;
}
/* ---------- win line ---------- */
.win-line {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
z-index: 5;
}
#win-line-el {
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
}
/* ---------- buttons ---------- */
.buttons {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 300px;
}
.btn {
border: none;
border-radius: 27px;
height: 54px;
font-family: inherit;
font-size: 16px;
font-weight: 700;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: transform 100ms ease, filter 150ms ease;
}
.btn:active {
transform: scale(0.97);
}
.btn.primary {
background: linear-gradient(90deg, var(--cyan), var(--pink));
color: #0d1025;
box-shadow: 0 6px 24px rgba(34, 211, 238, 0.25);
}
.btn.primary:hover {
filter: brightness(1.1);
}
.btn.ghost {
height: 48px;
font-size: 14px;
font-weight: 500;
border-radius: 24px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.7);
}
.btn.ghost:hover {
background: rgba(255, 255, 255, 0.09);
}
/* ---------- overlay ---------- */
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(13, 15, 36, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease;
padding: 24px;
}
.overlay.show {
opacity: 1;
pointer-events: auto;
}
.modal {
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
padding: 34px 28px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 24px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
transform: scale(0.85);
transition: transform 250ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.overlay.show .modal {
transform: scale(1);
}
.modal-title {
font-size: 24px;
font-weight: 800;
}
.modal .btn {
width: 100%;
}
/* ---------- confetti ---------- */
#confetti-layer {
position: fixed;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 100;
}
.confetti {
position: absolute;
top: -24px;
border-radius: 2px;
animation-name: confetti-fall;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@keyframes confetti-fall {
from {
transform: translate3d(0, 0, 0) rotate(0deg);
opacity: 1;
}
to {
transform: translate3d(var(--drift), 108vh, 0) rotate(var(--spin));
opacity: 0.85;
}
}
/* ---------- toast ---------- */
.toast {
position: fixed;
bottom: calc(24px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%) translateY(8px);
z-index: 90;
padding: 10px 20px;
font-size: 14px;
background: var(--glass-strong);
border: 1px solid var(--glass-border);
border-radius: 999px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease, transform 200ms ease;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}