Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches:
- main
- claude/3d-cachipun-game-d23abx
- claude/rock-paper-scissors-rebuild-o6zu5n
workflow_dispatch:

permissions:
Expand Down
90 changes: 53 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,56 +1,70 @@
<div align="center">

# 🇨🇱 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*

</div>

---

## 🎯 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. |

---

## ✨ Características · Features

| 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
Expand All @@ -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
```
Expand All @@ -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.

---

Expand Down
57 changes: 30 additions & 27 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#120a2a" />
<title>Cachipún 3D · Tres en línea a la chilena</title>
<meta name="description" content="Cachipún 3D — un tres en línea (gato) chileno con visuales impresionantes hechos en Three.js: vidrio refractivo, bloom y partículas. Juega contra la CPU o un amigo." />
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<title>Cachipún 3D · Piedra, papel o tijera</title>
<meta name="description" content="Cachipún 3D — piedra, papel o tijera a la chilena con visuales impresionantes hechos en Three.js: manos 3D articuladas, vidrio refractivo, bloom y confeti. Juega contra una CPU que aprende tus mañas." />
<link rel="stylesheet" href="./styles.css" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9C%8A%3C/text%3E%3C/svg%3E" />

Expand All @@ -33,14 +32,15 @@

<!-- Glass UI overlay -->
<div id="ui" class="ui">
<div class="ui__top">
<header class="topbar">
<div class="brand">
<h1 class="brand__title">CACHIPÚN <span>3D</span></h1>
<p class="brand__sub">tres en línea a la chilena</p>
<p class="brand__sub">piedra, papel o tijera</p>
</div>
<div class="topbar__actions">
<button id="modeBtn" class="chip" title="Cambiar modo de juego">
<span class="chip__icon">🤖</span><span id="modeLabel">vs CPU</span>
<button id="levelBtn" class="chip" title="Cambiar dificultad de la CPU">
<span class="chip__icon">🧠</span><span id="levelLabel">CPU astuta</span>
</button>
<button id="soundBtn" class="chip" title="Activar / silenciar sonido">
<span id="soundIcon">🔊</span>
Expand All @@ -49,38 +49,41 @@ <h1 class="brand__title">CACHIPÚN <span>3D</span></h1>
</header>

<section class="scoreboard">
<div class="score score--x">
<span class="score__tag" id="tagX">Tú</span>
<span class="score__val" id="scoreX">0</span>
<div class="score score--you">
<span class="score__tag">Tú</span>
<span class="score__val" id="scoreYou">0</span>
</div>
<div class="score score--draw">
<span class="score__tag">Empates</span>
<span class="score__val" id="scoreD">0</span>
</div>
<div class="score score--o">
<span class="score__tag" id="tagO">CPU</span>
<span class="score__val" id="scoreO">0</span>
<div class="score score--cpu">
<span class="score__tag">CPU</span>
<span class="score__val" id="scoreCpu">0</span>
</div>
</section>
</div>

<div class="status" id="status">Turno de <b>X</b></div>

<footer class="bottombar">
<button id="resetBtn" class="btn">Nueva mano</button>
<footer class="ui__bottom">
<div class="status" id="status">Elige tu jugada</div>
<div id="choices" class="choices">
<button class="choice" data-choice="piedra" title="Piedra (tecla 1)">
<span class="choice__emoji">✊</span>
<span class="choice__label">piedra</span>
</button>
<button class="choice" data-choice="papel" title="Papel (tecla 2)">
<span class="choice__emoji">✋</span>
<span class="choice__label">papel</span>
</button>
<button class="choice" data-choice="tijera" title="Tijera (tecla 3)">
<span class="choice__emoji">✌️</span>
<span class="choice__label">tijera</span>
</button>
</div>
<button id="clearBtn" class="btn btn--ghost">Reiniciar marcador</button>
</footer>

<p class="hint">Hecho con Three.js · el estado se guarda solo en tu navegador</p>
</div>

<!-- Win banner -->
<div id="banner" class="banner">
<div class="banner__card">
<div class="banner__emoji" id="bannerEmoji">🎉</div>
<div class="banner__title" id="bannerTitle">¡Ganaste!</div>
<div class="banner__sub" id="bannerSub">Pura pinta nomás</div>
<button id="bannerBtn" class="btn">Otra vez</button>
</div>
<p class="hint">Teclas 1 · 2 · 3 para jugar — hecho con Three.js, el marcador se guarda solo en tu navegador</p>
</div>

<script type="module" src="./src/main.js"></script>
Expand Down
115 changes: 43 additions & 72 deletions src/game.js
Original file line number Diff line number Diff line change
@@ -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 ----------
Expand All @@ -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;
Expand Down
Loading
Loading