Sports minigames under the spinner: penalty shootout, batting and a 3-point contest, played with the Space key while Claude works.

Two mods for Claude Code by FLOWVIA Labs.
| Mod | What it does |
|---|---|
| neon-sections | Splits Claude's answers into Conclusion / Rationale / Needs your input / Next steps and draws each section in its own neon box. Tool calls from a finished turn fold into one work-log line. |
| clutch | A sports minigame under the spinner while Claude works: penalty shootout, batting, and a 3-point contest, all played with the Space key. |



Mods use Claude Code's mod API, which is in early access. A Claude Code update can change that API, so a mod may stop working until it is updated here.
In a Claude Code session:
/plugin marketplace add FLOWVIA-Labs/flowvia-mods
/plugin install neon-sections@flowvia-mods
/plugin install clutch@flowvia-mods
Or from your shell:
claude plugin marketplace add FLOWVIA-Labs/flowvia-mods
claude plugin install neon-sections@flowvia-mods
claude plugin install clutch@flowvia-mods
Install one or both.
Both mods speak English and Korean. Each has a language option:
| Value | Behavior |
|---|---|
auto (default) | Korean when your Claude Code language setting is Korean (korean, ko, ko-KR, 한국어), English otherwise |
ko | Always Korean |
en | Always English |
Change it in /plugin → Installed → the mod → Configure options, or in /config.
neon-sections asks Claude to structure answers with these headings and then draws them as boxes:
### Conclusion
### Rationale
### Needs your input
### Next steps
### Conclusion · Done, · Waiting, or · Blocked. It shows as a badge on the Conclusion box.### 결론 and so on) are recognized too, whatever the language setting.Run /ns-setting to open the settings panel. You can set:
The same settings are available as commands:
| Command | Effect | |||||
|---|---|---|---|---|---|---|
/ns-setting on / off | Turn the boxes on or off | |||||
/ns-setting status | Show the current settings | |||||
| `/ns-setting color <section> #RRGGBB [dark\ | light]` | Set a section color | ||||
| `/ns-setting effect <glow\ | latest\ | hover\ | blink\ | breathe> <section> <on\ | off>` | Toggle an effect |
| `/ns-setting fold <on\ | off>` | Fold tool calls into the work log |
<section> is conclusion, attention, or next (or the heading text). The Rationale box always uses a fixed gray.
When Claude starts working, a small field appears under the spinner. Each new prompt picks a sport at random, and a long task switches sports every minute.
| Key | Action |
|---|---|
| Space | Shoot or swing |
| 1 / 2 / 3 | Switch to penalty shootout / baseball / 3-point contest |
| 9 | Pause the game for this turn |
/clutch turns the game on or off.language value and the light/dark theme, and never send them anywhere.See AGENTS.md for the layout, setup, and test commands.
MIT © FLOWVIA Labs
Not affiliated with, endorsed by, or sponsored by Anthropic. "Claude" and "Claude Code" are used only to describe what these mods work with.
hooks/register.tsx 170 lines1import { basketball, threePoint } from './basketball';
2import { atBat, swing } from './batting';
3import type { On, PluginOptions } from 'claude-code';
4import { frame } from './field';
5import { activityText, language, messages } from './language';
6import type { Activity } from './language';
7import { fitActivity, shoot } from './model';
8import { animate, createRuntime, hide, invalidate, markBatFrame, markBBFrame, prepareSport, rotate, scene, resetTurn, selectSport, setActivity, stop, stopRotation, updateShot } from './runtime';
9import { pickSport, SPORTS } from './sports';
10
11export function register(on: On, options: PluginOptions, b = createRuntime()) {
12 on('session.start', async ($, e, next) => {
13 const result = await next(e);
14 try {
15 resetTurn(b); b.turn = undefined;
16 const sport = await $.store.get('sport');
17 b.last = SPORTS.find(value => value === sport);
18 prepareSport(b);
19 b.enabled = (await $.store.get('enabled')) !== false;
20 await $.command.register({ name: 'clutch', description: messages[await language($, options)].description, immediate: true });
21 } catch { /* A failed optional mod must not block session startup. */ }
22 return result;
23 });
24 on('command.run', { command: 'clutch' }, async ($, e) => {
25 const args = e.args.trim();
26 const words = messages[await language($, options)];
27 if (!args) {
28 const value = !b.enabled;
29 await $.store.set('enabled', value);
30 b.enabled = value;
31 if (!b.enabled) { resetTurn(b); b.turn = undefined; }
32 $.ui.invalidate('ui.render');
33 return { text: b.enabled ? words.on : words.off };
34 }
35 return { text: words.usage };
36 });
37 on('turn.start', async ($, e, next) => {
38 // 2.1.295 omits agentId here; reject it defensively if the host supplies it.
39 if (b.enabled && !('agentId' in e && e.agentId !== undefined)) {
40 // A terminal field can be drawn before its turn starts.
41 const site = b.site;
42 const sport = b.prepared ? b.sport : pickSport(SPORTS, b.selectionRandom, b.last);
43 const redraw = b.activity.key !== 'ready' || (b.drawnSport !== undefined && b.drawnSport !== sport);
44 resetTurn(b); b.turn = e.turnId; b.site = site;
45 selectSport($, b, sport, await $.clock.now());
46 if (redraw) invalidate($);
47 }
48 return next(e);
49 });
50 on('turn.complete', ($, e, next) => {
51 if (b.enabled && !e.agentId && e.turnId === b.turn) { resetTurn(b); b.turn = undefined; prepareSport(b); }
52 return next(e);
53 });
54 on('session.end', ($, e, next) => {
55 if (b.enabled) { resetTurn(b); b.turn = undefined; }
56 return next(e);
57 });
58 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
59 if (!b.enabled) return next(e);
60 const result = await next(e);
61 try {
62 if (e.surface !== 'terminal') return result;
63 if (b.turn && (e.props.mode === 'thinking' || e.props.mode === 'responding'))
64 setActivity($, b, { key: e.props.mode === 'thinking' ? 'thinking' : 'writing' });
65 const { Box, Text, Raster } = $.ui.resolve(e);
66 let light = false;
67 try { light = String((await $.config.list()).find(r => r.key === 'theme')?.value ?? '').startsWith('light'); } catch { /* dark fallback */ }
68 const colors = light
69 ? { body: '#24292F', dim: '#6E7781', strong: '#111418', claude: '#B5502D', notice: '#57606A', space: '#8A5A00', spaceText: '#FFFFFF', key: '#D8D5CC' }
70 : { body: '#D7DAE0', dim: '#7D8590', strong: '#F4F1EA', claude: '#D97757', notice: '#9AA3B2', space: '#FFD23F', spaceText: '#111418', key: '#3A3F4B' };
71 const width = (e.viewport?.columns ?? 80) - 6, margin = Math.max(0, Math.floor((width - 60) / 2));
72 const words = messages[await language($, options)];
73 const activity = fitActivity(activityText(words, b.activity), Math.min(60, width));
74 const status = <Box marginLeft={margin}><Text color={colors.strong} weight="bold"><Text color={colors.claude} weight="bold">{activity.slice(0, 6)}</Text>{activity.slice(6)}</Text></Box>;
75 if (b.paused) {
76 hide(b);
77 return <Box flexDirection="column">{result}{status}<Box marginLeft={margin}><Text color={colors.notice}>{words.paused}</Text></Box></Box>;
78 }
79 if (width < 60) {
80 hide(b);
81 return <Box flexDirection="column">{result}{status}<Box marginLeft={0}><Text color={colors.notice}>{words.narrow}</Text></Box></Box>;
82 }
83 const now = await $.clock.now(); updateShot(b, now); rotate($, b, now);
84 const current = scene(b, now), cells = frame(current);
85 b.textStage = current.text?.stage ?? -1; markBatFrame(b, now, current); markBBFrame(b, now, current);
86 if (b.site !== e.requestId) stop(b);
87 b.site = e.requestId; b.lastCells = cells; b.drawnSport = b.mode;
88 const names = words.names;
89 const totals: [string, number][] = b.mode === 'bb'
90 ? [[words.totals.bb[0], b.bbScore.points], [words.totals.bb[1], b.bbScore.success], [words.totals.bb[2], b.bbScore.fail]]
91 : b.mode === 'bat' ? [[words.totals.bat[0], b.batScore.homerun], [words.totals.bat[1], b.batScore.hit], [words.totals.bat[2], b.batScore.strikeout]]
92 : [[words.totals.kick[0], b.score.goal], [words.totals.kick[1], b.score.save], [words.totals.kick[2], b.score.miss]];
93 const locked = b.kickAt !== undefined || b.batAt !== undefined || b.bbAt !== undefined;
94 const keys = [['Space', b.mode === 'bat' ? words.swing : words.shoot],
95 ...SPORTS.flatMap((sport, i) => sport === b.mode ? [] : [[String(i + 1), words.shortNames[sport]]]), ['9', words.pause]];
96 animate($, b, now);
97 return <Box flexDirection="column">{result}{status}
98 <Box marginLeft={margin}><Text color={colors.body}><Text color={colors.strong} weight="bold">{names[b.mode]}</Text><Text color={colors.dim}> │ </Text>{totals.map(([label, value], i) => <Text key={label} color={colors.body}>{i > 0 ? <Text color={colors.dim}> · </Text> : null}{label} <Text color={colors.strong} weight="bold">{value}</Text></Text>)}</Text></Box>
99 <Box marginLeft={margin}><Text color={colors.body}>{keys.map(([key, label], i) => {
100 const disabled = locked && key !== '9';
101 return <Text key={key} color={disabled ? colors.dim : colors.body}>{i > 0 ? <Text color={colors.body}> </Text> : null}<Text
102 color={disabled ? colors.dim : key === 'Space' ? colors.spaceText : colors.strong}
103 backgroundColor={disabled ? undefined : key === 'Space' ? colors.space : colors.key}
104 weight={disabled ? 'regular' : 'bold'}>{` ${key} `}</Text>{` ${label}`}</Text>;
105 })}</Text></Box>
106 <Box marginLeft={margin}><Raster key="field" columns={60} rows={9} cells={cells} /></Box>
107 </Box>;
108 } catch { hide(b); return result; }
109 });
110 on('prompt.edit', async ($, e, next) => {
111 if (!b.enabled || ('agentId' in e && e.agentId !== undefined)) return next(e);
112 let consume = false, countedSpaces = 0, now = 0;
113 const key = e.key?.key;
114 const arrow = key === 'left' || key === 'right' || key === 'up' || key === 'down' ? key : undefined;
115 try {
116 const space = /^ +$/.test(e.inputText);
117 const gameKey = !b.paused && !arrow && (space || e.inputText === '1' || e.inputText === '2' || e.inputText === '3' || e.inputText === '9');
118 consume = Boolean(b.turn && b.site !== undefined && e.text === '' && gameKey);
119 if (consume) {
120 now = await $.clock.now(); updateShot(b, now); rotate($, b, now);
121 if (e.inputText === '9') {
122 stop(b); stopRotation(b); b.paused = true; b.mode = b.sport;
123 b.kickAt = undefined; b.shot = undefined; b.landed = false; b.textStage = -1;
124 b.bat = undefined; b.batAt = undefined; b.batShown = 0; b.batCounted = false; b.batFinalAt = undefined;
125 b.bb = undefined; b.bbAt = undefined; b.bbShown = 0; b.bbCounted = false; b.bbFinalAt = undefined;
126 } else if (!space && b.kickAt === undefined && b.batAt === undefined && b.bbAt === undefined) {
127 const wanted = e.inputText === '1' ? 'kick' : e.inputText === '2' ? 'bat' : 'bb';
128 if (wanted !== b.mode) { stop(b); b.mode = wanted; b.kickAt = undefined; }
129 selectSport($, b, wanted, now);
130 } else if (space) {
131 b.spaces += e.inputText.length; countedSpaces = e.inputText.length;
132 if (b.mode === 'kick') { if (b.kickAt === undefined) { b.shot = shoot(b.random); b.kickAt = now; b.landed = false; b.textStage = -1; } }
133 else if (b.mode === 'bat') {
134 if (b.batAt === undefined) { b.bat = atBat(swing(b.random)); b.batAt = now; b.batShown = 0; b.batCounted = false; b.batFinalAt = undefined; }
135 }
136 else if (b.mode === 'bb') {
137 if (b.bbAt === undefined) { b.bb = basketball(threePoint(b.random), b.bbSpot, b.bbBall); b.bbAt = now; b.bbShown = 0; b.bbCounted = false; b.bbFinalAt = undefined; }
138 }
139 }
140 }
141 if (consume) { invalidate($); animate($, b, now); }
142 } catch {
143 b.spaces -= countedSpaces;
144 return next(e);
145 }
146 return consume ? { text: e.text, cursor: e.cursor } : next(e);
147 }).catch(($, e, next) => next(e));
148 on('tool.call', async ($, e, next) => {
149 if (!b.enabled) return next(e);
150 try {
151 if (b.turn && e.agentId === undefined) {
152 const parts = e.tool.split('__');
153 let activity: Activity = { key: 'tool', detail: e.tool.startsWith('mcp__') && parts.length >= 3 ? parts.at(-1) : e.tool };
154 if (e.tool === 'Bash') {
155 const command = typeof e.command === 'string' ? e.command : '';
156 activity = { key: /\b(test|jest|vitest|pytest)\b/.test(command) ? 'tests'
157 : /\bgit\b/.test(command) ? 'git' : 'command' };
158 } else if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
159 const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path;
160 activity = { key: 'editing', detail: typeof path === 'string' ? path.split('/').at(-1) : undefined };
161 } else if (['Read', 'Grep', 'Glob'].includes(e.tool)) activity = { key: 'reading' };
162 else if (e.tool === 'WebSearch' || e.tool === 'WebFetch') activity = { key: 'web' };
163 else if (e.tool === 'Agent') activity = { key: 'subagent' };
164 setActivity($, b, activity);
165 }
166 } catch { /* Observation failure cannot skip the call. */ }
167 return next(e);
168 }).catch(($, e, next) => next(e));
169}
170hooks/basketball.ts 69 lines1import { SPOTS } from './field';
2import type { BBScene, Scene } from './field';
3
4export type BBResult = 'three' | 'bang' | 'downtown' | 'clank' | 'brick' | 'air';
5export function threePoint(random: () => number): BBResult {
6 const r = random();
7 return r < .24 ? 'three' : r < .42 ? 'bang' : r < .60 ? 'downtown' : r < .75 ? 'clank' : r < .90 ? 'brick' : 'air';
8}
9const words: Record<BBResult, string> = {
10 three: 'THREEEEEEE!!!!!!!', bang: 'BANG!!!!!!!', downtown: 'FROM DOWNTOWN!!!!!!!',
11 clank: 'CLANK!!!!!!!', brick: 'BRICK!!!!!!!', air: 'AIR BALL!!!!!!!',
12};
13const BB3: Record<string, BBScene> = {
14 shot: { jump: 1, ball: [16, 3] }, // 0.00 jump and release (all results)
15 top: { ball: [22, 0], ghost: [[20, 1], [18, 2], [16, 3, 'M']] }, // 0.30 apex (made and missed shots)
16 in1: { ball: [29, 8], flash: 'W', ghost: [[28, 6], [27, 5], [25, 3, 'M']] }, // 0.55 made shot: through the net, net flashes
17 in2: { ball: [29, 9], sway: 'W' }, // 0.65 made shot: net sways, ball drops below it (behind text at 0.75s)
18 clank0: { ball: [32, 5], sparks: [[32, 6]], ghost: [[30, 4], [28, 2], [25, 0, 'M']] }, // 0.45 CLANK: right edge of rim
19 clank1: { ball: [38, 8], ghost: [[37, 6], [35, 5, 'M']] }, // 0.60 bounces out to the right
20 clank2: { ball: [42, 9], ghost: [[41, 8], [40, 7, 'M']] }, // 0.85 lands on the floor
21 brick0: { ball: [30, 4], sparks: [[31, 3]], ghost: [[28, 2], [25, 0], [21, 0, 'M']] }, // 0.40 BRICK: low, hard hit to the front of the backboard
22 brick1: { ball: [20, 5], ghost: [[22, 5], [25, 4], [28, 4, 'M']] }, // 0.60 bounces forward and left
23 brick2: { ball: [19, 9], ghost: [[20, 7], [21, 6, 'M']] }, // 0.85 onto the floor beside the shooter
24 air0: { ball: [20, 1], ghost: [[18, 1], [17, 2], [16, 3, 'M']] }, // 0.30 air ball: low apex
25 air1: { ball: [21, 8], ghost: [[21, 6], [21, 4], [21, 2, 'M']] }, // 0.55 falls short of the rim (no contact)
26 air2: { ball: [23, 9], ghost: [[22, 10], [21, 10, 'M']] }, // 0.80 lands on the floor and rolls
27 mtop: { rack: 0, ball: [22, 0, 'money'], ghost: [[20, 1], [18, 2], [16, 3, 'M']] }, // 0.30 money-ball apex, empty rack
28 min1: { rack: 0, cheer: 1, flash: 'Y', sparks: [[20, 3], [38, 2]], ball: [29, 8, 'money'], ghost: [[28, 6], [27, 5], [25, 3, 'M']] }, // 0.55 money ball scores
29 min2: { rack: 0, cheer: 2, sway: 'Y', sparks: [[17, 0], [40, 2]], ball: [29, 9, 'money'] } // 0.65 yellow net sway, crowd and sparks change positions
30};
31
32export function bbWait(cur: number, ball: number): Scene {
33 return { bb: { cur, rack: 5 - ball, hold: ball === 5 ? 'money' : 'bb' } };
34}
35export function basketball(result: BBResult, cur: number, ball: number) {
36 const money = ball === 5, good = result === 'three' || result === 'bang' || result === 'downtown';
37 const s0 = result === 'clank' || result === 'brick' ? 600 : 550;
38 const sprite = money ? 'money' : 'bb';
39 const spot = SPOTS[cur]!, x = Math.min(49, Math.max(10, spot[0] - 1)) + 1, y = spot[1] - 10;
40 const frames: [number, Scene][] = [[0, { bb: { cur, rack: 5 - ball, jump: 1, ball: [x, y, sprite] } }]];
41 const moves: [number, string][] = good
42 ? [[300, money ? 'mtop' : 'top'], [550, money ? 'min1' : 'in1'], [650, money ? 'min2' : 'in2'],
43 [750, money ? 'min2' : 'in2'], [850, money ? 'min2' : 'in2'], [950, money ? 'min2' : 'in2']]
44 : result === 'air' ? [[300, 'air0'], [550, 'air1'], [650, 'air1'], [800, 'air2']]
45 : [[300, money ? 'mtop' : 'top'], [result === 'clank' ? 450 : 400, result + '0'],
46 [600, result + '1'], [700, result + '1'], [850, result + '2']];
47 for (const [at, key] of moves) {
48 const o = BB3[key]!, flip = cur >= 3;
49 const bb: BBScene = { ...o, cur, rack: 5 - ball,
50 ball: o.ball ? [flip ? 58 - o.ball[0] : o.ball[0], o.ball[1], sprite] : undefined,
51 ghost: o.ghost?.map(([gx, gy, color]) => gx === 16 && gy === 3 && color === 'M'
52 ? [x, y, color] : [flip ? 59 - gx : gx, gy, color]),
53 sparks: o.sparks?.map(([sx, sy]) => [flip ? 57 - sx : sx, sy]),
54 };
55 if (good && at >= 750) bb.ball = undefined;
56 if (good && (at === 750 || at === 950)) bb.sway = undefined;
57 frames.push([at, { bb, ...(at >= s0 ? { text: { value: words[result], stage: at >= s0 + 100 ? 1 : 0,
58 tone: good ? 'good' : result === 'air' ? 'bad' : 'warn', cy: 14 } as const } : {}) }]);
59 }
60 frames.push([s0 + 1600, bbWait(ball === 5 ? (cur + 1) % 5 : cur, ball === 5 ? 1 : ball + 1)]);
61 return { result, good, frames, s0 };
62}
63export type Basketball = ReturnType<typeof basketball>;
64export function bbFrame(shot: Basketball, elapsed: number) {
65 let i = 0;
66 while (i + 1 < shot.frames.length && shot.frames[i + 1]![0] <= elapsed) i++;
67 return i;
68}
69hooks/batting.ts 69 lines1import type { Scene, SkyScene } from './field';
2
3export type BatResult = 'hr' | '3b' | '2b' | '1b' | 'foul' | 'k';
4export function swing(random: () => number): BatResult {
5 const r = random();
6 return r < .10 ? 'hr' : r < .15 ? '3b' : r < .35 ? '2b' : r < .65 ? '1b' : r < .80 ? 'foul' : 'k';
7}
8
9type Play = { moves: [number, SkyScene][]; base: SkyScene; text: string; cy: number; tone: 'good' | 'warn' | 'bad'; s0: number };
10const plays: Record<BatResult, Play> = {
11 hr: {
12 moves: [[550, { bat: 'F', ball: [26, 7], ghost: [[27, 9], [28, 11], [28, 13, 'M']] }],
13 [800, { bat: 0, run: [33, 13], flash: 1, ball: [24, 1], ghost: [[25, 3], [25, 5], [26, 7, 'M']], fld: { lf: [21, 4], cf: [27, 4] } }]],
14 base: { bat: 0, run: [35, 10], flash: 1, lit: ['b1'], fld: { lf: [21, 4], cf: [27, 4], b2: [32, 7] } },
15 text: 'HOMERUN!!!!!!!', cy: 9, tone: 'good', s0: 900,
16 },
17 '3b': {
18 moves: [[600, { bat: 0, run: [31, 14], ball: [36, 6], ghost: [[34, 9], [32, 12, 'M']] }],
19 [900, { bat: 0, run: [35, 10], ball: [38, 4], ghost: [[37, 5], [36, 6, 'M']], fld: { cf: [33, 4], rf: [40, 5], b2: [34, 7] } }]],
20 base: { bat: 0, run: [23, 11], lit: ['b3'], ball: [21, 9], fld: { cf: [33, 4], rf: [39, 4], b2: [34, 7] } },
21 text: 'TRIPLE!!!!!!!', cy: 9, tone: 'good', s0: 1100,
22 },
23 '2b': {
24 moves: [[550, { bat: 'F', ball: [26, 9], ghost: [[27, 11], [28, 13, 'M']] }],
25 [800, { bat: 0, run: [35, 10], ball: [22, 5], ghost: [[24, 6], [25, 8, 'M']], fld: { lf: [20, 5], cf: [27, 4], b2: [32, 8] } }]],
26 base: { bat: 0, run: [30, 6], lit: ['b2'], ball: [24, 8], fld: { lf: [21, 4], cf: [27, 4], b2: [32, 8] } },
27 text: 'DOUBLE!!!!', cy: 9, tone: 'good', s0: 1100,
28 },
29 '1b': {
30 moves: [[500, { bat: 'F', ball: [28, 10], ghost: [[28, 12], [29, 14, 'M']] }],
31 [750, { bat: 0, run: [33, 13], ball: [27, 6], ghost: [[27, 8], [28, 10, 'M']], fld: { ss: [24, 8, 'skyDiveR'], b2: [31, 7, 'skyDiveL'], cf: [29, 4] } }]],
32 base: { bat: 0, run: [36, 11], lit: ['b1'], ball: [28, 6], fld: { ss: [25, 8], b2: [32, 7], cf: [29, 5] } },
33 text: 'HIT!!', cy: 9, tone: 'good', s0: 1100,
34 },
35 foul: {
36 moves: [[550, { bat: 'F', ball: [36, 11], ghost: [[33, 13], [31, 14, 'M']] }],
37 [750, { bat: 'F', line: 1, ball: [53, 6], ghost: [[47, 8], [42, 9], [37, 11, 'M']] }]],
38 base: { bat: 'F', line: 1, ball: [53, 6] },
39 text: 'FOUL BALL!!!', cy: 9, tone: 'warn', s0: 800,
40 },
41 k: {
42 moves: [[400, { bat: 'F', ball: [29, 15], ghost: [[29, 14], [29, 13, 'M']] }],
43 [550, { bat: 'F', ball: [29, 17], k: 1 }]],
44 base: { bat: 'F', ball: [29, 17], k: 1 },
45 text: 'STRIKEOUT!!!!!!!', cy: 9, tone: 'bad', s0: 600,
46 },
47};
48export function atBat(result: BatResult) {
49 const play = plays[result];
50 const frames: [number, Scene][] = [
51 [0, { sky: {} }], [120, { sky: { pitch: 1, ball: [29, 13] } }],
52 [250, { sky: { ball: [29, 14], ghost: [[29, 13]] } }],
53 ];
54 if (result !== 'k') frames.push([350, { sky: { bat: 'S', spark: [28, 14] } }]);
55 for (const [at, sky] of play.moves) frames.push([at, { sky }]);
56 for (const stage of [0, 1, 2]) frames.push([play.s0 + stage * 150, {
57 sky: play.base, text: { value: play.text, stage, tone: play.tone, cy: play.cy },
58 }]);
59 if (result === 'foul') frames.push([1250, { ...frames.at(-1)![1], sky: { ...play.base, line: undefined } }]);
60 frames.push([play.s0 + 1800, { sky: {} }]);
61 return { result, frames, s0: play.s0 };
62}
63export type AtBat = ReturnType<typeof atBat>;
64export function batFrame(bat: AtBat, elapsed: number) {
65 let i = 0;
66 while (i + 1 < bat.frames.length && bat.frames[i + 1]![0] <= elapsed) i++;
67 return i;
68}
69hooks/field.ts 318 lines1import type { Keeper, Point } from './model';
2
3// Fixed visuals for the pk and sky scenes.
4const PAL: Record<string, string> = {
5 '.': 'transparent',
6 N: '#141B2D', n: '#22304A', X: '#0B0F1A', q: '#2A3146',
7 G: '#2E7D3A', g: '#38893F', D: '#1D5A2B', m: '#5E8C6A',
8 W: '#F4F1EA', w: '#A9B8C4', M: '#66748A',
9 d: '#B8743F', b: '#6B4226',
10 A: '#1A1E2C', h: '#C98A4E', H: '#B0733D',
11 S: '#E8B48A', r: '#3A2A20',
12 C: '#E8743B', c: '#A9522A', k: '#1F2A44',
13 T: '#22A6A0', t: '#157A75',
14 P: '#D5DCE4', p: '#8C99A8',
15 E: '#D9A35B', O: '#F26B1D', o: '#A8460F',
16 Y: '#FFD23F', F: '#F5A524', L: '#3DD68C', R: '#D6453D', V: '#4C6A92'
17};
18const kdiveL = ['W.rr.....', 'WSSrTTTkk', '.W.TTTkSS'];
19const SPR = {
20 shooter: ['S..S', 'SrrS', '.rr.', 'CCCC', '.CC.', '.kk.', '.S.S', '.k.k'],
21 shootUp: ['S..S', 'SrrS', '.rr.', 'CCCC', '.CC.', '.kk.', '.SS.', '.kk.'],
22 bb: ['OO', 'Oo'],
23 money: ['YC', 'CY'],
24
25 kicker: ['..rr.', '..rr.', '.CCCC', 'SCCCC', '.CCCS', '.kkk.', '.S..S', '.k..k'],
26 kickerF: ['..rr..', '..rr..', '.CCCC.', 'SCCCCS', '.CCC..', '.kkk..', '.S.SSk', '.k....'],
27 keeper: ['W.rr.W', 'T.SS.T', 'TTTTTT', '.TTTT.', '.kkkk.', '.S..S.', '.k..k.'],
28 kdiveL, kdiveR: kdiveL.map(row => [...row].reverse().join('')),
29 sball: ['Wk', 'WW'], spark: ['.Y.', 'YWY', '.Y.'],
30 skyFld: ['k', 'P'],
31 skyRun: ['k', 'C'],
32 skyCat: ['bk'],
33 skyPitch:['.k', 'PP'],
34 skyBatR: ['..', '.k', 'EC'],
35 skyBatS: ['....', '.kEE', '.C..'],
36 skyBatF: ['E.', '.k', '.C'],
37 skyDiveR:['Pk'], skyDiveL: ['Pk'].map(row => [...row].reverse().join('')),
38};
39// Fixed result lettering with TypeScript annotations.
40const FONT5: Record<string, string[]> = {
41 W: ['10001', '10001', '10101', '11011', '10001'],
42 C: ['111', '100', '100', '100', '111'],
43 A: ['111', '101', '111', '101', '101'],
44 B: ['110', '101', '110', '101', '110'],
45 D: ['110', '101', '101', '101', '110'],
46 E: ['111', '100', '110', '100', '111'],
47 F: ['111', '100', '110', '100', '100'],
48 G: ['111', '100', '101', '101', '111'],
49 H: ['101', '101', '111', '101', '101'],
50 I: ['111', '010', '010', '010', '111'],
51 K: ['101', '101', '110', '101', '101'],
52 L: ['100', '100', '100', '100', '111'],
53 M: ['10001', '11011', '10101', '10001', '10001'],
54 N: ['1001', '1101', '1011', '1001', '1001'],
55 O: ['111', '101', '101', '101', '111'],
56 P: ['111', '101', '111', '100', '100'],
57 R: ['110', '101', '110', '101', '101'],
58 S: ['111', '100', '111', '001', '111'],
59 T: ['111', '010', '010', '010', '010'],
60 U: ['101', '101', '101', '101', '111'],
61 '!': ['1', '1', '1', '0', '1'],
62 ' ': ['00', '00', '00', '00', '00']
63};
64const TSTAGE = [3, 5, 7, 10]; // S0 silhouette · S1 base size · S2 final batting size · S3 final penalty-kick size (2x)
65const TGAP = [1, 1, 1, 2];
66const TSPLIT = [2, 3, 5, 6];
67const TTONE = { good: ['Y', 'F'], bad: ['R', 'c'], warn: ['F', 'O'] }; // [top color, bottom color]
68function glyph(ch: string, H: number) {
69 const g = FONT5[ch] || FONT5[' ']!, w = g[0]!.length, W = Math.max(1, Math.round(w * H / 5));
70 const out = [];
71 for (let j = 0; j < H; j++) {
72 const r = g[Math.floor((2 * j + 1) * 5 / (2 * H))]!;
73 let s = '';
74 for (let i = 0; i < W; i++) s += r[Math.floor((2 * i + 1) * w / (2 * W))];
75 out.push(s);
76 }
77 return out;
78}
79export function textW(str: string, st: number) {
80 const H = TSTAGE[st]!;
81 let w = 0;
82 for (let k = 0; k < str.length; k++) w += glyph(str[k]!, H)[0]!.length;
83 return w + (str.length - 1) * TGAP[st]!;
84}
85function bigText(p: string[][], str: string, st: number, tone: keyof typeof TTONE, cx: number, cy: number) {
86 const H = TSTAGE[st]!, col = TTONE[tone] || TTONE.good;
87 const W = textW(str, st) + 2, BH = H + 2;
88 const left = cx - Math.floor(W / 2), top = cy - Math.floor(BH / 2);
89 const m = Array.from({ length: BH }, () => Array<number>(W).fill(0));
90 let x = 1;
91 for (let k = 0; k < str.length; k++) {
92 const g = glyph(str[k]!, H);
93 for (let j = 0; j < H; j++) for (let i = 0; i < g[j]!.length; i++) if (g[j]![i] === '1') m[1 + j]![x + i] = 1;
94 x += g[0]!.length + TGAP[st]!;
95 }
96 for (let j = 0; j < BH; j++) for (let i = 0; i < W; i++) {
97 if (m[j]![i]) { set(p, left + i, top + j, j - 1 < TSPLIT[st]! ? col[0]! : col[1]!); continue; }
98 let near = false;
99 for (let dj = -1; dj <= 1; dj++) for (let di = -1; di <= 1; di++) if (m[j + dj] && m[j + dj]![i + di]) near = true;
100 if (near) set(p, left + i, top + j, 'X');
101 }
102}
103
104export type Scene = { bb?: BBScene; sky?: SkyScene; ball?: Point; keeper?: Keeper; kick?: boolean; ghost?: Point[]; cheer?: boolean; rip?: Point; spark?: Point; text?: { value: string; stage: number; tone: 'good' | 'bad' | 'warn'; cy?: number } };
105function put(p: string[][], sprite: keyof typeof SPR, x: number, y: number) {
106 SPR[sprite].forEach((row, j) => [...row].forEach((c, i) => { if (c !== '.') set(p, x + i, y + j, c); }));
107}
108function ripple(p: string[][], cx: number, cy: number) {
109 for (let y = 3; y < 10; y++) for (let x = 16; x < 44; x++) {
110 const d = Math.hypot(x - cx, y - cy);
111 if (d < 4.2 && p[y]![x] === 'm') p[y]![x] = d < 2.6 ? 'W' : 'w';
112 }
113}
114function cheer(p: string[][]) {
115 for (let x = 0; x < 60; x++) if (x % 3 === 1) {
116 set(p, x, 1, ['Y', 'C', 'W'][(x >> 1) % 3]!); set(p, x, 3, ['C', 'W', 'Y'][(x >> 2) % 3]!);
117 }
118}
119function set(p: string[][], x: number, y: number, c: string) { if (y >= 0 && y < 18 && x >= 0 && x < 60) p[y]![x] = c; }
120function rect(p: string[][], x: number, y: number, w: number, h: number, c: string) {
121 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) set(p, x + i, y + j, c);
122}
123function crowd(p: string[][], y: number, cols: string[]) {
124 for (let x = 0; x < 60; x++) if (x % 3 === 1) set(p, x, y, cols[(x * 7) % cols.length]!);
125}
126function pkField(p: string[][]) {
127 rect(p, 0, 0, 60, 5, 'N'); crowd(p, 1, ['c', 'N', 't', 'p']); crowd(p, 3, ['p', 't', 'N', 'c']);
128 for (let x = 0; x < 60; x++) set(p, x, 5, (Math.floor(x / 4) % 2) ? 'n' : 'k');
129 for (let y = 6; y < 18; y++) for (let x = 0; x < 60; x++) p[y]![x] = (Math.floor(x / 6) % 2) ? 'g' : 'G';
130 rect(p, 16, 3, 28, 7, 'D');
131 for (let y = 3; y < 10; y++) for (let x = 16; x < 44; x++) if (x % 2 === 0 && y % 2 === 1) p[y]![x] = 'm';
132 rect(p, 0, 10, 60, 1, 'W');
133 rect(p, 15, 2, 30, 1, 'W'); rect(p, 15, 2, 1, 9, 'W'); rect(p, 44, 2, 1, 9, 'W');
134}
135// Explicit little-endian packing; no Node or browser globals are needed in the mod.
136const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
137function base64(bytes: Uint8Array): string {
138 let out = '';
139 for (let i = 0; i < bytes.length; i += 3) {
140 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
141 out += alphabet[(n >>> 18) & 63]! + alphabet[(n >>> 12) & 63]!
142 + (i + 1 < bytes.length ? alphabet[(n >>> 6) & 63] : '=')
143 + (i + 2 < bytes.length ? alphabet[n & 63] : '=');
144 }
145 return out;
146}
147export function frame(o: Scene = {}): string {
148 const p = Array.from({ length: 18 }, () => Array<string>(60).fill('G'));
149 const cells: Cells = {};
150 if (o.bb) {
151 const draw = bb3(o.bb);
152 (o.text ? bbText(draw, o.text.value, o.text.stage, o.text.tone) : draw)(p);
153 } else if (o.sky) sky(o.sky)(p, cells);
154 else {
155 pkField(p);
156 put(p, ...(o.keeper ?? ['keeper', 27, 3]));
157 if (o.cheer) cheer(p);
158 if (o.rip) ripple(p, ...o.rip);
159 put(p, o.kick ? 'kickerF' : 'kicker', 22, 10);
160 for (const [x, y] of o.ghost ?? []) set(p, x, y, 'w');
161 put(p, 'sball', ...(o.ball ?? [28, 15]));
162 if (o.spark) put(p, 'spark', ...o.spark);
163 }
164 if (o.text && !o.bb) bigText(p, o.text.value, o.text.stage, o.text.tone, 30, o.text.cy ?? 9);
165 const bytes = new Uint8Array(60 * 9 * 12), view = new DataView(bytes.buffer);
166 for (let row = 0; row < 9; row++) for (let col = 0; col < 60; col++) {
167 const top = p[row * 2]![col]!, bottom = p[row * 2 + 1]![col]!, at = (row * 60 + col) * 12;
168 const cell = cells[row + ',' + col];
169 view.setUint32(at, cell ? cell.ch.codePointAt(0)! : top === bottom ? 32 : 0x2580, true);
170 view.setUint32(at + 4, parseInt(PAL[cell?.f ?? top]!.slice(1), 16), true);
171 view.setUint32(at + 8, parseInt(PAL[cell?.b ?? bottom]!.slice(1), 16), true);
172 }
173 return base64(bytes);
174}
175
176// Independent sky-view reference.
177type Cells = Record<string, { ch: string; f: string; b: string }>;
178export type SkyScene = {
179 pitch?: number; flash?: number; line?: number; k?: number; bat?: 'R' | 'S' | 'F' | 0;
180 lit?: (keyof typeof SKYBASE)[]; fld?: Partial<Record<keyof typeof SKYFLD, [number, number, ('skyDiveR' | 'skyDiveL')?]>>;
181 run?: [number, number]; ball?: [number, number]; spark?: [number, number]; ghost?: [number, number, string?][];
182};
183function txt(o: Cells, row: number, col: number, str: string, f: string, b: string) {
184 for (let i = 0; i < str.length; i++) o[row + ',' + (col + i)] = { ch: str[i]!, f, b };
185}
186const SKY_L = [
187 'NcNNtNNpNNcNNNNNtNNcNNpNNtNNcN', // y0 stands
188 'NNNNNNNNNNNNNNNNNNNNNNNNNNNNNN', // y1
189 'NNtNNpNNcNNtNNcNNpNNtNDDDDDDDD', // y2 top of outfield fence x22-37
190 'NNNNNNNNNNNNNNNNNDDDDDDbbbbbbb', // y3 warning track x23-36
191 'pNNcNNtNNpNNcNDDDDbbbbbGggggGG', // y4
192 'NNNNNNNNNNNDDDbbbbggGGGGggggGG', // y5
193 'NtNNcNNpNFFDbbbGggggGGGGggggGG', // y6 foul pole F (9-10,6)
194 'NNNNNNNNNggwwGGGggggGGGddddddd', // y7 infield dirt x23-36
195 'NNpNNcNNtgggGwwGggggGddddddddW', // y8 2nd base (29-30,8)
196 'NNNNNNNNNNNgGGGwwgggdddddddddG', // y9
197 'cNNtNNpNNcNNtGGGgwwddddddddgGG', // y10
198 'NNNNNNNNNNNNNNNGgggwwddddgggGG', // y11
199 'NpNNcNNtNNpNNcNNtgggGWWGggggdd', // y12 3rd base (21-22,12) · mound x28-31
200 'NNNNNNNNNNNNNNNNNNNgGGGwwgggGG', // y13
201 'NNcNNtNNpNNcNNtNNpNNcGGGgwwgGG', // y14
202 'NNNNNNNNNNNNNNNNNNNNNNNGgggwwd', // y15
203 'pNNcNNtNNpNNcNNtNNpNNcNNtgggdW', // y16 home plate (29-30,16)
204 'NNNNNNNNNNNNNNNNNNNNNNNNNNNgdd' // y17 catcher position
205];
206const SKY = SKY_L.map(function (l) { return l + l.split('').reverse().join(''); });
207// Left pixel of each base (width 2) · fielder head pixel (body directly below, skyFld)
208// Runner head positions in run: 1st (36,11) · 2nd (30,6) · 3rd (23,11), just inside/above each base to keep it visible when lit.
209const SKYBASE: Record<'b1' | 'b2' | 'b3' | 'h', [number, number]> = { b1: [37, 12], b2: [29, 8], b3: [21, 12], h: [29, 16] };
210const SKYFLD: Record<'p' | 'b1' | 'b2' | 'ss' | 'b3' | 'lf' | 'cf' | 'rf', [number, number]> = { p: [30, 11], b1: [39, 9], b2: [36, 8], ss: [23, 8], b3: [20, 9], lf: [19, 6], cf: [30, 4], rf: [40, 6] };
211function skyLine(p: string[][], side: number, c: string) {
212 // Draw foul lines from home (y15) to the poles (y6). side −1 = 3rd-base side, 1 = 1st-base side; skip the 1st/3rd-base row (y12).
213 for (let y = 6; y <= 15; y++) {
214 if (y === 12) continue;
215 const a = 29 - 2 * (16 - y);
216 (side < 0 ? [a, a + 1] : [58 - a, 59 - a]).forEach(function (x) { set(p, x, y, c); });
217 }
218}
219function skyFlash(p: string[][]) {
220 // Home-run crowd flash: color crowd dots Y·C·F; add Y at x%6 = 3 on odd rows of the stands.
221 for (let y = 0; y < 18; y++) for (let x = 0; x < 60; x++) {
222 const k = SKY[y]![x]!;
223 if (k === 'c' || k === 't' || k === 'p') p[y]![x] = ['Y', 'C', 'F'][(x + y) % 3]!;
224 else if (k === 'N' && y % 2 === 1 && x % 6 === 3) p[y]![x] = 'Y';
225 }
226}
227function skyK(p: string[][], t: Cells) {
228 // Strikeout indicator: row 8, columns 48-56 background X; column 49 K(W); columns 51·53·55 lights (R, full cell).
229 rect(p, 48, 16, 9, 2, 'X');
230 txt(t, 8, 49, 'K', 'W', 'X');
231 [51, 53, 55].forEach(function (x) { rect(p, x, 16, 1, 2, 'R'); });
232}
233function sky(o: SkyScene) {
234 return function (p: string[][], t: Cells) {
235 for (let y = 0; y < 18; y++) for (let x = 0; x < 60; x++) p[y]![x] = SKY[y]![x]!;
236 if (o.flash) skyFlash(p);
237 if (o.line) skyLine(p, o.line, 'Y');
238 (o.lit || []).forEach(function (k) { const b = SKYBASE[k]; rect(p, b[0], b[1], 2, 1, 'Y'); });
239 const f: Record<keyof typeof SKYFLD, [number, number, ('skyDiveR' | 'skyDiveL')?]> = { ...SKYFLD, ...o.fld };
240 (Object.keys(f) as (keyof typeof f)[]).forEach(function (k) {
241 const q = f[k];
242 if (k === 'p' && o.pitch) put(p, 'skyPitch', q[0] - 1, q[1]);
243 else put(p, q[2] || 'skyFld', q[0], q[1]);
244 });
245 put(p, 'skyCat', 29, 17);
246 const bat = o.bat === undefined ? 'R' : o.bat;
247 if (bat) put(p, `skyBat${bat}`, 25, 14); else set(p, 27, 16, 'E'); // 0 = batter has run off, leaving only the bat
248 if (o.run) put(p, 'skyRun', o.run[0], o.run[1]);
249 (o.ghost || []).forEach(function (q) { set(p, q[0], q[1], q[2] || 'w'); });
250 if (o.spark) put(p, 'spark', o.spark[0], o.spark[1]);
251 if (o.ball) set(p, o.ball[0], o.ball[1], 'W');
252 if (o.k) skyK(p, t);
253 };
254}
255
256// 3-point drawing without a gauge.
257export type BBScene = {
258 cur?: number; rack?: number; jump?: number; hold?: 'bb' | 'money'; cheer?: number; flash?: string; sway?: string;
259 ball?: [number, number, ('bb' | 'money')?]; ghost?: [number, number, string?][]; sparks?: [number, number][];
260};
261export const SPOTS: [number, number][] = [[10, 17], [16, 13], [30, 11], [44, 13], [50, 17]];
262function bbField(p: string[][], cur: number) {
263 rect(p, 0, 0, 60, 10, 'A'); crowd(p, 1, ['c', 'A', 't', 'p']); crowd(p, 3, ['p', 't', 'A', 'c']);
264 for (let y = 10; y < 18; y++) for (let x = 0; x < 60; x++) p[y]![x] = ((x + y * 2) % 9 === 0) ? 'H' : 'h';
265 for (let a = -90; a <= 90; a += 2) { const r = a * Math.PI / 180; set(p, Math.round(30 + 20 * Math.sin(r)), Math.round(11 + 6 * (1 - Math.cos(r))), 'W'); }
266 rect(p, 23, 1, 14, 6, 'n'); rect(p, 23, 1, 14, 1, 'W'); rect(p, 23, 6, 14, 1, 'W'); rect(p, 23, 1, 1, 6, 'W'); rect(p, 36, 1, 1, 6, 'W');
267 rect(p, 27, 3, 6, 1, 'W'); rect(p, 27, 3, 1, 3, 'W'); rect(p, 32, 3, 1, 3, 'W');
268 for (let x = 27; x <= 32; x++) if (x % 2 === 1) set(p, x, 8, 'W');
269 for (let x = 28; x <= 31; x++) if (x % 2 === 0) set(p, x, 9, 'w');
270 rect(p, 26, 7, 8, 1, 'O');
271 SPOTS.forEach(function (s, i) { const c = i < cur ? 'M' : (i === cur ? 'Y' : 'C'); set(p, s[0], s[1], c); set(p, s[0] + 1, s[1], c); });
272}
273function netFlash(p: string[][], c: string) { rect(p, 27, 8, 6, 1, c); rect(p, 28, 9, 4, 1, c); }
274function rack(p: string[][], n: number) {
275 rect(p, 0, 16, 10, 1, 'p'); set(p, 0, 17, 'p'); set(p, 9, 17, 'p');
276 for (let i = 5 - n; i < 5; i++) put(p, i === 4 ? 'money' : 'bb', i * 2, 14);
277}
278const BBLINES: Record<string, string[]> = { 'FROM DOWNTOWN!!!!!!!': ['FROM', 'DOWNTOWN!!!!!!!'] };
279// Multiline text: stack line boxes (height H+2) with no gap; center the last line at cy.
280// If left is set, align every line box to left; otherwise center each line at x30.
281function bigLines(p: string[][], lines: string[], st: number, tone: keyof typeof TTONE, cy: number, left?: number) {
282 const BH = TSTAGE[st]! + 2;
283 lines.forEach(function (s, k) {
284 const cx = left === undefined ? 30 : left + Math.floor((textW(s, st) + 2) / 2);
285 bigText(p, s, st, tone, cx, cy - (lines.length - 1 - k) * BH);
286 });
287}
288// 3-point result text: S0 at the result → S1 (5px) after 0.1s, held until the end. All 6 results use S1.
289// Center both single-line and two-line text on the court.
290function bbText(base: (p: string[][]) => void, str: string, st: number, tone: keyof typeof TTONE) {
291 return function (p: string[][]) {
292 base(p);
293 if (BBLINES[str]) bigLines(p, BBLINES[str]!, st, tone, 12);
294 else bigText(p, str, st, tone, 30, 9);
295 };
296}
297function netSway(p: string[][], c: string) {
298 // Net sway: row 8 x27·29·31 → w, row 9 x28-31 → c (W, money ball Y), row 10 x29·30 → w. Sway at 0.65·0.85s; normal net at 0.75·0.95s.
299 [27, 29, 31].forEach(function (x) { set(p, x, 8, 'w'); });
300 rect(p, 28, 9, 4, 1, c); set(p, 29, 10, 'w'); set(p, 30, 10, 'w');
301}
302function bb3(o: BBScene) {
303 return function (p: string[][]) {
304 const cur = o.cur === undefined ? 1 : o.cur;
305 bbField(p, cur);
306 if (o.cheer) { const a = o.cheer === 2 ? ['Y', 'C'] : ['C', 'Y']; crowd(p, 1, [a[0]!]); crowd(p, 3, [a[1]!]); } // Money-ball crowd: 1 (row 1 C · row 3 Y) ↔ 2 (swapped)
307 if (o.flash) netFlash(p, o.flash);
308 if (o.sway) netSway(p, o.sway);
309 rack(p, o.rack === undefined ? 3 : o.rack);
310 const s = SPOTS[cur]!, sx = Math.min(49, Math.max(10, s[0] - 1)), sy = s[1] - 7 - (o.jump ? 1 : 0);
311 put(p, o.jump ? 'shootUp' : 'shooter', sx, sy);
312 if (o.hold) put(p, o.hold, sx + 1, sy - 2);
313 (o.ghost || []).forEach(function (q) { set(p, q[0], q[1], q[2] || 'w'); });
314 (o.sparks || []).forEach(function (q) { put(p, 'spark', q[0], q[1]); });
315 if (o.ball) put(p, o.ball[2] || 'bb', o.ball[0], o.ball[1]);
316 };
317}
318hooks/language.ts 63 lines1import type { EngineInterface, PluginOptions } from 'claude-code';
2
3export type Activity = {
4 key: 'ready' | 'thinking' | 'writing' | 'tool' | 'tests' | 'git' | 'command' | 'editing' | 'reading' | 'web' | 'subagent';
5 detail?: string;
6};
7
8export const messages = {
9 ko: {
10 names: { kick: '승부차기', bat: '야구', bb: '3점슛 콘테스트' },
11 shortNames: { kick: '승부차기', bat: '야구', bb: '3점슛 콘테스트' },
12 totals: { kick: ['골', '선방', '빗나감'], bat: ['홈런', '안타', '삼진'], bb: ['점수', '성공', '실패'] },
13 shoot: '슛', swing: '스윙', pause: '멈춤',
14 activity: {
15 ready: '준비 중', thinking: '생각 중', writing: '답 쓰는 중',
16 tool: (name = '') => `${name} 사용 중`,
17 tests: '테스트 실행 중', git: 'git 작업 중', command: '명령 실행 중',
18 editing: (file = '') => `파일 편집 중 (${file})`,
19 reading: '코드 읽는 중', web: '웹 찾는 중', subagent: '서브에이전트 작업 중',
20 },
21 paused: 'clutch 멈춤 · 다음 질문에서 다시 켜집니다',
22 narrow: '터미널을 넓히면 게임이 나옵니다',
23 description: '스포츠 미니게임 켜기·끄기',
24 on: 'clutch 켜짐', off: 'clutch 꺼짐',
25 usage: '사용법: /clutch (켜기·끄기)',
26 },
27 en: {
28 names: { kick: 'Penalty Shootout', bat: 'Baseball', bb: '3-Point Contest' },
29 shortNames: { kick: 'Shootout', bat: 'Baseball', bb: '3-Point' },
30 totals: { kick: ['Goals', 'Saves', 'Misses'], bat: ['HR', 'Hits', 'K'], bb: ['Pts', 'Made', 'Missed'] },
31 shoot: 'Shoot', swing: 'Swing', pause: 'Pause',
32 activity: {
33 ready: 'is getting ready', thinking: 'is thinking', writing: 'is writing',
34 tool: (name = '') => `is using ${name}`,
35 tests: 'is running tests', git: 'is working with git', command: 'is running a command',
36 editing: (file = '') => `is editing ${file || 'a file'}`,
37 reading: 'is reading code', web: 'is searching the web', subagent: 'is running a subagent',
38 },
39 paused: 'clutch paused · back on your next prompt',
40 narrow: 'Widen the terminal to play',
41 description: 'Toggle sports minigames',
42 on: 'clutch on', off: 'clutch off',
43 usage: 'Usage: /clutch (toggle on/off)',
44 },
45} as const;
46
47export async function language($: EngineInterface, options: PluginOptions): Promise<keyof typeof messages> {
48 let choice = options.language;
49 try {
50 choice = (await $.config.list()).find(row => row.key === 'clutch.language')?.value ?? choice;
51 } catch { /* Use the registered option when config is unavailable. */ }
52 if (choice === 'ko' || choice === 'en') return choice;
53 try {
54 const value = (await $.settings.read()).language;
55 return typeof value === 'string' && ['korean', 'ko', 'ko-kr', '한국어'].includes(value.trim().toLowerCase()) ? 'ko' : 'en';
56 } catch { return 'en'; }
57}
58
59export function activityText(words: typeof messages[keyof typeof messages], activity: Activity): string {
60 const value = words.activity[activity.key];
61 return typeof value === 'function' ? value(activity.detail) : value;
62}
63hooks/model.ts 32 lines1export type Point = [number, number];
2export type Keeper = ['keeper' | 'kdiveL' | 'kdiveR', number, number];
3export type Shot = { result: 'goal' | 'save' | 'miss'; ball: Point; keeper: Keeper; rip?: Point; spark?: Point };
4export type Score = Record<Shot['result'], number>;
5export function shoot(random: () => number): Shot {
6 const r1 = random(), r2 = random(), r3 = random(), r4 = random(), r5 = random();
7 const z = Math.min(4, Math.floor(r3 * 5)), x = [18, 23, 29, 34, 40][z]!;
8 const left = r5 < .5, dive: Keeper = left ? ['kdiveL', 18, 6] : ['kdiveR', 34, 6];
9 if (r1 < .30) return r4 < .5
10 ? { result: 'miss', ball: [x, 0], keeper: dive }
11 : { result: 'miss', ball: [left ? 15 : 43, 4], keeper: dive, spark: [left ? 13 : 44, 3] };
12 if (r2 < .30) return { result: 'save', ball: [x, z === 2 ? 6 : 5],
13 keeper: z < 2 ? ['kdiveL', x + 2, 5] : z > 2 ? ['kdiveR', x - 9, 5] : ['keeper', 27, 3] };
14 return { result: 'goal', ball: [x, 4], rip: [x + 1, 5],
15 keeper: z < 2 ? ['kdiveR', 34, 6] : z > 2 ? ['kdiveL', 18, 6] : dive };
16}
17export function kickPosition(elapsed: number, end: Point): Point {
18 const t = Math.min(elapsed / 250, 1);
19 return [Math.round(28 + (end[0] - 28) * t), Math.round(15 + (end[1] - 15) * t)];
20}
21export function fitActivity(activity: string, limit: number) {
22 const value = `Claude ${activity}`;
23 const width = (c: string) => /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u.test(c) ? 2 : 1;
24 if ([...value].reduce((n, c) => n + width(c), 0) <= limit) return value;
25 let result = '', columns = 0;
26 for (const c of value) {
27 if (columns + width(c) > limit - 1) break;
28 result += c; columns += width(c);
29 }
30 return result.trimEnd() + '…';
31}
32hooks/runtime.ts 203 lines1import { bbFrame, bbWait } from './basketball';
2import type { Basketball } from './basketball';
3import { batFrame } from './batting';
4import type { AtBat } from './batting';
5import type { EngineInterface, Timer } from 'claude-code';
6import { frame } from './field';
7import type { Scene } from './field';
8import { kickPosition } from './model';
9import type { Shot } from './model';
10import type { Activity } from './language';
11import { pickSport, SPORTS } from './sports';
12import type { Sport } from './sports';
13
14export function createRuntime(random: () => number = Math.random, selectionRandom: () => number = Math.random) {
15 return {
16 enabled: true, paused: false,
17 turn: undefined as string | undefined, site: undefined as string | undefined,
18 sport: 'kick' as Sport, selectionRandom, switchedAt: 0, prepared: false,
19 drawnSport: undefined as Sport | undefined,
20 last: undefined as Sport | undefined, sportWrites: Promise.resolve() as Promise<unknown>,
21 rotationTimer: undefined as Timer | undefined, rotationEpoch: 0,
22 mode: 'kick' as Sport, kickAt: undefined as number | undefined,
23 random, shot: undefined as Shot | undefined, landed: false, textStage: -1, score: { goal: 0, save: 0, miss: 0 },
24 activity: { key: 'ready' } as Activity, bat: undefined as AtBat | undefined, batAt: undefined as number | undefined,
25 batShown: 0, batCounted: false, batFinalAt: undefined as number | undefined, batScore: { homerun: 0, hit: 0, strikeout: 0 },
26 bb: undefined as Basketball | undefined, bbAt: undefined as number | undefined, bbSpot: 0, bbBall: 1,
27 bbShown: 0, bbCounted: false, bbFinalAt: undefined as number | undefined, bbScore: { points: 0, success: 0, fail: 0 },
28 spaces: 0,
29 timer: undefined as Timer | undefined, busy: false, generation: 0,
30 lastBlit: undefined as number | undefined, lastCells: '',
31 };
32}
33export type Runtime = ReturnType<typeof createRuntime>;
34export function setActivity($: EngineInterface, b: Runtime, activity: Activity) {
35 if (b.activity.key === activity.key && b.activity.detail === activity.detail) return;
36 b.activity = activity; $.ui.invalidate('ui.render');
37}
38export function stop(b: Runtime) { b.timer?.cancel(); b.timer = undefined; b.generation++; b.lastBlit = undefined; }
39export function hide(b: Runtime) { stop(b); b.site = undefined; }
40export function stopRotation(b: Runtime) { b.rotationTimer?.cancel(); b.rotationTimer = undefined; b.rotationEpoch++; }
41export function invalidate($: EngineInterface) {
42 try { $.ui.invalidate('ui.render'); } catch { /* A missing render must not break rotation or consume handling. */ }
43}
44export function prepareSport(b: Runtime) {
45 b.sport = pickSport(SPORTS, b.selectionRandom, b.last); b.mode = b.sport; b.prepared = true;
46}
47export function selectSport($: EngineInterface, b: Runtime, sport: Sport, now: number) {
48 stopRotation(b); b.sport = sport; b.mode = sport; b.switchedAt = now;
49 b.last = sport;
50 b.sportWrites = b.sportWrites.then(() => $.store.set('sport', sport)).catch(() => {});
51 const epoch = b.rotationEpoch;
52 b.rotationTimer = $.clock.every(60000, () => {
53 if (epoch !== b.rotationEpoch) return;
54 b.rotationTimer?.cancel(); b.rotationTimer = undefined;
55 void $.clock.now().then(now => {
56 if (epoch === b.rotationEpoch) rotate($, b, now);
57 }).catch(() => {});
58 });
59}
60export function rotate($: EngineInterface, b: Runtime, now: number) {
61 if (!b.enabled || !b.turn || b.paused || now - b.switchedAt < 60000
62 || b.kickAt !== undefined || b.batAt !== undefined || b.bbAt !== undefined) return;
63 selectSport($, b, pickSport(SPORTS, b.selectionRandom, b.sport), now);
64 invalidate($);
65}
66export function resetTurn(b: Runtime) {
67 stopRotation(b); b.prepared = false;
68 hide(b); b.paused = false; b.mode = b.sport; b.kickAt = undefined;
69 b.shot = undefined; b.landed = false; b.textStage = -1; b.activity = { key: 'ready' }; b.score = { goal: 0, save: 0, miss: 0 };
70 b.bat = undefined; b.batAt = undefined; b.batShown = 0; b.batCounted = false; b.batFinalAt = undefined;
71 b.batScore = { homerun: 0, hit: 0, strikeout: 0 };
72 b.bb = undefined; b.bbAt = undefined; b.bbSpot = 0; b.bbBall = 1; b.bbShown = 0; b.bbCounted = false; b.bbFinalAt = undefined;
73 b.bbScore = { points: 0, success: 0, fail: 0 };
74 b.spaces = 0;
75}
76export function updateShot(b: Runtime, now: number): boolean {
77 if (b.mode === 'bb') return updateBB(b, now);
78 if (b.mode === 'bat') return updateBat(b, now);
79 if (b.kickAt === undefined || !b.shot) return false;
80 let changed = false;
81 if (!b.landed && now - b.kickAt >= 250) {
82 b.score[b.shot.result]++; b.landed = true; changed = true;
83 }
84 if (now - b.kickAt >= 1750) { b.kickAt = undefined; b.shot = undefined; b.landed = false; changed = true; }
85 return changed;
86}
87export function scene(b: Runtime, now: number): Scene {
88 if (b.mode === 'bb') return b.bb && b.bbAt !== undefined ? b.bb.frames[currentBBFrame(b, now)]![1] : bbWait(b.bbSpot, b.bbBall);
89 if (b.mode === 'bat') return b.bat && b.batAt !== undefined ? b.bat.frames[currentBatFrame(b, now)]![1] : { sky: {} };
90 if (b.kickAt === undefined || !b.shot) return {};
91 const elapsed = now - b.kickAt, shot = b.shot;
92 if (elapsed >= 1750) return {};
93 return {
94 kick: true, ball: kickPosition(elapsed, shot.ball), keeper: elapsed >= 100 ? shot.keeper : undefined,
95 ghost: elapsed < 250 ? [elapsed - 40, elapsed - 80].filter(t => t >= 0).map(t => kickPosition(t, shot.ball)) : [],
96 ...(elapsed >= 250 ? { cheer: shot.result === 'goal', rip: shot.rip, spark: shot.spark,
97 text: { value: shot.result === 'goal' ? 'GOOOOOOOAL!!!!!!!' : 'BOOOOOOO!!!!!!!',
98 stage: elapsed >= 350 ? 1 : 0, tone: shot.result === 'goal' ? 'good' : 'bad' } } : {}),
99 };
100}
101export function animate($: EngineInterface, b: Runtime, now: number) {
102 if (b.timer || b.paused || !b.enabled || !b.turn || b.site === undefined || (b.mode === 'kick' && b.kickAt === undefined) || (b.mode === 'bat' && b.batAt === undefined) || (b.mode === 'bb' && b.bbAt === undefined)) return;
103 const epoch = b.generation;
104 const elapsed = b.kickAt === undefined ? 0 : now - b.kickAt;
105 const sinceBlit = b.lastBlit === undefined ? Infinity : now - b.lastBlit;
106 const stage = elapsed >= 350 ? 1 : 0;
107 // Retry the current text stage after throttling; do not replay stale stages.
108 const delay = Math.max(1, b.mode === 'bb' ? bbDelay(b, now, sinceBlit) : b.mode === 'bat' ? batDelay(b, now, sinceBlit)
109 : elapsed >= 1750 || (elapsed >= 250 && b.textStage < stage) ? 33 - sinceBlit
110 : elapsed >= 250 ? (elapsed < 350 ? 350 : 1750) - elapsed
111 : 250 - elapsed < 66 ? 250 - elapsed : 33);
112 b.timer = $.clock.every(delay, () => {
113 if (b.busy) return;
114 b.timer?.cancel(); b.timer = undefined;
115 b.busy = true;
116 let nextAt = now;
117 void (async () => {
118 const now = await $.clock.now(); nextAt = now;
119 if (epoch !== b.generation || !b.enabled || !b.turn || b.site === undefined) return;
120 if (b.lastBlit !== undefined && now - b.lastBlit < 33) return;
121 const changed = updateShot(b, now);
122 rotate($, b, now);
123 const current = scene(b, now), cells = frame(current);
124 // Draw scheduled 3-point frames even when centered text obscures the pixel changes.
125 if (cells !== b.lastCells || (b.mode === 'bb' && b.bb && currentBBFrame(b, now) > b.bbShown)) {
126 b.lastBlit = now;
127 try {
128 const result = await $.ui.blit({ requestId: b.site, key: 'field', columns: 60, rows: 9, cells });
129 if (epoch !== b.generation) return;
130 if (result.deny) { hide(b); }
131 else { b.lastCells = cells; b.drawnSport = b.mode; b.textStage = current.text?.stage ?? -1; markBatFrame(b, now, current); markBBFrame(b, now, current); }
132 } catch { if (epoch !== b.generation) return; hide(b); }
133 }
134 if (changed) invalidate($);
135 if ((b.mode === 'kick' && b.kickAt === undefined) || (b.mode === 'bat' && b.batAt === undefined) || (b.mode === 'bb' && b.bbAt === undefined)) stop(b);
136 })().catch(() => { stop(b); }).finally(() => { b.busy = false; if (epoch === b.generation) animate($, b, nextAt); });
137 });
138}
139
140function currentBatFrame(b: Runtime, now: number) {
141 const bat = b.bat!, i = batFrame(bat, now - b.batAt!);
142 // Even a callback arriving after the return time must show S2 once.
143 return i === bat.frames.length - 1 ? i - 1 : i;
144}
145export function markBatFrame(b: Runtime, now: number, current: Scene) {
146 if (!b.bat || b.batAt === undefined) return;
147 b.batShown = currentBatFrame(b, now);
148 if (current.text?.stage === 2 && b.batFinalAt === undefined) b.batFinalAt = now;
149}
150function updateBat(b: Runtime, now: number) {
151 if (!b.bat || b.batAt === undefined) return false;
152 const elapsed = now - b.batAt, bat = b.bat;
153 let changed = false;
154 if (!b.batCounted && elapsed >= bat.s0) {
155 if (bat.result === 'hr') b.batScore.homerun++;
156 else if (bat.result === 'k') b.batScore.strikeout++;
157 else if (bat.result !== 'foul') b.batScore.hit++;
158 b.batCounted = true; changed = true;
159 }
160 if (elapsed >= bat.frames.at(-1)![0] && b.batFinalAt !== undefined && now - b.batFinalAt >= 33) {
161 b.bat = undefined; b.batAt = undefined; b.batCounted = false; changed = true;
162 }
163 return changed;
164}
165function batDelay(b: Runtime, now: number, sinceBlit: number) {
166 const bat = b.bat!, elapsed = now - b.batAt!;
167 const current = batFrame(bat, elapsed);
168 const finalGap = b.batFinalAt === undefined ? 0 : b.batFinalAt + 33 - now;
169 return current > b.batShown ? Math.max(finalGap, 33 - sinceBlit)
170 : Math.max(bat.frames[current + 1]![0] - elapsed, 33 - sinceBlit);
171}
172
173function currentBBFrame(b: Runtime, now: number) {
174 return Math.min(bbFrame(b.bb!, now - b.bbAt!), b.bb!.frames.length - 2);
175}
176export function markBBFrame(b: Runtime, now: number, current: Scene) {
177 if (!b.bb || b.bbAt === undefined) return;
178 b.bbShown = currentBBFrame(b, now);
179 if (current.text?.stage === 1 && b.bbFinalAt === undefined) b.bbFinalAt = now;
180}
181function updateBB(b: Runtime, now: number) {
182 if (!b.bb || b.bbAt === undefined) return false;
183 const elapsed = now - b.bbAt, shot = b.bb;
184 let changed = false;
185 if (!b.bbCounted && elapsed >= shot.s0) {
186 if (shot.good) { b.bbScore.success++; b.bbScore.points += b.bbBall === 5 ? 2 : 1; }
187 else b.bbScore.fail++;
188 b.bbCounted = true; changed = true;
189 }
190 // A late callback must display S1 before advancing to the next ball.
191 if (elapsed >= shot.frames.at(-1)![0] && b.bbFinalAt !== undefined && now - b.bbFinalAt >= 33) {
192 if (b.bbBall === 5) { b.bbSpot = (b.bbSpot + 1) % 5; b.bbBall = 1; } else b.bbBall++;
193 b.bb = undefined; b.bbAt = undefined; b.bbCounted = false; changed = true;
194 }
195 return changed;
196}
197function bbDelay(b: Runtime, now: number, sinceBlit: number) {
198 const shot = b.bb!, elapsed = now - b.bbAt!, current = bbFrame(shot, elapsed);
199 const finalGap = b.bbFinalAt === undefined ? 0 : b.bbFinalAt + 33 - now;
200 return current > b.bbShown ? Math.max(finalGap, 33 - sinceBlit)
201 : Math.max(shot.frames[current + 1]![0] - elapsed, 33 - sinceBlit);
202}
203hooks/sports.ts 8 lines1export const SPORTS = ['kick', 'bat', 'bb'] as const;
2export type Sport = typeof SPORTS[number];
3
4export function pickSport<T>(sports: readonly T[], random: () => number, current?: T): T {
5 const choices = current === undefined ? sports : sports.filter(sport => sport !== current);
6 return choices[Math.floor(random() * choices.length)]!;
7}
8