diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 27d207a..dace795 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,7 @@ on: push: branches: - main - - claude/3d-cachipun-game-d23abx + - claude/rock-paper-scissors-rebuild-o6zu5n workflow_dispatch: permissions: diff --git a/README.md b/README.md index 7cdda64..389c3f5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@
-# 🇨🇱 Cachipún 3D +# ✊✋✌️ Cachipún 3D -### Un tres en línea chileno, hecho hermoso con Three.js -### A Chilean tic-tac-toe, made beautiful with Three.js +### Piedra, papel o tijera a la chilena, hecho hermoso con Three.js +### Chilean rock-paper-scissors, made beautiful with Three.js [**▶ Jugar / Play**](https://fabianimv.github.io/cachipun/) · Three.js · WebGL · GitHub Pages -*Vidrio refractivo · neón con bloom · sombras suaves · partículas · cero dependencias en runtime* +*Manos 3D articuladas · vidrio refractivo · neón con bloom · confeti · cero dependencias en runtime*
@@ -15,17 +15,34 @@ ## 🎯 Propósito · Purpose -**ES** — El propósito de este `cachipun.github.io` es simple: **usar Three.js para algo -hermoso**. Tomé las técnicas visuales de los juegos web que se volvieron virales hace -poco (la estética *liquid glass*, el bloom, las sombras suaves, las partículas) y las -llevé a un clásico de patio chileno. No es solo un gato: es una excusa para hacer que -algo cotidiano se vea espectacular en el navegador. +**ES** — El propósito de este repo es simple: **usar Three.js para algo hermoso**. +Dos manos 3D articuladas se enfrentan sobre un escenario de vidrio: eliges tu jugada, +ambas hacen el clásico **"¡ca… chi… pún!"** y revelan el gesto al mismo tiempo. +Confeti para quien gana, y una CPU que aprende tus mañas. -**EN** — The purpose of this `cachipun.github.io` is simple: **use Three.js to make -something beautiful**. I took the visual techniques from the web games that recently -went viral (the *liquid glass* look, bloom, soft shadows, particles) and brought them -to a Chilean playground classic. It's not just tic-tac-toe: it's an excuse to make -something ordinary look stunning in the browser. +**EN** — The purpose of this repo is simple: **use Three.js to make something +beautiful**. Two articulated 3D hands face off over a glass stage: pick your move, +both hands do the classic three-beat countdown and reveal at once. Confetti for the +winner, and a CPU that learns your patterns. + +--- + +## 🎮 Cómo jugar · How to play + +**ES** — Elige **piedra ✊**, **papel ✋** o **tijera ✌️** con los botones o las +teclas `1` · `2` · `3`. Piedra le gana a tijera, tijera a papel y papel a piedra. +El marcador se guarda solo en tu navegador. + +**EN** — Pick **rock ✊**, **paper ✋** or **scissors ✌️** with the buttons or the +`1` · `2` · `3` keys. Rock beats scissors, scissors beat paper, paper beats rock. +The scoreboard is saved in your browser. + +### 🧠 La CPU · The CPU + +| Modo · Mode | ES | EN | +|----|----|----| +| 🧠 **CPU astuta** | Predictor de Markov de orden 1: aprende qué sueles tirar después de tu jugada anterior y juega la contra. Un 25 % de azar la mantiene ganable. | Order-1 Markov predictor: it learns what you tend to throw after your previous move and plays the counter. A 25 % random slip keeps it beatable. | +| 🎲 **CPU al lote** | Puro azar, como jugar con tu sobrino. | Pure random, like playing your little cousin. | --- @@ -33,24 +50,21 @@ something ordinary look stunning in the browser. | ES | EN | |----|----| -| Tablero de **vidrio refractivo** (`transmission`) con reflejos por entorno PMREM | **Refractive glass** board (`transmission`) with PMREM environment reflections | -| Piezas de **neón** (X cian, O magenta) que brillan con **UnrealBloom** | **Neon** pieces (cyan X, magenta O) glowing via **UnrealBloom** | -| **Sombras PCF suaves**, luces de relleno de color y fondo con shader + niebla | **Soft PCF shadows**, colored rim lights and a shader gradient sky + fog | -| **Animaciones con easing** (rebote, resorte) con un motor de tweens propio | **Eased animations** (bounce, spring) via a tiny custom tween engine | -| **Cámara que respira** con parallax del mouse, **confeti** y línea ganadora iluminada | **Breathing camera** with mouse parallax, **confetti** and a lit winning line | -| **CPU imbatible** (minimax) y modo **2 jugadores** | **Unbeatable CPU** (minimax) and **2-player** mode | -| Partida y marcador guardados en **localStorage** | Game and scoreboard saved in **localStorage** | -| **Sonido** WebAudio silenciable · responsive (mouse + touch) | Mutable WebAudio **sound** · responsive (mouse + touch) | +| **Manos 3D articuladas** por dedo, poseadas por gesto (✊ ✋ ✌️) | **Articulated 3D hands** with per-finger joints posed per gesture | +| Coreografía del **"¡ca-chi-pún!"**: tres botes y revelación con resorte | The **three-beat countdown** choreography with a springy reveal | +| Escenario de **vidrio refractivo** (`transmission`) con reflejos PMREM | **Refractive glass** stage (`transmission`) with PMREM reflections | +| Neón (tú cian, CPU magenta) que brilla con **UnrealBloom** | Neon glow (cyan you, magenta CPU) via **UnrealBloom** | +| **Sombras PCF suaves**, luces rim de color, fondo con shader + niebla | **Soft PCF shadows**, colored rim lights, shader sky + fog | +| **Cámara que respira** con parallax del mouse, **confeti** para quien gana | **Breathing camera** with mouse parallax, **confetti** for the winner | +| Marcador y ajustes en **localStorage** · **sonido** WebAudio silenciable | Scoreboard and settings in **localStorage** · mutable WebAudio **sound** | +| Responsive (mouse + touch) · **sin build step ni CDN** | Responsive (mouse + touch) · **no build step, no CDN** | --- -## 🎮 Cómo jugar · How to play - -**ES** — Abre [la versión en línea](https://fabianimv.github.io/cachipun/). En local, como -usa módulos ES, necesitas un servidor estático (no funciona con `file://`): +## 🖥️ Correr local · Run locally -**EN** — Open [the live version](https://fabianimv.github.io/cachipun/). Locally, since it -uses ES modules, you need a static server (it won't work over `file://`): +Usa módulos ES, así que necesita un servidor estático (no funciona con `file://`): +It uses ES modules, so it needs a static server (it won't work over `file://`): ```bash python3 -m http.server 8000 # → http://localhost:8000 @@ -64,11 +78,11 @@ npx serve . ``` index.html · markup + import map (Three.js vendorizado / vendored) -styles.css · UI glassmorphism (HUD, marcador, banner) +styles.css · UI glassmorphism (HUD, marcador, botones de jugada) src/ - main.js · une lógica, escena, sonido y persistencia / glue layer - scene.js · escena 3D, efectos y animaciones / 3D scene & effects - game.js · reglas, ganador, CPU minimax, localStorage / rules & AI + main.js · flujo de juego, marcador, sonido / game flow, score, sound + scene.js · manos, coreografía y efectos 3D / hands, choreography & FX + game.js · reglas, cerebro de la CPU, localStorage / rules & CPU brain tween.js · motor de animación con easing / easing tween engine vendor/ · Three.js r160 (build + addons) — sin CDN en runtime / no runtime CDN ``` @@ -91,23 +105,25 @@ once at **Settings → Pages → Source: GitHub Actions** and push. ## 🛠️ Técnicas Three.js usadas · Three.js techniques used +- Manos riggeadas a mano con `Group` + `CapsuleGeometry` y poses por articulación / hand-rigged hands via joint groups - `MeshPhysicalMaterial` con `transmission`, `ior`, `thickness`, `clearcoat` (vidrio / glass) - `PMREMGenerator` + `RoomEnvironment` (reflejos realistas / realistic reflections) - `EffectComposer` → `RenderPass` + `UnrealBloomPass` + `OutputPass` (post-proceso / post-processing) - `PCFSoftShadowMap`, `ACESFilmicToneMapping`, `SRGBColorSpace` - `Points` + `BufferGeometry` (confeti y polvo / confetti and dust) - `ShaderMaterial` para el degradado del cielo / for the sky gradient -- `Raycaster` para seleccionar casillas en 3D / for picking 3D cells --- ## 📝 Nota cultural · Cultural note -**ES** — En Chile, *cachipún* es estrictamente "piedra, papel o tijera"; a esto se le dice -*gato* o *tres en línea*. Mantengo el nombre por cariño al proyecto. 😄 +**ES** — En Chile, *cachipún* es "piedra, papel o tijera". La primera versión de este +repo era en verdad un *gato* (tres en línea) por una confusión histórica que quedó +inmortalizada en el historial de git 😄. Ahora sí: cachipún de verdad. -**EN** — In Chile, *cachipún* is strictly "rock, paper, scissors"; this game is usually -called *gato* or *tres en línea*. I keep the name out of affection for the project. 😄 +**EN** — In Chile, *cachipún* means "rock, paper, scissors". The first version of this +repo was actually tic-tac-toe due to a historic mix-up, now immortalized in the git +history 😄. This time it's the real deal. --- diff --git a/index.html b/index.html index edbeac2..55fb855 100644 --- a/index.html +++ b/index.html @@ -4,9 +4,8 @@ - Cachipún 3D · Tres en línea a la chilena - - + Cachipún 3D · Piedra, papel o tijera + @@ -33,14 +32,15 @@
+

CACHIPÚN 3D

-

tres en línea a la chilena

+

piedra, papel o tijera

-
-
- - 0 +
+ + 0
Empates 0
-
- CPU - 0 +
+ CPU + 0
+
-
Turno de X
- -
- - - diff --git a/src/game.js b/src/game.js index 668ae42..1a1f8bc 100644 --- a/src/game.js +++ b/src/game.js @@ -1,91 +1,62 @@ -// Pure game logic for Cachipún (tres en línea / tic-tac-toe). -// No rendering here — just state, rules, the CPU brain and persistence. +// Pure game logic for Cachipún (piedra, papel o tijera / rock-paper-scissors). +// No rendering here — just rules, the CPU brain and persistence. -const STORAGE_KEY = "cachipun3d:v1"; +const STORAGE_KEY = "cachipun3d:v2"; -export const LINES = [ - [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows - [0, 3, 6], [1, 4, 7], [2, 5, 8], // cols - [0, 4, 8], [2, 4, 6], // diagonals -]; +export const CHOICES = ["piedra", "papel", "tijera"]; -export function emptyBoard() { - return Array(9).fill(null); -} +export const EMOJI = { piedra: "✊", papel: "✋", tijera: "✌️" }; -export function winningLine(board) { - for (const line of LINES) { - const [a, b, c] = line; - if (board[a] && board[a] === board[b] && board[a] === board[c]) { - return { player: board[a], line }; - } - } - return null; -} +// key beats value +export const BEATS = { piedra: "tijera", papel: "piedra", tijera: "papel" }; -export function isFull(board) { - return board.every((c) => c !== null); +// 1 = a wins, -1 = b wins, 0 = tie +export function judge(a, b) { + if (a === b) return 0; + return BEATS[a] === b ? 1 : -1; } -export function availableMoves(board) { - const moves = []; - for (let i = 0; i < 9; i++) if (!board[i]) moves.push(i); - return moves; +// the move that beats `choice` +export function counterOf(choice) { + return CHOICES.find((c) => BEATS[c] === choice); } -// Minimax CPU. `level` lets us add a little human-beatable randomness on easy. -export function cpuMove(board, cpu, human, level = "hard") { - const moves = availableMoves(board); - if (moves.length === 0) return -1; - - // First move: grab a corner or center for a livelier opening. - if (moves.length === 9) { - const openers = [0, 2, 4, 6, 8]; - return openers[Math.floor(Math.random() * openers.length)]; - } +function randomChoice() { + return CHOICES[(Math.random() * CHOICES.length) | 0]; +} - // Easy / medium: sometimes play a random legal move instead of the optimum. - const slipChance = level === "easy" ? 0.5 : level === "medium" ? 0.2 : 0; - if (Math.random() < slipChance) { - return moves[Math.floor(Math.random() * moves.length)]; +// CPU brain. +// · "facil" — uniform random, pure luck. +// · "dificil" — an order-1 Markov predictor over the player's move history: +// it learns which move you tend to throw after your previous one and plays +// the counter. A 25% random slip keeps it beatable and non-robotic. +export function cpuMove(history, level = "dificil") { + if (level !== "dificil" || history.length < 3 || Math.random() < 0.25) { + return randomChoice(); } - let bestScore = -Infinity; - let best = moves[0]; - for (const m of moves) { - board[m] = cpu; - const score = minimax(board, 0, false, cpu, human); - board[m] = null; - if (score > bestScore) { - bestScore = score; - best = m; - } + const counts = {}; + for (let i = 0; i < history.length - 1; i++) { + const prev = history[i]; + counts[prev] ??= { piedra: 0, papel: 0, tijera: 0 }; + counts[prev][history[i + 1]]++; } - return best; -} -function minimax(board, depth, maximizing, cpu, human) { - const result = winningLine(board); - if (result) return result.player === cpu ? 10 - depth : depth - 10; - if (isFull(board)) return 0; + const row = counts[history[history.length - 1]]; + if (!row) return randomChoice(); - if (maximizing) { - let best = -Infinity; - for (const m of availableMoves(board)) { - board[m] = cpu; - best = Math.max(best, minimax(board, depth + 1, false, cpu, human)); - board[m] = null; - } - return best; - } else { - let best = Infinity; - for (const m of availableMoves(board)) { - board[m] = human; - best = Math.min(best, minimax(board, depth + 1, true, cpu, human)); - board[m] = null; + let predicted = null; + let bestN = 0; + let total = 0; + for (const c of CHOICES) { + total += row[c]; + if (row[c] > bestN) { + bestN = row[c]; + predicted = c; } - return best; } + if (!total || !predicted) return randomChoice(); + return counterOf(predicted); } // ---------- persistence ---------- @@ -94,7 +65,7 @@ export function loadState() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const data = JSON.parse(raw); - if (!Array.isArray(data.board) || data.board.length !== 9) return null; + if (!data || typeof data !== "object" || typeof data.scores !== "object") return null; return data; } catch { return null; diff --git a/src/main.js b/src/main.js index 22ff163..1ac56a5 100644 --- a/src/main.js +++ b/src/main.js @@ -1,40 +1,34 @@ import { createScene } from "./scene.js"; -import { - emptyBoard, winningLine, isFull, cpuMove, loadState, saveState, -} from "./game.js"; +import { CHOICES, EMOJI, judge, cpuMove, loadState, saveState } from "./game.js"; // ---------- DOM ---------- const $ = (id) => document.getElementById(id); const ui = { loader: $("loader"), - modeBtn: $("modeBtn"), modeLabel: $("modeLabel"), modeIcon: $("modeBtn").querySelector(".chip__icon"), + levelBtn: $("levelBtn"), levelLabel: $("levelLabel"), levelIcon: $("levelBtn").querySelector(".chip__icon"), soundBtn: $("soundBtn"), soundIcon: $("soundIcon"), - scoreX: $("scoreX"), scoreD: $("scoreD"), scoreO: $("scoreO"), - tagX: $("tagX"), tagO: $("tagO"), + scoreYou: $("scoreYou"), scoreD: $("scoreD"), scoreCpu: $("scoreCpu"), status: $("status"), - resetBtn: $("resetBtn"), clearBtn: $("clearBtn"), - banner: $("banner"), bannerEmoji: $("bannerEmoji"), - bannerTitle: $("bannerTitle"), bannerSub: $("bannerSub"), bannerBtn: $("bannerBtn"), + choices: $("choices"), + choiceBtns: Array.from(document.querySelectorAll("[data-choice]")), + clearBtn: $("clearBtn"), }; // ---------- state ---------- const defaultState = () => ({ - board: emptyBoard(), - current: "X", - mode: "cpu", // "cpu" | "2p" - scores: { X: 0, O: 0, draw: 0 }, + scores: { you: 0, cpu: 0, draw: 0 }, + level: "dificil", // "facil" | "dificil" sound: true, - finished: false, - winner: null, - line: null, + history: [], // the player's past moves, food for the CPU brain + streak: 0, // current player win streak }); let state = Object.assign(defaultState(), loadState() || {}); // sanitise loaded fields -if (state.mode !== "cpu" && state.mode !== "2p") state.mode = "cpu"; -if (!state.scores) state.scores = { X: 0, O: 0, draw: 0 }; - -const HUMAN = "X", CPU = "O"; +if (state.level !== "facil" && state.level !== "dificil") state.level = "dificil"; +if (!state.scores || typeof state.scores.you !== "number") state.scores = { you: 0, cpu: 0, draw: 0 }; +if (!Array.isArray(state.history)) state.history = []; +if (typeof state.streak !== "number") state.streak = 0; // ---------- sound (tiny WebAudio synth) ---------- let audioCtx = null; @@ -54,33 +48,31 @@ function beep(freq, dur = 0.12, type = "sine", gain = 0.06) { } catch { /* ignore */ } } const sfx = { - place: (p) => beep(p === "X" ? 520 : 400, 0.12, "triangle"), + beat: (i) => beep([392, 440, 523][i] || 523, 0.1, "triangle", 0.05), + reveal: () => beep(760, 0.09, "square", 0.04), win: () => [660, 880, 1100].forEach((f, i) => setTimeout(() => beep(f, 0.18, "sine", 0.07), i * 110)), + lose: () => [330, 262, 196].forEach((f, i) => setTimeout(() => beep(f, 0.2, "sawtooth", 0.045), i * 130)), draw: () => beep(240, 0.25, "sawtooth", 0.05), click: () => beep(700, 0.05, "square", 0.03), }; // ---------- scene ---------- -const scene = createScene($("scene"), { onCellClick: handleMove }); +const scene = createScene($("scene")); -// ---------- rendering of UI ---------- +// ---------- UI rendering ---------- function renderScores() { - ui.scoreX.textContent = state.scores.X; - ui.scoreO.textContent = state.scores.O; + ui.scoreYou.textContent = state.scores.you; + ui.scoreCpu.textContent = state.scores.cpu; ui.scoreD.textContent = state.scores.draw; } -function renderMode() { - if (state.mode === "cpu") { - ui.modeLabel.textContent = "vs CPU"; - ui.modeIcon.textContent = "🤖"; - ui.tagX.textContent = "Tú"; - ui.tagO.textContent = "CPU"; +function renderLevel() { + if (state.level === "dificil") { + ui.levelLabel.textContent = "CPU astuta"; + ui.levelIcon.textContent = "🧠"; } else { - ui.modeLabel.textContent = "2 jugadores"; - ui.modeIcon.textContent = "👥"; - ui.tagX.textContent = "Jugador X"; - ui.tagO.textContent = "Jugador O"; + ui.levelLabel.textContent = "CPU al lote"; + ui.levelIcon.textContent = "🎲"; } } @@ -88,9 +80,10 @@ function renderSound() { ui.soundIcon.textContent = state.sound ? "🔊" : "🔇"; } -function setStatus(html, isO = false, pop = false) { +function setStatus(html, tone = "", pop = false) { ui.status.innerHTML = html; - ui.status.classList.toggle("turn-o", isO); + ui.status.classList.toggle("status--win", tone === "win"); + ui.status.classList.toggle("status--lose", tone === "lose"); if (pop) { ui.status.classList.remove("pop"); void ui.status.offsetWidth; @@ -98,151 +91,101 @@ function setStatus(html, isO = false, pop = false) { } } -function renderTurn() { - if (state.finished) return; - if (state.mode === "cpu") { - setStatus(state.current === HUMAN ? "Tu turno" : "La CPU está pensando…", state.current === "O"); - } else { - setStatus(`Turno de ${state.current}`, state.current === "O"); - } +function setIdleStatus() { + setStatus("Elige tu jugada"); } -// ---------- game flow ---------- -function handleMove(index) { - if (state.finished || state.board[index]) return; - if (state.mode === "cpu" && state.current !== HUMAN) return; - - applyMove(index, state.current); - if (state.finished) return; - - if (state.mode === "cpu" && state.current === CPU) { - scene.setInteractive(false); - renderTurn(); - setTimeout(triggerCpu, 520); - } +function setChoicesEnabled(on) { + ui.choices.classList.toggle("choices--locked", !on); + ui.choiceBtns.forEach((b) => { b.disabled = !on; }); } -function applyMove(index, player) { - state.board[index] = player; - scene.placePiece(index, player, true); - sfx.place(player); - - const win = winningLine(state.board); - if (win) return finish(win.player, win.line); - if (isFull(state.board)) return finish(null, null); - - state.current = player === "X" ? "O" : "X"; - renderTurn(); - persist(); -} - -function triggerCpu() { - if (state.finished) return; - const move = cpuMove(state.board.slice(), CPU, HUMAN, "hard"); - if (move >= 0) applyMove(move, CPU); - scene.setInteractive(true); +// ---------- game flow ---------- +let busy = false; + +function play(choice) { + if (busy || !CHOICES.includes(choice)) return; + busy = true; + setChoicesEnabled(false); + + const cpu = cpuMove(state.history, state.level); + const outcome = judge(choice, cpu); + const COUNT = ["¡Ca…", "¡Ca… chi…", "¡Ca-chi-PÚN!"]; + + scene.playRound(choice, cpu, outcome, { + onBeat: (i) => { + setStatus(`${COUNT[i]}`); + sfx.beat(i); + }, + onReveal: () => { + setStatus(`${EMOJI[choice]} vs ${EMOJI[cpu]}`); + sfx.reveal(); + }, + onResult: () => { + applyResult(choice, cpu, outcome); + }, + onDone: () => { + busy = false; + setChoicesEnabled(true); + setIdleStatus(); + }, + }); } -function finish(winner, line) { - state.finished = true; - state.winner = winner; - state.line = line; +function applyResult(choice, cpu, outcome) { + state.history.push(choice); + if (state.history.length > 40) state.history.shift(); - if (winner) { - state.scores[winner]++; - scene.highlightWin(line, winner); - scene.celebrate(); + const duel = `${EMOJI[choice]} vs ${EMOJI[cpu]}`; + if (outcome === 1) { + state.scores.you++; + state.streak++; + const fire = state.streak >= 3 ? ` · 🔥 racha x${state.streak}` : ""; + setStatus(`${duel} — ¡Ganaste! 🎉${fire}`, "win", true); sfx.win(); + } else if (outcome === -1) { + state.scores.cpu++; + state.streak = 0; + setStatus(`${duel} — Gana la CPU 🤖`, "lose", true); + sfx.lose(); } else { state.scores.draw++; + setStatus(`${duel} — ¡Empate! 🤝`, "", true); sfx.draw(); } renderScores(); persist(); - setTimeout(() => showBanner(winner), winner ? 700 : 350); - renderResultStatus(winner); -} - -function renderResultStatus(winner) { - if (!winner) { setStatus("¡Empate! 🤝", false, true); return; } - if (state.mode === "cpu") { - setStatus(winner === HUMAN ? "¡Ganaste! 🎉" : "Ganó la CPU 🤖", winner === "O", true); - } else { - setStatus(`¡Ganó ${winner}! 🎉`, winner === "O", true); - } -} - -function showBanner(winner) { - let emoji, title, sub; - if (!winner) { - emoji = "🤝"; title = "¡Empate!"; sub = "Quedó pa' la historia, va de nuevo"; - } else if (state.mode === "cpu") { - if (winner === HUMAN) { - emoji = "😎"; title = "¡Ganaste, crack!"; sub = "Le pasaste la aplanadora a la CPU"; - } else { - emoji = "🤖"; title = "Ganó la CPU"; sub = "Nada que hacerle… ¡revancha altiro!"; - } - } else { - emoji = "🎉"; title = `¡Ganó ${winner}!`; sub = winner === "X" ? "Pura pinta nomás" : "Bien jugado"; - } - ui.bannerEmoji.textContent = emoji; - ui.bannerTitle.textContent = title; - ui.bannerSub.textContent = sub; - ui.banner.classList.add("show"); -} - -function hideBanner() { ui.banner.classList.remove("show"); } - -function newRound() { - hideBanner(); - scene.clearBoard(true); - state.board = emptyBoard(); - state.current = "X"; - state.finished = false; - state.winner = null; - state.line = null; - scene.setInteractive(true); - renderTurn(); - persist(); - sfx.click(); } function persist() { saveState(state); } -// ---------- restore a saved board into the 3D scene ---------- -function restoreBoard() { - state.board.forEach((p, i) => { if (p) scene.placePiece(i, p, false); }); - if (state.finished) { - if (state.winner && state.line) scene.highlightWin(state.line, state.winner); - renderResultStatus(state.winner); - } else { - renderTurn(); - // if it's the CPU's turn on reload, let it play - if (state.mode === "cpu" && state.current === CPU) { - scene.setInteractive(false); - setTimeout(triggerCpu, 700); - } - } -} - // ---------- wire up controls ---------- -ui.resetBtn.addEventListener("click", newRound); -ui.bannerBtn.addEventListener("click", newRound); +ui.choiceBtns.forEach((btn) => { + btn.addEventListener("click", () => play(btn.dataset.choice)); +}); + +// keyboard: 1 = piedra, 2 = papel, 3 = tijera +window.addEventListener("keydown", (e) => { + const idx = ["1", "2", "3"].indexOf(e.key); + if (idx >= 0) play(CHOICES[idx]); +}); ui.clearBtn.addEventListener("click", () => { - state.scores = { X: 0, O: 0, draw: 0 }; + state.scores = { you: 0, cpu: 0, draw: 0 }; + state.streak = 0; + state.history = []; renderScores(); persist(); sfx.click(); }); -ui.modeBtn.addEventListener("click", () => { - state.mode = state.mode === "cpu" ? "2p" : "cpu"; - renderMode(); +ui.levelBtn.addEventListener("click", () => { + state.level = state.level === "dificil" ? "facil" : "dificil"; + renderLevel(); + persist(); sfx.click(); - newRound(); }); ui.soundBtn.addEventListener("click", () => { @@ -254,9 +197,10 @@ ui.soundBtn.addEventListener("click", () => { // ---------- boot ---------- renderScores(); -renderMode(); +renderLevel(); renderSound(); -restoreBoard(); +setIdleStatus(); +setChoicesEnabled(true); window.addEventListener("load", () => { setTimeout(() => ui.loader.classList.add("hidden"), 350); diff --git a/src/scene.js b/src/scene.js index deb8c4f..0f009eb 100644 --- a/src/scene.js +++ b/src/scene.js @@ -1,10 +1,11 @@ // 3D presentation layer for Cachipún, built on Three.js. -// Visual techniques borrowed from the recent viral Three.js games: +// Two stylised neon hands face off over a refractive glass stage: // · PMREM room environment for realistic reflections -// · refractive "liquid glass" board (MeshPhysicalMaterial transmission) -// · emissive neon pieces lit up by UnrealBloom post-processing +// · articulated fingers posed per gesture (piedra / papel / tijera) +// · the classic "¡ca… chi… pún!" fist-bobbing countdown, springy reveals +// · emissive materials lit up by UnrealBloom post-processing // · soft PCF shadows, rim lights, floating dust + confetti particles -// · eased, springy animations and a gently breathing camera. +// · eased animations and a gently breathing camera. import * as THREE from "three"; import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; @@ -14,14 +15,38 @@ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js" import { OutputPass } from "three/addons/postprocessing/OutputPass.js"; import { tween, Ease, updateTweens } from "./tween.js"; -const S = 1.9; // spacing between cell centres -const BOARD_TOP = 0.32; // y of the board surface const COLORS = { - X: new THREE.Color("#38e8ff"), - O: new THREE.Color("#ff5edb"), + player: new THREE.Color("#38e8ff"), + cpu: new THREE.Color("#ff5edb"), }; +const HAND_X = 2.75; // wrist distance from centre +const HAND_Y = 1.8; // resting height of the hands +const BOB = 0.42; // seconds per countdown bob +const HAND_GLOW = 0.22; // resting emissive intensity of the hands + +// Joint targets per gesture. Fingers are listed across the palm; the two +// middle ones are the longest, so "tijera" extends those for the classic V. +// Each finger is [knuckleCurl, midCurl] in radians; spread is a fan-out yaw. +const POSES = { + fist: { + fingers: [[1.5, 1.5], [1.5, 1.5], [1.5, 1.5], [1.5, 1.5]], + spread: [0, 0, 0, 0], + thumb: [0.75, 1.0], + }, + papel: { + fingers: [[-0.06, 0], [-0.06, 0], [-0.06, 0], [-0.06, 0]], + spread: [-0.22, -0.07, 0.07, 0.22], + thumb: [0.08, 0.08], + }, + tijera: { + fingers: [[1.5, 1.5], [0, 0], [0, 0], [1.5, 1.5]], + spread: [0, -0.3, 0.3, 0], + thumb: [0.75, 1.0], + }, +}; +POSES.piedra = POSES.fist; -export function createScene(canvas, { onCellClick }) { +export function createScene(canvas) { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); @@ -39,223 +64,208 @@ export function createScene(canvas, { onCellClick }) { scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.1, 100); - const CAM_HOME = new THREE.Vector3(0, 8.2, 8.6); + const CAM_HOME = new THREE.Vector3(0, 3.6, 9.0); camera.position.copy(CAM_HOME); - camera.lookAt(0, 0, 0); + camera.lookAt(0, 1.4, 0); buildBackground(scene); buildLights(scene); buildFloor(scene); - - const board = buildBoard(scene); + buildStage(scene); + const spark = buildSpark(scene); const dust = buildDust(scene); + // ---------- the two hands ---------- + // Rig handles placement + idle float; the inner group is what animations + // move, so both never fight over the same transform. + const playerHand = makeHand(COLORS.player, +1); + playerHand.rig.position.set(-HAND_X, HAND_Y, 0); + playerHand.rig.rotation.y = Math.PI / 2; // fingers point at the centre + scene.add(playerHand.rig); + + const cpuHand = makeHand(COLORS.cpu, -1); + cpuHand.rig.position.set(HAND_X, HAND_Y, 0); + cpuHand.rig.rotation.y = -Math.PI / 2; + scene.add(cpuHand.rig); + + setPose(playerHand, "fist"); + setPose(cpuHand, "fist"); + // ---------- post processing ---------- const composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); const bloom = new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), - 0.85, 0.55, 0.8 + 0.55, 0.5, 0.85 ); composer.addPass(bloom); composer.addPass(new OutputPass()); - // ---------- interaction ---------- - const raycaster = new THREE.Raycaster(); - const pointer = new THREE.Vector2(); - let hovered = -1; - let interactive = true; + // mouse parallax target + const parallax = new THREE.Vector2(0, 0); + window.addEventListener("pointermove", (e) => { + parallax.x = (e.clientX / window.innerWidth - 0.5); + parallax.y = (e.clientY / window.innerHeight - 0.5); + }); - const pieces = new Array(9).fill(null); const confetti = []; - function setPointerFromEvent(e) { - const x = e.touches ? e.touches[0].clientX : e.clientX; - const y = e.touches ? e.touches[0].clientY : e.clientY; - pointer.x = (x / window.innerWidth) * 2 - 1; - pointer.y = -(y / window.innerHeight) * 2 + 1; + // ---------- pose animation ---------- + function setPose(hand, name) { + for (const [obj, key, target] of poseJoints(hand, name)) obj[key] = target; } - function pickCell() { - raycaster.setFromCamera(pointer, camera); - const hits = raycaster.intersectObjects(board.hitAreas, false); - return hits.length ? hits[0].object.userData.index : -1; - } - - function onMove(e) { - setPointerFromEvent(e); - if (!interactive) { setHover(-1); return; } - setHover(pickCell()); - } - - function onClick(e) { - if (!interactive) return; - setPointerFromEvent(e); - const idx = pickCell(); - if (idx >= 0 && !pieces[idx]) onCellClick(idx); - } - - function setHover(idx) { - if (idx === hovered) return; - hovered = idx; - canvas.style.cursor = idx >= 0 ? "pointer" : "default"; - const target = idx >= 0 ? board.cellPos(idx) : null; - if (target) { - board.hover.visible = true; - board.hover.position.set(target.x, BOARD_TOP + 0.02, target.z); - } + function tweenPose(hand, name, duration = 0.26, ease = Ease.outBack) { + const joints = poseJoints(hand, name); + const starts = joints.map(([obj, key]) => obj[key]); tween({ - duration: 0.25, - ease: Ease.outCubic, + duration, ease, onUpdate: (v) => { - const from = board.hover.material.opacity; - board.hover.material.opacity = idx >= 0 - ? THREE.MathUtils.lerp(from, 0.5, v) - : THREE.MathUtils.lerp(from, 0, v); + joints.forEach(([obj, key, target], i) => { + obj[key] = starts[i] + (target - starts[i]) * v; + }); }, - onComplete: () => { if (idx < 0) board.hover.visible = false; }, }); } - canvas.addEventListener("pointermove", onMove); - canvas.addEventListener("pointerdown", onClick); - window.addEventListener("pointerleave", () => setHover(-1)); - - // mouse parallax target - const parallax = new THREE.Vector2(0, 0); - window.addEventListener("pointermove", (e) => { - parallax.x = (e.clientX / window.innerWidth - 0.5); - parallax.y = (e.clientY / window.innerHeight - 0.5); - }); - - // ---------- piece factory ---------- - function makePiece(player) { - const group = new THREE.Group(); - const color = COLORS[player]; - const mat = new THREE.MeshPhysicalMaterial({ - color: color.clone().multiplyScalar(0.25), - emissive: color, - emissiveIntensity: 1.5, - metalness: 0.6, - roughness: 0.18, - clearcoat: 1, - clearcoatRoughness: 0.15, + function poseJoints(hand, name) { + const p = POSES[name]; + const joints = []; + hand.fingers.forEach((f, i) => { + joints.push([f.root.rotation, "x", p.fingers[i][0]]); + joints.push([f.mid.rotation, "x", p.fingers[i][1]]); + joints.push([f.root.rotation, "y", p.spread[i]]); }); + joints.push([hand.thumb.root.rotation, "x", p.thumb[0]]); + joints.push([hand.thumb.mid.rotation, "x", p.thumb[1]]); + return joints; + } - if (player === "X") { - const geo = new THREE.BoxGeometry(1.55, 0.34, 0.34); - for (const r of [Math.PI / 4, -Math.PI / 4]) { - const bar = new THREE.Mesh(geo, mat); - bar.rotation.y = r; - bar.castShadow = true; - group.add(bar); - } - } else { - const ring = new THREE.Mesh(new THREE.TorusGeometry(0.62, 0.2, 24, 60), mat); - ring.rotation.x = Math.PI / 2; - ring.castShadow = true; - group.add(ring); - } - group.userData.mat = mat; - return group; + // schedule a callback on the render clock (stays in sync with the tweens) + function after(delay, fn) { + tween({ delay, duration: 0.0001, onComplete: fn }); } - function placePiece(index, player, animate = true) { - if (pieces[index]) return; - const piece = makePiece(player); - const pos = board.cellPos(index); - piece.position.set(pos.x, BOARD_TOP + 0.25, pos.z); - scene.add(piece); - pieces[index] = piece; - - if (animate) { - piece.scale.setScalar(0.01); - piece.position.y = BOARD_TOP + 2.4; - piece.rotation.y = Math.PI; + // ---------- round choreography ---------- + // Bob three times in fist, then snap both hands into their gestures. + // Callbacks: onBeat(i) per bob, onReveal() at the snap, onResult() when the + // win/lose/tie flourish starts, onDone() once hands are back to idle fists. + function playRound(playerChoice, cpuChoice, outcome, cb = {}) { + tweenPose(playerHand, "fist", 0.18, Ease.outCubic); + tweenPose(cpuHand, "fist", 0.18, Ease.outCubic); + + for (let i = 0; i < 3; i++) { + after(i * BOB, () => cb.onBeat?.(i)); tween({ - duration: 0.65, ease: Ease.outBack, + delay: i * BOB, duration: BOB, ease: Ease.linear, onUpdate: (v) => { - piece.scale.setScalar(0.01 + v * 0.99); - piece.position.y = THREE.MathUtils.lerp(BOARD_TOP + 2.4, BOARD_TOP + 0.25, v); - piece.rotation.y = Math.PI * (1 - v); - }, - onComplete: () => { - piece.scale.setScalar(1); - piece.position.y = BOARD_TOP + 0.25; - piece.rotation.y = 0; + const dip = Math.sin(v * Math.PI); + for (const hand of [playerHand, cpuHand]) { + hand.group.position.y = -dip * 0.62; + hand.group.rotation.x = dip * 0.22; + } }, }); } - } - function clearBoard(animate = true) { - pieces.forEach((p, i) => { - if (!p) return; - if (animate) { - tween({ - duration: 0.42, ease: Ease.inOutCubic, - onUpdate: (v) => { - p.scale.setScalar(1 - v); - p.position.y = BOARD_TOP + 0.25 + v * 1.6; - p.userData.mat.emissiveIntensity = 1.5 * (1 - v); - }, - onComplete: () => { scene.remove(p); disposePiece(p); }, - }); + const revealAt = 3 * BOB + 0.02; + after(revealAt, () => { + tweenPose(playerHand, playerChoice, 0.26, Ease.outBack); + tweenPose(cpuHand, cpuChoice, 0.26, Ease.outBack); + punch(playerHand); + punch(cpuHand); + pulseSpark(); + cb.onReveal?.(); + }); + + after(revealAt + 0.55, () => { + if (outcome === 1) { + winFx(playerHand, COLORS.player); + loseFx(cpuHand); + } else if (outcome === -1) { + winFx(cpuHand, COLORS.cpu); + loseFx(playerHand); } else { - scene.remove(p); - disposePiece(p); + tieFx(); } + cb.onResult?.(); + }); + + after(revealAt + 2.0, () => { + tweenPose(playerHand, "fist", 0.32, Ease.inOutCubic); + tweenPose(cpuHand, "fist", 0.32, Ease.inOutCubic); + cb.onDone?.(); }); - pieces.fill(null); - board.cells.forEach((c) => resetCellGlow(c)); } - function disposePiece(p) { - p.traverse((o) => { if (o.geometry) o.geometry.dispose(); }); - if (p.userData.mat) p.userData.mat.dispose(); + // a short forward thrust on reveal, like slamming the hand down + function punch(hand) { + tween({ + duration: 0.45, ease: Ease.outCubic, + onUpdate: (v) => { hand.group.position.z = Math.sin(v * Math.PI) * 0.55; }, + }); } - function highlightWin(line, player) { - const color = COLORS[player]; - line.forEach((idx, k) => { - const cell = board.cells[idx]; - tween({ - duration: 0.5, delay: k * 0.09, ease: Ease.outCubic, - onUpdate: (v) => { - cell.material.emissive.copy(color); - cell.material.emissiveIntensity = v * 1.2; - cell.position.y = THREE.MathUtils.lerp(0, 0.18, v); - }, - }); - const piece = pieces[idx]; - if (piece) { - tween({ - duration: 1.4, delay: k * 0.09, ease: Ease.outElastic, - onUpdate: (v) => { piece.position.y = BOARD_TOP + 0.25 + Math.sin(v * Math.PI) * 0.35; }, - }); - } + function winFx(hand, color) { + tween({ + duration: 0.6, ease: Ease.outCubic, + onUpdate: (v) => { + hand.group.position.y = Math.sin(v * Math.PI) * 0.6; + hand.mat.emissiveIntensity = HAND_GLOW + Math.sin(v * Math.PI) * 0.9; + }, }); - spawnConfetti(player); + const origin = hand.rig.position.clone().multiplyScalar(0.62); + origin.y = HAND_Y + 0.4; + spawnConfetti(color, origin); + camPulse = 1; } - function resetCellGlow(cell) { - cell.material.emissive.setHex(0x000000); - cell.material.emissiveIntensity = 0; - cell.position.y = 0; + function loseFx(hand) { + tween({ + duration: 0.7, ease: Ease.outCubic, + onUpdate: (v) => { + const sag = Math.sin(v * Math.PI); + hand.group.position.y = -sag * 0.3; + hand.group.rotation.x = sag * 0.3; + hand.mat.emissiveIntensity = HAND_GLOW * (1 - sag * 0.6); + }, + }); + } + + function tieFx() { + tween({ + duration: 0.6, ease: Ease.linear, + onUpdate: (v, raw) => { + const wiggle = Math.sin(raw * Math.PI * 4) * (1 - raw) * 0.14; + playerHand.group.rotation.z = wiggle; + cpuHand.group.rotation.z = -wiggle; + }, + }); + } + + function pulseSpark() { + tween({ + duration: 0.6, ease: Ease.outCubic, + onUpdate: (v) => { + const s = 1 + Math.sin(v * Math.PI) * 0.7; + spark.scale.setScalar(s); + spark.material.emissiveIntensity = 1.2 + Math.sin(v * Math.PI) * 0.9; + }, + }); } // ---------- confetti ---------- - function spawnConfetti(player) { + function spawnConfetti(color, origin) { const count = 220; const geo = new THREE.BufferGeometry(); const positions = new Float32Array(count * 3); const colors = new Float32Array(count * 3); const velocities = []; - const palette = [COLORS[player], new THREE.Color("#ffd84d"), new THREE.Color("#b388ff"), new THREE.Color("#ffffff")]; + const palette = [color, new THREE.Color("#ffd84d"), new THREE.Color("#b388ff"), new THREE.Color("#ffffff")]; for (let i = 0; i < count; i++) { - positions[i * 3] = (Math.random() - 0.5) * 2; - positions[i * 3 + 1] = 2 + Math.random() * 2; - positions[i * 3 + 2] = (Math.random() - 0.5) * 2; + positions[i * 3] = origin.x + (Math.random() - 0.5) * 1.6; + positions[i * 3 + 1] = origin.y + Math.random() * 1.5; + positions[i * 3 + 2] = origin.z + (Math.random() - 0.5) * 1.6; const c = palette[(Math.random() * palette.length) | 0]; colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b; velocities.push(new THREE.Vector3( @@ -305,8 +315,6 @@ export function createScene(canvas, { onCellClick }) { let startTime = 0; let lastTime = 0; - function celebrate() { camPulse = 1; } - function animate(now) { requestAnimationFrame(animate); if (!startTime) { startTime = now; lastTime = now; } @@ -317,16 +325,21 @@ export function createScene(canvas, { onCellClick }) { updateTweens(dt); updateConfetti(dt); + // idle float on the rigs (animations own the inner groups) + playerHand.rig.position.y = HAND_Y + Math.sin(t * 1.3) * 0.07; + cpuHand.rig.position.y = HAND_Y + Math.sin(t * 1.3 + Math.PI) * 0.07; + spark.rotation.y += dt * 1.2; + spark.rotation.x += dt * 0.7; + // breathing + parallax camera const sweep = camPulse * Math.sin(t * 2) * 0.6; camera.position.x = CAM_HOME.x + parallax.x * 1.6 + Math.sin(t * 0.4) * 0.25 + sweep; camera.position.y = CAM_HOME.y - parallax.y * 1.0 + Math.sin(t * 0.55) * 0.18; camera.position.z = CAM_HOME.z + Math.cos(t * 0.4) * 0.2; - camera.lookAt(0, 0.2, 0); + camera.lookAt(0, 1.4, 0); camPulse *= 0.985; dust.rotation.y += dt * 0.02; - board.hover.material.opacity > 0 && (board.hover.rotation.z += dt * 0.4); composer.render(); } @@ -342,13 +355,93 @@ export function createScene(canvas, { onCellClick }) { bloom.setSize(w, h); }); - return { - placePiece, - clearBoard, - highlightWin, - celebrate, - setInteractive: (v) => { interactive = v; if (!v) setHover(-1); }, - }; + return { playRound }; +} + +// ---------------- hand building ---------------- + +// A stylised low-poly hand pointing down its local +z, wrist at the origin. +// `side` mirrors the thumb so both hands read naturally when they face off. +function makeHand(color, side) { + const mat = new THREE.MeshPhysicalMaterial({ + color: color.clone().multiplyScalar(0.55), + emissive: color, + emissiveIntensity: HAND_GLOW, + metalness: 0.35, + roughness: 0.42, + clearcoat: 1, + clearcoatRoughness: 0.25, + }); + + const rig = new THREE.Group(); + const group = new THREE.Group(); + rig.add(group); + + // forearm + const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.27, 0.9, 8, 20), mat); + arm.rotation.x = Math.PI / 2; + arm.position.z = -0.75; + arm.castShadow = true; + group.add(arm); + + // neon cuff at the wrist + const cuffMat = new THREE.MeshStandardMaterial({ + color: 0x111122, emissive: color, emissiveIntensity: 1.1, + roughness: 0.35, metalness: 0.3, + }); + const cuff = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.18, 28), cuffMat); + cuff.rotation.x = Math.PI / 2; + cuff.position.z = -0.26; + group.add(cuff); + + // palm + const palm = new THREE.Mesh(new THREE.BoxGeometry(1.05, 0.4, 1.15), mat); + palm.position.z = 0.2; + palm.castShadow = true; + group.add(palm); + + // four fingers across the palm's leading edge (middle two the longest) + const fingers = []; + const xs = [-0.38, -0.13, 0.13, 0.38]; + const lens = [[0.34, 0.28], [0.44, 0.34], [0.46, 0.36], [0.38, 0.3]]; + for (let i = 0; i < 4; i++) { + const f = makeFinger(mat, 0.115, lens[i]); + f.root.position.set(xs[i], 0.05, 0.72); + group.add(f.root); + fingers.push(f); + } + + // thumb, mirrored per hand + const thumb = makeFinger(mat, 0.13, [0.32, 0.26]); + thumb.root.position.set(0.56 * side, -0.04, 0.28); + thumb.root.rotation.y = -0.95 * side; + group.add(thumb.root); + + return { rig, group, fingers, thumb, mat }; +} + +// Two capsule segments with joints at the knuckle and mid-phalanx. +// Positive rotation.x on either joint curls the finger toward the palm. +function makeFinger(mat, r, [l1, l2]) { + const root = new THREE.Group(); + + const seg1 = new THREE.Mesh(new THREE.CapsuleGeometry(r, l1, 6, 14), mat); + seg1.rotation.x = Math.PI / 2; + seg1.position.z = l1 / 2 + r * 0.4; + seg1.castShadow = true; + root.add(seg1); + + const mid = new THREE.Group(); + mid.position.z = l1 + r * 0.9; + root.add(mid); + + const seg2 = new THREE.Mesh(new THREE.CapsuleGeometry(r * 0.9, l2, 6, 14), mat); + seg2.rotation.x = Math.PI / 2; + seg2.position.z = l2 / 2 + r * 0.3; + seg2.castShadow = true; + mid.add(seg2); + + return { root, mid }; } // ---------------- scene building helpers ---------------- @@ -384,7 +477,7 @@ function buildBackground(scene) { function buildLights(scene) { scene.add(new THREE.HemisphereLight(0x9ad6ff, 0x2a1457, 0.45)); - const key = new THREE.DirectionalLight(0xffffff, 2.4); + const key = new THREE.DirectionalLight(0xffffff, 1.9); key.position.set(5, 11, 6); key.castShadow = true; key.shadow.mapSize.set(2048, 2048); @@ -398,12 +491,12 @@ function buildLights(scene) { key.shadow.radius = 6; scene.add(key); - const rimA = new THREE.PointLight(0x38e8ff, 60, 30); + const rimA = new THREE.PointLight(0x38e8ff, 28, 30); rimA.position.set(-7, 4, 5); scene.add(rimA); - const rimB = new THREE.PointLight(0xff5edb, 60, 30); - rimB.position.set(7, 3, -5); + const rimB = new THREE.PointLight(0xff5edb, 18, 30); + rimB.position.set(8, 5, -6); scene.add(rimB); } @@ -418,13 +511,13 @@ function buildFloor(scene) { scene.add(floor); } -function buildBoard(scene) { +function buildStage(scene) { const group = new THREE.Group(); scene.add(group); - // glass slab + // glass slab the duel happens over const base = new THREE.Mesh( - new THREE.BoxGeometry(6.4, 0.6, 6.4), + new THREE.BoxGeometry(8.8, 0.55, 4.4), new THREE.MeshPhysicalMaterial({ color: new THREE.Color("#6f5bd0"), metalness: 0, @@ -438,75 +531,40 @@ function buildBoard(scene) { attenuationDistance: 3.5, }) ); - base.position.y = 0; + base.position.y = 0.1; base.castShadow = true; base.receiveShadow = true; group.add(base); - // glowing grid lines - const lineMat = new THREE.MeshStandardMaterial({ - color: 0x111122, emissive: new THREE.Color("#9ad6ff"), emissiveIntensity: 1.1, - roughness: 0.4, metalness: 0.2, - }); - const lenLong = 5.6, thick = 0.07, hgt = 0.1; - for (const x of [-S / 2, S / 2]) { - const bar = new THREE.Mesh(new THREE.BoxGeometry(thick, hgt, lenLong), lineMat); - bar.position.set(x, BOARD_TOP, 0); - group.add(bar); - } - for (const z of [-S / 2, S / 2]) { - const bar = new THREE.Mesh(new THREE.BoxGeometry(lenLong, hgt, thick), lineMat); - bar.position.set(0, BOARD_TOP, z); - group.add(bar); + // glowing ring under each hand, tinted per side + for (const [x, hex] of [[-HAND_X, "#38e8ff"], [HAND_X, "#ff5edb"]]) { + const ring = new THREE.Mesh( + new THREE.TorusGeometry(0.95, 0.05, 14, 60), + new THREE.MeshStandardMaterial({ + color: 0x111122, emissive: new THREE.Color(hex), emissiveIntensity: 1.4, + roughness: 0.4, metalness: 0.2, + }) + ); + ring.rotation.x = -Math.PI / 2; + ring.position.set(x, 0.4, 0); + group.add(ring); } - // per-cell highlight tiles (used for win glow) - const cells = []; - const tileGeo = new THREE.BoxGeometry(S * 0.86, 0.12, S * 0.86); - for (let i = 0; i < 9; i++) { - const mat = new THREE.MeshStandardMaterial({ - color: 0x2a1b54, emissive: 0x000000, emissiveIntensity: 0, - roughness: 0.5, metalness: 0.1, transparent: true, opacity: 0.55, - }); - const tile = new THREE.Mesh(tileGeo, mat); - const p = cellPos(i); - tile.position.set(p.x, BOARD_TOP - 0.05, p.z); - tile.receiveShadow = true; - group.add(tile); - cells.push(tile); - } + return group; +} - // hover indicator - const hover = new THREE.Mesh( - new THREE.PlaneGeometry(S * 0.8, S * 0.8), - new THREE.MeshBasicMaterial({ - color: 0xffffff, transparent: true, opacity: 0, - blending: THREE.AdditiveBlending, depthWrite: false, +// small emissive gem spinning where the hands meet +function buildSpark(scene) { + const spark = new THREE.Mesh( + new THREE.OctahedronGeometry(0.22), + new THREE.MeshStandardMaterial({ + color: 0x221144, emissive: new THREE.Color("#b388ff"), emissiveIntensity: 1.2, + roughness: 0.3, metalness: 0.4, }) ); - hover.rotation.x = -Math.PI / 2; - hover.visible = false; - group.add(hover); - - // invisible raycast targets - const hitAreas = []; - const hitGeo = new THREE.PlaneGeometry(S, S); - for (let i = 0; i < 9; i++) { - const hit = new THREE.Mesh(hitGeo, new THREE.MeshBasicMaterial({ visible: false })); - hit.rotation.x = -Math.PI / 2; - const p = cellPos(i); - hit.position.set(p.x, BOARD_TOP + 0.05, p.z); - hit.userData.index = i; - group.add(hit); - hitAreas.push(hit); - } - - return { group, cells, hover, hitAreas, cellPos }; -} - -function cellPos(i) { - const col = i % 3, row = Math.floor(i / 3); - return { x: (col - 1) * S, z: (row - 1) * S }; + spark.position.set(0, HAND_Y, 0); + scene.add(spark); + return spark; } function buildDust(scene) { diff --git a/styles.css b/styles.css index d312d8f..e846a2c 100644 --- a/styles.css +++ b/styles.css @@ -1,7 +1,7 @@ :root { --bg: #0c0820; - --x: #38e8ff; - --o-fix: #ff5edb; + --you: #38e8ff; + --cpu: #ff5edb; --glass: rgba(255, 255, 255, 0.07); --glass-border: rgba(255, 255, 255, 0.16); --text: #f3eefe; @@ -50,8 +50,8 @@ html, body { height: 54px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, 0.15); - border-top-color: var(--x); - border-right-color: var(--o-fix); + border-top-color: var(--you); + border-right-color: var(--cpu); animation: spin 0.9s linear infinite; } .loader__text { color: var(--muted); letter-spacing: 0.06em; font-size: 14px; } @@ -69,9 +69,16 @@ html, body { padding: clamp(14px, 3vw, 30px); } .ui button { pointer-events: auto; } +.ui__top, .ui__bottom { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} +.ui__top { align-items: stretch; } /* glass surface helper */ -.topbar, .scoreboard, .status, .chip, .btn, .banner__card { +.topbar, .scoreboard, .status, .chip, .btn, .choice { background: var(--glass); border: 1px solid var(--glass-border); backdrop-filter: blur(16px) saturate(150%); @@ -92,7 +99,7 @@ html, body { font-size: clamp(20px, 5vw, 30px); font-weight: 800; letter-spacing: 0.04em; - background: linear-gradient(100deg, var(--x), #b388ff 50%, var(--o-fix)); + background: linear-gradient(100deg, var(--you), #b388ff 50%, var(--cpu)); -webkit-background-clip: text; background-clip: text; color: transparent; @@ -117,36 +124,74 @@ html, body { .chip:hover { transform: translateY(-2px); background: rgba(255, 255, 255, 0.13); } .chip:active { transform: translateY(0) scale(0.97); } -/* center cluster */ +/* scoreboard sits right under the topbar, keeping the stage clear */ .scoreboard { align-self: center; display: flex; gap: clamp(14px, 6vw, 46px); - padding: 12px clamp(18px, 5vw, 34px); - border-radius: 20px; - margin-top: -2px; + padding: 8px clamp(18px, 5vw, 34px); + border-radius: 18px; } .score { display: flex; flex-direction: column; align-items: center; min-width: 56px; } .score__tag { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); } -.score__val { font-size: clamp(26px, 7vw, 40px); font-weight: 800; line-height: 1.05; } -.score--x .score__val { color: var(--x); text-shadow: 0 0 18px rgba(56, 232, 255, 0.5); } -.score--o .score__val { color: var(--o-fix); text-shadow: 0 0 18px rgba(255, 94, 219, 0.5); } +.score__val { font-size: clamp(20px, 5vw, 30px); font-weight: 800; line-height: 1.05; } +.score--you .score__val { color: var(--you); text-shadow: 0 0 18px rgba(56, 232, 255, 0.5); } +.score--cpu .score__val { color: var(--cpu); text-shadow: 0 0 18px rgba(255, 94, 219, 0.5); } .score--draw .score__val { color: var(--text); opacity: 0.8; } .status { align-self: center; - margin-bottom: 10px; padding: 10px 22px; border-radius: 999px; font-size: clamp(14px, 4vw, 17px); letter-spacing: 0.02em; transition: transform 0.2s ease; } -.status b { color: var(--x); } -.status.turn-o b { color: var(--o-fix); } +.status b { color: var(--text); } +.status--win { color: var(--you); border-color: rgba(56, 232, 255, 0.4); } +.status--lose { color: var(--cpu); border-color: rgba(255, 94, 219, 0.4); } .status.pop { transform: scale(1.08); } +.status .vs { font-size: 1.15em; } +.status .vs i { font-style: normal; font-size: 0.75em; color: var(--muted); } + +/* ---------- choice buttons ---------- */ +.choices { + display: flex; + gap: clamp(10px, 3vw, 18px); + transition: opacity 0.25s ease; +} +.choices--locked { opacity: 0.55; } +.choice { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 14px clamp(18px, 5vw, 30px) 10px; + border-radius: 20px; + color: var(--text); + cursor: pointer; + transition: transform 0.15s ease, background 0.2s ease, border-color 0.2s ease; +} +.choice:hover:not(:disabled) { + transform: translateY(-4px); + background: rgba(255, 255, 255, 0.13); + border-color: rgba(56, 232, 255, 0.45); +} +.choice:active:not(:disabled) { transform: translateY(0) scale(0.95); } +.choice:disabled { cursor: default; } +.choice__emoji { + font-size: clamp(30px, 8vw, 42px); + line-height: 1; + filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.45)); +} +.choice__label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--muted); +} -.bottombar { display: flex; gap: 10px; justify-content: center; } .btn { padding: 12px 22px; border-radius: 14px; @@ -173,35 +218,8 @@ html, body { pointer-events: none; } -/* ---------- Win banner ---------- */ -.banner { - position: fixed; - inset: 0; - z-index: 40; - display: flex; - align-items: center; - justify-content: center; - background: rgba(8, 4, 22, 0.45); - opacity: 0; - visibility: hidden; - transition: opacity 0.4s ease, visibility 0.4s ease; -} -.banner.show { opacity: 1; visibility: visible; } -.banner__card { - pointer-events: auto; - text-align: center; - padding: 34px 40px; - border-radius: 26px; - transform: translateY(16px) scale(0.96); - transition: transform 0.45s cubic-bezier(.18,.9,.3,1.3); -} -.banner.show .banner__card { transform: translateY(0) scale(1); } -.banner__emoji { font-size: 58px; line-height: 1; filter: drop-shadow(0 6px 18px rgba(0,0,0,0.4)); } -.banner__title { font-size: 30px; font-weight: 800; margin: 12px 0 4px; } -.banner__sub { color: var(--muted); margin-bottom: 22px; font-size: 15px; } -.banner__card .btn { background: linear-gradient(100deg, rgba(56,232,255,0.25), rgba(255,94,219,0.25)); } - @media (max-width: 480px) { .brand__sub { display: none; } .hint { display: none; } + .btn--ghost { padding: 8px 16px; font-size: 13px; } }