SLOPSHOPPER

match-3

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

newpanecommandstatus
v1.1.0MITupdated 2026-10-07nachtgold/claude-code-match-3
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · match-3
│ ┃ Match-3 ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./game.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /match3 │ ⎿ match-3: Match-3 opened: level 1. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Match-3
▣ client module ./game.tsx
README

Match-3 for Claude Code

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.

Match-3 in a Claude Code pane: level 3, Quarry, with stones, a rocket, a bomb and a prism on the board

Install

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.

Play

  • /match3 opens the game and resumes the level you are on.
  • /match3 new starts again at level 1.
  • Mouse: click two neighbouring gems, or drag a gem onto its neighbour.
  • Keyboard (click into the board first): arrows move the cursor, Enter picks a gem, an arrow swaps it. 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.

Gems and specials

Five colors: ● red, ★ yellow, ▲ green, ◆ blue, ■ purple.

MatchCreatesEffect
4 in a row↔ / ↕ rocketclears its whole row or column
L or T shape✹ bombblasts the 3×3 area around it
5 in a row✦ prismswap 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.

Combos: swap two specials

SwapEffect
rocket + rocketa full row and column cross
rocket + bomba cross three rows and three columns wide
bomb + bomba 5×5 blast
prism + rocket / bombevery gem of that color turns into that special and fires
prism + prismclears the whole board

Levels

#LevelGoalMoves
1Meadowopen 8×8 board, reach 4000 points20
2Ice Cellarbreak all the ice, some of it two layers thick26
3Quarrysmash every stone (some take two hits) on a board with holes28
4Hourglasscollect 25 red and 25 blue through a three-column waist24
5Treasure Vaultbring 3 treasures ($) to the bottom past chained gems30

Each level awards one to three stars by score, and moves left over count as a bonus.

Language

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.

Where it runs

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.

What it touches

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.

Develop

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

License

MIT © nachtgold

Source 5 files
hooks/register.tsx 116 lines
1import { 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}
116
hooks/i18n.ts 130 lines
1// 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}
130
hooks/game.tsx 343 lines
1import 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
343
hooks/engine.ts 685 lines
1// 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}
685
types/index.d.ts 11 lines
1// `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