Undici giochi da fare mentre Claude lavora (Snake, Tetris, Breakout, Wordle del codice, Labirinto 3D…), con i record e la pausa quando Claude finisce

Undici giochi nel pannello di Claude Code, da fare mentre Claude lavora. La partita va in pausa da sola quando Claude finisce, così non perdi il momento di tornare al prompt.
/play apre il pannello con il menu e gli dà i tasti. Risponde «Arcade aperto: Esc torna al prompt, ctrl+x tab al gioco.». Se il gioco non riceve i tasti compare «Arcade: clicca sul gioco per dargli i tasti».
I giochi, nell'ordine del menu:
a): indovina in sei tentativi una parola di cinque lettere presa dal codice che Claude ha scritto, o dal vocabolario della programmazione; ogni parola risolta vale (7 − tentativi) × 10 punti, la prima mancata chiude la partita.b): un labirinto in prima persona; trova la porta verde nell'angolo opposto prima che scada il tempo. Ogni uscita vale 100 punti per livello più 5 per ogni secondo rimasto, e il labirinto dopo è più grande.Com'è fatto ogni gioco: tutti stanno in una cornice dai bordi arrotondati con lo stesso tono di colori, e ogni evento si vede (punti nel bordo, lampi, cornice che cambia colore).
| Dove | Tasti |
|---|---|
| Menu | ↑ ↓ (o w s) scegli · Invio o spazio gioca · 1–9, poi a e b, parte subito |
| Snake | frecce o WASD per girare |
| 2048 | frecce o WASD per spostare le tessere |
| Claude runner | spazio o ↑ per saltare, ↓ per scendere |
| Flappy Claude | spazio o ↑ per volare (il primo colpo fa partire) |
| Acchiappa il bug | 1–9: la cella col bug, come sul tastierino |
| Tetris | ← → muovi · ↑ ruota · ↓ scendi · spazio fa cadere |
| Campo minato | frecce muovi · spazio (o Invio) scopre · f bandierina |
| Pioggia di parole | scrivi le parole · ⌫ corregge · Invio o spazio ricomincia la parola |
| Breakout | ← → (o a d) muovi · spazio o ↑ lancia la palla |
| Wordle del codice | scrivi 5 lettere · Invio prova · ⌫ cancella |
| Labirinto 3D | ↑ ↓ (o w s) avanti e indietro · ← → (o a d) gira · m mostra o nasconde la mappa |
| In partita | p pausa (Tab in Pioggia di parole e Wordle del codice) · Esc torna al prompt |
| In pausa | Invio, spazio o il tasto di pausa riprende · m menu |
| Partita finita | Invio o spazio rigioca · m menu |
Sopra il gioco una riga dice «● Claude sta lavorando…» oppure «✓ Claude ha finito: Esc torna al prompt». Sotto, l'icona e il nome del gioco con punti e record; sotto il gioco, i tasti. La pausa e la fine della partita compaiono in un riquadro: ambra «❚❚ PAUSA», rosso «FINE», verde «★ NUOVO RECORD».
Edit o Write riuscito prende i nomi dal testo scritto, per Pioggia di parole.Edit e dal contenuto di Write, già passati allo strumento; mai dai file che possono contenere segreti (.env, chiavi .pem/.key, file con secret, credential, password o token nel nome).$.store, chiave best): un file JSON nella cartella di configurazione di Claude Code, scritto a ogni fine partita. Con due sessioni aperte vale il record più alto delle due.Invio o spazio rigiocano solo dopo 600 millisecondi, per non ripartire per sbaglio.Tab e non p.m in partita mostra o nasconde la mappa; in pausa e a fine partita torna al menu.claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install arcade@enrik-mods
Oppure, dal prompt di Claude Code: /plugin install arcade --marketplace Wowbagger1994/claude-mods.
Dalla cartella della mod:
claude plugin validate .
claude plugin test .
I test sono 293, in 16 file.
hooks/register.tsx 164 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GameId } from '../types'
5import { isGameId } from './games/catalog'
6import { addWords, isSecretFile, wordsFrom } from './words'
7
8const PLUGIN = 'arcade'
9const PANE = 'arcade'
10const TITLE = 'Arcade'
11/** Claude working this long earns an invitation to play. */
12const INVITE_AFTER_MS = 10_000
13/** At most one invitation in this long: a reminder, not a nag. */
14const INVITE_COOLDOWN_MS = 10 * 60_000
15/** Room the pane gives the board below its own line. */
16const MIN_BOARD_ROWS = 14
17
18const best = atom({ plugin: 'arcade', key: 'best' } as const, {})
19const isBusy = atom({ plugin: 'arcade', key: 'isBusy' } as const, false)
20const doneCount = atom({ plugin: 'arcade', key: 'doneCount' } as const, 0)
21const words = atom({ plugin: 'arcade', key: 'words' } as const, [])
22
23/** The main conversation's turn in progress, so a fork's turns neither start nor end Claude's work. */
24let activeTurn: string | null = null
25let lastInviteAt = Number.NEGATIVE_INFINITY
26
27const isPaneShown = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
28
29/** Opens the pane and gives the board the keys. */
30const openArcade = async ($: EngineInterface) => {
31 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
32 if (!opened.isPlaced) {
33 $.ui.toast('Arcade: allarga il terminale per vedere il pannello')
34 return
35 }
36 // Without the focus the keys stay with the prompt: a click on the board gives them.
37 const isFocused = await $.ui
38 .focus({ requestId: PANE, key: 'board' })
39 .then(r => !('deny' in r && r.deny !== undefined))
40 .catch((error: unknown) => {
41 $.ui.log(`${PLUGIN}: focus non dato al gioco: ${String(error)}`, { to: 'debug' })
42 return false
43 })
44 if (!isFocused) $.ui.toast('Arcade: clicca sul gioco per dargli i tasti')
45}
46
47const invite = async ($: EngineInterface, turnId: string) => {
48 try {
49 if (activeTurn !== turnId) return
50 const now = await $.clock.now()
51 if (now - lastInviteAt < INVITE_COOLDOWN_MS || (await isPaneShown($))) return
52 lastInviteAt = now
53 $.ui.toast('Claude ci mette un po’: /play per una partita (undici giochi, con i record)')
54 } catch (error) {
55 $.ui.log(`${PLUGIN}: invito non mostrato: ${String(error)}`, { to: 'debug' })
56 }
57}
58
59/** Where the records outlive the session: `$.state` is the session's, `$.store` the machine's. */
60const STORE_BEST = 'best'
61
62/** What the store holds, as far as it is records: known games, finite scores. */
63const savedBest = (raw: unknown): Partial<Record<GameId, number>> =>
64 typeof raw !== 'object' || raw === null
65 ? {}
66 : Object.fromEntries(Object.entries(raw).filter(([g, s]) => isGameId(g) && typeof s === 'number' && Number.isFinite(s)))
67
68/** The better of two sets of records, game by game. */
69const bestOf = (a: Partial<Record<GameId, number>>, b: Partial<Record<GameId, number>>): Partial<Record<GameId, number>> =>
70 Object.fromEntries([...new Set([...Object.keys(a), ...Object.keys(b)])].map(g => [g, Math.max(a[g as GameId] ?? 0, b[g as GameId] ?? 0)]))
71
72/** A board's message: `{ kind: 'score', game, score }` when a game ends; anything else is ignored. */
73const recordScore = async ($: EngineInterface, data: unknown) => {
74 if (typeof data !== 'object' || data === null) return
75 const { kind, game, score } = data as Record<string, unknown>
76 if (kind !== 'score' || !isGameId(game) || typeof score !== 'number' || !Number.isFinite(score)) return
77 await update($, best, b => (score > (b[game] ?? 0) ? { ...b, [game]: score } : b))
78 // Another session may have set a record meanwhile: the store keeps the better of both.
79 await $.store.set(STORE_BEST, bestOf(savedBest(await $.store.get(STORE_BEST)), await read($, best)))
80}
81
82/** The names in what Claude just wrote join the typing game's words. */
83const harvest = async ($: EngineInterface, path: string, code: string, ran: { deny?: string; isError?: true }) => {
84 if (ran.deny !== undefined || ran.isError === true || isSecretFile(path)) return
85 const fresh = wordsFrom(code)
86 if (fresh.length > 0) await update($, words, pool => addWords(pool, fresh))
87}
88
89export const register: Register = on => {
90 on('session.start', async ($, e, next) => {
91 await $.command.register({ name: 'play', description: 'Undici giochi da fare mentre Claude lavora, con i record' })
92 // A session that stopped mid-turn left this on: a new one starts idle.
93 activeTurn = null
94 await update($, isBusy, () => false)
95 const saved = savedBest(await $.store.get(STORE_BEST))
96 await update($, best, b => bestOf(b, saved))
97 return next(e)
98 })
99
100 on('command.run', { command: 'play' }, async $ => {
101 await openArcade($)
102 return { text: 'Arcade aperto: Esc torna al prompt, ctrl+x tab al gioco.' }
103 })
104
105 on('turn.start', async ($, e, next) => {
106 const started = await next(e)
107 if (activeTurn === null) {
108 activeTurn = e.turnId
109 await update($, isBusy, () => true)
110 $.clock.after(INVITE_AFTER_MS, () => void invite($, e.turnId))
111 }
112 return started
113 }).catch(($, e, next) => next(e))
114
115 on('turn.complete', async ($, e, next) => {
116 const done = await next(e)
117 if (e.turnId === activeTurn) {
118 activeTurn = null
119 await update($, isBusy, () => false)
120 await update($, doneCount, n => n + 1)
121 if (await isPaneShown($)) $.ui.toast('Claude ha finito')
122 }
123 return done
124 }).catch(($, e, next) => next(e))
125
126 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
127 const ran = await next(e)
128 await harvest($, e.file_path, e.new_string, ran)
129 return ran
130 }).catch(($, e, next) => next(e))
131
132 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
133 const ran = await next(e)
134 await harvest($, e.file_path, e.content, ran)
135 return ran
136 }).catch(($, e, next) => next(e))
137
138 on('ui.message', async ($, e) => {
139 await recordScore($, e.data)
140 return {}
141 })
142
143 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
144 const el = $.ui.resolve(e)
145 if (!('Client' in el)) return <el.Text dimColor>I giochi si giocano nel terminale.</el.Text>
146 const props = {
147 best: await read($, best),
148 isBusy: await read($, isBusy),
149 doneCount: await read($, doneCount),
150 words: await read($, words),
151 seed: Math.floor(await $.clock.now()),
152 }
153 return (
154 <el.Client
155 key="board"
156 module="./board.tsx"
157 props={props}
158 width={e.props.bodyColumns}
159 height={Math.max(MIN_BOARD_ROWS, e.props.scroll.bodyRows)}
160 />
161 )
162 })
163}
164hooks/games/catalog.ts 19 lines1import type { GameId } from '../../types'
2import { BREAKOUT } from './breakout'
3import { BUGS } from './bugs'
4import { G2048, RUNNER, SNAKE } from './classics'
5import { FLAPPY } from './flappy'
6import { MAZE } from './maze'
7import { MINES } from './mines'
8import { TETRIS } from './tetris'
9import type { AnyGame } from './types'
10import { TYPING } from './typing'
11import { WORDLE } from './wordle'
12
13/** The games in menu order: the key beside each (1–9, then letters) starts it. */
14export const GAMES: readonly AnyGame[] = [SNAKE, G2048, RUNNER, FLAPPY, BUGS, TETRIS, MINES, TYPING, BREAKOUT, WORDLE, MAZE]
15
16export const gameOf = (id: GameId): AnyGame => GAMES.find(g => g.id === id) ?? GAMES[0]!
17
18export const isGameId = (value: unknown): value is GameId => GAMES.some(g => g.id === value)
19hooks/words.ts 21 lines1/** Words kept for the typing game: enough for many rounds, small enough to hand the board. */
2export const MAX_WORDS = 200
3const MIN_LENGTH = 4
4const MAX_LENGTH = 14
5
6/** Names in a piece of code: identifiers of 4 to 14 letters, constants in capitals left out (hard to type). */
7export const wordsFrom = (code: string): string[] => {
8 const found = code.match(/[A-Za-z][A-Za-z0-9]*/g) ?? []
9 const fit = found.filter(w => w.length >= MIN_LENGTH && w.length <= MAX_LENGTH && w !== w.toUpperCase())
10 return [...new Set(fit)]
11}
12
13/** Files whose words could be secrets (`.env`, keys, credentials): never shown on the board. */
14const SECRET_FILE = /(^|\/)(\.env[^/]*|[^/]*(secret|credential|password|token)[^/]*|[^/]*\.(pem|key|p12|pfx))$/i
15
16export const isSecretFile = (path: string): boolean => SECRET_FILE.test(path)
17
18/** The pool with the newest words first, each once, at most MAX_WORDS. */
19export const addWords = (pool: readonly string[], fresh: readonly string[]): string[] =>
20 [...new Set([...fresh, ...pool])].slice(0, MAX_WORDS)
21hooks/board.tsx 114 lines1import type { ClientModule, ClientSurface } from 'claude-code'
2
3import { gameOf } from './games/catalog'
4import { PALETTE } from './games/style'
5import type { Row } from './games/types'
6import { bannerRows, lookOf, menuRows } from './menu'
7import type { BoardProps, Play, Step } from './play'
8import { CHROME_ROWS, drawOf, GAMES, isMoving, newPlay, onKey, onProps, onTick, pauseKeyOf, scoreOf, statusOf } from './play'
9
10/** 20 frames a second: smooth enough for the dino, light on the terminal. */
11const TICK_MS = 50
12/** The least room a game gets, however small the pane. */
13const MIN_GAME_ROWS = 12
14const BUSY = '#f59e0b'
15const DONE = '#22c55e'
16
17type Surface = ClientSurface<Play>
18type Elements = Surface['elements']
19
20/** Applies a step: redraws, and hands a finished game's score to the hooks module for the record. */
21const apply = (surface: Surface, step: Step) => {
22 if (step.play !== surface.state) surface.setState(step.play)
23 if (step.score !== null) surface.post({ kind: 'score', game: step.score.game, score: step.score.score })
24}
25
26/** Rows drawn as Texts; `prefix` keeps the keys of two blocks in one Box apart. */
27const rows = (el: Elements, lines: Row[], prefix = 'r') =>
28 lines.map((line, r) => (
29 <el.Text key={`${prefix}${r}`} wrap="truncate-end">
30 {line.map((span, i) => (
31 <el.Text key={`s${i}`} color={span.fg} backgroundColor={span.bg} bold={span.bold}>
32 {span.text}
33 </el.Text>
34 ))}
35 </el.Text>
36 ))
37
38const claudeLine = (el: Elements, props: BoardProps) =>
39 props.isBusy ? (
40 <el.Text color={BUSY}>● Claude sta lavorando…</el.Text>
41 ) : (
42 <el.Text color={DONE}>✓ Claude ha finito: Esc torna al prompt</el.Text>
43 )
44
45const menu = (el: Elements, p: Play, props: BoardProps, columns: number) => <el.Box flexDirection="column">{rows(el, menuRows(p.cursor, props.best, columns))}</el.Box>
46
47/** Under the game: the keys while playing; a framed banner when paused or over. */
48const message = (el: Elements, p: Play, columns: number) => {
49 const def = gameOf(p.game)
50 const pauseKey = pauseKeyOf(def) === 'tab' ? 'Tab' : 'p'
51 if (p.screen === 'paused') {
52 const why = p.pausedBy === 'claude' ? ['Claude ha finito:', 'partita in pausa.'] : ['In pausa.']
53 return rows(el, bannerRows({ title: '❚❚ PAUSA', color: PALETTE.hurry, lines: [...why, 'Invio riprende · m menu'] }, columns), 'banner')
54 }
55 if (p.screen === 'over') {
56 const record = p.isRecord ? ' Nuovo record!' : ''
57 const title = p.isRecord ? '★ NUOVO RECORD' : 'FINE'
58 const color = p.isRecord ? PALETTE.good : PALETTE.danger
59 return rows(el, bannerRows({ title, color, lines: [`Partita finita: ${scoreOf(p)} punti.${record}`, 'Invio rigioca · m menu'] }, columns), 'banner')
60 }
61 return [<el.Text key="help" dimColor>{`${def.keys} · ${pauseKey} pausa · Esc torna al prompt`}</el.Text>]
62}
63
64const game = (el: Elements, p: Play, props: BoardProps, columns: number) => {
65 const status = statusOf(p)
66 const look = lookOf(p.game)
67 return (
68 <el.Box flexDirection="column">
69 <el.Text>
70 <el.Text color={look.color} bold>{`${look.icon} `}</el.Text>
71 <el.Text bold>{gameOf(p.game).name}</el.Text>
72 <el.Text>{` punti ${scoreOf(p)}`}</el.Text>
73 <el.Text dimColor>{` record ${props.best[p.game] ?? 0}`}</el.Text>
74 {status !== '' && <el.Text color="claude">{` ${status}`}</el.Text>}
75 </el.Text>
76 {rows(el, drawOf(p))}
77 {message(el, p, columns)}
78 </el.Box>
79 )
80}
81
82/** The arcade: a menu, then the game picked, driven by the frame clock and the keys. */
83const Board: ClientModule<BoardProps, Play> = (props, surface) => {
84 const el = surface.elements
85 const size = () => ({ columns: surface.columns, rows: Math.max(surface.rows, CHROME_ROWS + MIN_GAME_ROWS) })
86 if (surface.state === undefined) {
87 surface.every(TICK_MS, () => {
88 const p = surface.state
89 if (p !== undefined && isMoving(p)) apply(surface, onTick(p, TICK_MS, latest.props))
90 })
91 surface.onKey(k => {
92 const p = surface.state
93 if (p !== undefined) apply(surface, onKey(p, k.key, latest.props, size()))
94 })
95 surface.setState(newPlay(props))
96 }
97 latest.props = props
98 const p = surface.state ?? newPlay(props)
99 const seen = onProps(p, props)
100 if (seen !== p) surface.setState(seen)
101
102 return (
103 <el.Box flexDirection="column">
104 {claudeLine(el, props)}
105 {seen.screen === 'menu' ? menu(el, seen, props, surface.columns) : game(el, seen, props, surface.columns)}
106 </el.Box>
107 )
108}
109
110/** The props of the last call: what the timer and the key handler, set up once, read. */
111const latest: { props: BoardProps } = { props: { best: {}, isBusy: false, doneCount: 0, seed: 1, words: [] } }
112
113export default Board
114hooks/games/breakout.ts 519 lines1import { directionOf, isSpace } from './keys'
2import type { Rng } from './rng'
3import { nextRandom } from './rng'
4import { centerPad, framed, pack, PALETTE } from './style'
5import type { GameDef, Row, Span } from './types'
6
7/**
8 * Breakout on a field of half-block pixels: each text row holds two pixel
9 * rows, each column one pixel, so the ball moves half a row at a time and the
10 * pixels come out about square.
11 */
12
13/** The ball: where it is in pixels (fractional) and its speed in pixels per second, down positive. */
14export type Ball = { x: number; y: number; vx: number; vy: number }
15
16/** Waiting on the paddle for a launch, in flight, a moment for a lost ball, a moment for a cleared wall. */
17export type Phase = 'serve' | 'play' | 'lost' | 'cleared'
18
19export type BonusKind = 'wide' | 'slow' | 'life'
20
21/** A bonus falling from a broken brick: its middle column and its pixel row. */
22export type Drop = { kind: BonusKind; x: number; y: number }
23
24/** A brick just broken, lit for a moment where it was. */
25export type Shatter = { row: number; col: number; ms: number }
26
27/** Text floating up for a moment: the points of a brick, the name of a bonus caught. */
28export type Popup = { x: number; row: number; text: string; color: string; ms: number }
29
30export type Breakout = {
31 /** Columns and text rows of the field, inside the frame. */
32 width: number
33 height: number
34 /** Columns of room, to center the frame. */
35 columns: number
36 /** The wall: true where a brick stands, top row first. */
37 bricks: boolean[][]
38 brickWidth: number
39 /** The wall's first column, to center it in the field. */
40 brickLeft: number
41 /** The paddle's first column. */
42 paddleX: number
43 ball: Ball
44 phase: Phase
45 /** Milliseconds left of a `lost` or `cleared` moment. */
46 phaseMs: number
47 lives: number
48 score: number
49 level: number
50 /** Milliseconds left of a wide paddle and of a slow ball. */
51 wideMs: number
52 slowMs: number
53 drops: Drop[]
54 shatters: Shatter[]
55 popups: Popup[]
56 /** The column the last lost ball fell through. */
57 lostX: number
58 isOver: boolean
59 rng: Rng
60}
61
62export const LIVES = 3
63/** Points of the wall's lowest row; each row up is worth this much more. */
64export const ROW_POINTS = 10
65/** The wall's first text row: one free row above it to bounce in. */
66export const BRICK_TOP = 1
67const MIN_BRICK_ROWS = 3
68const MAX_BRICK_ROWS = 6
69/** One row of bricks for each this many rows of field. */
70const ROWS_PER_BRICK_ROW = 3
71/** Bricks are this wide, wider in a wide field. */
72const BRICK_WIDTH = 5
73const WIDE_BRICK_WIDTH = 6
74const WIDE_FIELD = 48
75export const PADDLE_WIDTH = 7
76export const WIDE_PADDLE_WIDTH = 11
77/** Columns the paddle moves for a key. */
78export const PADDLE_STEP = 3
79/** Ball speed in pixels per second at level 1, more at each level, never over the cap. */
80export const BASE_SPEED = 24
81const LEVEL_SPEEDUP = 0.12
82const MAX_SPEED = 44
83/** A slow ball moves at this share of its speed. */
84export const SLOW_FACTOR = 0.6
85/** The steepest bounce off the paddle's ends, from straight up; a launch's range. */
86const MAX_BOUNCE = (60 * Math.PI) / 180
87const MIN_LAUNCH = (15 * Math.PI) / 180
88const MAX_LAUNCH = (35 * Math.PI) / 180
89/** The ball never moves more than this many pixels in one step, so it cannot jump a brick. */
90const MAX_STEP = 0.5
91/** The share of broken bricks that drop a bonus; the share of bonuses that are a life (the rest, half wide, half slow). */
92export const DROP_CHANCE = 0.2
93const LIFE_SHARE = 0.2
94/** Pixels per second a bonus falls. */
95export const DROP_SPEED = 9
96export const WIDE_MS = 12_000
97export const SLOW_MS = 10_000
98export const MAX_LIVES = 5
99/** How long a lost ball and a cleared wall hold the game; a broken brick stays lit; a popup floats. */
100export const LOST_MS = 1_200
101export const CLEARED_MS = 1_500
102export const SHATTER_MS = 240
103export const POPUP_MS = 700
104const MIN_WIDTH = 28
105const MAX_WIDTH = 58
106const MIN_HEIGHT = 10
107const MAX_HEIGHT = 22
108/** Rows and columns the frame takes around the field. */
109const FRAME_CELLS = 2
110/** A hair under a whole pixel, to keep a reflected ball inside the field. */
111const EPSILON = 1e-6
112
113const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
114
115// ── The field ──────────────────────────────────────────────────────────────
116
117export const paddleWidth = (g: Breakout): number => (g.wideMs > 0 ? WIDE_PADDLE_WIDTH : PADDLE_WIDTH)
118/** The pixel row of the paddle's top: it fills the field's last text row. */
119export const paddleRow = (g: Breakout): number => g.height * 2 - 2
120export const speedAt = (level: number): number => Math.min(MAX_SPEED, BASE_SPEED * (1 + LEVEL_SPEEDUP * (level - 1)))
121export const bricksLeft = (g: Breakout): number => g.bricks.reduce((n, row) => n + row.filter(Boolean).length, 0)
122const brickRowsFor = (height: number) => clamp(Math.floor(height / ROWS_PER_BRICK_ROW), MIN_BRICK_ROWS, MAX_BRICK_ROWS)
123/** Points of a brick in `row`, the top row worth the most. */
124export const pointsOf = (g: Breakout, row: number): number => (g.bricks.length - row) * ROW_POINTS
125
126const fullWall = (rows: number, cols: number): boolean[][] => Array.from({ length: rows }, () => Array<boolean>(cols).fill(true))
127
128/** The ball resting on the paddle's middle, ready to launch. */
129const restingBall = (g: Breakout): Ball => ({ x: g.paddleX + paddleWidth(g) / 2, y: paddleRow(g) - 0.5, vx: 0, vy: 0 })
130
131export const newBreakout = (columns: number, rows: number, rng: Rng): Breakout => {
132 const width = clamp(columns - FRAME_CELLS, MIN_WIDTH, MAX_WIDTH)
133 const height = clamp(rows - FRAME_CELLS, MIN_HEIGHT, MAX_HEIGHT)
134 const brickWidth = width >= WIDE_FIELD ? WIDE_BRICK_WIDTH : BRICK_WIDTH
135 const cols = Math.floor(width / brickWidth)
136 const base = {
137 width,
138 height,
139 columns,
140 bricks: fullWall(brickRowsFor(height), cols),
141 brickWidth,
142 brickLeft: Math.floor((width - cols * brickWidth) / 2),
143 paddleX: Math.floor((width - PADDLE_WIDTH) / 2),
144 ball: { x: 0, y: 0, vx: 0, vy: 0 },
145 phase: 'serve' as const,
146 phaseMs: 0,
147 lives: LIVES,
148 score: 0,
149 level: 1,
150 wideMs: 0,
151 slowMs: 0,
152 drops: [],
153 shatters: [],
154 popups: [],
155 lostX: 0,
156 isOver: false,
157 rng,
158 }
159 return { ...base, ball: restingBall(base) }
160}
161
162/** The brick a pixel falls in, standing or not; null outside the wall. */
163const brickCellOf = (g: Breakout, x: number, y: number): { row: number; col: number } | null => {
164 const row = Math.floor(y / 2) - BRICK_TOP
165 const col = Math.floor((x - g.brickLeft) / g.brickWidth)
166 const isInside = row >= 0 && row < g.bricks.length && col >= 0 && col < (g.bricks[0]?.length ?? 0) && x >= g.brickLeft
167 return isInside ? { row, col } : null
168}
169
170const standingAt = (g: Breakout, x: number, y: number) => {
171 const cell = brickCellOf(g, x, y)
172 return cell !== null && g.bricks[cell.row]?.[cell.col] === true ? cell : null
173}
174
175// ── Moves ──────────────────────────────────────────────────────────────────
176
177/** The paddle `dx` columns over, inside the field; a ball at rest rides along. */
178export const movePaddle = (g: Breakout, dx: number): Breakout => {
179 const paddleX = clamp(g.paddleX + dx, 0, g.width - paddleWidth(g))
180 const moved = { ...g, paddleX }
181 return g.phase === 'serve' ? { ...moved, ball: restingBall(moved) } : moved
182}
183
184/** The ball off the paddle, up and a little to one side, picked at random. */
185export const launch = (g: Breakout): Breakout => {
186 if (g.phase !== 'serve') return g
187 const [side, r1] = nextRandom(g.rng)
188 const [tilt, r2] = nextRandom(r1)
189 const angle = (MIN_LAUNCH + tilt * (MAX_LAUNCH - MIN_LAUNCH)) * (side < 0.5 ? -1 : 1)
190 const speed = speedAt(g.level)
191 return { ...g, phase: 'play', ball: { ...restingBall(g), vx: speed * Math.sin(angle), vy: -speed * Math.cos(angle) }, rng: r2 }
192}
193
194export const keyBreakout = (g: Breakout, key: string): Breakout => {
195 if (g.isOver) return g
196 const dir = directionOf(key)
197 if (dir === 'left' || dir === 'right') return movePaddle(g, dir === 'left' ? -PADDLE_STEP : PADDLE_STEP)
198 return isSpace(key) || dir === 'up' ? launch(g) : g
199}
200
201// ── Collisions ─────────────────────────────────────────────────────────────
202
203/** A bonus, sometimes, out of a broken brick at column `x`, pixel row `y`. */
204const maybeDrop = (g: Breakout, x: number, y: number): Breakout => {
205 const [chance, r1] = nextRandom(g.rng)
206 if (chance >= DROP_CHANCE) return { ...g, rng: r1 }
207 const [pick, r2] = nextRandom(r1)
208 const kind: BonusKind = pick < LIFE_SHARE ? 'life' : pick < (1 + LIFE_SHARE) / 2 ? 'wide' : 'slow'
209 return { ...g, drops: [...g.drops, { kind, x, y }], rng: r2 }
210}
211
212/** The brick at `cell` broken: its points, a flash and a popup where it was, maybe a bonus, maybe the wall cleared. */
213export const breakBrick = (g: Breakout, cell: { row: number; col: number }): Breakout => {
214 const points = pointsOf(g, cell.row)
215 const middle = g.brickLeft + cell.col * g.brickWidth + Math.floor(g.brickWidth / 2)
216 const textRow = BRICK_TOP + cell.row
217 const broken: Breakout = {
218 ...g,
219 bricks: g.bricks.map((row, r) => (r === cell.row ? row.map((b, c) => (c === cell.col ? false : b)) : row)),
220 score: g.score + points,
221 shatters: [...g.shatters, { ...cell, ms: SHATTER_MS }],
222 popups: [...g.popups, { x: middle, row: textRow, text: `+${points}`, color: LIGHTS[cell.row % LIGHTS.length]!, ms: POPUP_MS }],
223 }
224 if (bricksLeft(broken) === 0) return { ...broken, phase: 'cleared', phaseMs: CLEARED_MS, drops: [] }
225 return maybeDrop(broken, middle, textRow * 2 + 1)
226}
227
228/** The ball off the paddle: straight up from its middle, slanting more the nearer an end it lands. */
229const bounceOffPaddle = (g: Breakout, ball: Ball): Ball => {
230 const half = paddleWidth(g) / 2
231 const offset = clamp((ball.x - (g.paddleX + half)) / half, -1, 1)
232 const speed = Math.hypot(ball.vx, ball.vy)
233 const angle = offset * MAX_BOUNCE
234 const top = paddleRow(g)
235 return { x: ball.x, y: top - (ball.y - top) - EPSILON, vx: speed * Math.sin(angle), vy: -speed * Math.cos(angle) }
236}
237
238/** A ball through the bottom: a life gone, and the game with it when none are left. */
239export const loseBall = (g: Breakout): Breakout => {
240 const lives = g.lives - 1
241 return { ...g, lives, phase: 'lost', phaseMs: LOST_MS, lostX: clamp(Math.floor(g.ball.x), 0, g.width - 1), drops: [], wideMs: 0, slowMs: 0, isOver: lives <= 0 }
242}
243
244/** The ball off the walls and the ceiling, at its new place. */
245const offWalls = (g: Breakout, b: Ball): Ball => {
246 const fromSide = b.x < 0 ? { x: -b.x, vx: Math.abs(b.vx) } : b.x >= g.width ? { x: 2 * g.width - b.x - EPSILON, vx: -Math.abs(b.vx) } : { x: b.x, vx: b.vx }
247 const fromTop = b.y < 0 ? { y: -b.y, vy: Math.abs(b.vy) } : { y: b.y, vy: b.vy }
248 return { ...fromSide, ...fromTop }
249}
250
251/** The ball moved `dt` seconds (short enough not to skip a pixel), and what it hits. */
252const stepBall = (g: Breakout, dt: number): Breakout => {
253 const from = g.ball
254 const to = offWalls(g, { ...from, x: from.x + from.vx * dt, y: from.y + from.vy * dt })
255 const hit = standingAt(g, to.x, to.y)
256 if (hit !== null) {
257 // Into a brick from the side when it stays in the same row of bricks: then it turns back across, else up or down.
258 const isFromSide = Math.floor(from.y / 2) === Math.floor(to.y / 2)
259 const ball = { ...from, vx: isFromSide ? -to.vx : to.vx, vy: isFromSide ? to.vy : -to.vy }
260 return breakBrick({ ...g, ball }, hit)
261 }
262 const top = paddleRow(g)
263 const isOnPaddle = to.vy > 0 && from.y < top && to.y >= top && to.x >= g.paddleX - MAX_STEP && to.x < g.paddleX + paddleWidth(g) + MAX_STEP
264 if (isOnPaddle) return { ...g, ball: bounceOffPaddle(g, to) }
265 if (to.y >= g.height * 2) return loseBall({ ...g, ball: to })
266 return { ...g, ball: to }
267}
268
269/** `ms` of flight in short steps, stopping at a lost ball or a cleared wall. */
270const fly = (g: Breakout, ms: number): Breakout => {
271 const factor = g.slowMs > 0 ? SLOW_FACTOR : 1
272 const distance = Math.hypot(g.ball.vx, g.ball.vy) * factor * (ms / 1000)
273 const steps = Math.max(1, Math.ceil(distance / MAX_STEP))
274 const dt = (factor * ms) / 1000 / steps
275 let state = g
276 for (let i = 0; i < steps && state.phase === 'play'; i++) state = stepBall(state, dt)
277 return state
278}
279
280const BONUS_NAMES: Record<BonusKind, string> = { wide: 'larga!', slow: 'lenta!', life: '+1 ♥' }
281
282/** A bonus caught by the paddle at work. */
283export const catchBonus = (g: Breakout, kind: BonusKind): Breakout => {
284 const popup = { x: g.paddleX + Math.floor(paddleWidth(g) / 2), row: g.height - 2, text: BONUS_NAMES[kind], color: BONUS_COLORS[kind], ms: POPUP_MS }
285 const caught = { ...g, popups: [...g.popups, popup] }
286 if (kind === 'life') return { ...caught, lives: Math.min(MAX_LIVES, g.lives + 1) }
287 if (kind === 'slow') return { ...caught, slowMs: SLOW_MS }
288 // Wider about its middle, kept inside the field.
289 const grow = (WIDE_PADDLE_WIDTH - paddleWidth(g)) / 2
290 return { ...caught, wideMs: WIDE_MS, paddleX: clamp(Math.round(g.paddleX - grow), 0, g.width - WIDE_PADDLE_WIDTH) }
291}
292
293/** Falling bonuses moved on: the ones the paddle meets caught, the ones past it gone. */
294const fall = (g: Breakout, ms: number): Breakout => {
295 const moved = g.drops.map(d => ({ ...d, y: d.y + DROP_SPEED * (ms / 1000) }))
296 const width = paddleWidth(g)
297 const isCaught = (d: Drop) => d.y >= paddleRow(g) - 1 && d.x + 1 >= g.paddleX && d.x - 1 < g.paddleX + width
298 const caught = moved.filter(isCaught)
299 const left = { ...g, drops: moved.filter(d => !isCaught(d) && d.y < g.height * 2) }
300 return caught.reduce((state, d) => catchBonus(state, d.kind), left)
301}
302
303/** The bonuses' time running out: a wide paddle shrinks back about its middle. */
304const wearOff = (g: Breakout, ms: number): Breakout => {
305 const wideMs = Math.max(0, g.wideMs - ms)
306 const shrunk = g.wideMs > 0 && wideMs === 0
307 const paddleX = shrunk ? clamp(g.paddleX + (WIDE_PADDLE_WIDTH - PADDLE_WIDTH) / 2, 0, g.width - PADDLE_WIDTH) : g.paddleX
308 return { ...g, wideMs, slowMs: Math.max(0, g.slowMs - ms), paddleX }
309}
310
311/** After a lost ball, the next one on the paddle; after a cleared wall, the next level's. */
312const afterMoment = (g: Breakout): Breakout => {
313 const next =
314 g.phase === 'cleared'
315 ? { ...g, level: g.level + 1, bricks: fullWall(g.bricks.length, g.bricks[0]?.length ?? 0), shatters: [], wideMs: 0, slowMs: 0, paddleX: Math.floor((g.width - PADDLE_WIDTH) / 2) }
316 : g
317 return { ...next, phase: 'serve', phaseMs: 0, ball: restingBall(next) }
318}
319
320const age = <E extends { ms: number }>(effects: readonly E[], ms: number): E[] => effects.filter(e => e.ms > ms).map(e => ({ ...e, ms: e.ms - ms }))
321
322/** Time passing: the effects fade, the moments run out, the ball flies and the bonuses fall. */
323export const tickBreakout = (g: Breakout, ms: number): Breakout => {
324 if (g.isOver) return g
325 const aged = { ...g, shatters: age(g.shatters, ms), popups: age(g.popups, ms) }
326 if (g.phase === 'lost' || g.phase === 'cleared') {
327 return g.phaseMs > ms ? { ...aged, phaseMs: g.phaseMs - ms } : afterMoment(aged)
328 }
329 if (g.phase === 'serve') return aged
330 return fall(wearOff(fly(aged, ms), ms), ms)
331}
332
333// ── Drawing ────────────────────────────────────────────────────────────────
334
335/** The wall's rows, top to bottom, as a rainbow: each colour, its lit top edge, its shaded side. */
336export const COLORS = ['#ef4444', '#f97316', '#facc15', '#22c55e', '#38bdf8', '#a855f7']
337export const LIGHTS = ['#fca5a5', '#fdba74', '#fef08a', '#86efac', '#bae6fd', '#d8b4fe']
338export const SHADES = ['#991b1b', '#9a3412', '#a16207', '#166534', '#0369a1', '#6b21a8']
339export const BONUS_COLORS: Record<BonusKind, string> = { wide: PALETTE.sky, slow: PALETTE.purple, life: PALETTE.danger }
340const BONUS_GLYPHS: Record<BonusKind, string> = { wide: '↔', slow: '≈', life: '♥' }
341const BALL = PALETTE.white
342const TRAIL = '#52525b'
343/** The paddle's lit top, its body, and its ends. */
344export const PADDLE_LIGHT = '#fbcfe8'
345export const PADDLE = '#f472b6'
346const PADDLE_END = '#be185d'
347const CHIP_TEXT = '#18181b'
348/** How far back the ball's trail is drawn, in seconds of flight. */
349const TRAIL_S = 0.06
350/** A broken brick shows white for the first half of its flash, crumbled for the rest. */
351const FLASH = PALETTE.white
352/** The frame of a cleared wall blinks between two greens this often. */
353const BLINK_MS = 200
354const CLEARED_GLOW = '#86efac'
355
356/** The pixels of the ball and its trail, when it is in flight or at rest on the paddle. */
357const ballPixels = (g: Breakout): { x: number; y: number; color: string }[] => {
358 if (g.phase === 'lost' || g.phase === 'cleared') return []
359 const ball = { x: Math.floor(g.ball.x), y: Math.floor(g.ball.y), color: BALL }
360 if (g.phase !== 'play') return [ball]
361 const trail = { x: Math.floor(g.ball.x - g.ball.vx * TRAIL_S), y: Math.floor(g.ball.y - g.ball.vy * TRAIL_S), color: TRAIL }
362 const isApart = trail.x !== ball.x || trail.y !== ball.y
363 return isApart && standingAt(g, trail.x + 0.5, trail.y + 0.5) === null ? [ball, trail] : [ball]
364}
365
366/** The paddle's colour at a pixel: a lit top over its body, its ends darker; null off it. */
367const paddlePixel = (g: Breakout, x: number, y: number): string | null => {
368 const top = paddleRow(g)
369 if (y < top || y > top + 1 || x < g.paddleX || x >= g.paddleX + paddleWidth(g)) return null
370 const isEnd = x === g.paddleX || x === g.paddleX + paddleWidth(g) - 1
371 if (y === top) return isEnd ? PADDLE : PADDLE_LIGHT
372 return isEnd ? PADDLE_END : PADDLE
373}
374
375/** Two pixels, one over the other, as one cell of half blocks. */
376const halfBlock = (top: string | null, bottom: string | null): Span | null => {
377 if (top === null && bottom === null) return null
378 if (top === null) return { text: '▄', fg: bottom! }
379 if (bottom === null || top === bottom) return { text: bottom === null ? '▀' : '█', fg: top }
380 return { text: '▀', fg: top, bg: bottom }
381}
382
383type Pixel = { x: number; y: number; color: string }
384
385/** Ball, trail and paddle in a cell: drawn when the ball or its trail is there. */
386const ballCell = (g: Breakout, pixels: readonly Pixel[], x: number, row: number): Span | null => {
387 const at = (y: number) => pixels.find(p => p.x === x && p.y === y)?.color ?? null
388 const top = at(row * 2)
389 const bottom = at(row * 2 + 1)
390 if (top === null && bottom === null) return null
391 return halfBlock(top ?? paddlePixel(g, x, row * 2), bottom ?? paddlePixel(g, x, row * 2 + 1))
392}
393
394/** A standing brick's cell: its colour under a lit top edge, its last column shaded with a hair of gap. */
395const brickCell = (g: Breakout, x: number, row: number): Span | null => {
396 const cell = standingAt(g, x + 0.5, row * 2)
397 if (cell === null) return null
398 const isLast = (x - g.brickLeft) % g.brickWidth === g.brickWidth - 1
399 const i = cell.row % COLORS.length
400 return isLast ? { text: '▉', fg: SHADES[i]! } : { text: '▆', fg: COLORS[i]!, bg: LIGHTS[i]! }
401}
402
403/** A brick just broken: white at first, then crumbling in its colour. */
404const shatterCell = (g: Breakout, x: number, row: number): Span | null => {
405 const cell = brickCellOf(g, x + 0.5, row * 2)
406 const shatter = cell === null ? undefined : g.shatters.find(s => s.row === cell.row && s.col === cell.col)
407 if (shatter === undefined) return null
408 return shatter.ms > SHATTER_MS / 2 ? { text: '█', fg: FLASH, bold: true } : { text: '░', fg: COLORS[shatter.row % COLORS.length]! }
409}
410
411/** A falling bonus, a pill three columns wide: `▐↔▌`. */
412const dropCell = (g: Breakout, x: number, row: number): Span | null => {
413 const drop = g.drops.find(d => Math.floor(d.y / 2) === row && Math.abs(x - d.x) <= 1)
414 if (drop === undefined) return null
415 const color = BONUS_COLORS[drop.kind]
416 if (x === drop.x) return { text: BONUS_GLYPHS[drop.kind], fg: CHIP_TEXT, bg: color, bold: true }
417 return { text: x < drop.x ? '▐' : '▌', fg: color }
418}
419
420const paddleCell = (g: Breakout, x: number, row: number): Span | null => halfBlock(paddlePixel(g, x, row * 2), paddlePixel(g, x, row * 2 + 1))
421
422/** Where the lost ball fell through, a spark at the bottom while the moment lasts. */
423const lostCell = (g: Breakout, x: number, row: number): Span | null =>
424 g.phase === 'lost' && x === g.lostX && row === g.height - 1 ? { text: '✸', fg: PALETTE.danger, bold: true } : null
425
426/** The row a message shows on: halfway between the wall and the paddle. */
427export const messageRow = (g: Breakout): number => Math.floor((BRICK_TOP + g.bricks.length + g.height - 1) / 2)
428
429/** The message of the moment, if any: a lost ball, a cleared wall, the launch to make. */
430const messageOf = (g: Breakout): { text: string; color: string } | null => {
431 if (g.isOver) return { text: 'partita finita', color: PALETTE.danger }
432 if (g.phase === 'lost') return { text: 'palla persa', color: PALETTE.danger }
433 if (g.phase === 'cleared') return { text: `livello ${g.level} superato!`, color: PALETTE.good }
434 return g.phase === 'serve' ? { text: 'spazio per lanciare', color: PALETTE.label } : null
435}
436
437/** Text over the field, keyed by `column,row`: the popups, rising, and the message. */
438const overlayOf = (g: Breakout): Map<string, Span> => {
439 const out = new Map<string, Span>()
440 const put = (text: string, x: number, row: number, color: string) =>
441 [...text].forEach((ch, i) => {
442 if (x + i >= 0 && x + i < g.width && row >= 0) out.set(`${x + i},${row}`, { text: ch, fg: color, bold: true })
443 })
444 for (const p of g.popups) {
445 const rise = Math.floor((1 - p.ms / POPUP_MS) * 2)
446 put(p.text, p.x - Math.floor([...p.text].length / 2), p.row - rise, p.color)
447 }
448 const message = messageOf(g)
449 if (message !== null) {
450 const text = ` ${message.text} `
451 put(text, Math.floor((g.width - [...text].length) / 2), messageRow(g), message.color)
452 }
453 return out
454}
455
456const fieldRows = (g: Breakout): Row[] => {
457 const pixels = ballPixels(g)
458 const overlay = overlayOf(g)
459 return Array.from({ length: g.height }, (_, row) =>
460 pack(
461 Array.from(
462 { length: g.width },
463 (_, x): Span =>
464 ballCell(g, pixels, x, row) ??
465 overlay.get(`${x},${row}`) ??
466 brickCell(g, x, row) ??
467 shatterCell(g, x, row) ??
468 dropCell(g, x, row) ??
469 paddleCell(g, x, row) ??
470 lostCell(g, x, row) ?? { text: ' ' },
471 ),
472 ),
473 )
474}
475
476/** The bottom edge: the lives as hearts, then the bonuses at work with their seconds left. */
477const footerOf = (g: Breakout): Span[] => {
478 const lives = Math.max(0, g.lives)
479 const timer = (kind: BonusKind, ms: number): Span[] =>
480 ms > 0 ? [{ text: '─', fg: PALETTE.frame }, { text: ` ${BONUS_GLYPHS[kind]} ${Math.ceil(ms / 1000)}s `, fg: BONUS_COLORS[kind], bold: true }] : []
481 return [
482 { text: ' ', fg: PALETTE.label },
483 { text: '♥'.repeat(lives), fg: PALETTE.danger, bold: true },
484 { text: '♡'.repeat(Math.max(0, LIVES - lives)), fg: PALETTE.label },
485 { text: ' ', fg: PALETTE.label },
486 ...timer('wide', g.wideMs),
487 ...timer('slow', g.slowMs),
488 ]
489}
490
491/** The frame: red for a lost ball, blinking green for a cleared wall, dim otherwise. */
492const frameColor = (g: Breakout): string => {
493 if (g.isOver || g.phase === 'lost') return PALETTE.danger
494 if (g.phase === 'cleared') return Math.floor((CLEARED_MS - g.phaseMs) / BLINK_MS) % 2 === 0 ? PALETTE.good : CLEARED_GLOW
495 return PALETTE.frame
496}
497
498/** The field in a rounded frame, the level in its top edge and the lives in its bottom one, centered. */
499export const drawBreakout = (g: Breakout): Row[] => {
500 const color = frameColor(g)
501 const titleColor = g.phase === 'cleared' ? PALETTE.good : g.phase === 'lost' || g.isOver ? PALETTE.danger : PALETTE.label
502 const board = framed(fieldRows(g), g.width, { title: `livello ${g.level}`, color, titleColor, footer: footerOf(g) })
503 const pad = centerPad(g.columns, g.width + FRAME_CELLS)
504 return pad.text === '' ? board : board.map(r => pack([pad, ...r]))
505}
506
507export const BREAKOUT: GameDef<Breakout> = {
508 id: 'breakout',
509 name: 'Breakout',
510 keys: '←→ o a/d muovi · spazio o ↑ lancia la palla',
511 create: ctx => newBreakout(ctx.columns, ctx.rows, ctx.rng),
512 key: keyBreakout,
513 tick: tickBreakout,
514 isOver: g => g.isOver,
515 score: g => g.score,
516 draw: drawBreakout,
517 status: g => `mattoni ${bricksLeft(g)}`,
518}
519hooks/games/bugs.ts 128 lines1import { drawBugs } from './bugs-draw'
2import type { Rng } from './rng'
3import { nextInt } from './rng'
4import type { GameDef, Row, Span } from './types'
5
6/** A bug in a cell of the keypad grid (1–9), and how long it has been there. */
7export type Bug = { cell: number; age: number; life: number }
8
9/** The last press, shown on its cell for a moment: a hit, with the points it made, or a miss. */
10export type Flash = { cell: number; isHit: boolean; points: number; left: number }
11
12export type Bugs = {
13 bugs: Bug[]
14 /** Milliseconds left in the round. */
15 timeLeft: number
16 /** Milliseconds played: bugs live shorter and come faster as it grows. */
17 elapsed: number
18 /** Milliseconds before the next bug shows up. */
19 spawnIn: number
20 lives: number
21 score: number
22 flash: Flash | null
23 /** Columns of room, to center the grid; rows of room, to pick tall cells or short ones. */
24 columns: number
25 rows: number
26 isOver: boolean
27 rng: Rng
28}
29
30export const ROUND_MS = 30_000
31export const LIVES = 3
32export const HIT_POINTS = 10
33/** Extra points for a bug hit within the first third of its life. */
34export const FAST_BONUS = 5
35const FIRST_SPAWN_MS = 600
36const FLASH_MS = 400
37/** A bug lives this long at the start, and this long at the end of the round. */
38const LIFE_START_MS = 1_600
39const LIFE_END_MS = 700
40/** A new bug every this many ms at the start, and at the end. */
41const SPAWN_START_MS = 900
42const SPAWN_END_MS = 420
43const CELLS = 9
44
45/** From `start` to `end` as the round goes by. */
46const ramp = (elapsed: number, start: number, end: number) => start + (end - start) * Math.min(1, elapsed / ROUND_MS)
47
48export const lifeAt = (elapsed: number): number => Math.round(ramp(elapsed, LIFE_START_MS, LIFE_END_MS))
49export const spawnGapAt = (elapsed: number): number => Math.round(ramp(elapsed, SPAWN_START_MS, SPAWN_END_MS))
50
51/** Rows the tall grid takes: the time bar and three rows of five-row cells. */
52export const ROOMY_ROWS = 16
53
54export const newBugs = (columns: number, rng: Rng, rows = ROOMY_ROWS): Bugs => ({
55 bugs: [],
56 timeLeft: ROUND_MS,
57 elapsed: 0,
58 spawnIn: FIRST_SPAWN_MS,
59 lives: LIVES,
60 score: 0,
61 flash: null,
62 columns,
63 rows,
64 isOver: false,
65 rng,
66})
67
68/** A bug in a free cell, picked at random; none when all nine are taken. */
69const spawn = (g: Bugs): Bugs => {
70 const free = Array.from({ length: CELLS }, (_, i) => i + 1).filter(c => !g.bugs.some(b => b.cell === c))
71 if (free.length === 0) return { ...g, spawnIn: spawnGapAt(g.elapsed) }
72 const [i, rng] = nextInt(g.rng, free.length)
73 const bug = { cell: free[i]!, age: 0, life: lifeAt(g.elapsed) }
74 return { ...g, bugs: [...g.bugs, bug], spawnIn: spawnGapAt(g.elapsed), rng }
75}
76
77const end = (g: Bugs): Bugs => (g.timeLeft <= 0 || g.lives <= 0 ? { ...g, isOver: true } : g)
78
79/** Time passing: bugs age and the ones left too long cost a life; new ones come. */
80export const tickBugs = (g: Bugs, ms: number): Bugs => {
81 if (g.isOver) return g
82 const aged = g.bugs.map(b => ({ ...b, age: b.age + ms }))
83 const escaped = aged.filter(b => b.age >= b.life)
84 const moved: Bugs = {
85 ...g,
86 bugs: aged.filter(b => b.age < b.life),
87 timeLeft: Math.max(0, g.timeLeft - ms),
88 elapsed: g.elapsed + ms,
89 spawnIn: g.spawnIn - ms,
90 lives: g.lives - escaped.length,
91 flash: g.flash === null || g.flash.left <= ms ? null : { ...g.flash, left: g.flash.left - ms },
92 }
93 return end(moved.spawnIn <= 0 ? spawn(moved) : moved)
94}
95
96/** A digit hits the bug in that cell; a digit on an empty cell costs a life. */
97export const pressBugs = (g: Bugs, key: string): Bugs => {
98 const cell = Number(key)
99 if (g.isOver || !/^[1-9]$/.test(key)) return g
100 const bug = g.bugs.find(b => b.cell === cell)
101 if (bug === undefined) return end({ ...g, lives: g.lives - 1, flash: { cell, isHit: false, points: 0, left: FLASH_MS } })
102 const points = HIT_POINTS + (bug.age < bug.life / 3 ? FAST_BONUS : 0)
103 return {
104 ...g,
105 bugs: g.bugs.filter(b => b.cell !== cell),
106 score: g.score + points,
107 flash: { cell, isHit: true, points, left: FLASH_MS },
108 }
109}
110
111export { drawBugs }
112
113export const bugsStatus = (g: Bugs): string =>
114 `⏱ ${Math.ceil(g.timeLeft / 1000)} s · ${'♥'.repeat(Math.max(0, g.lives))}${'♡'.repeat(LIVES - Math.max(0, g.lives))}`
115
116export const BUGS: GameDef<Bugs> = {
117 id: 'bugs',
118 name: 'Acchiappa il bug',
119 keys: 'premi il numero della cella col bug (1–9, come il tastierino)',
120 create: ctx => newBugs(ctx.columns, ctx.rng, ctx.rows),
121 key: pressBugs,
122 tick: tickBugs,
123 isOver: g => g.isOver,
124 score: g => g.score,
125 draw: g => drawBugs(g),
126 status: bugsStatus,
127}
128hooks/games/classics.ts 98 lines1import type { Ate, Fx2048 } from '../draw'
2import { draw2048, drawDino, drawSnake, EAT_FLASH_MS } from '../draw'
3import type { Dino } from './dino'
4import { dropDino, jumpDino, newDino, stepDino } from './dino'
5import type { Game2048 } from './g2048'
6import { mergesOf, move2048, new2048, slide } from './g2048'
7import { directionOf, isSpace } from './keys'
8import type { Direction, Snake } from './snake'
9import { newSnake, snakeInterval, stepSnake, turnSnake } from './snake'
10import type { GameDef } from './types'
11
12const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
13
14/**
15 * The snake and the milliseconds since it last moved: it steps faster as it
16 * grows. The rest is for the drawing: the room, the time played, the last bite.
17 */
18export type SnakePlay = { snake: Snake; since: number; columns?: number; clock?: number; ate?: Ate | null }
19
20/** The bite `ms` older, gone once its flash is over. */
21const aged = (ate: Ate | null | undefined, ms: number): Ate | null =>
22 ate === undefined || ate === null || ate.ms + ms >= EAT_FLASH_MS ? null : { ...ate, ms: ate.ms + ms }
23
24export const SNAKE: GameDef<SnakePlay> = {
25 id: 'snake',
26 name: 'Snake',
27 keys: 'frecce o WASD per girare',
28 create: ctx => ({
29 snake: newSnake(clamp(Math.floor((ctx.columns - 2) / 2), 10, 30), clamp(ctx.rows - 2, 6, 16), ctx.rng),
30 since: 0,
31 columns: ctx.columns,
32 clock: 0,
33 ate: null,
34 }),
35 key: (s, key) => {
36 const dir = directionOf(key)
37 return dir === undefined ? s : { ...s, snake: turnSnake(s.snake, dir) }
38 },
39 tick: (s, ms) => {
40 let snake = s.snake
41 let since = s.since + ms
42 let ate = aged(s.ate, ms)
43 while (since >= snakeInterval(snake) && !snake.isOver) {
44 since -= snakeInterval(snake)
45 const before = snake
46 snake = stepSnake(snake)
47 if (snake.score > before.score) ate = { at: snake.body[0]!, points: snake.score - before.score, ms: 0 }
48 }
49 return { ...s, snake, since, clock: (s.clock ?? 0) + ms, ate }
50 },
51 isOver: s => s.snake.isOver,
52 score: s => s.snake.score,
53 draw: s => drawSnake(s.snake, s),
54}
55
56/** The 2048 game, the room it is drawn in, and what the last move did. */
57export type Play2048 = { game: Game2048; columns: number; rows: number; last: Fx2048 | null }
58
59/** What a move from `before` to `after` did, for the drawing to mark. */
60export const moveFx = (before: Game2048, after: Game2048, dir: Direction): Fx2048 => {
61 const slid = slide(before.grid, dir).grid
62 const added = after.grid.flatMap((row, r) => row.flatMap((n, c) => (n !== slid[r]![c] ? [{ r, c }] : [])))
63 return { merged: mergesOf(before.grid, dir), spawned: added[0] ?? null, gained: after.score - before.score }
64}
65
66export const G2048: GameDef<Play2048> = {
67 id: '2048',
68 name: '2048',
69 keys: 'frecce o WASD per spostare le tessere',
70 create: ctx => ({ game: new2048(ctx.rng), columns: ctx.columns, rows: ctx.rows, last: null }),
71 key: (p, key) => {
72 const dir = directionOf(key)
73 if (dir === undefined) return p
74 const game = move2048(p.game, dir)
75 return game === p.game ? p : { ...p, game, last: moveFx(p.game, game, dir) }
76 },
77 isOver: p => p.game.isOver,
78 score: p => p.game.score,
79 draw: p => draw2048(p.game, p),
80}
81
82export const RUNNER: GameDef<Dino> = {
83 id: 'dino',
84 name: 'Claude runner',
85 keys: 'spazio o ↑ per saltare, ↓ per scendere',
86 // Two columns go to the frame.
87 create: ctx => ({ ...newDino(clamp(ctx.columns - 2, 30, 90), ctx.rng), screen: { columns: ctx.columns, rows: ctx.rows } }),
88 key: (d, key) => {
89 const dir = directionOf(key)
90 if (isSpace(key) || dir === 'up') return jumpDino(d)
91 return dir === 'down' ? dropDino(d) : d
92 },
93 tick: stepDino,
94 isOver: d => d.isOver,
95 score: d => d.score,
96 draw: drawDino,
97}
98hooks/games/flappy.ts 282 lines1import { FEET_APART, FEET_TOGETHER, MASCOT, MASCOT_BODY } from '../draw'
2import { directionOf, isSpace } from './keys'
3import type { Rng } from './rng'
4import { nextInt } from './rng'
5import { centerPad, framed, pack, PALETTE } from './style'
6import type { GameDef, Row, Span } from './types'
7
8/** A pipe: its left column (fractional while it scrolls) and the first row of its gap. */
9export type Pipe = { x: number; gapTop: number; isPassed: boolean }
10
11export type Flappy = {
12 /** Columns of sky, inside the frame. */
13 width: number
14 /** Rows of sky, inside the frame; its bottom edge is the ground. */
15 height: number
16 /** Columns of room, to center the frame. */
17 columns: number
18 /** The mascot's top row, fractional; 0 is the top of the sky. */
19 y: number
20 /** Rows per second, down positive. */
21 vy: number
22 pipes: Pipe[]
23 /** Columns before the next pipe enters at the right edge. */
24 nextIn: number
25 /** False until the first flap: the mascot hovers and nothing scrolls. */
26 isFlying: boolean
27 /** Columns flown, for the wing beat and the scrolling sky. */
28 distance: number
29 /** Milliseconds the wings stay up after a flap. */
30 flapFor: number
31 /** Milliseconds the `+1` of the last pipe passed still shows. */
32 cheerFor: number
33 score: number
34 isOver: boolean
35 rng: Rng
36}
37
38/** The mascot's left column and size in cells, as drawn. */
39export const BIRD_X = 6
40export const BIRD_WIDTH = 9
41export const BIRD_HEIGHT = 3
42/** What a pipe must touch to end the flight: the solid body, a little inside the drawing. */
43export const HITBOX = { left: BIRD_X + 2, width: 5, top: 0.4, bottom: 2.6 }
44
45/** Rows per second², down. */
46export const GRAVITY = 45
47/** A flap's upward speed: it rises about 2 rows (v² / 2g). */
48export const FLAP_SPEED = 14
49/** Columns per second the pipes scroll. */
50export const SPEED = 14
51export const PIPE_WIDTH = 4
52/** Rows of the gap: the 3-row mascot with room to spare. */
53export const GAP = 8
54/** Columns from one pipe to the next. */
55export const PIPE_SPACING = 28
56/** How far a gap may move from the last one, so it can always be reached in time. */
57export const MAX_GAP_SHIFT = 5
58/** How long the wings stay up after a flap, and the `+1` shows after a pipe. */
59export const WING_MS = 180
60export const CHEER_MS = 600
61/** The least sky: a gap with a row above and below it. */
62const MIN_HEIGHT = GAP + 2
63const MAX_HEIGHT = 24
64/** Columns of the whole board, frame included. */
65const MIN_WIDTH = 40
66const MAX_WIDTH = 100
67/** Rows and columns the frame takes around the sky. */
68const FRAME_CELLS = 2
69
70const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
71
72export const newFlappy = (columns: number, rows: number, rng: Rng): Flappy => {
73 const height = clamp(rows - FRAME_CELLS, MIN_HEIGHT, MAX_HEIGHT)
74 return {
75 width: clamp(columns, MIN_WIDTH, MAX_WIDTH) - FRAME_CELLS,
76 height,
77 columns,
78 y: Math.floor((height - BIRD_HEIGHT) / 2),
79 vy: 0,
80 pipes: [],
81 nextIn: 0,
82 isFlying: false,
83 distance: 0,
84 flapFor: 0,
85 cheerFor: 0,
86 score: 0,
87 isOver: false,
88 rng,
89 }
90}
91
92/** A flap: up at FLAP_SPEED, whatever the fall; the first one starts the flight. */
93export const flap = (f: Flappy): Flappy => (f.isOver ? f : { ...f, vy: -FLAP_SPEED, isFlying: true, flapFor: WING_MS })
94
95/** The row a new gap starts on: random, within reach of the last one, never touching the edges. */
96const nextGapTop = (f: Flappy): [number, Rng] => {
97 const highest = 1
98 const lowest = f.height - GAP - 1
99 const last = f.pipes[f.pipes.length - 1]?.gapTop ?? Math.floor((f.height - GAP) / 2)
100 const low = Math.max(highest, last - MAX_GAP_SHIFT)
101 const high = Math.min(lowest, last + MAX_GAP_SHIFT)
102 const [offset, rng] = nextInt(f.rng, high - low + 1)
103 return [low + offset, rng]
104}
105
106const hitsPipe = (f: Flappy, p: Pipe): boolean => {
107 const isAcross = p.x < HITBOX.left + HITBOX.width && p.x + PIPE_WIDTH > HITBOX.left
108 const isOutsideGap = f.y + HITBOX.top < p.gapTop || f.y + HITBOX.bottom > p.gapTop + GAP
109 return isAcross && isOutsideGap
110}
111
112/** `ms` of flight: gravity, scrolling pipes, a point for each pipe left behind; a pipe or the ground ends it. */
113export const tickFlappy = (f: Flappy, ms: number): Flappy => {
114 if (f.isOver || !f.isFlying) return f
115 const dt = ms / 1000
116 const vy = f.vy + GRAVITY * dt
117 // The mean of the speeds at both ends: the exact parabola. The top of the sky is a ceiling.
118 const y = Math.max(0, f.y + ((f.vy + vy) / 2) * dt)
119 const moved = SPEED * dt
120 const scrolled = f.pipes.map(p => ({ ...p, x: p.x - moved })).filter(p => p.x + PIPE_WIDTH > 0)
121 const passed = scrolled.filter(p => !p.isPassed && p.x + PIPE_WIDTH <= HITBOX.left).length
122 const pipes = scrolled.map(p => (p.x + PIPE_WIDTH <= HITBOX.left ? { ...p, isPassed: true } : p))
123
124 const base: Flappy = {
125 ...f,
126 y,
127 vy,
128 pipes,
129 nextIn: f.nextIn - moved,
130 distance: f.distance + moved,
131 score: f.score + passed,
132 flapFor: Math.max(0, f.flapFor - ms),
133 cheerFor: passed > 0 ? CHEER_MS : Math.max(0, f.cheerFor - ms),
134 }
135 const withPipe = base.nextIn > 0 ? base : addPipe(base)
136 const isDown = withPipe.y + BIRD_HEIGHT > withPipe.height
137 return isDown || withPipe.pipes.some(p => hitsPipe(withPipe, p)) ? { ...withPipe, isOver: true } : withPipe
138}
139
140const addPipe = (f: Flappy): Flappy => {
141 const [gapTop, rng] = nextGapTop(f)
142 return { ...f, pipes: [...f.pipes, { x: f.width + f.nextIn, gapTop, isPassed: false }], nextIn: f.nextIn + PIPE_SPACING, rng }
143}
144
145// ── Drawing ─────────────────────────────────────────────────────────────────
146
147/** A pipe's columns, lit side to shaded side: its body, then the wider-looking cap at the gap. */
148const PIPE_BODY: readonly Span[] = [
149 { text: '▐', fg: '#4ade80' },
150 { text: '█', fg: PALETTE.good },
151 { text: '█', fg: '#16a34a' },
152 { text: '▌', fg: '#15803d' },
153]
154const PIPE_CAP: readonly Span[] = [
155 { text: '█', fg: '#86efac' },
156 { text: '█', fg: '#4ade80' },
157 { text: '█', fg: PALETTE.good },
158 { text: '█', fg: '#15803d' },
159]
160const GROUND = '#a8a29e'
161const STAR = '#52525b'
162/** How fast each layer of the far sky drifts, as a share of the pipes' speed. */
163const STAR_DRIFT = 0.1
164const SKYLINE_DRIFT = 0.25
165const CLOUD_DRIFT = 0.45
166/** One star in about this many cells; the skyline's buildings, BLOCK columns wide and up to MAX_TOWER rows tall. */
167const STAR_ODDS = 60
168const BLOCK = 3
169const MAX_TOWER = 3
170/** A building is at most one row tall for each this many rows of sky. */
171const TOWER_SHARE = 6
172const CLOUD_PERIOD = 26
173const CLOUD = [' ▄██▄ ', '▀▀▀▀▀▀▀']
174/** Columns flown between two beats of the feet; ground ties every TIE columns. */
175const STRIDE = 2
176const TIE = 6
177/** Falling faster than this, in rows per second, the wings trail down. */
178const DIVE_SPEED = 8
179
180/** The mascot's middle row with the wings up after a flap, level, or trailing in a dive. */
181const WINGS_UP = '▐▜█████▛▌'
182const WINGS_DOWN = '▗▜█████▛▖'
183const DEAD_FACE = ' ▐╳███╳▌ '
184
185/** A fixed scramble of a number, for stars and buildings that stay put as they scroll. */
186const hash = (n: number): number => {
187 const a = Math.imul(n ^ (n >>> 16), 0x85ebca6b)
188 const b = Math.imul(a ^ (a >>> 13), 0xc2b2ae35)
189 return (b ^ (b >>> 16)) >>> 0
190}
191
192/** The mascot, row by row: wings by the last flap and the fall, feet beating, eyes crossed after a crash. */
193const birdRows = (f: Flappy): string[] => {
194 const isBeat = f.isFlying && !f.isOver && Math.floor(f.distance / STRIDE) % 2 === 1
195 const wings = f.flapFor > 0 ? WINGS_UP : f.vy > DIVE_SPEED && !f.isOver ? WINGS_DOWN : MASCOT_BODY[1]!
196 return [f.isOver ? DEAD_FACE : MASCOT_BODY[0]!, wings, isBeat ? FEET_APART : FEET_TOGETHER]
197}
198
199const pipeCell = (f: Flappy, x: number, row: number): Span | null => {
200 const pipe = f.pipes.find(p => x >= Math.round(p.x) && x < Math.round(p.x) + PIPE_WIDTH)
201 if (pipe === undefined || (row >= pipe.gapTop && row < pipe.gapTop + GAP)) return null
202 const isCap = row === pipe.gapTop - 1 || row === pipe.gapTop + GAP
203 return (isCap ? PIPE_CAP : PIPE_BODY)[x - Math.round(pipe.x)]!
204}
205
206/** The far sky, slowest first to nearest: a skyline in the last rows, stars, clouds in the upper half. */
207const farCell = (f: Flappy, x: number, row: number): Span | null => {
208 const block = Math.floor((x + f.distance * SKYLINE_DRIFT) / BLOCK)
209 const tall = hash(block) % (clamp(Math.floor(f.height / TOWER_SHARE), 1, MAX_TOWER) + 1)
210 if (row >= f.height - tall) return { text: row === f.height - tall ? '▄' : '█', fg: PALETTE.faint }
211 const cloudX = Math.floor(x + f.distance * CLOUD_DRIFT)
212 const cloud = Math.floor(cloudX / CLOUD_PERIOD)
213 const cloudRow = 1 + (hash(cloud + 7) % Math.max(1, Math.floor(f.height / 2) - 2))
214 const glyph = hash(cloud) % 3 === 0 ? undefined : CLOUD[row - cloudRow]?.[cloudX % CLOUD_PERIOD]
215 if (glyph !== undefined && glyph !== ' ') return { text: glyph, fg: PALETTE.frame }
216 const starX = Math.floor(x + f.distance * STAR_DRIFT)
217 const star = hash(starX * 131 + row * 7919)
218 if (star % STAR_ODDS !== 0) return null
219 return (star >>> 20) % 4 === 0 ? { text: '✦', fg: PALETTE.label } : { text: '·', fg: STAR }
220}
221
222/** The `+1` floating up from the mascot's head while a passed pipe is cheered. */
223const cheerCell = (f: Flappy, x: number, row: number): Span | null => {
224 if (f.cheerFor <= 0) return null
225 const rise = Math.floor((1 - f.cheerFor / CHEER_MS) * 2)
226 const at = Math.round(f.y) - 1 - rise
227 const glyph = '+1'[x - (BIRD_X + 3)]
228 return row === at && glyph !== undefined ? { text: glyph, fg: PALETTE.good, bold: true } : null
229}
230
231/** Sparks around a crash. */
232const crashCell = (f: Flappy, x: number, row: number): Span | null => {
233 if (!f.isOver) return null
234 const top = Math.round(f.y)
235 const sparks: Record<string, string> = { [`${BIRD_X - 1},${top}`]: '✸', [`${BIRD_X + BIRD_WIDTH},${top + 1}`]: '✸', [`${BIRD_X + BIRD_WIDTH - 1},${top - 1}`]: '·', [`${BIRD_X},${top + 3}`]: '·' }
236 const spark = sparks[`${x},${row}`]
237 return spark === undefined ? null : { text: spark, fg: spark === '✸' ? PALETTE.hurry : PALETTE.danger, bold: true }
238}
239
240const skyCell = (f: Flappy, sprite: readonly string[], x: number, row: number): Span => {
241 const glyph = sprite[row - Math.round(f.y)]?.[x - BIRD_X]
242 if (glyph !== undefined && glyph !== ' ') return { text: glyph, fg: f.isOver ? PALETTE.danger : MASCOT, bold: f.isOver && glyph === '╳' }
243 const near = crashCell(f, x, row) ?? cheerCell(f, x, row) ?? pipeCell(f, x, row)
244 // The far sky stays behind the mascot: nothing of it shows between its feet.
245 return near ?? (glyph === undefined ? farCell(f, x, row) : null) ?? { text: ' ' }
246}
247
248/** The bottom edge: the ground scrolling at the pipes' speed, the `+1` cut in under the mascot. */
249const groundOf = (f: Flappy): Span[] => {
250 const ground = (from: number, to: number): Span[] =>
251 Array.from({ length: Math.max(0, to - from) }, (_, i) => ({ text: Math.floor(from + i + f.distance) % TIE === 0 ? '┴' : '─', fg: GROUND }))
252 if (f.cheerFor <= 0) return ground(0, f.width - 1)
253 const label = ' +1 '
254 return [...ground(0, BIRD_X + 1), { text: label, fg: PALETTE.good, bold: true }, ...ground(BIRD_X + 1 + label.length, f.width - 1)]
255}
256
257const frameColor = (f: Flappy): string => (f.isOver ? PALETTE.danger : f.cheerFor > 0 ? PALETTE.good : PALETTE.frame)
258
259/** The sky in a rounded frame: the key cut into its top edge, lit on a flap; the ground in its bottom edge. */
260export const drawFlappy = (f: Flappy): Row[] => {
261 const sprite = birdRows(f)
262 const sky = Array.from({ length: f.height }, (_, row) => pack(Array.from({ length: f.width }, (_, x) => skyCell(f, sprite, x, row))))
263 const title = f.isOver ? 'schianto!' : 'spazio'
264 const titleColor = f.isOver ? PALETTE.danger : f.flapFor > 0 ? PALETTE.sky : PALETTE.label
265 const board = framed(sky, f.width, { title, color: frameColor(f), titleColor, footer: groundOf(f) })
266 const pad = centerPad(f.columns, f.width + FRAME_CELLS)
267 return pad.text === '' ? board : board.map(r => pack([pad, ...r]))
268}
269
270export const FLAPPY: GameDef<Flappy> = {
271 id: 'flappy',
272 name: 'Flappy Claude',
273 keys: 'spazio o ↑ per volare',
274 create: ctx => newFlappy(ctx.columns, ctx.rows, ctx.rng),
275 key: (f, key) => (isSpace(key) || directionOf(key) === 'up' ? flap(f) : f),
276 tick: tickFlappy,
277 isOver: f => f.isOver,
278 score: f => f.score,
279 draw: drawFlappy,
280 status: f => (f.isFlying ? '' : 'spazio per partire'),
281}
282hooks/games/maze.ts 206 lines1import { directionOf } from './keys'
2import { drawMaze } from './maze-view'
3import type { Rng } from './rng'
4import { nextInt } from './rng'
5import type { GameDef } from './types'
6
7/** A cell of the maze: wall, open floor, or the exit door cut into the outer wall. */
8export const WALL = '#'
9export const OPEN = ' '
10export const EXIT = 'E'
11
12/** The maze, row by row (y grows south), one character per cell. */
13export type Grid = readonly string[]
14export type Point = { x: number; y: number }
15
16/** The exit reached: the points it made, and how long the celebration still lasts. */
17export type Cleared = { points: number; ms: number }
18
19export type Maze = {
20 level: number
21 grid: Grid
22 /** Where the player stands, in cells: x grows east, y south. */
23 x: number
24 y: number
25 /** Where the player looks, in steps of `TURN_DEGREES` clockwise from east (0 east, 6 south). */
26 heading: number
27 /** Cells the player has stood in, as `y * size + x`. */
28 visited: readonly boolean[]
29 timeLeft: number
30 timeLimit: number
31 /** Milliseconds spent in this maze: the door's glow and the opening hint follow it. */
32 playedMs: number
33 score: number
34 isOver: boolean
35 showMap: boolean
36 cleared: Cleared | null
37 /** The room the drawing fills, frame included. */
38 columns: number
39 rows: number
40 rng: Rng
41}
42
43/** Level 1 is 9×9 cells, each level two more a side. */
44const FIRST_SIZE = 9
45const SIZE_STEP = 2
46export const TURN_DEGREES = 15
47export const HEADINGS = 360 / TURN_DEGREES
48/** Cells walked per press of ↑ or ↓. */
49export const STEP = 0.25
50/** Half the player's width: how close it may come to a wall. */
51export const RADIUS = 0.2
52/** A maze's time: a base, and more for each room of it. */
53const TIME_BASE_MS = 20_000
54const TIME_PER_ROOM_MS = 3_000
55/** An exit's points: this much times the level, and this much per whole second left. */
56export const LEVEL_POINTS = 100
57export const SECOND_POINTS = 5
58/** How long the exit is celebrated before the next maze. */
59export const CLEARED_MS = 1_800
60
61export const sizeOf = (level: number): number => FIRST_SIZE + SIZE_STEP * (level - 1)
62
63/** Rooms of a maze `size` cells a side: the open cells at odd coordinates. */
64const roomsOf = (size: number) => ((size - 1) / 2) ** 2
65
66export const timeLimitOf = (size: number): number => TIME_BASE_MS + TIME_PER_ROOM_MS * roomsOf(size)
67
68export const pointsFor = (level: number, timeLeft: number): number => LEVEL_POINTS * level + SECOND_POINTS * Math.floor(timeLeft / 1000)
69
70export const cellAt = (grid: Grid, x: number, y: number): string => grid[Math.floor(y)]?.[Math.floor(x)] ?? WALL
71
72const NEIGHBOURS: readonly Point[] = [
73 { x: 2, y: 0 },
74 { x: -2, y: 0 },
75 { x: 0, y: 2 },
76 { x: 0, y: -2 },
77]
78
79/**
80 * A perfect maze `size` cells a side (odd): rooms at odd coordinates, carved
81 * depth first from the top-left room, so every room is reached by one path
82 * only; the exit is a door in the east wall beside the bottom-right room.
83 */
84export const carveMaze = (size: number, rng: Rng): [Grid, Rng] => {
85 const cells = Array.from({ length: size }, () => Array<string>(size).fill(WALL))
86 const isInside = (p: Point) => p.x > 0 && p.y > 0 && p.x < size - 1 && p.y < size - 1
87 let r = rng
88 const stack: Point[] = [{ x: 1, y: 1 }]
89 cells[1]![1] = OPEN
90 while (stack.length > 0) {
91 const here = stack[stack.length - 1]!
92 const options = NEIGHBOURS.map(d => ({ x: here.x + d.x, y: here.y + d.y })).filter(p => isInside(p) && cells[p.y]![p.x] === WALL)
93 if (options.length === 0) {
94 stack.pop()
95 continue
96 }
97 const [i, next] = nextInt(r, options.length)
98 r = next
99 const to = options[i]!
100 cells[(here.y + to.y) / 2]![(here.x + to.x) / 2] = OPEN
101 cells[to.y]![to.x] = OPEN
102 stack.push(to)
103 }
104 cells[size - 2]![size - 1] = EXIT
105 return [cells.map(row => row.join('')), r]
106}
107
108const markVisited = (visited: readonly boolean[], size: number, x: number, y: number): readonly boolean[] => {
109 const i = Math.floor(y) * size + Math.floor(x)
110 return visited[i] === true ? visited : visited.map((v, j) => v || j === i)
111}
112
113/** A new maze for `level`, the player in the top-left room looking down its open corridor. */
114export const newLevel = (level: number, base: Pick<Maze, 'score' | 'showMap' | 'columns' | 'rows' | 'rng'>): Maze => {
115 const size = sizeOf(level)
116 const [grid, rng] = carveMaze(size, base.rng)
117 const timeLimit = timeLimitOf(size)
118 return {
119 ...base,
120 level,
121 grid,
122 x: 1.5,
123 y: 1.5,
124 heading: cellAt(grid, 2, 1) === OPEN ? 0 : HEADINGS / 4,
125 visited: markVisited(Array<boolean>(size * size).fill(false), size, 1, 1),
126 timeLeft: timeLimit,
127 timeLimit,
128 playedMs: 0,
129 isOver: false,
130 cleared: null,
131 rng,
132 }
133}
134
135export const newMaze = (columns: number, rows: number, rng: Rng): Maze =>
136 newLevel(1, { score: 0, showMap: true, columns, rows, rng })
137
138export const angleOf = (heading: number): number => (heading * TURN_DEGREES * Math.PI) / 180
139
140/** The four corners of the player standing at `p`. */
141const cornersOf = (p: Point): Point[] => [-RADIUS, RADIUS].flatMap(dx => [-RADIUS, RADIUS].map(dy => ({ x: p.x + dx, y: p.y + dy })))
142
143const isClear = (grid: Grid, p: Point) => cornersOf(p).every(c => cellAt(grid, c.x, c.y) !== WALL)
144const touchesExit = (grid: Grid, p: Point) => cornersOf(p).some(c => cellAt(grid, c.x, c.y) === EXIT)
145
146/** The exit reached: the points made, the celebration started; the player stays at the door, looking at it. */
147const reachExit = (g: Maze): Maze => {
148 const points = pointsFor(g.level, g.timeLeft)
149 return { ...g, score: g.score + points, cleared: { points, ms: CLEARED_MS } }
150}
151
152/** A step along the heading (back, when `distance` is negative), sliding along a wall it meets at a slant. */
153export const walk = (g: Maze, distance: number): Maze => {
154 const angle = angleOf(g.heading)
155 const dx = Math.cos(angle) * distance
156 const dy = Math.sin(angle) * distance
157 const tries: Point[] = [
158 { x: g.x + dx, y: g.y + dy },
159 { x: g.x + dx, y: g.y },
160 { x: g.x, y: g.y + dy },
161 ]
162 const to = tries.find(p => (p.x !== g.x || p.y !== g.y) && isClear(g.grid, p))
163 if (to === undefined) return g
164 if (touchesExit(g.grid, to)) return reachExit(g)
165 return { ...g, ...to, visited: markVisited(g.visited, g.grid.length, to.x, to.y) }
166}
167
168export const turn = (g: Maze, steps: number): Maze => ({ ...g, heading: (((g.heading + steps) % HEADINGS) + HEADINGS) % HEADINGS })
169
170export const keyMaze = (g: Maze, key: string): Maze => {
171 if (g.isOver) return g
172 if (key === 'm') return { ...g, showMap: !g.showMap }
173 if (g.cleared !== null) return g
174 const dir = directionOf(key)
175 if (dir === 'up') return walk(g, STEP)
176 if (dir === 'down') return walk(g, -STEP)
177 if (dir === 'left') return turn(g, -1)
178 if (dir === 'right') return turn(g, 1)
179 return g
180}
181
182/** Time passing: the clock runs down and ends the game at zero; after an exit, the next maze. */
183export const tickMaze = (g: Maze, ms: number): Maze => {
184 if (g.isOver) return g
185 if (g.cleared !== null) {
186 return g.cleared.ms > ms ? { ...g, cleared: { ...g.cleared, ms: g.cleared.ms - ms } } : newLevel(g.level + 1, g)
187 }
188 const timeLeft = Math.max(0, g.timeLeft - ms)
189 return { ...g, timeLeft, playedMs: g.playedMs + ms, isOver: timeLeft === 0 }
190}
191
192export const mazeStatus = (g: Maze): string => `livello ${g.level} · ${Math.ceil(g.timeLeft / 1000)} s`
193
194export const MAZE: GameDef<Maze> = {
195 id: 'maze',
196 name: 'Labirinto 3D',
197 keys: '↑↓ avanti e indietro · ←→ gira · m mappa',
198 create: ctx => newMaze(ctx.columns, ctx.rows, ctx.rng),
199 key: keyMaze,
200 tick: tickMaze,
201 isOver: g => g.isOver,
202 score: g => g.score,
203 draw: g => drawMaze(g),
204 status: mazeStatus,
205}
206hooks/games/mines.ts 257 lines1import { directionOf, isSpace } from './keys'
2import type { Rng } from './rng'
3import { nextInt } from './rng'
4import { centerPad, framed, meter, pack, PALETTE, widthOf } from './style'
5import type { GameDef, Row, Span } from './types'
6
7export type Point = { x: number; y: number }
8
9/** What the last key did, shown until the next one: cells opened, a flag set or taken off. */
10export type MinesEvent = { kind: 'open'; cells: Point[] } | { kind: 'flag'; isOn: boolean }
11
12export type Mines = {
13 width: number
14 height: number
15 /** Where the mines are; null until the first cell is opened, so that one is never a mine. */
16 mines: boolean[][] | null
17 mineCount: number
18 revealed: boolean[][]
19 flagged: boolean[][]
20 cursor: Point
21 /** Milliseconds since the first cell was opened, while the game runs. */
22 elapsedMs: number
23 isOver: boolean
24 hasWon: boolean
25 /** The mine that ended the game, drawn in red. */
26 exploded: Point | null
27 rng: Rng
28 /** Columns of the room, to center the drawing; 0 draws it at the left. */
29 columns: number
30 event: MinesEvent | null
31}
32
33const MAX = { width: 16, height: 10 }
34const MIN = { width: 8, height: 6 }
35/** Each cell is three columns wide, plus the two-column frame. */
36const CELL_WIDTH = 3
37const FRAME_ROWS = 2
38export const MINE_SHARE = 0.15
39/** A won game's bonus: this much, less a little per second, never below the floor. */
40export const WIN_BONUS = 1000
41const BONUS_PER_SECOND = 5
42const MIN_WIN_BONUS = 100
43
44/** The classic colours of 1 to 8, brightened for a dark terminal. */
45export const NUMBER_COLORS = ['', '#60a5fa', '#4ade80', '#f87171', '#c084fc', '#fbbf24', '#2dd4bf', '#fafafa', '#a1a1aa']
46/** A covered cell: a raised tile, `▐█▌`, half a column of gap each side. */
47export const TILE = '#52525b'
48const CURSOR = PALETTE.claude
49const DARK_TEXT = '#1a1a1a'
50/** The wash on the cells the last key opened. */
51export const OPENED_BG = '#14532d'
52/** Cells of the progress meter in the bottom edge; fewer on a field narrower than `WIDE_FIELD`. */
53const METER_CELLS = 8
54const SHORT_METER_CELLS = 5
55const WIDE_FIELD = 12
56
57const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n))
58const grid = <T,>(width: number, height: number, value: T): T[][] =>
59 Array.from({ length: height }, () => Array<T>(width).fill(value))
60
61export const newMines = (width: number, height: number, rng: Rng, columns = 0): Mines => ({
62 width,
63 height,
64 mines: null,
65 mineCount: Math.round(width * height * MINE_SHARE),
66 revealed: grid(width, height, false),
67 flagged: grid(width, height, false),
68 cursor: { x: Math.floor(width / 2), y: Math.floor(height / 2) },
69 elapsedMs: 0,
70 isOver: false,
71 hasWon: false,
72 exploded: null,
73 rng,
74 columns,
75 event: null,
76})
77
78const neighbours = (m: Pick<Mines, 'width' | 'height'>, p: Point): Point[] =>
79 [-1, 0, 1]
80 .flatMap(dy => [-1, 0, 1].map(dx => ({ x: p.x + dx, y: p.y + dy })))
81 .filter(q => (q.x !== p.x || q.y !== p.y) && q.x >= 0 && q.y >= 0 && q.x < m.width && q.y < m.height)
82
83/** Mines laid at random, none on the first cell opened nor around it (unless the board is too small for that). */
84export const layMines = (m: Mines, first: Point): Mines => {
85 const all = Array.from({ length: m.width * m.height }, (_, i) => ({ x: i % m.width, y: Math.floor(i / m.width) }))
86 const near = new Set([first, ...neighbours(m, first)].map(p => `${p.x},${p.y}`))
87 const away = all.filter(p => !near.has(`${p.x},${p.y}`))
88 const candidates = away.length >= m.mineCount ? away : all.filter(p => p.x !== first.x || p.y !== first.y)
89 let rng = m.rng
90 const pool = [...candidates]
91 const chosen: Point[] = []
92 for (let i = 0; i < m.mineCount && pool.length > 0; i++) {
93 const [j, next] = nextInt(rng, pool.length)
94 rng = next
95 chosen.push(pool.splice(j, 1)[0]!)
96 }
97 const mines = grid(m.width, m.height, false).map((row, y) => row.map((_, x) => chosen.some(p => p.x === x && p.y === y)))
98 return { ...m, mines, mineCount: chosen.length, rng }
99}
100
101export const minesAround = (m: Mines, p: Point): number => neighbours(m, p).filter(q => m.mines?.[q.y]?.[q.x] === true).length
102
103const revealedCount = (m: Mines): number => m.revealed.flat().filter(Boolean).length
104
105/** Opens a cell: a mine ends the game; a cell with no mines around opens its neighbours too. */
106export const reveal = (start: Mines, p: Point): Mines => {
107 const m = start.mines === null ? layMines(start, p) : start
108 if (m.isOver || m.revealed[p.y]?.[p.x] === true || m.flagged[p.y]?.[p.x] === true) return m
109 if (m.mines?.[p.y]?.[p.x] === true) return { ...m, isOver: true, hasWon: false, exploded: p }
110
111 const revealed = m.revealed.map(row => [...row])
112 const queue = [p]
113 while (queue.length > 0) {
114 const q = queue.shift()!
115 if (revealed[q.y]![q.x] || m.flagged[q.y]![q.x]) continue
116 revealed[q.y]![q.x] = true
117 if (minesAround(m, q) === 0) queue.push(...neighbours(m, q).filter(n => !revealed[n.y]![n.x]))
118 }
119 const opened = { ...m, revealed }
120 const hasWon = revealedCount(opened) === m.width * m.height - m.mineCount
121 return hasWon ? { ...opened, isOver: true, hasWon: true } : opened
122}
123
124export const toggleFlag = (m: Mines, p: Point): Mines =>
125 m.isOver || m.revealed[p.y]?.[p.x] === true
126 ? m
127 : { ...m, flagged: m.flagged.map((row, y) => row.map((f, x) => (x === p.x && y === p.y ? !f : f))) }
128
129/** The game after a cell was opened, remembering which cells that opened. */
130const withOpened = (before: Mines, after: Mines): Mines => {
131 if (after === before) return before
132 const cells = after.revealed.flatMap((row, y) => row.flatMap((isOpen, x) => (isOpen && before.revealed[y]?.[x] !== true ? [{ x, y }] : [])))
133 return { ...after, event: { kind: 'open', cells } }
134}
135
136/** The game after a flag was toggled, remembering whether it went on or off. */
137const withFlag = (before: Mines, after: Mines): Mines =>
138 after === before ? before : { ...after, event: { kind: 'flag', isOn: after.flagged[after.cursor.y]?.[after.cursor.x] === true } }
139
140export const keyMines = (m: Mines, key: string): Mines => {
141 if (m.isOver) return m
142 if (isSpace(key) || key === 'return') return withOpened(m, reveal(m, m.cursor))
143 if (key === 'f') return withFlag(m, toggleFlag(m, m.cursor))
144 const dir = directionOf(key)
145 if (dir === undefined) return m
146 const dx = dir === 'left' ? -1 : dir === 'right' ? 1 : 0
147 const dy = dir === 'up' ? -1 : dir === 'down' ? 1 : 0
148 return { ...m, cursor: { x: clamp(m.cursor.x + dx, 0, m.width - 1), y: clamp(m.cursor.y + dy, 0, m.height - 1) }, event: null }
149}
150
151/** The clock runs from the first cell opened to the end. */
152export const tickMines = (m: Mines, ms: number): Mines => (m.mines === null || m.isOver ? m : { ...m, elapsedMs: m.elapsedMs + ms })
153
154/** Cells opened, and on a win a bonus that shrinks with the seconds taken. */
155export const scoreMines = (m: Mines): number => {
156 const seconds = Math.floor(m.elapsedMs / 1000)
157 const bonus = m.hasWon ? Math.max(MIN_WIN_BONUS, WIN_BONUS - seconds * BONUS_PER_SECOND) : 0
158 return revealedCount(m) + bonus
159}
160
161const flagsLeft = (m: Mines): number => m.mineCount - m.flagged.flat().filter(Boolean).length
162
163/** A tile three columns wide: `▐` and `▌` in its colour around the middle. */
164const tile = (color: string, middle: Span = { text: '█', fg: color }): Span[] => [{ text: '▐', fg: color }, middle, { text: '▌', fg: color }]
165
166const onTile = (text: string, fg: string): Span => ({ text, fg, bg: TILE, bold: true })
167
168const isFresh = (m: Mines, x: number, y: number) => m.event?.kind === 'open' && m.event.cells.some(p => p.x === x && p.y === y)
169
170/** An opened cell: its count in its colour, or a faint dot; washed green when the last key opened it. */
171const openedSpan = (m: Mines, x: number, y: number): Span => {
172 const n = minesAround(m, { x, y })
173 const span: Span = n === 0 ? { text: ' · ', fg: PALETTE.frame } : { text: ` ${n} `, fg: NUMBER_COLORS[n], bold: true }
174 return isFresh(m, x, y) ? { ...span, bg: OPENED_BG } : span
175}
176
177/** A cell as the game shows it, three columns: a tile, a flag, a number, or at the end the mines. */
178const cellSpans = (m: Mines, x: number, y: number): Span[] => {
179 const isMine = m.mines?.[y]?.[x] === true
180 const isFlag = m.flagged[y]?.[x] === true
181 if (m.isOver && isMine && (m.hasWon || isFlag)) return tile(TILE, onTile('⚑', m.hasWon ? PALETTE.good : PALETTE.danger))
182 if (m.isOver && isMine) {
183 const isExploded = m.exploded?.x === x && m.exploded?.y === y
184 return isExploded ? tile(PALETTE.danger, { text: '✸', fg: PALETTE.white, bg: PALETTE.danger, bold: true }) : [{ text: ' ✱ ', fg: PALETTE.danger, bold: true }]
185 }
186 if (isFlag) return tile(TILE, onTile(m.isOver ? '✗' : '⚑', PALETTE.danger))
187 return m.revealed[y]?.[x] === true ? [openedSpan(m, x, y)] : tile(TILE)
188}
189
190/** The cell under the cursor, in Claude's orange: a lit tile, or an opened cell on orange. */
191const cursorSpans = (m: Mines, x: number, y: number): Span[] => {
192 if (m.revealed[y]?.[x] === true) return [{ ...openedSpan(m, x, y), fg: DARK_TEXT, bg: CURSOR, bold: true }]
193 const middle: Span = m.flagged[y]?.[x] === true ? { text: '⚑', fg: DARK_TEXT, bg: CURSOR, bold: true } : { text: '█', fg: CURSOR }
194 return tile(CURSOR, middle)
195}
196
197/** What the last key did, or how the game ended, for the bottom edge. */
198const eventLabel = (m: Mines): Span[] => {
199 if (m.isOver) return [m.hasWon ? { text: ' ripulito! ', fg: PALETTE.good, bold: true } : { text: ' BOOM ', fg: PALETTE.danger, bold: true }]
200 if (m.event?.kind === 'open' && m.event.cells.length > 0) return [{ text: ` +${m.event.cells.length} `, fg: PALETTE.good, bold: true }]
201 if (m.event?.kind === 'flag') return [{ text: m.event.isOn ? ' ⚑ messa ' : ' ⚑ tolta ', fg: PALETTE.hurry, bold: true }]
202 return []
203}
204
205/**
206 * The bottom edge: the share of safe cells opened as a meter, then the last
207 * event; on a field too narrow for all, the meter goes first, then the share.
208 */
209const footerOf = (m: Mines): Span[] => {
210 const share = revealedCount(m) / Math.max(1, m.width * m.height - m.mineCount)
211 const cells = m.width >= WIDE_FIELD ? METER_CELLS : SHORT_METER_CELLS
212 const event = eventLabel(m)
213 const percent: Span = { text: ` ${Math.floor(share * 100)}% `, fg: PALETTE.label }
214 // A blank with no colour becomes the frame's line: `▰▰▱▱ 31% ─ +43 ─`.
215 const tail = event.length > 0 ? [{ text: ' ' }, ...event] : []
216 const room = m.width * CELL_WIDTH - 1
217 const choices = [[...meter(share, cells, PALETTE.good), percent, ...tail], [percent, ...tail], event]
218 return choices.find(spans => widthOf(spans) <= room) ?? event
219}
220
221/** The top edge: the mines in the field and the flags set, amber with more flags than mines. */
222const titleOf = (m: Mines) => {
223 const flags = m.flagged.flat().filter(Boolean).length
224 return { title: `✱ ${m.mineCount} · ⚑ ${flags}`, titleColor: flags > m.mineCount ? PALETTE.hurry : PALETTE.label }
225}
226
227const frameColor = (m: Mines): string => (!m.isOver ? PALETTE.frame : m.hasWon ? PALETTE.good : PALETTE.danger)
228
229/** The field in a rounded frame, centered: the counts on top, the progress and the last event below. */
230export const drawMines = (m: Mines): Row[] => {
231 const rows = m.revealed.map((row, y) =>
232 row.flatMap((_, x) => (!m.isOver && m.cursor.x === x && m.cursor.y === y ? cursorSpans(m, x, y) : cellSpans(m, x, y))),
233 )
234 const width = m.width * CELL_WIDTH
235 const pad = centerPad(m.columns, width + 2)
236 return framed(rows, width, { ...titleOf(m), color: frameColor(m), footer: footerOf(m) }).map(row => pack([pad, ...row]))
237}
238
239export const MINES: GameDef<Mines> = {
240 id: 'mines',
241 name: 'Campo minato',
242 keys: 'frecce muovi · spazio scopre · f bandierina',
243 create: ctx =>
244 newMines(
245 clamp(Math.floor((ctx.columns - 2) / CELL_WIDTH), MIN.width, MAX.width),
246 clamp(ctx.rows - FRAME_ROWS, MIN.height, MAX.height),
247 ctx.rng,
248 ctx.columns,
249 ),
250 key: keyMines,
251 tick: tickMines,
252 isOver: m => m.isOver,
253 score: scoreMines,
254 draw: drawMines,
255 status: m => `⚑ ${flagsLeft(m)} · ${Math.floor(m.elapsedMs / 1000)}s`,
256}
257hooks/games/tetris.ts 368 lines1import { directionOf, isSpace } from './keys'
2import type { Rng } from './rng'
3import { nextInt } from './rng'
4import { centerPad, framed, meter, pack, PALETTE } from './style'
5import type { GameDef, Row, Span } from './types'
6
7export type Kind = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L'
8export type Point = { x: number; y: number }
9
10/** A falling piece: its shape (filled cells of a square matrix, row-major) and the matrix's top-left on the board. */
11export type Piece = { kind: Kind; cells: Point[]; size: number; x: number; y: number }
12
13/** Row-major, top first; null for an empty cell, else the kind that locked there. */
14export type Board = (Kind | null)[][]
15
16export type Tetris = {
17 width: number
18 height: number
19 board: Board
20 piece: Piece
21 next: Kind
22 /** Kinds still to come from the current bag of seven. */
23 bag: Kind[]
24 rng: Rng
25 score: number
26 lines: number
27 level: number
28 /** Milliseconds since the piece last fell a row. */
29 fallMs: number
30 isOver: boolean
31 /** Columns of the room, to center the drawing; 0 draws it at the left. */
32 columns: number
33 /** Rows just cleared, lit for a moment: the board as it was with them, and which they were. */
34 flash: { board: Board; rows: number[]; ms: number } | null
35 /** A hard drop's streak: the cells the piece fell through, for a moment. */
36 trail: { kind: Kind; cells: Point[]; ms: number } | null
37 /** The last clear's points and name, cut into the well's bottom edge for a while. */
38 banner: { text: string; ms: number } | null
39}
40
41export const WIDTH = 10
42const MAX_HEIGHT = 20
43const MIN_HEIGHT = 14
44/** Rows of the board's room the frame takes. */
45const FRAME_ROWS = 2
46const KINDS: readonly Kind[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
47/** Points for 1, 2, 3 and 4 rows cleared at once, times the level. */
48export const LINE_POINTS = [0, 100, 300, 500, 800]
49export const LINES_PER_LEVEL = 10
50const SOFT_DROP_POINTS = 1
51const HARD_DROP_POINTS = 2
52/** Milliseconds per row at level 1, less at each level, never below the floor. */
53const BASE_FALL_MS = 800
54const FALL_STEP_MS = 70
55const MIN_FALL_MS = 80
56/** How long cleared rows stay lit, a hard drop's streak lasts, a clear's points show. */
57export const FLASH_MS = 240
58export const TRAIL_MS = 160
59export const BANNER_MS = 1600
60/** A clear of 1, 2, 3 and 4 rows, by name. */
61const CLEAR_NAMES = ['', 'singola', 'doppia', 'tripla', 'TETRIS!']
62/** Where a rotation that does not fit may try instead: in place, one left, one right. */
63const KICKS = [0, -1, 1]
64
65const SHAPES: Record<Kind, string[]> = {
66 I: ['....', 'XXXX', '....', '....'],
67 O: ['XX', 'XX'],
68 T: ['.X.', 'XXX', '...'],
69 S: ['.XX', 'XX.', '...'],
70 Z: ['XX.', '.XX', '...'],
71 J: ['X..', 'XXX', '...'],
72 L: ['..X', 'XXX', '...'],
73}
74
75export const COLORS: Record<Kind, string> = {
76 I: '#22d3ee',
77 O: '#facc15',
78 T: '#a855f7',
79 S: '#22c55e',
80 Z: '#ef4444',
81 J: '#3b82f6',
82 L: '#f97316',
83}
84/** Each kind's lit top edge, the bevel over its colour. */
85export const LIGHTS: Record<Kind, string> = {
86 I: '#a5f3fc',
87 O: '#fef08a',
88 T: '#d8b4fe',
89 S: '#86efac',
90 Z: '#fca5a5',
91 J: '#93c5fd',
92 L: '#fdba74',
93}
94
95const cellsOf = (shape: string[]): Point[] =>
96 shape.flatMap((line, y) => [...line].flatMap((c, x) => (c === 'X' ? [{ x, y }] : [])))
97
98/** A piece of `kind` at the top, centred. */
99export const spawn = (kind: Kind, width: number): Piece => {
100 const size = SHAPES[kind].length
101 return { kind, cells: cellsOf(SHAPES[kind]), size, x: Math.floor((width - size) / 2), y: 0 }
102}
103
104/** The piece turned a quarter clockwise within its square. */
105export const rotate = (p: Piece): Piece =>
106 p.kind === 'O' ? p : { ...p, cells: p.cells.map(c => ({ x: p.size - 1 - c.y, y: c.x })) }
107
108const blocksOf = (p: Piece): Point[] => p.cells.map(c => ({ x: p.x + c.x, y: p.y + c.y }))
109
110/** True when every block of the piece is inside the board and on an empty cell. */
111export const fits = (board: Board, p: Piece): boolean =>
112 blocksOf(p).every(b => b.x >= 0 && b.x < (board[0]?.length ?? 0) && b.y >= 0 && b.y < board.length && board[b.y]?.[b.x] === null)
113
114/** Seven kinds in a random order: each comes once per bag. */
115const shuffledBag = (rng: Rng): [Kind[], Rng] => {
116 let r = rng
117 const bag = [...KINDS]
118 for (let i = bag.length - 1; i > 0; i--) {
119 const [j, next] = nextInt(r, i + 1)
120 r = next
121 const swap = bag[i]!
122 bag[i] = bag[j]!
123 bag[j] = swap
124 }
125 return [bag, r]
126}
127
128/** The next kind out of the bag, refilling it when empty. */
129const draw = (bag: Kind[], rng: Rng): [Kind, Kind[], Rng] => {
130 const [full, r] = bag.length > 0 ? [bag, rng] : shuffledBag(rng)
131 return [full[0]!, full.slice(1), r]
132}
133
134export const emptyBoard = (width: number, height: number): Board =>
135 Array.from({ length: height }, () => Array<Kind | null>(width).fill(null))
136
137export const newTetris = (width: number, height: number, rng: Rng, columns = 0): Tetris => {
138 const [first, bag1, r1] = draw([], rng)
139 const [next, bag2, r2] = draw(bag1, r1)
140 return {
141 width,
142 height,
143 board: emptyBoard(width, height),
144 piece: spawn(first, width),
145 next,
146 bag: bag2,
147 rng: r2,
148 score: 0,
149 lines: 0,
150 level: 1,
151 fallMs: 0,
152 isOver: false,
153 columns,
154 flash: null,
155 trail: null,
156 banner: null,
157 }
158}
159
160export const fallInterval = (level: number): number => Math.max(MIN_FALL_MS, BASE_FALL_MS - (level - 1) * FALL_STEP_MS)
161
162/** The piece fixed into the board, full rows cleared and scored, the next piece in play (or the end). */
163export const lock = (t: Tetris): Tetris => {
164 const blocks = blocksOf(t.piece)
165 const merged = t.board.map((row, y) => row.map((cell, x) => (blocks.some(b => b.x === x && b.y === y) ? t.piece.kind : cell)))
166 const kept = merged.filter(row => row.some(cell => cell === null))
167 const full = merged.flatMap((row, y) => (row.every(cell => cell !== null) ? [y] : []))
168 const cleared = full.length
169 const board = [...emptyBoard(t.width, cleared), ...kept]
170 const lines = t.lines + cleared
171 const points = (LINE_POINTS[cleared] ?? 0) * t.level
172 const score = t.score + points
173 const [next, bag, rng] = draw(t.bag, t.rng)
174 const piece = spawn(t.next, t.width)
175 return {
176 ...t,
177 board,
178 piece,
179 next,
180 bag,
181 rng,
182 score,
183 lines,
184 level: Math.floor(lines / LINES_PER_LEVEL) + 1,
185 fallMs: 0,
186 isOver: !fits(board, piece),
187 flash: cleared > 0 ? { board: merged, rows: full, ms: FLASH_MS } : null,
188 banner: cleared > 0 ? { text: `+${points} ${CLEAR_NAMES[cleared]}`, ms: BANNER_MS } : t.banner,
189 }
190}
191
192const moved = (t: Tetris, dx: number, dy: number): Piece | null => {
193 const p = { ...t.piece, x: t.piece.x + dx, y: t.piece.y + dy }
194 return fits(t.board, p) ? p : null
195}
196
197/** One row down; a piece that cannot fall locks. */
198const fall = (t: Tetris): Tetris => {
199 const p = moved(t, 0, 1)
200 return p === null ? lock(t) : { ...t, piece: p }
201}
202
203const turn = (t: Tetris): Tetris => {
204 const turned = rotate(t.piece)
205 const kicked = KICKS.map(dx => ({ ...turned, x: turned.x + dx })).find(p => fits(t.board, p))
206 return kicked === undefined ? t : { ...t, piece: kicked }
207}
208
209/** Where the piece would come to rest, dropped straight down. */
210const landing = (board: Board, piece: Piece): Piece => {
211 let p = piece
212 while (fits(board, { ...p, y: p.y + 1 })) p = { ...p, y: p.y + 1 }
213 return p
214}
215
216const hardDrop = (t: Tetris): Tetris => {
217 const piece = landing(t.board, t.piece)
218 const rows = piece.y - t.piece.y
219 const cells = blocksOf(t.piece).flatMap(b => Array.from({ length: rows }, (_, dy) => ({ x: b.x, y: b.y + dy })))
220 const trail = rows > 0 ? { kind: t.piece.kind, cells, ms: TRAIL_MS } : t.trail
221 return lock({ ...t, piece, score: t.score + rows * HARD_DROP_POINTS, trail })
222}
223
224export const keyTetris = (t: Tetris, key: string): Tetris => {
225 if (t.isOver) return t
226 if (isSpace(key)) return hardDrop(t)
227 const dir = directionOf(key)
228 if (dir === 'left' || dir === 'right') {
229 const p = moved(t, dir === 'left' ? -1 : 1, 0)
230 return p === null ? t : { ...t, piece: p }
231 }
232 if (dir === 'up') return turn(t)
233 if (dir === 'down') {
234 const p = moved(t, 0, 1)
235 return p === null ? lock(t) : { ...t, piece: p, score: t.score + SOFT_DROP_POINTS, fallMs: 0 }
236 }
237 return t
238}
239
240/** An effect `ms` older, gone once its time is up. */
241const aged = <E extends { ms: number }>(effect: E | null, ms: number): E | null =>
242 effect === null || effect.ms <= ms ? null : { ...effect, ms: effect.ms - ms }
243
244/** Gravity: the piece falls a row each interval, faster at each level. */
245export const tickTetris = (t: Tetris, ms: number): Tetris => {
246 let state = { ...t, fallMs: t.fallMs + ms, flash: aged(t.flash, ms), trail: aged(t.trail, ms), banner: aged(t.banner, ms) }
247 while (!state.isOver && state.fallMs >= fallInterval(state.level)) {
248 const rest = state.fallMs - fallInterval(state.level)
249 state = { ...fall(state), fallMs: rest }
250 }
251 return state
252}
253
254/** A block, two columns: its colour under a lit top edge, like a bevel. */
255const BLOCK = '▆▆'
256/** Where the piece will land, its shadow. */
257const GHOST = '░░'
258/** A hard drop's streak, a thin line down the middle of each column. */
259const STREAK = '▕▏'
260/** The blocks of a lost game, greyed. */
261const DEAD = { fg: '#52525b', bg: '#71717a' }
262/** The jammed piece of a lost game: white crosses on red, unlike any block. */
263export const JAMMED: Span = { text: '╳╳', fg: PALETTE.white, bg: PALETTE.danger, bold: true }
264const EMPTY: Span = { text: ' ·', fg: PALETTE.faint }
265const CLEARED: Span = { text: '██', fg: PALETTE.white, bold: true }
266/** Inside of the side boxes; the blank between the well and them. */
267const PANEL_INNER = 13
268const GAP = 2
269/** Free rows over the stack below which the well turns amber, then red: a share of its height. */
270const HURRY_SHARE = 1 / 3
271const DANGER_SHARE = 1 / 6
272
273const blockOf = (kind: Kind): Span => ({ text: BLOCK, fg: COLORS[kind], bg: LIGHTS[kind] })
274
275/** What the well shows at this moment: the board, the lit rows, the piece and its shadow, a streak. */
276type View = { board: Board; lit: number[]; piece: Point[]; pieceSpan: Span; ghost: Point[]; trail: Point[] }
277
278const has = (points: readonly Point[], x: number, y: number) => points.some(p => p.x === x && p.y === y)
279
280const viewOf = (t: Tetris): View => {
281 if (t.isOver) return { board: t.board, lit: [], piece: blocksOf(t.piece), pieceSpan: JAMMED, ghost: [], trail: [] }
282 const trail = t.trail?.cells ?? []
283 if (t.flash !== null) return { board: t.flash.board, lit: t.flash.rows, piece: [], pieceSpan: EMPTY, ghost: [], trail }
284 const ghost = blocksOf(landing(t.board, t.piece))
285 return { board: t.board, lit: [], piece: blocksOf(t.piece), pieceSpan: blockOf(t.piece.kind), ghost, trail }
286}
287
288const cellOf = (t: Tetris, v: View, x: number, y: number): Span => {
289 if (v.lit.includes(y)) return CLEARED
290 if (has(v.piece, x, y)) return v.pieceSpan
291 const kind = v.board[y]?.[x] ?? null
292 if (kind !== null) return t.isOver ? { text: BLOCK, ...DEAD } : blockOf(kind)
293 if (has(v.ghost, x, y)) return { text: GHOST, fg: COLORS[t.piece.kind] }
294 if (has(v.trail, x, y)) return { text: STREAK, fg: LIGHTS[t.trail!.kind] }
295 return EMPTY
296}
297
298/** Rows free over the highest block. */
299const freeRows = (board: Board): number => {
300 const top = board.findIndex(row => row.some(cell => cell !== null))
301 return top === -1 ? board.length : top
302}
303
304/** The well's frame: red when lost, green as rows clear, amber then red as the stack nears the top. */
305const wellColor = (t: Tetris): string => {
306 if (t.isOver) return PALETTE.danger
307 if (t.flash !== null) return PALETTE.good
308 const free = freeRows(t.board)
309 return free <= t.height * DANGER_SHARE ? PALETTE.danger : free <= t.height * HURRY_SHARE ? PALETTE.hurry : PALETTE.frame
310}
311
312/** The bottom edge: a clear's points while they show, the end, or a plain line. */
313const wellFooter = (t: Tetris): Span[] => {
314 if (t.isOver) return [{ text: ' pozzo pieno ', fg: PALETTE.danger, bold: true }]
315 return t.banner === null ? [] : [{ text: ` ${t.banner.text} `, fg: PALETTE.good, bold: true }]
316}
317
318const wellRows = (t: Tetris): Row[] => {
319 const v = viewOf(t)
320 const rows = v.board.map((row, y) => row.map((_, x) => cellOf(t, v, x, y)))
321 return framed(rows, t.width * 2, { color: wellColor(t), footer: wellFooter(t) })
322}
323
324/** The next piece in a box, centered, its blocks as in the well. */
325const nextBox = (kind: Kind): Row[] => {
326 const shape = SHAPES[kind].filter(line => line.includes('X')).map(line => line.replace(/\.+$/, ''))
327 const wide = Math.max(...shape.map(line => line.length)) * 2
328 const rows = shape.map(line => [centerPad(PANEL_INNER, wide), ...[...line].map((c): Span => (c === 'X' ? blockOf(kind) : { text: ' ' }))])
329 return framed([[], ...rows, ...(rows.length < 2 ? [[]] : []), []], PANEL_INNER, { title: 'Prossimo' })
330}
331
332/** The level in a box: rows cleared, and a meter of the way to the next level. */
333const levelBox = (t: Tetris): Row[] => {
334 const share = (t.lines % LINES_PER_LEVEL) / LINES_PER_LEVEL
335 return framed(
336 [
337 [{ text: ' righe ', fg: PALETTE.label }, { text: String(t.lines), fg: PALETTE.white, bold: true }],
338 [{ text: ' ' }, ...meter(share, PANEL_INNER - 3, PALETTE.sky)],
339 ],
340 PANEL_INNER,
341 { title: `livello ${t.level}` },
342 )
343}
344
345/** The well, and beside it the next piece and the level when there is room, centered in the columns. */
346export const drawTetris = (t: Tetris): Row[] => {
347 const well = wellRows(t)
348 const wellWidth = t.width * 2 + 2
349 const fullWidth = wellWidth + GAP + PANEL_INNER + 2
350 const hasPanel = t.columns === 0 || t.columns >= fullWidth
351 const panel = hasPanel ? [...nextBox(t.next), [], ...levelBox(t)] : []
352 const pad = centerPad(t.columns, hasPanel ? fullWidth : wellWidth)
353 return well.map((row, i) => pack([pad, ...row, ...(panel[i] === undefined ? [] : [{ text: ' '.repeat(GAP) }, ...panel[i]!])]))
354}
355
356export const TETRIS: GameDef<Tetris> = {
357 id: 'tetris',
358 name: 'Tetris',
359 keys: '←→ muovi · ↑ ruota · ↓ scendi · spazio fa cadere',
360 create: ctx => newTetris(WIDTH, Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, ctx.rows - FRAME_ROWS)), ctx.rng, ctx.columns),
361 key: keyTetris,
362 tick: tickTetris,
363 isOver: t => t.isOver,
364 score: t => t.score,
365 draw: drawTetris,
366 status: t => `livello ${t.level} · righe ${t.lines}`,
367}
368hooks/games/types.ts 49 lines1import type { GameId } from '../../types'
2import type { Rng } from './rng'
3
4/** A run of text drawn alike: what one `Text` draws. */
5export type Span = { text: string; fg?: string; bg?: string; bold?: boolean }
6export type Row = Span[]
7
8/** What a game starts from: the room it has, a random source, the words of the code Claude is editing. */
9export type GameContext = {
10 /** Cells across the board may use. */
11 columns: number
12 /** Rows the board may use, below the score line and above the help line. */
13 rows: number
14 rng: Rng
15 /** Names from the files Claude edited lately, most recent first; may be empty. */
16 words: readonly string[]
17}
18
19/**
20 * One game of the arcade: plain functions over its own state, never mutated.
21 * The arcade owns the menu, pause, record and game-over screens; the game
22 * only plays.
23 */
24export type GameDef<S> = {
25 id: GameId
26 name: string
27 /** One line of help shown under the board. */
28 keys: string
29 /**
30 * The game reads letters as moves (typing): the arcade then pauses on Tab
31 * and leaves `p` and `m` to the game.
32 */
33 isTyping?: boolean
34 create: (ctx: GameContext) => S
35 /** A key: a special key's name (`up`, `return`, `tab`, `backspace`…) or the character typed (`" "` for space). */
36 key: (state: S, key: string) => S
37 /** Time passing, in ms; absent for a game that moves on keys only. */
38 tick?: (state: S, ms: number) => S
39 isOver: (state: S) => boolean
40 score: (state: S) => number
41 draw: (state: S) => Row[]
42 /** A word or two beside the score (`livello 3`, `vite ♥♥`); absent, nothing. */
43 status?: (state: S) => string
44}
45
46/** A game of any state type, as the arcade holds it. */
47// eslint-disable-next-line @typescript-eslint/no-explicit-any
48export type AnyGame = GameDef<any>
49