SLOPSHOPPER

auto-tetris

Draws an auto-playing Tetris in a docked panel, moved only by the tokens the session writes. No one controls it

newpaneguardcommandtimer
v0.1.0MITupdated 2026-10-06YeomYuJun/claude-mods/auto-tetris
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-tetris
│ ┃ TETRIS ✕ › fix the failing auth test and add an audit log call │ ┃ ┌────────────────────┐ │ ┃ │ ▀▀▀▀▀▀▀▀ │ ⏺ 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 │ ┃ │ ┃ › /tetris │ ┃ ⎿ auto-tetris: Opened the Tetris panel │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TETRIS
┌────────────────────┐ │ ▀▀▀▀▀▀▀▀ │ │ │ │ │ │
README

English | 한국어

auto-tetris

Draws an auto-playing Tetris, moved only by the tokens the session writes, in the /tetris docked panel. No one controls it (the panel has no buttons or keys).

The in-game labels are in English. The size notice and command replies are in English or Korean. See Language.

Install

claude plugin marketplace add YeomYuJun/claude-mods
claude plugin install auto-tetris@claude-mods

The full steps are in the repository README.

/tetris opens and closes the panel. In the fullscreen layout it docks next to the transcript at 24 columns wide. If it is smaller than 24 columns × 22 rows, it shows the required size.

It works only in the fullscreen renderer (/tui fullscreen). In the classic renderer (/tui default) the panel opens above the input box, but it is too small and shows only a size notice.

Features

Rules

What happens in the sessionIn the game
12 output tokens (text, thinking, tool arguments)One move: rotate once, shift one cell, or drop one cell
The model starts a tool callThe current piece drops to its planned spot
NothingIt stops. Time alone does not move pieces down
  • Responses from subagents and team members move the same game.
  • Falling speed follows token speed.
  • The game keeps going while the panel is closed.

AI skill

An AI makes the moves. Its skill follows the tier of the model writing the response (haiku, sonnet, opus, fable).

  • haiku plays with a simple evaluation and makes frequent mistakes.
  • sonnet and up evaluate the board with more features.
  • opus and fable look two moves ahead, through the next piece.
  • Each tier has its own evaluation noise, judgment error, and execution error (one column off from the target) rates (TIERS in hooks/game/ai.ts).

Screen

  • The board, and the AI's target landing spot marked with ░
  • NEXT, LINES, SCORE, COMBO, PIECES, GAME, AI (current tier)
  • Output tokens, input tokens, and a recent token speed bar
  • Session state: streaming, thinking, tool call, waiting, idle
  • Clearing lines shows SINGLE to TETRIS and COMBO xN

Language

The size notice, the /tetris description and the command replies follow the plugin's language option: auto (default), en or ko. Change it in /config; the plugin reloads with the new value. The labels drawn inside the game (NEXT, SCORE and the state words) stay in English in both, because the board raster takes only one-column characters.

auto takes the first of these that names Korean or English, and falls back to English:

  1. A language key in the settings files, if one is set ("korean", "english"). The Claude Code settings reference does not list this key
  2. The LC_ALL environment variable
  3. The LANG environment variable (ko_KR.UTF-8)

On Windows LC_ALL and LANG are usually unset, so without such a key the plugin shows English. A change to the settings key takes effect in the next session.

Development

claude plugin test "<repo-path>/auto-tetris"
claude plugin validate "<repo-path>/auto-tetris"

hooks/game, hooks/meter, and hooks/frame are pure modules that do not use $.

Source 20 files
hooks/register.ts 215 lines
1import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
2
3import Frame from './frame'
4import Game from './game'
5import Messages, { type Language } from './messages'
6import Meter from './meter'
7import Names from './names'
8import Queue from './queue'
9import View from './view'
10
11type Host = {
12  now: () => Promise<number>
13  every: (ms: number, fn: () => void) => Timer
14  blit: (cells: string, cols: number, rows: number) => Promise<{ deny?: string }>
15  invalidate: () => void
16}
17
18// userConfig 의 language 가 auto 면 settings 의 language, LC_ALL, LANG 순으로 본다. $ 는 훅 안에만 있어 session.start 에서 부른다
19async function languageIn($: Pick<EngineInterface, 'settings' | 'env'>, option: unknown): Promise<Language> {
20  if (option === 'en' || option === 'ko') return option
21  const valueOf = async (read: () => Promise<unknown>) => {
22    try {
23      return await read()
24    } catch {
25      return undefined
26    }
27  }
28  return Messages.languageOf(option, [
29    await valueOf(async () => (await $.settings.read()).language),
30    await valueOf(() => $.env.get('LC_ALL')),
31    await valueOf(() => $.env.get('LANG')),
32  ])
33}
34
35// 엔진이 기록한 이 플러그인의 열린 패널. 모듈을 다시 불러와도(언어 변경 등) 패널은 떠 있고 이 기록에 남는다
36async function isPaneUp($: Pick<EngineInterface, 'ui'>, id: string): Promise<boolean> {
37  try {
38    return (await $.ui.panes()).some(pane => pane.id === id)
39  } catch {
40    return false
41  }
42}
43
44export function register(on: On, options: PluginOptions) {
45  let messages = Messages.messagesOf(Messages.languageOf(options.language))
46  let host: Host | undefined
47  let isOpen = false
48  let game = Game.create(0x1b873593)
49  const meter = Meter.create()
50  const queue = Queue.create()
51  let timer: Timer | undefined
52  let mounted: { cols: number; rows: number } | undefined
53  let lastCells = ''
54  let lastAt: number | undefined
55
56  function credit(tokens: number) {
57    Queue.addTokens(queue, tokens, Names.TOKENS_PER_MOVE * Names.MAX_BACKLOG_MOVES)
58  }
59
60  // 큐 머리부터 둔다. 낙하 표시면 떨어뜨리기 시작하고, 토큰이면 이번 프레임 몫만큼 수를 둔다.
61  // 애니메이션(줄 지우기·낙하·게임 오버) 중에는 기다린다
62  function play(dtMs: number, rate: number) {
63    if (Game.isBusy(game)) return
64    if (Queue.takeDrop(queue)) { Game.drop(game); return }
65    const n = Queue.movesFor(queue, rate, Names.TOKENS_PER_MOVE, dtMs)
66    let done = 0
67    while (done < n && !Game.isBusy(game) && Game.move(game)) done++
68    Queue.refund(queue, n - done, Names.TOKENS_PER_MOVE)
69  }
70
71  // 패널이 닫혀 있으면 애니메이션을 기다리지 않고 큐를 순서대로 바로 비운다(보는 사람이 없다)
72  function playClosed() {
73    for (let i = 0; i < 400 && (queue.items.length > 0 || Game.isBusy(game)); i++) {
74      if (Game.isBusy(game)) { Game.advance(game, Game.OVER_MS); continue }
75      const before = Queue.tokensOf(queue) + Queue.dropsOf(queue)
76      play(60_000, 0)
77      if (Queue.tokensOf(queue) + Queue.dropsOf(queue) === before && !Game.isBusy(game)) break
78    }
79  }
80
81  function frameOf(cols: number, rows: number, now: number): string {
82    return Frame.encode(Frame.compose({ game, meter, cols, rows, now }))
83  }
84
85  async function onFrame() {
86    const h = host
87    if (!h || !isOpen || !mounted) return
88    const now = await h.now()
89    const dt = lastAt === undefined ? 0 : Math.min(1000, Math.max(0, now - lastAt))
90    lastAt = now
91    Meter.tick(meter, now)
92    const rate = Meter.rateOf(meter)
93    Game.advance(game, dt, Queue.fallRowsOf(rate))
94    play(dt, rate)
95    const cells = frameOf(mounted.cols, mounted.rows, now)
96    if (cells === lastCells) return
97    lastCells = cells
98    const result = await h.blit(cells, mounted.cols, mounted.rows)
99    // 크기가 바뀌었거나 아직 붙지 않았다: 다시 그려 달라고 한다
100    if (result.deny !== undefined) { lastCells = ''; h.invalidate() }
101  }
102
103  function startFrames() {
104    if (timer || !host) return
105    timer = host.every(Names.FRAME_MS, () => { void onFrame() })
106  }
107
108  function stopFrames() {
109    timer?.cancel()
110    timer = undefined
111    mounted = undefined
112    lastAt = undefined
113  }
114
115  on('session.start', async ($, e, next) => {
116    host = {
117      now: () => $.clock.now(),
118      every: (ms, fn) => $.clock.every(ms, fn),
119      blit: (cells, columns, rows) => $.ui.blit({ requestId: Names.PANE_ID, key: Names.RASTER_KEY, cells, columns, rows }),
120      invalidate: () => $.ui.invalidate('ui.render'),
121    }
122    game = Game.create(((await $.clock.now()) | 0) ^ 0x1b873593)
123    // 첫 응답 전에도 세션 모델의 등급으로 둔다. 모델을 읽지 못하면 기본 등급 그대로
124    try { Game.setTier(game, Game.tierOf(await $.session.model())) } catch { /* 기본 등급 */ }
125    messages = Messages.messagesOf(await languageIn($, options.language))
126    await $.command.register({ name: Names.COMMAND_NAME, description: messages.commandDescription })
127    // 다시 불러온 모듈은 패널을 닫힌 것으로 안다. 패널이 떠 있으면 열린 상태로 되돌리고 다시 그려 프레임을 잇는다
128    if (await isPaneUp($, Names.PANE_ID)) {
129      isOpen = true
130      Meter.resetRate(meter)
131      $.ui.invalidate('ui.render')
132    }
133    return next(e)
134  })
135
136  on('command.run', { command: Names.COMMAND_NAME }, async ($, e) => {
137    if (isOpen) {
138      await $.ui.close({ id: Names.PANE_ID })
139      isOpen = false
140      stopFrames()
141      return { text: messages.panelHidden }
142    }
143    const docked = e.presentation?.isFullscreen === true
144    await $.ui.open(docked
145      ? { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true, columns: Names.COLS }
146      : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true, rows: Names.INLINE_ROWS })
147    isOpen = true
148    Meter.resetRate(meter)
149    return { text: messages.panelShown }
150  })
151
152  on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
153    const result = await next(e)
154    if (result.deny === undefined) { isOpen = false; stopFrames() }
155    return result
156  })
157
158  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
159    if (e.requestId !== Names.PANE_ID) return next(e)
160    const kit = $.ui.resolve(e)
161    const cols = e.props.bodyColumns
162    const rows = e.props.scroll.bodyRows
163    if (!('Raster' in kit)) {
164      mounted = undefined
165      return View.noticeView(kit, [messages.notTerminal])
166    }
167    if (cols < Names.COLS || rows < Names.MIN_ROWS) {
168      mounted = undefined
169      return View.noticeView(kit, messages.tooSmall(Names.COLS, Names.MIN_ROWS, cols, rows))
170    }
171    const cells = frameOf(cols, rows, await $.clock.now())
172    mounted = { cols, rows }
173    lastCells = cells
174    if (isOpen) startFrames()
175    return View.rasterView(kit.Raster, cols, rows, cells)
176  })
177
178  on('turn.start', ($, e, next) => {
179    Meter.onTurn(meter, e.turnId)
180    return next(e)
181  })
182
183  on('turn.step', async function* ($, e, next) {
184    const step = `${e.agentId ?? 'main'}:${e.turnId}:${e.index}`
185    Meter.onTurn(meter, e.turnId)
186    // 이 응답을 쓰는 모델(서브에이전트 포함)의 등급으로 다음 블록부터 둔다
187    Game.setTier(game, Game.tierOf(e.model))
188    Meter.onStepStart(meter, step, await $.clock.now())
189    const stream = next(e)
190    for await (const chunk of stream) {
191      if (chunk.kind === 'text' || chunk.kind === 'thinking') credit(Meter.onText(meter, step, chunk.kind, chunk.text))
192      else if (chunk.kind === 'input') credit(Meter.onInput(meter, step, chunk.json))
193      else if (chunk.kind === 'tool') { Meter.onToolChunk(meter); Queue.addDrop(queue) }
194      else if (chunk.kind === 'stop') credit(Meter.onStop(meter, step, chunk.usage, await $.clock.now()))
195      if (!isOpen) playClosed()
196      yield chunk
197    }
198    return await stream.result
199  })
200
201  on('turn.complete', ($, e, next) => {
202    Meter.onTurnEnd(meter, e.turnId)
203    return next(e)
204  })
205
206  on('tool.call', async ($, e, next) => {
207    Meter.onToolStart(meter)
208    try {
209      return await next(e)
210    } finally {
211      Meter.onToolEnd(meter)
212    }
213  })
214}
215
hooks/frame/index.ts 4 lines
1export * from './grid.js'
2export * from './compose.js'
3export * as default from '.'
4
hooks/game/index.ts 3 lines
1export * from './game.js'
2export * as default from '.'
3
hooks/messages/index.ts 4 lines
1export * from './types.js'
2export * from './language.js'
3export * as default from '.'
4
hooks/meter/index.ts 3 lines
1export * from './meter.js'
2export * as default from '.'
3
hooks/names/index.ts 20 lines
1export const PLUGIN_NAME = 'auto-tetris'
2export const PANE_ID = 'tetris'
3export const COMMAND_NAME = 'tetris'
4export const PANE_TITLE = 'TETRIS'
5export const RASTER_KEY = 'board'
6
7// 고정 가로: 보드 한 칸 = 2열 × 1행, 보드 20열 + 테두리 2열 + 여백 2열
8export const COLS = 24
9// 보드와 테두리까지는 반드시 보여야 한다. 그 아래 정보 줄은 남는 만큼만 그린다
10export const MIN_ROWS = 22
11// 인라인(전체 화면 레이아웃이 아닐 때) 패널이 원하는 높이
12export const INLINE_ROWS = 40
13
14export const FRAME_MS = 33
15export const TOKENS_PER_MOVE = 12
16// 패널이 닫혀 있거나 애니메이션 중에 쌓아 둘 수 있는 수의 상한
17export const MAX_BACKLOG_MOVES = 40
18
19export * as default from '.'
20
hooks/queue/index.ts 3 lines
1export * from './queue.js'
2export * as default from '.'
3
hooks/view/index.ts 3 lines
1export * from './pane-view.jsx'
2export * as default from '.'
3
hooks/frame/grid.ts 69 lines
1// Raster 의 셀 격자: 셀마다 [코드 포인트, 전경, 배경] 세 u32, 행 우선.
2
3export const DEFAULT_COLOR = 0x01000000
4export const SPACE = 0x20
5
6export type Grid = { cols: number; rows: number; words: Uint32Array }
7
8export function gridOf(cols: number, rows: number): Grid {
9  const words = new Uint32Array(cols * rows * 3)
10  for (let i = 0; i < cols * rows; i++) {
11    words[i * 3] = SPACE
12    words[i * 3 + 1] = DEFAULT_COLOR
13    words[i * 3 + 2] = DEFAULT_COLOR
14  }
15  return { cols, rows, words }
16}
17
18export function put(g: Grid, x: number, y: number, ch: number, fg: number, bg: number = DEFAULT_COLOR): void {
19  if (x < 0 || y < 0 || x >= g.cols || y >= g.rows) return
20  const i = (y * g.cols + x) * 3
21  g.words[i] = ch
22  g.words[i + 1] = fg
23  g.words[i + 2] = bg
24}
25
26// 글자는 ASCII 와 폭 1 기호만 쓴다(Raster 는 폭 1 BMP 문자만 받는다)
27export function text(g: Grid, x: number, y: number, s: string, fg: number, bg: number = DEFAULT_COLOR): void {
28  let col = x
29  for (const ch of s) put(g, col++, y, ch.codePointAt(0) ?? SPACE, fg, bg)
30}
31
32export function charAt(g: Grid, x: number, y: number): string {
33  return String.fromCodePoint(g.words[(y * g.cols + x) * 3] ?? SPACE)
34}
35
36export function lineOf(g: Grid, y: number): string {
37  let s = ''
38  for (let x = 0; x < g.cols; x++) s += charAt(g, x, y)
39  return s
40}
41
42const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
43
44// 훅 환경에 btoa·Buffer 가 있다는 보장이 없어 직접 인코딩한다(u32 는 little-endian 으로 편다)
45export function encode(g: Grid): string {
46  const n = g.words.length * 4
47  const bytes = new Uint8Array(n)
48  for (let i = 0; i < g.words.length; i++) {
49    const w = g.words[i]!
50    bytes[i * 4] = w & 0xff
51    bytes[i * 4 + 1] = (w >>> 8) & 0xff
52    bytes[i * 4 + 2] = (w >>> 16) & 0xff
53    bytes[i * 4 + 3] = (w >>> 24) & 0xff
54  }
55  let out = ''
56  let i = 0
57  for (; i + 2 < n; i += 3) {
58    const v = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
59    out += B64[(v >>> 18) & 63]! + B64[(v >>> 12) & 63]! + B64[(v >>> 6) & 63]! + B64[v & 63]!
60  }
61  if (i < n) {
62    const a = bytes[i]!
63    const b = i + 1 < n ? bytes[i + 1]! : 0
64    const v = (a << 16) | (b << 8)
65    out += B64[(v >>> 18) & 63]! + B64[(v >>> 12) & 63]! + (i + 1 < n ? B64[(v >>> 6) & 63]! : '=') + '='
66  }
67  return out
68}
69
hooks/frame/compose.ts 173 lines
1// 게임과 토큰 미터를 고정 폭 24 열 화면 한 장으로 그린다.
2
3import Game, { type Game as GameState } from '../game'
4import Meter, { type Meter as MeterState, type Status } from '../meter'
5import Names from '../names'
6import { DEFAULT_COLOR, gridOf, put, text, type Grid } from './grid.js'
7
8const HALF_TOP = 0x2580 // ▀
9const SHADE = 0x2591 // ░
10const H_LINE = 0x2500
11const V_LINE = 0x2502
12const TL = 0x250c
13const TR = 0x2510
14const BL = 0x2514
15const BR = 0x2518
16const BAR = 0x258d // ▍
17const SPARK = [0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588]
18
19const WELL = 0x141414
20const FRAME = 0x3d3d3d
21const LABEL = 0x7a7a7a
22const VALUE = 0xe8e6e1
23const ACCENT = 0xecb25c
24const SPARK_DIM = 0x7d6238
25const WHITE = 0xf0f0eb
26
27export const PIECE_COLORS = [0x5bc0de, 0xf2c14e, 0xb388eb, 0x8bd17c, 0xe56b6f, 0x5b8def, 0xf0a35e]
28
29const STATUS_VIEW: Record<Status, { word: string; color: number }> = {
30  streaming: { word: 'streaming', color: 0x8bd17c },
31  thinking: { word: 'thinking', color: 0xb388eb },
32  tool: { word: 'tool call', color: 0xf2c14e },
33  waiting: { word: 'waiting', color: LABEL },
34  idle: { word: 'idle', color: LABEL },
35}
36
37function mix(a: number, b: number, t: number): number {
38  const ch = (shift: number) => Math.round(((a >> shift) & 255) * (1 - t) + ((b >> shift) & 255) * t)
39  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
40}
41
42function lighter(c: number): number {
43  return mix(c, 0xffffff, 0.18)
44}
45
46export function tokensOf(count: number): string {
47  const n = Math.max(0, Math.round(count))
48  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
49  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
50  return String(n)
51}
52
53// 보드 한 칸 = 2 열. ▀ 의 전경(위 절반)을 밝게, 배경(아래 절반)을 기본색으로 칠해 입체감을 준다
54function block(g: Grid, x: number, y: number, color: number): void {
55  put(g, x, y, HALF_TOP, lighter(color), color)
56  put(g, x + 1, y, HALF_TOP, lighter(color), color)
57}
58
59function row(g: Grid, x0: number, y: number, label: string, value: string, color: number): void {
60  text(g, x0 + 2, y, label, LABEL)
61  text(g, x0 + 22 - value.length, y, value, color)
62}
63
64export type FrameInput = { game: GameState; meter: MeterState; cols: number; rows: number; now: number }
65
66export function compose(input: FrameInput): Grid {
67  const { game, meter, cols, rows, now } = input
68  const g = gridOf(cols, rows)
69  const x0 = Math.max(0, Math.floor((cols - Names.COLS) / 2))
70  const bx = x0 + 2
71  const by = 1
72  const blink = Math.floor(now / 400) % 2 === 0
73
74  // 테두리와 우물
75  put(g, x0 + 1, 0, TL, FRAME)
76  put(g, x0 + 22, 0, TR, FRAME)
77  put(g, x0 + 1, 21, BL, FRAME)
78  put(g, x0 + 22, 21, BR, FRAME)
79  for (let x = x0 + 2; x < x0 + 22; x++) { put(g, x, 0, H_LINE, FRAME); put(g, x, 21, H_LINE, FRAME) }
80  for (let y = 1; y < 21; y++) {
81    put(g, x0 + 1, y, V_LINE, FRAME)
82    put(g, x0 + 22, y, V_LINE, FRAME)
83    for (let x = bx; x < bx + 20; x++) put(g, x, y, 0x20, DEFAULT_COLOR, WELL)
84  }
85
86  // 굳은 칸
87  for (let y = Game.HID; y < Game.BH; y++) {
88    for (let x = 0; x < Game.BW; x++) {
89      const v = game.board[y * Game.BW + x]
90      if (!v) continue
91      let color = PIECE_COLORS[v - 1]!
92      if (game.clearing?.rows.includes(y) && Math.floor(game.clearing.ms / 100) % 2 === 0) color = WHITE
93      if (game.over) color = mix(color, WELL, 0.55)
94      block(g, bx + x * 2, by + y - Game.HID, color)
95    }
96  }
97
98  // AI 가 노린 자리(░)와 지금 블록. 실행 실수가 나면 블록은 ░ 옆에 떨어진다
99  const cur = game.cur
100  if (cur && !game.clearing) {
101    if (Game.fits(game.board, cur.k, cur.tr, cur.aimX, cur.y)) {
102      let gy = cur.y
103      while (Game.fits(game.board, cur.k, cur.tr, cur.aimX, gy + 1)) gy++
104      const ghost = mix(PIECE_COLORS[cur.k]!, WELL, 0.5)
105      for (const [cx, cy] of Game.ROT[cur.k]![cur.tr]!) {
106        const y = gy + cy
107        if (y < Game.HID) continue
108        const x = bx + (cur.aimX + cx) * 2
109        put(g, x, by + y - Game.HID, SHADE, ghost, WELL)
110        put(g, x + 1, by + y - Game.HID, SHADE, ghost, WELL)
111      }
112    }
113    for (const [cx, cy] of Game.ROT[cur.k]![cur.r]!) {
114      const y = cur.y + cy
115      if (y >= Game.HID) block(g, bx + (cur.x + cx) * 2, by + y - Game.HID, PIECE_COLORS[cur.k]!)
116    }
117  }
118  if (game.flash) {
119    const a = Math.max(0, 0.7 * (1 - game.flash.ms / Game.FLASH_MS))
120    for (const [x, y] of game.flash.cells) {
121      if (y < Game.HID) continue
122      const v = game.board[y * Game.BW + x]
123      if (v) block(g, bx + x * 2, by + y - Game.HID, mix(PIECE_COLORS[v - 1]!, WHITE, a))
124    }
125  }
126
127  // 우물 안 문구: 지운 줄·콤보, 게임 오버. 가운데 맞추고 글자 뒤는 우물색으로 덮는다
128  const banner = (row: number, s: string, color: number) => {
129    const x = bx + Math.floor((20 - s.length) / 2)
130    text(g, x - 1, by + row, ` ${s} `, color, WELL)
131  }
132  if (game.popup && !game.over) {
133    const fade = game.popup.ms > Game.POPUP_MS * 0.7 ? 0.55 : 0
134    game.popup.lines.forEach((line, i) => banner(5 + i, line, mix(i === 0 ? WHITE : ACCENT, WELL, fade)))
135  }
136  if (game.over) banner(9, 'GAME OVER', blink ? 0xe56b6f : 0x6b3335)
137
138  // 보드 아래 정보. 높이가 모자라면 아래쪽 줄부터 빠진다(put 이 격자 밖을 버린다)
139  let y = 23
140  text(g, x0 + 2, y, 'NEXT', LABEL)
141  const cells = Game.ROT[game.next]![0]!
142  const minX = Math.min(...cells.map(c => c[0]))
143  const maxX = Math.max(...cells.map(c => c[0]))
144  const minY = Math.min(...cells.map(c => c[1]))
145  const left = x0 + 22 - (maxX - minX + 1) * 2
146  for (const [cx, cy] of cells) block(g, left + (cx - minX) * 2, y + cy - minY, PIECE_COLORS[game.next]!)
147  y += 3
148  for (let x = x0 + 2; x < x0 + 22; x++) put(g, x, y, H_LINE, 0x333333)
149  y += 1
150  row(g, x0, y++, 'LINES', String(game.lines), VALUE)
151  row(g, x0, y++, 'SCORE', String(game.score), VALUE)
152  row(g, x0, y++, 'COMBO', `${game.combo} max ${game.maxCombo}`, game.combo >= 2 ? ACCENT : VALUE)
153  row(g, x0, y++, 'PIECES', String(game.pieces), VALUE)
154  row(g, x0, y++, 'GAME', String(game.games), VALUE)
155  row(g, x0, y++, 'AI', game.tier.toUpperCase(), ACCENT)
156  y += 1
157  row(g, x0, y++, 'OUT TOK', tokensOf(meter.out), ACCENT)
158  row(g, x0, y++, 'IN TOK', tokensOf(meter.inp), VALUE)
159  row(g, x0, y++, 'tok/s', String(Math.round(Meter.rateOf(meter))), VALUE)
160  const top = Math.max(60, ...meter.bins)
161  meter.bins.forEach((v, i) => {
162    const level = v <= 0 ? -1 : Math.min(7, Math.floor((v / top) * 8))
163    put(g, x0 + 2 + i, y, level < 0 ? 0x20 : SPARK[level]!, i === meter.bins.length - 1 ? ACCENT : SPARK_DIM)
164  })
165  y += 2
166  const status = Meter.statusOf(meter, now)
167  const view = STATUS_VIEW[status]
168  const color = status === 'tool' && !blink ? mix(view.color, 0x262626, 0.5) : view.color
169  put(g, x0 + 2, y, BAR, color)
170  text(g, x0 + 4, y, view.word, color)
171  return g
172}
173
hooks/game/game.ts 192 lines
1// 테트리스 규칙. 블록을 둘 자리는 ai.ts 가 고른다. 시간과 입력은 바깥에서 받는다(순수 모듈).
2
3import { BH, BW, HID, I_PIECE, O_PIECE, ROT, collapse, fits, fullRowsOf } from './board.js'
4import { DEFAULT_TIER, choose, type Tier } from './ai.js'
5
6export * from './board.js'
7export { DEFAULT_TIER, TIERS, holesOf, tierOf, type Tier } from './ai.js'
8
9export const CLEAR_MS = 400
10export const OVER_MS = 2500
11export const FLASH_MS = 330
12export const POPUP_MS = 1200
13// 콤보 가산점: 이어진 두 번째 배치부터 COMBO_BONUS × (콤보 - 1)
14export const COMBO_BONUS = 50
15const CLEAR_NAMES = ['', 'SINGLE', 'DOUBLE', 'TRIPLE', 'TETRIS']
16// 속도를 주지 않은 advance 의 낙하: 한 번에 바닥까지
17export const INSTANT_ROWS = 1_000_000
18
19// falling: 도구 호출로 떨어지는 중. fallAcc 는 아직 내려가지 않은 행의 몫
20// tr·tx: 실제로 가는 회전·열. aimX: AI 가 노린 열(░ 로 그린다). 실행 실수가 나면 tx 와 다르다
21export type Piece = { k: number; r: number; x: number; y: number; tr: number; tx: number; aimX: number; falling: boolean; fallAcc: number }
22
23export type Game = {
24  board: Uint8Array
25  bag: number[]
26  next: number
27  cur?: Piece
28  clearing?: { rows: number[]; ms: number }
29  over?: { ms: number }
30  flash?: { cells: Array<[number, number]>; ms: number }
31  // 우물 안에 잠깐 띄우는 문구(지운 줄 이름, 콤보)
32  popup?: { lines: string[]; ms: number }
33  // 줄을 지운 배치가 몇 번 이어졌는가. 줄을 못 지운 배치에서 0
34  combo: number
35  maxCombo: number
36  // 블록을 두는 AI 의 등급. 다음에 나오는 블록부터 적용된다
37  tier: Tier
38  lines: number
39  score: number
40  pieces: number
41  games: number
42  seed: number
43}
44
45function rnd(g: Game): number {
46  let s = g.seed | 0
47  s ^= s << 13
48  s ^= s >>> 17
49  s ^= s << 5
50  g.seed = s
51  return (s >>> 0) / 4294967296
52}
53
54function drawBag(g: Game): number {
55  if (g.bag.length === 0) {
56    g.bag = [0, 1, 2, 3, 4, 5, 6]
57    for (let i = 6; i > 0; i--) {
58      const j = Math.floor(rnd(g) * (i + 1))
59      const t = g.bag[i]!
60      g.bag[i] = g.bag[j]!
61      g.bag[j] = t
62    }
63  }
64  return g.bag.pop()!
65}
66
67function spawn(g: Game): void {
68  const k = g.next
69  g.next = drawBag(g)
70  const y = k === I_PIECE ? HID - 1 : HID
71  const x = k === I_PIECE ? 3 : k === O_PIECE ? 4 : 3
72  if (!fits(g.board, k, 0, x, y)) {
73    g.cur = undefined
74    g.over = { ms: 0 }
75    return
76  }
77  const target = choose(g.board, k, g.next, y, g.tier, () => rnd(g))
78  g.cur = { k, r: 0, x, y, tr: target.r, tx: target.x, aimX: target.aimX, falling: false, fallAcc: 0 }
79  g.pieces++
80}
81
82function lock(g: Game): void {
83  const cur = g.cur
84  if (!cur) return
85  for (const [cx, cy] of ROT[cur.k]![cur.r]!) {
86    const Y = cur.y + cy
87    if (Y >= 0) g.board[Y * BW + cur.x + cx] = cur.k + 1
88  }
89  g.cur = undefined
90  const full = fullRowsOf(g.board)
91  if (full.length > 0) {
92    g.clearing = { rows: full, ms: 0 }
93    g.lines += full.length
94    g.combo++
95    g.maxCombo = Math.max(g.maxCombo, g.combo)
96    g.score += ([0, 100, 300, 500, 800][full.length] ?? 800) + COMBO_BONUS * Math.max(0, g.combo - 1)
97    const lines = [CLEAR_NAMES[full.length] ?? 'TETRIS']
98    if (g.combo >= 2) lines.push(`COMBO x${g.combo}`)
99    g.popup = { lines, ms: 0 }
100  } else {
101    g.combo = 0
102    spawn(g)
103  }
104}
105
106export function setTier(g: Game, tier: Tier): void {
107  g.tier = tier
108}
109
110export function create(seed: number): Game {
111  const g: Game = { board: new Uint8Array(BW * BH), bag: [], next: 0, tier: DEFAULT_TIER, combo: 0, maxCombo: 0, lines: 0, score: 0, pieces: 0, games: 1, seed: seed | 0 || 0x1b873593 }
112  g.next = drawBag(g)
113  spawn(g)
114  return g
115}
116
117// 줄 지우기·게임 오버·낙하 중에는 수를 받지 않는다
118export function isBusy(g: Game): boolean {
119  return g.clearing !== undefined || g.over !== undefined || g.cur?.falling === true
120}
121
122// 한 수: 목표 회전으로 한 번 돌리거나, 목표 열로 한 칸 옮기거나, 한 칸 내린다(닿으면 굳는다)
123export function move(g: Game): boolean {
124  const cur = g.cur
125  if (!cur || isBusy(g)) return false
126  if (cur.r !== cur.tr) {
127    const nr = (cur.r + 1) % 4
128    if (fits(g.board, cur.k, nr, cur.x, cur.y)) cur.r = nr
129    else if (fits(g.board, cur.k, nr, cur.x - 1, cur.y)) { cur.r = nr; cur.x-- }
130    else if (fits(g.board, cur.k, nr, cur.x + 1, cur.y)) { cur.r = nr; cur.x++ }
131    else cur.tr = cur.r
132    return true
133  }
134  if (cur.x !== cur.tx) {
135    const d = Math.sign(cur.tx - cur.x)
136    if (fits(g.board, cur.k, cur.r, cur.x + d, cur.y)) cur.x += d
137    else cur.tx = cur.x
138    return true
139  }
140  if (fits(g.board, cur.k, cur.r, cur.x, cur.y + 1)) cur.y++
141  else lock(g)
142  return true
143}
144
145// 도구 호출 한 번: 지금 블록을 계획한 회전·열로 맞추고 떨어뜨리기 시작한다. 내려가는 것은 advance 가 행 단위로 한다
146export function drop(g: Game): boolean {
147  const cur = g.cur
148  if (!cur || isBusy(g)) return false
149  if (fits(g.board, cur.k, cur.tr, cur.tx, cur.y)) { cur.r = cur.tr; cur.x = cur.tx }
150  cur.falling = true
151  cur.fallAcc = 0
152  return true
153}
154
155function land(g: Game, cur: Piece): void {
156  g.flash = { cells: ROT[cur.k]![cur.r]!.map(([cx, cy]) => [cur.x + cx, cur.y + cy] as [number, number]), ms: 0 }
157  lock(g)
158}
159
160// 시간이 흐른다: 낙하·줄 지우기·게임 오버·번쩍임 애니메이션을 진행한다. fallRows 는 낙하 속도(행/초)
161export function advance(g: Game, dtMs: number, fallRows: number = INSTANT_ROWS): void {
162  const cur = g.cur
163  if (cur?.falling) {
164    cur.fallAcc += (dtMs / 1000) * fallRows
165    while (cur.fallAcc >= 1) {
166      cur.fallAcc--
167      if (fits(g.board, cur.k, cur.r, cur.x, cur.y + 1)) cur.y++
168      else { land(g, cur); break }
169    }
170  }
171  if (g.flash) { g.flash.ms += dtMs; if (g.flash.ms >= FLASH_MS) g.flash = undefined }
172  if (g.popup) { g.popup.ms += dtMs; if (g.popup.ms >= POPUP_MS) g.popup = undefined }
173  if (g.clearing) {
174    g.clearing.ms += dtMs
175    if (g.clearing.ms < CLEAR_MS) return
176    collapse(g.board, g.clearing.rows)
177    g.clearing = undefined
178    spawn(g)
179  }
180  if (g.over) {
181    g.over.ms += dtMs
182    if (g.over.ms < OVER_MS) return
183    g.board.fill(0)
184    g.over = undefined
185    g.combo = 0
186    g.games++
187    g.bag = []
188    g.next = drawBag(g)
189    spawn(g)
190  }
191}
192
hooks/messages/types.ts 8 lines
1export type Messages = {
2  commandDescription: string
3  panelShown: string
4  panelHidden: string
5  tooSmall: (minCols: number, minRows: number, cols: number, rows: number) => string[]
6  notTerminal: string
7}
8