Match-3 puzzle game in a Claude Code pane: /match3 – 5 colors, 5 levels, rockets, bombs, prisms and combos

A match-3 puzzle game as a Claude Code mod. Type /match3 and the board opens in a pane next to your session: swap gems, build rockets and bombs, chain combos, and play through five levels while Claude works.

Claude Code v2.1.287 or later (Desktop app: v2.1.286 or later).
claude plugin marketplace add nachtgold/claude-code-match-3
claude plugin install match-3@nachtgold
In a running session, run /reload-plugins afterwards.
/match3 opens the game and resumes the level you are on./match3 new starts again at level 1.h hint, r restart.The levels come one after another. Clear a level's goal and the next one starts. Run out of moves and you retry the same level. Your current level and best scores are saved across sessions.
Five colors: ● red, ★ yellow, ▲ green, ◆ blue, ■ purple.
| Match | Creates | Effect |
|---|---|---|
| 4 in a row | ↔ / ↕ rocket | clears its whole row or column |
| L or T shape | ✹ bomb | blasts the 3×3 area around it |
| 5 in a row | ✦ prism | swap it with a gem: every gem of that color is cleared |
Specials are tiles filled with their color, the color's shape on both sides and the effect's mark in the middle (●↔●, ◆↕◆, ▲✹▲), so you can tell their color by shape too. They match like a gem of that color.
A special caught in another blast fires too, so chain reactions happen. Each cascade raises the points multiplier.
| Swap | Effect |
|---|---|
| rocket + rocket | a full row and column cross |
| rocket + bomb | a cross three rows and three columns wide |
| bomb + bomb | a 5×5 blast |
| prism + rocket / bomb | every gem of that color turns into that special and fires |
| prism + prism | clears the whole board |
| # | Level | Goal | Moves |
|---|---|---|---|
| 1 | Meadow | open 8×8 board, reach 4000 points | 20 |
| 2 | Ice Cellar | break all the ice, some of it two layers thick | 26 |
| 3 | Quarry | smash every stone (some take two hits) on a board with holes | 28 |
| 4 | Hourglass | collect 25 red and 25 blue through a three-column waist | 24 |
| 5 | Treasure Vault | bring 3 treasures ($) to the bottom past chained gems | 30 |
Each level awards one to three stars by score, and moves left over count as a bonus.
The game speaks English or German. By default it follows Claude Code's language setting, then your system locale (LC_ALL, LC_MESSAGES, LANG). To choose yourself, run /plugin configure match-3@nachtgold and set Language to en or de.
Das Spiel gibt es auf Deutsch und Englisch: Es folgt der language-Einstellung von Claude Code bzw. der Systemsprache, oder du stellst es unter /plugin configure match-3@nachtgold fest ein.
The board draws in the terminal and in the Code tab of the Desktop app. In the terminal, the pane and mouse input need Claude Code's fullscreen layout. In VS Code and on mobile the command only prints a note.
claude plugin validate . lists everything the mod hooks and calls. It registers the /match3 command, opens one pane, reads Claude Code's settings and the LC_ALL, LC_MESSAGES and LANG variables to pick a language, and keeps your progress in the plugin's own store. It reads no files and makes no network requests.
claude plugin test .
The rules (matching, specials, gravity, goals) live in hooks/engine.ts, the board in hooks/game.tsx, and the command and pane in hooks/register.tsx. To try local changes, start Claude Code with claude --plugin-dir ..
MIT © nachtgold
hooks/register.tsx 116 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Progress } from '../types'
5import { TEXTS, pickLang } from './i18n'
6import type { Lang } from './i18n'
7
8const PANE = 'match3'
9const LEVEL_COUNT = 5
10const EMPTY: Progress = { level: 0, best: {} }
11const progress = atom({ plugin: 'match-3', key: 'progress' } as const, EMPTY)
12const launch = atom({ plugin: 'match-3', key: 'launch' } as const, { level: 0, nonce: 0 })
13
14type Result = { type: 'result'; level: number; score: number; stars: number; won: boolean }
15
16function isResult(data: unknown): data is Result {
17 return typeof data === 'object' && data !== null && (data as Result).type === 'result'
18}
19
20// Older saves held only the best results; anything without a level starts over.
21function normalize(value: unknown): Progress {
22 const p = value as Partial<Progress> | undefined
23 return typeof p?.level === 'number' && typeof p.best === 'object' && p.best !== null ? (p as Progress) : EMPTY
24}
25
26// The option wins; then Claude Code's own `language` setting; then the locale.
27async function resolveLang($: EngineInterface, option: unknown): Promise<Lang> {
28 let settings: { language?: unknown } = {}
29 try {
30 settings = (await $.settings.read()) as { language?: unknown }
31 } catch {
32 // No settings to read (a test, a host without them): the locale decides.
33 }
34 let intl: string | undefined
35 try {
36 intl = Intl.DateTimeFormat().resolvedOptions().locale
37 } catch {
38 intl = undefined
39 }
40 return pickLang(
41 typeof option === 'string' ? option : undefined,
42 typeof settings.language === 'string' ? settings.language : undefined,
43 await $.env.get('LC_ALL'),
44 await $.env.get('LC_MESSAGES'),
45 await $.env.get('LANG'),
46 intl,
47 )
48}
49
50export const register: Register = (on, options) => {
51 let lang: Lang = 'en'
52
53 on('session.start', async ($, e, next) => {
54 lang = await resolveLang($, options.language)
55 await $.command.register({
56 name: 'match3',
57 description: TEXTS[lang].command,
58 argumentHint: lang === 'de' ? '[neu]' : '[new]',
59 })
60 const saved = normalize(await $.store.get('progress'))
61 await update($, progress, () => saved)
62 await update($, launch, prev => ({ level: saved.level, nonce: (prev?.nonce ?? 0) + 1 }))
63
64 return next(e)
65 })
66
67 on('command.run', { command: 'match3' }, async ($, e) => {
68 const arg = e.args.trim().toLowerCase()
69 if (arg === 'new' || arg === 'neu') {
70 const reset = { ...normalize(await read($, progress)), level: 0 }
71 await update($, progress, () => reset)
72 await $.store.set('progress', reset)
73 await update($, launch, prev => ({ level: 0, nonce: prev.nonce + 1 }))
74 }
75 await $.ui.open({ id: PANE, title: 'Match-3', focus: true, holdToasts: true, rows: 26, columns: 90 })
76 const { level } = normalize(await read($, progress))
77
78 return { text: TEXTS[lang].opened(level + 1) }
79 })
80
81 on('ui.message', async ($, e, next) => {
82 if (!isResult(e.data) || !e.data.won) return next(e)
83 const r = e.data
84 const all = normalize(await read($, progress))
85 const prev = all.best[String(r.level)]
86 const saved: Progress = {
87 level: r.level + 1 < LEVEL_COUNT ? r.level + 1 : 0,
88 best: {
89 ...all.best,
90 [String(r.level)]: { score: Math.max(prev?.score ?? 0, r.score), stars: Math.max(prev?.stars ?? 0, r.stars) },
91 },
92 }
93 await update($, progress, () => saved)
94 await update($, launch, l => ({ ...l, level: saved.level }))
95 await $.store.set('progress', saved)
96 $.ui.status(TEXTS[lang].status(r.level + 1, '★'.repeat(r.stars)))
97
98 return next(e)
99 })
100
101 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
102 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
103 const { Text } = $.ui.resolve(e)
104 return <Text>{TEXTS[lang].needsSurface}</Text>
105 }
106 const { Box, Client } = $.ui.resolve(e)
107 const props = { launch: await read($, launch), lang }
108
109 return (
110 <Box flexDirection="column">
111 <Client key="game" module="./game.tsx" props={props} width="100%" />
112 </Box>
113 )
114 })
115}
116hooks/i18n.ts 130 lines1// Every text the game shows, in English and German.
2
3export type Lang = 'en' | 'de'
4
5const en = {
6 levels: [
7 { name: 'Meadow', blurb: 'Open field: score points, build combos.' },
8 { name: 'Ice Cellar', blurb: 'Break all the ice: matches on ice melt it.' },
9 { name: 'Quarry', blurb: 'Smash every stone: match next to it or blast it.' },
10 { name: 'Hourglass', blurb: 'Narrow waist: collect 25 red and 25 blue.' },
11 { name: 'Treasure Vault', blurb: 'Bring 3 treasures ($) to the bottom. Chains block.' },
12 ],
13 colors: ['Red', 'Yellow', 'Green', 'Blue', 'Purple'],
14 cantMove: 'That one can’t move.',
15 noMatch: 'No match – swapped back.',
16 shuffled: 'No moves left – reshuffled!',
17 treasure: (n: number, of: number) => `Treasure saved! (${n}/${of})`,
18 madeRainbow: '5 in a row: prism!',
19 madeBomb: 'L/T shape: bomb!',
20 madeRocket: '4 in a row: rocket!',
21 cascade: (n: number) => `Cascade ×${n}!`,
22 won: 'Level cleared!',
23 wonBonus: (n: number) => `Level cleared! Moves bonus +${n}`,
24 lost: 'Out of moves – level failed.',
25 hintUsed: 'Hint (−50 points)',
26 comboAll: 'MEGA! Double prism clears everything!',
27 comboColor: (color: string) => `Prism: all ${color} gone!`,
28 comboBombs: 'Prism + bomb: bomb shower!',
29 comboRockets: 'Prism + rocket: rocket storm!',
30 comboCross: 'Rocket cross!',
31 comboBigBlast: 'Double bomb: 5×5 blast!',
32 comboBigCross: 'Bomb + rocket: giant cross!',
33 goalScore: (n: number, of: number) => `Score ${n}/${of}`,
34 goalIce: (n: number) => `Ice left: ${n}`,
35 goalStone: (n: number) => `Stones left: ${n}`,
36 goalTreasure: (n: number, of: number) => `$ Treasures ${n}/${of}`,
37 level: 'Level',
38 moves: 'Moves',
39 score: 'Score',
40 goal: 'Goal',
41 nextLevel: (n: number) => `On to level ${n} …`,
42 allCleared: (n: number) => `All ${n} levels cleared!`,
43 next: 'Next',
44 again: 'Play again',
45 retry: 'Retry',
46 hint: 'Hint',
47 restart: 'Restart',
48 help: ['Click two neighbours or drag', 'Arrows + Enter · h hint · r restart'],
49 legend: [
50 '↔↕ Rocket 4 in a row: row/column',
51 '✹ Bomb L/T shape: 3×3 blast',
52 '✦ Prism 5 in a row: one color',
53 'Swap two specials for a combo',
54 ],
55 command: 'Play Match-3 (resumes your level; "/match3 new" starts at level 1)',
56 opened: (n: number) => `Match-3 opened: level ${n}.`,
57 status: (n: number, stars: string) => `Match-3: level ${n} cleared ${stars}`,
58 needsSurface: 'Match-3 needs the terminal or the Desktop app.',
59}
60
61const de: typeof en = {
62 levels: [
63 { name: 'Blumenwiese', blurb: 'Freies Feld: sammle Punkte, baue Kombos.' },
64 { name: 'Eiskeller', blurb: 'Brich alles Eis: Treffer auf Eisfeldern tauen es.' },
65 { name: 'Steinbruch', blurb: 'Zertrümmere alle Steine: Treffer daneben oder Explosionen.' },
66 { name: 'Sanduhr', blurb: 'Enge Taille: sammle 25 Rote und 25 Blaue.' },
67 { name: 'Schatzkammer', blurb: 'Bring 3 Schätze ($) nach unten. Ketten blockieren.' },
68 ],
69 colors: ['Rot', 'Gelb', 'Grün', 'Blau', 'Lila'],
70 cantMove: 'Das lässt sich nicht bewegen.',
71 noMatch: 'Kein Treffer – Tausch zurück.',
72 shuffled: 'Keine Züge mehr – neu gemischt!',
73 treasure: (n, of) => `Schatz geborgen! (${n}/${of})`,
74 madeRainbow: '5er-Kette: Prisma entsteht!',
75 madeBomb: 'L/T-Form: Bombe entsteht!',
76 madeRocket: '4er-Kette: Rakete entsteht!',
77 cascade: n => `Kaskade ×${n}!`,
78 won: 'Geschafft!',
79 wonBonus: n => `Geschafft! Restzug-Bonus +${n}`,
80 lost: 'Keine Züge mehr – leider verloren.',
81 hintUsed: 'Tipp (−50 Punkte)',
82 comboAll: 'MEGA! Doppel-Prisma räumt alles ab!',
83 comboColor: color => `Prisma: alle ${color} weg!`,
84 comboBombs: 'Prisma + Bombe: Bombenregen!',
85 comboRockets: 'Prisma + Rakete: Raketenhagel!',
86 comboCross: 'Raketen-Kreuz!',
87 comboBigBlast: 'Doppelbombe: 5×5 Explosion!',
88 comboBigCross: 'Bombe + Rakete: Riesenkreuz!',
89 goalScore: (n, of) => `Punkte ${n}/${of}`,
90 goalIce: n => `Eis übrig: ${n}`,
91 goalStone: n => `Steine übrig: ${n}`,
92 goalTreasure: (n, of) => `$ Schätze ${n}/${of}`,
93 level: 'Level',
94 moves: 'Züge',
95 score: 'Punkte',
96 goal: 'Ziel',
97 nextLevel: n => `Weiter zu Level ${n} …`,
98 allCleared: n => `Alle ${n} Level geschafft!`,
99 next: 'Weiter',
100 again: 'Von vorn',
101 retry: 'Nochmal',
102 hint: 'Tipp',
103 restart: 'Neu starten',
104 help: ['Klick zwei Nachbarn oder ziehen', 'Pfeile + Enter · h Tipp · r neu'],
105 legend: [
106 '↔↕ Rakete 4er-Reihe: Reihe/Spalte',
107 '✹ Bombe L/T-Form: sprengt 3×3',
108 '✦ Prisma 5er-Reihe: eine Farbe',
109 'Zwei Specials tauschen = Kombo',
110 ],
111 command: 'Match-3 spielen (setzt beim aktuellen Level fort; "/match3 neu" beginnt bei Level 1)',
112 opened: n => `Match-3 geöffnet: Level ${n}.`,
113 status: (n, stars) => `Match-3: Level ${n} geschafft ${stars}`,
114 needsSurface: 'Match-3 braucht das Terminal oder die Desktop-App.',
115}
116
117export const TEXTS: Record<Lang, typeof en> = { en, de }
118
119export type Texts = typeof en
120
121// German for a German language or locale, English for everything else.
122export function pickLang(...hints: (string | undefined)[]): Lang {
123 for (const hint of hints) {
124 const value = (hint ?? '').trim().toLowerCase()
125 if (!value || value === 'auto' || value === 'c' || value === 'posix' || value.startsWith('c.')) continue
126 return /^(de|german|deutsch)/.test(value) ? 'de' : 'en'
127 }
128 return 'en'
129}
130hooks/game.tsx 343 lines1import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
2
3import type { GameProps } from '../types'
4import {
5 COLORS, GLYPHS, LEVELS, SWAP_FRAMES, adjacent, at, goalText, levelText, newGame, rc, starsOf, tick, trySwap, useHint,
6} from './engine'
7import type { Game, Gem } from './engine'
8import { TEXTS } from './i18n'
9import type { Lang } from './i18n'
10
11type Ctx = {
12 g: Game
13 lang: Lang
14 launchNonce: number
15 down: number
16 wide: boolean
17 // Ticks left before a won level moves on by itself.
18 advanceIn: number
19 // The cursor shows only while the keyboard drives the game.
20 isKeyboard: boolean
21}
22type S = { ctx: Ctx; v: number }
23type Surface = ClientSurface<S>
24
25// One board cell in character cells: wide and tall enough for a glyph with
26// room around it, and for a special's three glyphs.
27const CELL_W = 5
28const CELL_H = 2
29const HEADER_ROWS = 1
30const SIDE_W = 40
31const ADVANCE_TICKS = 35
32const BG = ['#1f232b', '#272c36']
33const ICE_BG = ['', '#2d5f80', '#4f97c4']
34const LAST = LEVELS.length - 1
35
36function redraw(surface: Surface) {
37 const s = surface.state!
38 surface.setState({ ctx: s.ctx, v: s.v + 1 })
39}
40
41function start(ctx: Ctx, lvl: number) {
42 ctx.g = newGame(Math.max(0, Math.min(LAST, lvl)), ctx.lang)
43 ctx.advanceIn = 0
44}
45
46function report(surface: Surface, ctx: Ctx) {
47 const g = ctx.g
48 if (g.reported || (g.phase !== 'won' && g.phase !== 'lost')) return
49 g.reported = true
50 if (g.phase === 'won' && g.lvl < LAST) ctx.advanceIn = ADVANCE_TICKS
51 surface.post({ type: 'result', level: g.lvl, score: g.score, stars: starsOf(g), won: g.phase === 'won' })
52}
53
54// After a loss: the same level again. After a win: the next one.
55function proceed(ctx: Ctx) {
56 const g = ctx.g
57 if (g.phase === 'won') start(ctx, g.lvl === LAST ? 0 : g.lvl + 1)
58 else if (g.phase === 'lost') start(ctx, g.lvl)
59}
60
61function onTick(surface: Surface) {
62 const ctx = surface.state?.ctx
63 if (!ctx) return
64 if (ctx.advanceIn > 0 && --ctx.advanceIn === 0) {
65 proceed(ctx)
66 redraw(surface)
67 return
68 }
69 if (tick(ctx.g)) {
70 report(surface, ctx)
71 redraw(surface)
72 }
73}
74
75function move(g: Game, i: number, dr: number, dc: number): number {
76 const [r, c] = rc(g, i)
77 const j = at(g, r + dr, c + dc)
78 return j < 0 ? i : j
79}
80
81const DIRS: Record<string, [number, number]> = {
82 up: [-1, 0], down: [1, 0], left: [0, -1], right: [0, 1],
83}
84
85function onKey(surface: Surface, k: ClientKeyEvent) {
86 const ctx = surface.state?.ctx
87 if (!ctx) return
88 const g = ctx.g
89 const key = k.key.toLowerCase()
90 ctx.isKeyboard = true
91 const over = g.phase === 'won' || g.phase === 'lost'
92
93 if (over && (key === 'return' || key === ' ' || key === 'n')) proceed(ctx)
94 else if (key === 'r') start(ctx, g.lvl)
95 else if (key === 'h') useHint(g)
96 else if (DIRS[key]) {
97 const [dr, dc] = DIRS[key]
98 if (g.sel >= 0) {
99 const target = move(g, g.sel, dr, dc)
100 if (target !== g.sel) trySwap(g, g.sel, target)
101 g.cursor = target
102 g.sel = -1
103 } else g.cursor = move(g, g.cursor, dr, dc)
104 } else if (key === 'return' || key === ' ') {
105 g.sel = g.sel === g.cursor ? -1 : g.cursor
106 } else return
107 redraw(surface)
108}
109
110export function cellAt(g: Game, p: ClientPointerEvent): number {
111 const r = Math.floor((p.y - HEADER_ROWS) / CELL_H)
112 const c = Math.floor(p.x / CELL_W)
113 if (p.x < 0 || p.y < HEADER_ROWS || c >= g.C) return -1
114 return at(g, r, c)
115}
116
117function onPointer(surface: Surface, p: ClientPointerEvent) {
118 const ctx = surface.state?.ctx
119 if (!ctx || p.button !== 'left') return
120 const g = ctx.g
121 const i = cellAt(g, p)
122 ctx.isKeyboard = false
123 if (p.type === 'down') {
124 ctx.down = i
125 return
126 }
127 if (p.type !== 'up') return
128 const from = ctx.down
129 ctx.down = -1
130 if (i < 0) return
131 if (from >= 0 && from !== i && adjacent(g, from, i)) {
132 // Dragged a gem onto its neighbour.
133 trySwap(g, from, i)
134 } else if (g.sel >= 0 && adjacent(g, g.sel, i)) {
135 trySwap(g, g.sel, i)
136 g.sel = -1
137 } else {
138 g.sel = g.sel === i ? -1 : i
139 }
140 g.cursor = i
141 redraw(surface)
142}
143
144// ---------- drawing ----------
145
146type Align = 'center' | 'flex-start' | 'flex-end'
147type Part = { ch: string; color?: string; bold?: boolean }
148// A cell: its glyphs, its fill, and where the glyphs sit (a slide moves them).
149type Look = { parts: Part[]; bg?: string; alignX?: Align; alignY?: Align }
150
151const DARK = '#141414'
152const MARK = { row: '↔', col: '↕', bomb: '✹' } as const
153
154// Plain gems: the color's shape in its color. Specials: a tile filled with
155// their color, the shape on both sides and the effect's mark in the middle,
156// all dark on the fill, so color and shape both say which color it counts as.
157function gemParts(gem: Gem | null): { parts: Part[]; fill?: string } {
158 if (!gem) return { parts: [] }
159 const color = COLORS[gem.c] ?? '#ffffff'
160 const shape = GLYPHS[gem.c]
161 if (gem.k === 'row' || gem.k === 'col' || gem.k === 'bomb') {
162 return {
163 parts: [{ ch: shape, color: DARK }, { ch: MARK[gem.k], color: DARK, bold: true }, { ch: shape, color: DARK }],
164 fill: color,
165 }
166 }
167 if (gem.k === 'rainbow') {
168 return {
169 parts: [{ ch: GLYPHS[0], color: COLORS[0] }, { ch: '✦', color: DARK, bold: true }, { ch: GLYPHS[3], color: COLORS[3] }],
170 fill: '#f2f2f2',
171 }
172 }
173 if (gem.k === 'treasure') return { parts: [{ ch: '$', color: '#ffcc33', bold: true }] }
174 return { parts: [{ ch: shape, color }] }
175}
176
177// A swap slides in three frames: both gems lean towards each other, cross
178// into the other cell, then settle in the middle.
179function swapFrame(g: Game, i: number): { gem: Gem | null; alignX: Align; alignY: Align } | null {
180 if ((g.phase !== 'swap' && g.phase !== 'unswap') || !g.swap) return null
181 const [a, b] = g.swap
182 if (i !== a && i !== b) return null
183 const other = i === a ? b : a
184 const frame = SWAP_FRAMES - g.timer
185 const isSideways = rc(g, a)[0] === rc(g, b)[0]
186 const toward: Align = other > i ? 'flex-end' : 'flex-start'
187 const alignX = isSideways ? toward : 'center'
188 const alignY = isSideways ? 'center' : toward
189 if (frame <= 0) return { gem: g.cells[i].gem, alignX, alignY }
190 if (frame === 1) return { gem: g.cells[other].gem, alignX, alignY }
191 return { gem: g.cells[other].gem, alignX: 'center', alignY: 'center' }
192}
193
194// Each cell is a fixed CELL_W × CELL_H box with its glyphs in the middle: the
195// box, not a glyph's own width, sets the spacing, so every surface lines up.
196function cellLook(g: Game, i: number, showCursor: boolean): Look {
197 const cell = g.cells[i]
198 const [r, c] = rc(g, i)
199 if (cell.hole) return { parts: [] }
200 if (cell.stone > 0) {
201 const ch = cell.stone > 1 ? '▓' : '░'
202 return { parts: [{ ch: ch + ch + ch, color: '#b0b0b0', bold: true }], bg: '#4a4a4a' }
203 }
204 if (g.phase === 'flash' && g.flash.includes(i)) return { parts: [{ ch: '✧', color: DARK, bold: true }], bg: '#e8e8e8' }
205
206 const slide = swapFrame(g, i)
207 const { parts, fill } = gemParts(slide ? slide.gem : cell.gem)
208 let bg = fill ?? (cell.ice ? ICE_BG[cell.ice] : BG[(r + c) % 2])
209 if (!fill && cell.lock) bg = '#5a4526'
210
211 // Hint, cursor and selection: a tint on plain cells, brackets on every cell
212 // (a special's own fill would hide a tint).
213 let mark: string | null = null
214 if (g.hint && g.hint.includes(i)) { mark = '#5fd75f'; if (!fill) bg = '#2f6b3a' }
215 if (showCursor && g.cursor === i && g.phase !== 'won' && g.phase !== 'lost') { mark = '#ffffff'; if (!fill) bg = '#56607a' }
216 if (g.sel === i) { mark = '#ffd700'; if (!fill) bg = '#9a7d12' }
217 const framed = mark && fill
218 ? [{ ch: '[', color: DARK, bold: true }, ...parts, { ch: ']', color: DARK, bold: true }]
219 : parts
220 return { parts: framed, bg, alignX: slide?.alignX, alignY: slide?.alignY }
221}
222
223function stars(n: number): string {
224 return '★'.repeat(n) + '☆'.repeat(3 - n)
225}
226
227function Board(surface: Surface, g: Game, showCursor: boolean) {
228 const { Box, Text } = surface.elements
229 const rows = []
230 for (let r = 0; r < g.R; r++) {
231 const cells = []
232 for (let c = 0; c < g.C; c++) {
233 const look = cellLook(g, r * g.C + c, showCursor)
234 cells.push(
235 <Box
236 width={CELL_W}
237 height={CELL_H}
238 flexShrink={0}
239 flexDirection="column"
240 justifyContent={look.alignY ?? 'center'}
241 alignItems={look.alignX ?? 'center'}
242 overflow="hidden"
243 backgroundColor={look.bg}
244 >
245 <Box flexDirection="row">
246 {look.parts.map(p => <Text color={p.color} bold={p.bold}>{p.ch}</Text>)}
247 </Box>
248 </Box>,
249 )
250 }
251 rows.push(<Box flexDirection="row" height={CELL_H}>{cells}</Box>)
252 }
253 return (
254 // No border: surfaces disagree on whether one takes a cell, and the
255 // pointer maths needs the board's first cell at a known place.
256 <Box flexDirection="column" width={g.C * CELL_W} flexShrink={0}>
257 {rows}
258 </Box>
259 )
260}
261
262function Side(surface: Surface, ctx: Ctx) {
263 const { Box, Text, Button } = surface.elements
264 const g = ctx.g
265 const t = TEXTS[ctx.lang]
266 const def = LEVELS[g.lvl]
267 const won = g.phase === 'won'
268 const lost = g.phase === 'lost'
269 return (
270 <Box flexDirection="column" paddingLeft={ctx.wide ? 2 : 0} width={SIDE_W}>
271 <Text>
272 <Text bold>{t.moves} </Text>
273 <Text color={g.moves <= 5 ? '#ff5f5f' : '#ffffff'} bold>{String(g.moves)}</Text>
274 <Text bold> {t.score} </Text>
275 <Text color="#ffd75f" bold>{String(g.score)}</Text>
276 </Text>
277 <Text color="#ffd75f">{stars(starsOf(g))} <Text dimColor>({def.stars[1]} / {def.stars[2]})</Text></Text>
278 <Text bold>{t.goal}</Text>
279 {goalText(g).map(x => (
280 <Text color={x.done ? '#5fd75f' : x.color}>{x.done ? ' ✔ ' : ' • '}{x.text}</Text>
281 ))}
282 <Text> </Text>
283 <Text color={won ? '#5fd75f' : lost ? '#ff5f5f' : '#e5c07b'} wrap="wrap">{g.msg || ' '}</Text>
284 {won && g.lvl < LAST && <Text color="#5fd75f">{t.nextLevel(g.lvl + 2)}</Text>}
285 {won && g.lvl === LAST && <Text color="#5fd75f" bold>{t.allCleared(LEVELS.length)}</Text>}
286 <Box flexDirection="row" gap={1}>
287 {won && (
288 <Button key="next" variant="primary" label={g.lvl < LAST ? t.next : t.again} onPress={() => { proceed(ctx); redraw(surface) }} />
289 )}
290 {lost && <Button key="retry" variant="primary" label={t.retry} onPress={() => { proceed(ctx); redraw(surface) }} />}
291 {!won && !lost && <Button key="hint" label={t.hint} onPress={() => { useHint(g); redraw(surface) }} />}
292 {!won && !lost && <Button key="restart" label={t.restart} onPress={() => { start(ctx, g.lvl); redraw(surface) }} />}
293 </Box>
294 <Text> </Text>
295 {t.help.map(line => <Text dimColor>{line}</Text>)}
296 <Text> </Text>
297 {t.legend.map(line => <Text dimColor wrap="truncate-end">{line}</Text>)}
298 </Box>
299 )
300}
301
302const Match3: ClientModule<GameProps, S> = (props, surface) => {
303 let s = surface.state
304 if (!s) {
305 const lang = props.lang ?? 'en'
306 const ctx: Ctx = { g: newGame(props.launch?.level ?? 0, lang), lang, launchNonce: props.launch?.nonce ?? 0, down: -1, wide: true, advanceIn: 0, isKeyboard: false }
307 s = { ctx, v: 0 }
308 surface.setState(s)
309 surface.every(90, () => onTick(surface))
310 surface.onKey(k => onKey(surface, k))
311 surface.onPointer(p => onPointer(surface, p))
312 }
313 const ctx = s.ctx
314 // An instance kept from an earlier version of this module may hold no game.
315 if (!ctx.g) start(ctx, props.launch?.level ?? 0)
316 if (props.lang && props.lang !== ctx.lang) {
317 // A language change reaches the running game's texts at once.
318 ctx.lang = props.lang
319 ctx.g.lang = props.lang
320 }
321 if (props.launch && props.launch.nonce !== ctx.launchNonce) {
322 ctx.launchNonce = props.launch.nonce
323 start(ctx, props.launch.level)
324 }
325
326 const { Box, Text } = surface.elements
327 const g = ctx.g
328 ctx.wide = surface.columns === 0 || surface.columns >= g.C * CELL_W + SIDE_W
329 return (
330 <Box flexDirection="column">
331 <Text bold color="#d787ff" wrap="truncate-end">
332 {TEXTS[ctx.lang].level} {g.lvl + 1}/{LEVELS.length}: {levelText(g.lvl, ctx.lang).name} <Text dimColor>{levelText(g.lvl, ctx.lang).blurb}</Text>
333 </Text>
334 <Box flexDirection={ctx.wide ? 'row' : 'column'}>
335 {Board(surface, g, ctx.isKeyboard)}
336 {Side(surface, ctx)}
337 </Box>
338 </Box>
339 )
340}
341
342export default Match3
343hooks/engine.ts 685 lines1// Match-3 rules: board, matches, specials, combos, gravity, goals.
2// Pure data in, data out, so the surface module and the tests share it.
3
4import { TEXTS } from './i18n'
5import type { Lang, Texts } from './i18n'
6
7export type Kind = 'n' | 'row' | 'col' | 'bomb' | 'rainbow' | 'treasure'
8export type Gem = { c: number; k: Kind; id: number }
9export type Cell = { hole: boolean; stone: number; lock: boolean; ice: number; gem: Gem | null }
10
11export type Goal =
12 | { type: 'score'; target: number }
13 | { type: 'ice' }
14 | { type: 'stone' }
15 | { type: 'collect'; colors: { c: number; n: number }[] }
16 | { type: 'treasure'; n: number }
17
18export type LevelDef = {
19 moves: number
20 layout: string[]
21 goals: Goal[]
22 // Score for 1, 2 and 3 stars; the first star also needs every goal met.
23 stars: [number, number, number]
24}
25
26export type Phase = 'idle' | 'swap' | 'unswap' | 'flash' | 'fall' | 'won' | 'lost'
27
28export type Game = {
29 lvl: number
30 lang: Lang
31 R: number
32 C: number
33 cells: Cell[]
34 moves: number
35 score: number
36 cascade: number
37 collected: number[]
38 treasures: number
39 phase: Phase
40 timer: number
41 flash: number[]
42 created: { i: number; gem: Gem }[]
43 swap: [number, number] | null
44 // What the swap under way turns out to be once its animation ends.
45 swapKind: 'match' | 'combo' | 'none'
46 cursor: number
47 sel: number
48 hint: [number, number] | null
49 msg: string
50 msgTicks: number
51 nextId: number
52 reported: boolean
53}
54
55export const COLORS = ['#ff5f5f', '#ffd75f', '#5fd75f', '#5fafff', '#d787ff']
56export const GLYPHS = ['●', '★', '▲', '◆', '■']
57
58// Layout legend: . gem # hole i/I ice (1/2 layers) s/S stone (1/2 hits)
59// L locked gem T treasure
60export const LEVELS: LevelDef[] = [
61 {
62 moves: 20,
63 layout: ['........', '........', '........', '........', '........', '........', '........', '........'],
64 goals: [{ type: 'score', target: 4000 }],
65 stars: [4000, 6500, 9000],
66 },
67 {
68 moves: 26,
69 layout: [
70 '.........',
71 '.........',
72 '..iiiii..',
73 '.iiIIIii.',
74 '.iiIIIii.',
75 '.iiIIIii.',
76 '..iiiii..',
77 '.........',
78 '.........',
79 ],
80 goals: [{ type: 'ice' }],
81 stars: [0, 6000, 9500],
82 },
83 {
84 moves: 28,
85 layout: [
86 '#.......#',
87 '.........',
88 '.s..s..s.',
89 '.........',
90 'sSs.s.sSs',
91 '.........',
92 '..s.S.s..',
93 '.........',
94 '#.......#',
95 ],
96 goals: [{ type: 'stone' }],
97 stars: [0, 6500, 10000],
98 },
99 {
100 moves: 24,
101 layout: [
102 '.........',
103 '#.......#',
104 '##.....##',
105 '###...###',
106 '###...###',
107 '##.....##',
108 '#.......#',
109 '.........',
110 '.........',
111 ],
112 goals: [{ type: 'collect', colors: [{ c: 0, n: 25 }, { c: 3, n: 25 }] }],
113 stars: [0, 5500, 8500],
114 },
115 {
116 moves: 30,
117 layout: [
118 '.T.....T.',
119 '.........',
120 '.........',
121 'LL.LLL.LL',
122 '.........',
123 '..L...L..',
124 '.........',
125 '.........',
126 '#.......#',
127 ],
128 goals: [{ type: 'treasure', n: 3 }],
129 stars: [0, 6000, 9000],
130 },
131]
132
133const tx = (g: Game): Texts => TEXTS[g.lang]
134
135export const levelText = (lvl: number, lang: Lang) => TEXTS[lang].levels[lvl]
136
137const rnd = (n: number) => Math.floor(Math.random() * n)
138
139export const rc = (g: Game, i: number) => [Math.floor(i / g.C), i % g.C] as const
140export const at = (g: Game, r: number, c: number) =>
141 r >= 0 && r < g.R && c >= 0 && c < g.C ? r * g.C + c : -1
142
143export function isSpecial(gem: Gem | null): boolean {
144 return gem !== null && gem.k !== 'n' && gem.k !== 'treasure'
145}
146
147export function movable(g: Game, i: number): boolean {
148 const cell = g.cells[i]
149 return !!cell && !cell.hole && cell.stone === 0 && !cell.lock && cell.gem !== null
150}
151
152function colorAt(g: Game, i: number): number {
153 if (i < 0) return -1
154 const cell = g.cells[i]
155 if (cell.hole || cell.stone > 0 || !cell.gem) return -1
156 const { k, c } = cell.gem
157 return k === 'rainbow' || k === 'treasure' ? -1 : c
158}
159
160function gem(g: Game, c: number, k: Kind = 'n'): Gem {
161 return { c, k, id: g.nextId++ }
162}
163
164// ---------- setup ----------
165
166export function newGame(lvl: number, lang: Lang = 'en'): Game {
167 const def = LEVELS[lvl]
168 const R = def.layout.length
169 const C = def.layout[0].length
170 const g: Game = {
171 lvl, lang, R, C, cells: [], moves: def.moves, score: 0, cascade: 1,
172 collected: [0, 0, 0, 0, 0], treasures: 0, phase: 'idle', timer: 0,
173 flash: [], created: [], swap: null, swapKind: 'none', cursor: Math.floor(R / 2) * C + Math.floor(C / 2),
174 sel: -1, hint: null, msg: TEXTS[lang].levels[lvl].blurb, msgTicks: 60, nextId: 1, reported: false,
175 }
176 for (const row of def.layout) {
177 for (const ch of row) {
178 g.cells.push({
179 hole: ch === '#',
180 stone: ch === 's' ? 1 : ch === 'S' ? 2 : 0,
181 lock: ch === 'L',
182 ice: ch === 'i' ? 1 : ch === 'I' ? 2 : 0,
183 gem: ch === 'T' ? { c: -1, k: 'treasure', id: 0 } : null,
184 })
185 }
186 }
187 for (const cell of g.cells) if (cell.gem) cell.gem.id = g.nextId++
188 fillFresh(g)
189 return g
190}
191
192// Colors every empty gem cell so the start has no match but at least one move.
193function fillFresh(g: Game) {
194 const empties = g.cells.flatMap((cell, i) =>
195 !cell.hole && cell.stone === 0 && (!cell.gem || cell.gem.k === 'n') ? [i] : [])
196 for (let tries = 0; tries < 300; tries++) {
197 for (const i of empties) g.cells[i].gem = null
198 for (const i of empties) {
199 const [r, c] = rc(g, i)
200 const banned = new Set<number>()
201 const l1 = colorAt(g, at(g, r, c - 1))
202 if (l1 >= 0 && l1 === colorAt(g, at(g, r, c - 2))) banned.add(l1)
203 const u1 = colorAt(g, at(g, r - 1, c))
204 if (u1 >= 0 && u1 === colorAt(g, at(g, r - 2, c))) banned.add(u1)
205 const options = [0, 1, 2, 3, 4].filter(x => !banned.has(x))
206 g.cells[i].gem = gem(g, options[rnd(options.length)])
207 }
208 if (findRuns(g).length === 0 && findMove(g)) return
209 }
210}
211
212// ---------- matching ----------
213
214type Run = { cells: number[]; dir: 'h' | 'v' }
215
216export function findRuns(g: Game): Run[] {
217 const runs: Run[] = []
218 const scan = (outer: number, inner: number, idx: (o: number, n: number) => number, dir: 'h' | 'v') => {
219 for (let o = 0; o < outer; o++) {
220 let n = 0
221 while (n < inner) {
222 const c = colorAt(g, idx(o, n))
223 let m = n + 1
224 while (c >= 0 && m < inner && colorAt(g, idx(o, m)) === c) m++
225 if (c >= 0 && m - n >= 3) {
226 const cells: number[] = []
227 for (let k = n; k < m; k++) cells.push(idx(o, k))
228 runs.push({ cells, dir })
229 }
230 n = m
231 }
232 }
233 }
234 scan(g.R, g.C, (r, c) => r * g.C + c, 'h')
235 scan(g.C, g.R, (c, r) => r * g.C + c, 'v')
236 return runs
237}
238
239function hasMatchAt(g: Game, i: number): boolean {
240 const c = colorAt(g, i)
241 if (c < 0) return false
242 const [r, col] = rc(g, i)
243 const count = (dr: number, dc: number) => {
244 let n = 0
245 let rr = r + dr
246 let cc = col + dc
247 while (colorAt(g, at(g, rr, cc)) === c) { n++; rr += dr; cc += dc }
248 return n
249 }
250 return count(0, -1) + count(0, 1) >= 2 || count(-1, 0) + count(1, 0) >= 2
251}
252
253function swapGems(g: Game, a: number, b: number) {
254 const t = g.cells[a].gem
255 g.cells[a].gem = g.cells[b].gem
256 g.cells[b].gem = t
257}
258
259function isCombo(g: Game, a: number, b: number): boolean {
260 const ga = g.cells[a].gem
261 const gb = g.cells[b].gem
262 if (!ga || !gb || ga.k === 'treasure' || gb.k === 'treasure') return false
263 return (isSpecial(ga) && isSpecial(gb)) || ga.k === 'rainbow' || gb.k === 'rainbow'
264}
265
266export function findMove(g: Game): [number, number] | null {
267 for (let i = 0; i < g.cells.length; i++) {
268 if (!movable(g, i)) continue
269 const [r, c] = rc(g, i)
270 for (const j of [at(g, r, c + 1), at(g, r + 1, c)]) {
271 if (j < 0 || !movable(g, j)) continue
272 if (isCombo(g, i, j)) return [i, j]
273 swapGems(g, i, j)
274 const ok = hasMatchAt(g, i) || hasMatchAt(g, j)
275 swapGems(g, i, j)
276 if (ok) return [i, j]
277 }
278 }
279 return null
280}
281
282// Groups touching runs into shapes and decides which special each one makes:
283// 5 in a line → rainbow, L/T → bomb, 4 in a line → row/column rocket.
284export function analyze(g: Game, prefer: number[]) {
285 const runs = findRuns(g)
286 const parent = runs.map((_, k) => k)
287 const find = (k: number): number => (parent[k] === k ? k : (parent[k] = find(parent[k])))
288 const owner = new Map<number, number>()
289 runs.forEach((run, k) => {
290 for (const i of run.cells) {
291 const other = owner.get(i)
292 if (other === undefined) owner.set(i, k)
293 else parent[find(k)] = find(other)
294 }
295 })
296 const groups = new Map<number, Run[]>()
297 runs.forEach((run, k) => {
298 const root = find(k)
299 groups.set(root, [...(groups.get(root) ?? []), run])
300 })
301
302 const clear = new Set<number>()
303 const created: { i: number; gem: Gem }[] = []
304 for (const group of groups.values()) {
305 const cells = new Set(group.flatMap(run => run.cells))
306 for (const i of cells) clear.add(i)
307 const color = colorAt(g, group[0].cells[0])
308 const longest = group.reduce((a, b) => (b.cells.length > a.cells.length ? b : a))
309 const hasH = group.some(run => run.dir === 'h')
310 const hasV = group.some(run => run.dir === 'v')
311 let kind: Kind | null = null
312 if (longest.cells.length >= 5) kind = 'rainbow'
313 else if (hasH && hasV) kind = 'bomb'
314 else if (longest.cells.length === 4) kind = longest.dir === 'h' ? 'row' : 'col'
315 if (!kind) continue
316 let pos = prefer.find(p => cells.has(p))
317 if (pos === undefined && kind === 'bomb') {
318 const hCells = new Set(group.filter(run => run.dir === 'h').flatMap(run => run.cells))
319 pos = group.filter(run => run.dir === 'v').flatMap(run => run.cells).find(i => hCells.has(i))
320 }
321 if (pos === undefined) pos = longest.cells[Math.floor(longest.cells.length / 2)]
322 created.push({ i: pos, gem: gem(g, kind === 'rainbow' ? -1 : color, kind) })
323 }
324 return { clear, created }
325}
326
327// ---------- clearing ----------
328
329function mostCommonColor(g: Game): number {
330 const counts = [0, 0, 0, 0, 0]
331 for (const cell of g.cells) if (cell.gem && cell.gem.k !== 'rainbow' && cell.gem.c >= 0) counts[cell.gem.c]++
332 return counts.indexOf(Math.max(...counts))
333}
334
335function allOfColor(g: Game, c: number): number[] {
336 return g.cells.flatMap((cell, i) => (cell.gem && cell.gem.c === c && cell.gem.k !== 'treasure' ? [i] : []))
337}
338
339function area(g: Game, i: number, radius: number): number[] {
340 const [r, c] = rc(g, i)
341 const out: number[] = []
342 for (let dr = -radius; dr <= radius; dr++)
343 for (let dc = -radius; dc <= radius; dc++) {
344 const j = at(g, r + dr, c + dc)
345 if (j >= 0) out.push(j)
346 }
347 return out
348}
349
350const rowOf = (g: Game, i: number) => Array.from({ length: g.C }, (_, c) => rc(g, i)[0] * g.C + c)
351const colOf = (g: Game, i: number) => Array.from({ length: g.R }, (_, r) => r * g.C + rc(g, i)[1])
352
353// Grows a clear set through every special it touches: chain reactions.
354export function expand(g: Game, initial: Iterable<number>, triggered: Set<number>): Set<number> {
355 const out = new Set<number>()
356 const queue = [...initial]
357 while (queue.length) {
358 const i = queue.pop()!
359 if (out.has(i) || g.cells[i].hole) continue
360 out.add(i)
361 const cell = g.cells[i]
362 const gm = cell.gem
363 if (!gm || cell.lock || !isSpecial(gm) || triggered.has(i)) continue
364 triggered.add(i)
365 if (gm.k === 'row') queue.push(...rowOf(g, i))
366 else if (gm.k === 'col') queue.push(...colOf(g, i))
367 else if (gm.k === 'bomb') queue.push(...area(g, i, 1))
368 else if (gm.k === 'rainbow') queue.push(...allOfColor(g, mostCommonColor(g)))
369 }
370 return out
371}
372
373// Two specials swapped together (or a rainbow with anything): the big effects.
374export function comboClear(g: Game, a: number, b: number): { cells: Set<number>; msg: string } {
375 const ga = g.cells[a].gem!
376 const gb = g.cells[b].gem!
377 const triggered = new Set([a, b])
378 const rocket = (x: Gem) => x.k === 'row' || x.k === 'col'
379 let start: number[] = [a, b]
380 let msg = ''
381
382 if (ga.k === 'rainbow' && gb.k === 'rainbow') {
383 start = g.cells.map((_, i) => i)
384 msg = tx(g).comboAll
385 } else if (ga.k === 'rainbow' || gb.k === 'rainbow') {
386 const other = ga.k === 'rainbow' ? gb : ga
387 const targets = allOfColor(g, other.c)
388 if (other.k === 'n') {
389 msg = tx(g).comboColor(tx(g).colors[other.c])
390 } else {
391 for (const i of targets) {
392 if (g.cells[i].lock) continue
393 const k: Kind = other.k === 'bomb' ? 'bomb' : rnd(2) ? 'row' : 'col'
394 g.cells[i].gem = { ...g.cells[i].gem!, k }
395 }
396 // The swapped special itself fires as converted, the rainbow is spent.
397 triggered.delete(ga.k === 'rainbow' ? b : a)
398 msg = other.k === 'bomb' ? tx(g).comboBombs : tx(g).comboRockets
399 }
400 start = [a, b, ...targets]
401 } else if (rocket(ga) && rocket(gb)) {
402 start = [...rowOf(g, b), ...colOf(g, b)]
403 msg = tx(g).comboCross
404 } else if (ga.k === 'bomb' && gb.k === 'bomb') {
405 start = area(g, b, 2)
406 msg = tx(g).comboBigBlast
407 } else {
408 const [r, c] = rc(g, b)
409 for (let d = -1; d <= 1; d++) {
410 if (r + d >= 0 && r + d < g.R) start.push(...rowOf(g, at(g, r + d, 0)))
411 if (c + d >= 0 && c + d < g.C) start.push(...colOf(g, at(g, 0, c + d)))
412 }
413 msg = tx(g).comboBigCross
414 }
415 return { cells: expand(g, start, triggered), msg }
416}
417
418function applyClear(g: Game) {
419 const stoneHits = new Set<number>()
420 for (const i of g.flash) {
421 const cell = g.cells[i]
422 if (cell.stone > 0) { stoneHits.add(i); continue }
423 if (cell.lock) { cell.lock = false; g.score += 30; continue }
424 if (cell.gem && cell.gem.k !== 'treasure') {
425 if (cell.gem.c >= 0) g.collected[cell.gem.c]++
426 g.score += 20 * g.cascade
427 cell.gem = null
428 const [r, c] = rc(g, i)
429 for (const j of [at(g, r - 1, c), at(g, r + 1, c), at(g, r, c - 1), at(g, r, c + 1)])
430 if (j >= 0 && g.cells[j].stone > 0) stoneHits.add(j)
431 }
432 if (cell.ice > 0) { cell.ice--; g.score += 40 }
433 }
434 for (const i of stoneHits) { g.cells[i].stone--; g.score += 60 }
435 for (const { i, gem: made } of g.created) {
436 g.cells[i].gem = made
437 g.cells[i].lock = false
438 g.score += made.k === 'rainbow' ? 400 : made.k === 'bomb' ? 200 : 120
439 }
440 g.flash = []
441 g.created = []
442}
443
444// ---------- gravity ----------
445
446function columnSegments(g: Game, c: number): number[][] {
447 const segs: number[][] = []
448 let cur: number[] = []
449 for (let r = 0; r < g.R; r++) {
450 const i = r * g.C + c
451 const cell = g.cells[i]
452 if (cell.hole) continue
453 if (cell.stone > 0 || cell.lock) {
454 if (cur.length) segs.push(cur)
455 cur = []
456 } else cur.push(i)
457 }
458 if (cur.length) segs.push(cur)
459 return segs
460}
461
462function treasureGoal(g: Game): number {
463 const goal = LEVELS[g.lvl].goals.find(x => x.type === 'treasure')
464 return goal && goal.type === 'treasure' ? goal.n : 0
465}
466
467// One step of falling: every gem above a gap moves down one cell, tops refill.
468function fallStep(g: Game): boolean {
469 let moved = false
470 const needed = treasureGoal(g)
471 for (let c = 0; c < g.C; c++) {
472 columnSegments(g, c).forEach((seg, s) => {
473 for (let j = seg.length - 1; j > 0; j--) {
474 if (!g.cells[seg[j]].gem && g.cells[seg[j - 1]].gem) {
475 g.cells[seg[j]].gem = g.cells[seg[j - 1]].gem
476 g.cells[seg[j - 1]].gem = null
477 moved = true
478 }
479 }
480 if (!g.cells[seg[0]].gem) {
481 const onBoard = g.cells.filter(cell => cell.gem?.k === 'treasure').length
482 const wantTreasure = s === 0 && needed - g.treasures - onBoard > 0 && onBoard < 2 && Math.random() < 0.25
483 g.cells[seg[0]].gem = wantTreasure ? gem(g, -1, 'treasure') : gem(g, rnd(5))
484 moved = true
485 }
486 })
487 // A treasure in the column's lowest cell is collected.
488 for (let r = g.R - 1; r >= 0; r--) {
489 const cell = g.cells[r * g.C + c]
490 if (cell.hole) continue
491 if (cell.gem?.k === 'treasure') {
492 cell.gem = null
493 g.treasures++
494 g.score += 500
495 say(g, tx(g).treasure(g.treasures, needed))
496 moved = true
497 }
498 break
499 }
500 }
501 return moved
502}
503
504function shuffle(g: Game) {
505 const idx = g.cells.flatMap((cell, i) => (movable(g, i) && cell.gem!.k === 'n' ? [i] : []))
506 for (let tries = 0; tries < 300; tries++) {
507 for (const i of idx) g.cells[i].gem!.c = rnd(5)
508 if (findRuns(g).length === 0 && findMove(g)) break
509 }
510 say(g, tx(g).shuffled)
511}
512
513// ---------- goals ----------
514
515export function goalText(g: Game): { text: string; done: boolean; color?: string }[] {
516 return LEVELS[g.lvl].goals.flatMap(goal => {
517 switch (goal.type) {
518 case 'score':
519 return [{ text: tx(g).goalScore(g.score, goal.target), done: g.score >= goal.target }]
520 case 'ice': {
521 const n = g.cells.reduce((s, cell) => s + cell.ice, 0)
522 return [{ text: tx(g).goalIce(n), done: n === 0, color: '#8fd3ff' }]
523 }
524 case 'stone': {
525 const n = g.cells.filter(cell => cell.stone > 0).length
526 return [{ text: tx(g).goalStone(n), done: n === 0, color: '#bbbbbb' }]
527 }
528 case 'collect':
529 return goal.colors.map(({ c, n }) => ({
530 text: `${GLYPHS[c]} ${tx(g).colors[c]} ${Math.min(g.collected[c], n)}/${n}`,
531 done: g.collected[c] >= n,
532 color: COLORS[c],
533 }))
534 case 'treasure':
535 return [{ text: tx(g).goalTreasure(g.treasures, goal.n), done: g.treasures >= goal.n, color: '#ffcc33' }]
536 }
537 })
538}
539
540export function goalsMet(g: Game): boolean {
541 return goalText(g).every(x => x.done)
542}
543
544export function starsOf(g: Game): number {
545 if (!goalsMet(g)) return 0
546 const [, two, three] = LEVELS[g.lvl].stars
547 return g.score >= three ? 3 : g.score >= two ? 2 : 1
548}
549
550// ---------- turn flow ----------
551
552export function say(g: Game, msg: string, ticks = 25) {
553 g.msg = msg
554 g.msgTicks = ticks
555}
556
557export function adjacent(g: Game, a: number, b: number): boolean {
558 const [ra, ca] = rc(g, a)
559 const [rb, cb] = rc(g, b)
560 return Math.abs(ra - rb) + Math.abs(ca - cb) === 1
561}
562
563// Ticks a swap's slide takes, each way.
564export const SWAP_FRAMES = 3
565
566// The player swaps a and b; answers whether the swap was taken.
567export function trySwap(g: Game, a: number, b: number): boolean {
568 if (g.phase !== 'idle' || a < 0 || b < 0 || !adjacent(g, a, b)) return false
569 if (!movable(g, a) || !movable(g, b)) {
570 say(g, tx(g).cantMove, 12)
571 return false
572 }
573 g.hint = null
574 g.sel = -1
575 g.cascade = 1
576 if (isCombo(g, a, b)) g.swapKind = 'combo'
577 else {
578 swapGems(g, a, b)
579 g.swapKind = hasMatchAt(g, a) || hasMatchAt(g, b) ? 'match' : 'none'
580 swapGems(g, a, b)
581 }
582 if (g.swapKind !== 'none') g.moves--
583 // The gems slide for SWAP_FRAMES ticks; tick() commits the swap after.
584 g.swap = [a, b]
585 g.phase = 'swap'
586 g.timer = SWAP_FRAMES
587 return true
588}
589
590function resolve(g: Game, prefer: number[]): boolean {
591 const { clear, created } = analyze(g, prefer)
592 if (clear.size === 0) return false
593 const cells = expand(g, clear, new Set())
594 g.flash = [...cells]
595 g.created = created
596 const best = created.reduce<Kind | null>((k, x) =>
597 x.gem.k === 'rainbow' || k === 'rainbow' ? 'rainbow' : x.gem.k === 'bomb' || k === 'bomb' ? 'bomb' : x.gem.k, null)
598 if (best === 'rainbow') say(g, tx(g).madeRainbow)
599 else if (best === 'bomb') say(g, tx(g).madeBomb)
600 else if (best) say(g, tx(g).madeRocket)
601 else if (g.cascade >= 3) say(g, tx(g).cascade(g.cascade), 15)
602 g.phase = 'flash'
603 g.timer = 3
604 return true
605}
606
607function endOfMove(g: Game) {
608 g.cascade = 1
609 const isScoreLevel = LEVELS[g.lvl].goals.some(x => x.type === 'score')
610 if (goalsMet(g) && (!isScoreLevel || g.moves <= 0)) {
611 const bonus = g.moves * 150
612 g.score += bonus
613 g.phase = 'won'
614 say(g, bonus ? tx(g).wonBonus(bonus) : tx(g).won, 1e9)
615 return
616 }
617 if (g.moves <= 0) {
618 g.phase = 'lost'
619 say(g, tx(g).lost, 1e9)
620 return
621 }
622 if (!findMove(g)) shuffle(g)
623 g.phase = 'idle'
624}
625
626// One animation tick; answers whether anything visible changed.
627export function tick(g: Game): boolean {
628 let changed = false
629 if (g.msgTicks > 0 && --g.msgTicks === 0) {
630 g.msg = ''
631 changed = true
632 }
633 switch (g.phase) {
634 case 'idle':
635 case 'won':
636 case 'lost':
637 return changed
638 case 'swap': {
639 if (--g.timer > 0) return true
640 const [a, b] = g.swap!
641 swapGems(g, a, b)
642 if (g.swapKind === 'none') {
643 // Slide back.
644 g.phase = 'unswap'
645 g.timer = SWAP_FRAMES
646 return true
647 }
648 g.swap = null
649 if (g.swapKind === 'combo') {
650 const { cells, msg } = comboClear(g, a, b)
651 say(g, msg)
652 g.flash = [...cells]
653 g.created = []
654 g.phase = 'flash'
655 g.timer = 4
656 } else resolve(g, [a, b])
657 return true
658 }
659 case 'unswap':
660 if (--g.timer > 0) return true
661 if (g.swap) swapGems(g, g.swap[0], g.swap[1])
662 g.swap = null
663 g.phase = 'idle'
664 say(g, tx(g).noMatch, 12)
665 return true
666 case 'flash':
667 if (--g.timer > 0) return true
668 applyClear(g)
669 g.phase = 'fall'
670 return true
671 case 'fall':
672 if (fallStep(g)) return true
673 g.cascade++
674 if (!resolve(g, [])) endOfMove(g)
675 return true
676 }
677}
678
679export function useHint(g: Game) {
680 if (g.phase !== 'idle') return
681 g.hint = findMove(g)
682 g.score = Math.max(0, g.score - 50)
683 say(g, tx(g).hintUsed, 15)
684}
685types/index.d.ts 11 lines1// `level` is the level the player is on (0-based); `best` the best result per won level.
2export type Progress = { level: number; best: Record<string, { score: number; stars: number }> }
3export type Launch = { level: number; nonce: number }
4export type GameProps = { launch: Launch; lang: 'en' | 'de' }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'match-3': { progress: Progress; launch: Launch }
9 }
10}
11