SLOPSHOPPER

clutch

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

newspinnerguardcommandprompttimer
v0.3.0MITupdated 2026-10-20FLOWVIA-Labs/flowvia-mods/clutch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clutch
› 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 › /clutch ⎿ clutch: clutch off ✻ Thinking… Claude is thinking Baseball │ HR 0 · Hits 0 · K 0 Space Swing 1 Shootout 3 3-Point 9 Pause ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
✻ Thinking… Claude is thinking Baseball │ HR 0 · Hits 0 · K 0 Space Swing 1 Shootout 3 3-Point 9 Pause ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀ ▀▀▀▀▀▀ ▀ ▀ ▀ ▀▀
README

flowvia-mods

Two mods for Claude Code by FLOWVIA Labs.

ModWhat it does
neon-sectionsSplits 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.
clutchA sports minigame under the spinner while Claude works: penalty shootout, batting, and a 3-point contest, all played with the Space key.

neon-sections drawing an answer as Conclusion, Rationale, Needs your input, and Next steps boxes

clutch: a baseball at-bat under the Claude Code spinner

clutch: the 3-point contest under the Claude Code spinner

Requirements

  • Claude Code v2.1.287 or later in the terminal (mods are on by default from this version), or the desktop app v2.1.286 or later.
  • Tested with Claude Code 2.1.304.
  • clutch runs in the terminal only and needs a window at least 66 columns wide.

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.

Install

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.

Language

Both mods speak English and Korean. Each has a language option:

ValueBehavior
auto (default)Korean when your Claude Code language setting is Korean (korean, ko, ko-KR, 한국어), English otherwise
koAlways Korean
enAlways English

Change it in /plugin → Installed → the mod → Configure options, or in /config.

neon-sections

neon-sections asks Claude to structure answers with these headings and then draws them as boxes:

### Conclusion
### Rationale
### Needs your input
### Next steps
  • A report on finished work can carry a status: ### Conclusion · Done, · Waiting, or · Blocked. It shows as a badge on the Conclusion box.
  • Korean headings (### 결론 and so on) are recognized too, whatever the language setting.
  • Short answers and progress notes between tool calls stay plain text.
  • When a turn ends, its tool calls fold into one Work log line. In the desktop app and the fullscreen terminal you can expand it.
  • Code blocks get a COPY button in the desktop app and the fullscreen terminal.

Settings

Run /ns-setting to open the settings panel. You can set:

  • each section's color for dark and light themes
  • effects: double-border glow, neon on the latest answer only, highlight on hover, blink, and breathe. Effects other than glow work in the desktop app and the fullscreen terminal only.

The same settings are available as commands:

CommandEffect
/ns-setting on / offTurn the boxes on or off
/ns-setting statusShow 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.

clutch

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.

KeyAction
SpaceShoot or swing
1 / 2 / 3Switch to penalty shootout / baseball / 3-point contest
9Pause the game for this turn
  • Game keys are read only while Claude is working and the prompt box is empty. Once you start typing, keys go to the prompt as usual. To start a message with Space, 1, 2, 3, or 9, press 9 first to pause the game.
  • The scoreboard shows what Claude is doing right now (thinking, editing a file, running tests, and so on).
  • /clutch turns the game on or off.

Privacy

  • Neither mod makes network requests.
  • Both mods read your Claude Code settings only for the language value and the light/dark theme, and never send them anywhere.
  • clutch stores two values locally: the last sport played and whether the game is on.

Development

See AGENTS.md for the layout, setup, and test commands.

License

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.

Source 8 files
hooks/register.tsx 170 lines
1import { 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}
170
hooks/basketball.ts 69 lines
1import { 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}
69
hooks/batting.ts 69 lines
1import 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}
69
hooks/field.ts 318 lines
1import 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}
318
hooks/language.ts 63 lines
1import 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}
63
hooks/model.ts 32 lines
1export 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}
32
hooks/runtime.ts 203 lines
1import { 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}
203
hooks/sports.ts 8 lines
1export 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