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

English | 한국어
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.
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.
| What happens in the session | In 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 call | The current piece drops to its planned spot |
| Nothing | It stops. Time alone does not move pieces down |
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.TIERS in hooks/game/ai.ts).░NEXT, LINES, SCORE, COMBO, PIECES, GAME, AI (current tier)streaming, thinking, tool call, waiting, idleSINGLE to TETRIS and COMBO xNThe 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:
language key in the settings files, if one is set ("korean", "english"). The Claude Code settings reference does not list this keyLC_ALL environment variableLANG 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.
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 $.
hooks/register.ts 215 lines1import 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}
215hooks/frame/index.ts 4 lines1export * from './grid.js'
2export * from './compose.js'
3export * as default from '.'
4hooks/game/index.ts 3 lines1export * from './game.js'
2export * as default from '.'
3hooks/messages/index.ts 4 lines1export * from './types.js'
2export * from './language.js'
3export * as default from '.'
4hooks/meter/index.ts 3 lines1export * from './meter.js'
2export * as default from '.'
3hooks/names/index.ts 20 lines1export 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 '.'
20hooks/queue/index.ts 3 lines1export * from './queue.js'
2export * as default from '.'
3hooks/view/index.ts 3 lines1export * from './pane-view.jsx'
2export * as default from '.'
3hooks/frame/grid.ts 69 lines1// 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}
69hooks/frame/compose.ts 173 lines1// 게임과 토큰 미터를 고정 폭 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}
173hooks/game/game.ts 192 lines1// 테트리스 규칙. 블록을 둘 자리는 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}
192hooks/messages/types.ts 8 lines1export 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