Initial commit: game development
This commit is contained in:
commit
176d7cc300
5 files changed
+1084
No files matched your search
+179
@@ -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.
|
||||||
@@ -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 |
@@ -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
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user