SLOPSHOPPER

clawd-bros

Clawdたちがプロンプト上の帯(終点ステージ)で大乱闘。ツール呼び出しで技が出て、ダメージ%と残機は帯のHUDに表示。P1はWASDと<>で操作できる

newbandguardcommandtoaststatus
v0.2.0no licenseupdated 2026-10-15jnk0vc/clawd-bros
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-bros
› fix the failing auth test and add an audit log call ╭───────────────────────────────────────╮ │ clawd-bros │ ⏺ Read(src/auth.ts) │ Clawd Bros: 一時停止しました(/clawd-bros で再開) │ ⎿ 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 › /clawd-bros ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Clawd Bros

Clawd Bros 紹介動画(30秒)

Claude Codeのプロンプト上の帯を「終点」ステージにして、Clawd 4体が大乱闘するmodです。Claudeがツールを呼ぶたびに対応するClawdが技を出し、各Clawdのダメージ%と残機は帯の最下段のHUDに出ます。P1はキーボードで自分で操作することもできます。

場外KOのフレア

P1を操作中の乱闘。HUDの左端がYOUになる

インストール

Claude Codeをターミナルで起動し、プロンプトに次の1行を入力します。

/plugin install clawd-bros --marketplace jnk0vc/clawd-bros

マーケットプレイスの追加を聞かれたらy、続けてインストール先のスコープを選びます。インストール後に始めたセッションで、帯に試合が出ます。

デスクトップアプリのCodeタブではこのコマンドを使えません。ターミナルでユーザースコープに入れておけば、デスクトップアプリのローカルセッションでも読み込まれます。

遊び方

何もしなくても試合は進みます。4体ともCPUで、ツール呼び出しが無い間も歩く・跳ぶ・ジャブを出す・たまに得意技を出す、を繰り返します。Claudeが作業を始めると、使ったツールに応じた技が出て試合が一気に動きます。

Clawd反応するツール技
P1 WRITE(オレンジ)Edit MultiEdit NotebookEdit横スマッシュ(溜めてから強打)
Writeメテオ(跳んで相手の頭上から急降下)
P2 READ(青)Read3連ジャブ
Grep Glob LS ToolSearch LSP などビーム(飛び道具)
P3 BASH(黄)Bashダッシュ攻撃
BashOutput KillShell Monitor など回転攻撃
P4 AGENT(緑)Agent Task WorkflowミニClawdを召喚して走らせる
WebFetch WebSearchつかみ投げ(遠くからワイヤーで引き寄せる)
MCPツール・Skill・その他すべて回転攻撃
  • ダメージ%が高いほど遠くへふっとび、画面外へ出るとKOです。出た側の端から扇状のフレアが出ます。
  • ツールがエラーになったり拒否されたりすると、その系統のClawdがしばらくピヨります。
  • 短い間に6回以上ツールを呼ぶとスマッシュボールが現れます。次のツール呼び出しで拾ったClawdの、その次の技が最後の切りふだ(画面端までのレーザー)になります。
  • ストックは3つです。最後の1体になると「GAME!」の後に勝者を表示し、次の試合が始まります。

帯の最下段のHUDには、4体分のダメージ%と残機が並びます。%はダメージが増えるほど白→黄→橙→赤と色が変わり、残機は小さなClawdの頭(▟▙)で表します。

 P1 WRITE 34% ▟▙ ▟▙ ▟▙    P2 READ 102% ▟▙ ▟▙    P3 BASH 0% ▟▙ ▟▙ ▟▙    P4 AGENT OUT

同じ内容に直近の出来事を足した1行が、ステータスラインにも出ます。

CLAWD BROS  P1 WRITE 34% ●●●  P2 READ 102% ●●  P3 BASH 0% ●●●  P4 AGENT OUT  ▸ P3 BASH ▸ Bash: DASH

P1を操作する

帯の最下段にある「[P1を操作]」の行をクリックすると、キー入力がP1(WRITE)に入ります。

キー動き
A / D(← →も可)左右に移動。押し続けると走る
W(↑・スペースも可)ジャンプ。空中でもう1回跳べて、その後は上必殺で復帰
S(↓も可)空中で急降下
<(,も可)弱攻撃(ジャブ)
>(.も可)必殺技(横スマッシュ)。スマッシュボールを持っていれば最後の切りふだ
  • 操作中はP1の頭上とHUDが「YOU」になり、ツール呼び出しでP1が勝手に技を出すことはなくなります。スマッシュボールはツール呼び出しで拾えます。
  • Escでキー入力をプロンプトへ戻せます。10秒キーを押さなければ、P1はCPUに戻ります。
  • ターミナルでは、クリックを受け取れるフルスクリーン表示のときに使えます。

コマンド

コマンド動作
/clawd-bros一時停止と再開を切り替える
/clawd-bros pause / /clawd-bros resume一時停止する / 再開する
/clawd-bros reset新しい試合を始める

一時停止は、Claude Codeを起動し直しても続きます。結果はトーストで知らせ、会話には何も残しません。帯は[-](またはctrl+x ctrl+a)で畳めます。

描画のしくみ

  • ターミナルでは、ステージ(最大10行)とHUD 1行を1枚のセルグリッド(Raster)として描き、10fpsで$.ui.blitにより差し替えます。その下にP1操作用の行(Client)を1行置きます。
  • ステージが7行未満、または幅が40列未満になるときは描きません。フルスクリーンでないターミナルでは、帯全体を端末の高さの1/3までに抑えます。
  • デスクトップアプリでは、同じフレームをSVGのドット絵にして5fpsで描き直します。
  • 使う文字はASCIIとブロック要素(▀▄█▌▐▖▗▘▙▚▛▜▝▞▟)だけです。曖昧幅の文字は使っていないので、日本語環境のターミナルでも列がずれません。

Claude Codeの関数フック(mod)APIはアーリーアクセスで、リリース間で変わることがあります。このmodはClaude Code 2.1.295で作り、同バージョンのclaude plugin testで確認しています。

開発

hooks/register.tsx   イベントの配線(タイマー・帯の描画・ツール呼び出しの観測・コマンド・キー入力)
hooks/engine.ts      試合のシミュレーション(物理・CPUの思考・P1の手動操作・技・KO)
hooks/render.ts      セルグリッドへの描画(ステージとHUD)と、Raster/SVGへの変換
hooks/pad.tsx        P1操作用の行。クリックで受けたキーをフックへ送る Client モジュール
hooks/model.ts       engine と render が共有する型・座標系・色
types/index.d.ts     $.state に置く値の型
tests/               claude plugin test で動くテスト
claude plugin validate .
claude plugin test .

tsconfig.jsonが継承する.claude-plugin/types/は、Claude Codeがこのフォルダからmodを読み込んだときに生成します。型チェックはその後にtsc -p .で行えます。

Source 6 files
hooks/register.tsx 345 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ClawdBrosState } from '../types'
5import {
6  createWorld,
7  hudText,
8  newMatch,
9  onPrompt,
10  onToolCall,
11  onToolResult,
12  pilotInput,
13  resizeWorld,
14  step,
15} from './engine'
16import { FIGHTER_SPECS, HUD_ROWS, MAX_COLUMNS, MAX_ROWS, MIN_COLUMNS, MIN_ROWS, TICK_MS } from './model'
17import type { PilotCommand, World } from './model'
18import { drawFrame, gridToRasterBase64, gridToSvg } from './render'
19
20const COMMAND = 'clawd-bros'
21const STAGE_KEY = 'stage'
22const PAD_KEY = 'pad'
23/** コントローラー行の分。ステージはこれを引いた高さで描く */
24const PAD_ROWS = 1
25const P1_COLOR = `#${(FIGHTER_SPECS[0]?.color ?? 0xd97757).toString(16).padStart(6, '0')}`
26const PILOT_COMMANDS: readonly PilotCommand[] = ['left', 'right', 'jump', 'down', 'attack', 'special']
27const PAUSE_STORE_KEY = 'isPaused'
28/** フルスクリーンでない端末では maxRows が端末の高さそのものなので、帯はその1/3までにする */
29const MAIN_SCREEN_SHARE = 3
30/** デスクトップは再描画1回が重いので2ティックに1回(5fps) */
31const REMOTE_EVERY = 2
32/** 非ターミナル面の描画がこのティック数途絶えたら、そちら向けの更新を止める */
33const REMOTE_IDLE_TICKS = 50
34
35const initialFrame: ClawdBrosState['frame'] = 0
36const frame = atom({ plugin: 'clawd-bros', key: 'frame' } as const, initialFrame)
37const isPaused = atom({ plugin: 'clawd-bros', key: 'isPaused' } as const, false)
38
39/** 帯に貼った Raster。rows はステージ + HUD の行数 */
40type Mount = { requestId: string; columns: number; rows: number }
41
42// ゲームの状態はモジュール変数に持つ(ホットリロードで試合がやり直しになるだけで害はない)
43const game: {
44  world: World | null
45  loop: Timer | null
46  mount: Mount | null
47  isWorking: boolean
48  lastStatus: string | undefined
49  lastRemoteDraw: number
50  denies: number
51  lastInvalidate: number
52  seed: number
53} = {
54  world: null,
55  loop: null,
56  mount: null,
57  isWorking: false,
58  lastStatus: undefined,
59  lastRemoteDraw: Number.NEGATIVE_INFINITY,
60  denies: 0,
61  lastInvalidate: Number.NEGATIVE_INFINITY,
62  seed: 1,
63}
64
65/** コントローラー行ごとの、前回までに届いた累計回数 */
66const padCounts = new Map<string, Record<PilotCommand, number>>()
67
68function isPilotCommand(value: unknown): value is PilotCommand {
69  return typeof value === 'string' && (PILOT_COMMANDS as readonly string[]).includes(value)
70}
71
72/**
73 * コントローラー行の post({ counts, last }) をエンジンへの入力に直す。
74 * counts は押した回数の累計なので、前回との差が出たコマンドを1回ずつ入れる。
75 * 初めて見る行(リロード直後など)や数え直し(行の作り直し)のときは last だけ入れる。
76 */
77function applyPad(w: World, source: string, data: unknown): void {
78  if (typeof data !== 'object' || data === null) return
79  const raw = (data as { counts?: unknown }).counts
80  const last = (data as { last?: unknown }).last
81  if (typeof raw !== 'object' || raw === null) return
82  const counts = {} as Record<PilotCommand, number>
83  for (const cmd of PILOT_COMMANDS) {
84    const v = (raw as Record<string, unknown>)[cmd]
85    counts[cmd] = typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0
86  }
87  const prev = padCounts.get(source)
88  padCounts.set(source, counts)
89  const isFresh = prev === undefined || PILOT_COMMANDS.some(cmd => counts[cmd] < prev[cmd])
90  if (isFresh) {
91    if (isPilotCommand(last)) pilotInput(w, last)
92
93    return
94  }
95  for (const cmd of PILOT_COMMANDS) {
96    if (counts[cmd] > prev[cmd]) pilotInput(w, cmd)
97  }
98}
99
100function ensureWorld(columns: number, rows: number): World {
101  if (game.world === null) {
102    game.world = createWorld(columns, rows, game.seed)
103  } else if (game.world.width !== columns || game.world.height !== rows) {
104    resizeWorld(game.world, columns, rows)
105  }
106
107  return game.world
108}
109
110function noteDeny($: EngineInterface, w: World): void {
111  game.denies += 1
112  // 帯が畳まれた・サイズが変わった等。描き直しを頼んで Raster を貼り直してもらう
113  if (game.denies >= 5 && w.tick - game.lastInvalidate >= 20) {
114    game.lastInvalidate = w.tick
115    $.ui.invalidate('ui.render')
116  }
117}
118
119function tick($: EngineInterface): void {
120  const w = game.world
121  if (w === null) return
122  step(w, { isWorking: game.isWorking })
123
124  const target = game.mount
125  // 失敗が続く間は10ティックに1回だけ試す(畳まれた帯へ毎回エンコードしない)
126  if (target !== null && (game.denies < 5 || w.tick % 10 === 0)) {
127    if (target.columns !== w.width || target.rows !== w.height + HUD_ROWS) {
128      noteDeny($, w)
129    } else {
130      $.ui
131        .blit({
132          requestId: target.requestId,
133          key: STAGE_KEY,
134          cells: gridToRasterBase64(drawFrame(w)),
135          columns: target.columns,
136          rows: target.rows,
137        })
138        .then(
139          result => {
140            if (result.deny === undefined) game.denies = 0
141            else noteDeny($, w)
142          },
143          () => noteDeny($, w),
144        )
145    }
146  }
147
148  if (w.tick - game.lastRemoteDraw < REMOTE_IDLE_TICKS && w.tick % REMOTE_EVERY === 0) {
149    update($, frame, n => n + 1).catch(() => undefined)
150  }
151
152  const hud = hudText(w)
153  if (hud !== game.lastStatus) {
154    game.lastStatus = hud
155    $.ui.status(hud)
156  }
157}
158
159function runTick($: EngineInterface): void {
160  try {
161    tick($)
162  } catch (error) {
163    $.ui.log(`clawd-bros: tick failed: ${String(error)}`, { to: 'debug' })
164  }
165}
166
167function startLoop($: EngineInterface): void {
168  if (game.loop !== null) return
169  game.loop = $.clock.every(TICK_MS, () => runTick($))
170}
171
172function stopLoop($: EngineInterface): void {
173  game.loop?.cancel()
174  game.loop = null
175  game.lastStatus = undefined
176  $.ui.status(undefined)
177}
178
179/** 一時停止の切り替え。帯の再描画用に $.state、次回起動用に $.store へ書く */
180async function setPaused($: EngineInterface, paused: boolean): Promise<void> {
181  await update($, isPaused, () => paused)
182  await $.store.set(PAUSE_STORE_KEY, paused)
183  if (paused) stopLoop($)
184  else startLoop($)
185}
186
187export const register: Register = on => {
188  on('session.start', async ($, e, next) => {
189    await $.command.register({
190      name: COMMAND,
191      description: 'Clawd Bros を一時停止/再開する(reset で新しい試合)',
192      argumentHint: '[pause|resume|reset]',
193      immediate: true,
194    })
195    game.seed = Math.floor(await $.clock.now()) | 0
196    // 帯がまだ描かれていなければ仮のサイズで作る(描画時に帯の幅へ合わせ直す)
197    if (game.world === null) game.world = createWorld(100, MAX_ROWS, game.seed)
198    // 一時停止は $.store にも書いてあるので、起動し直しても止めたままにする
199    // 読めなければ止めていない扱い(ゲームが始まらないよりはよい)
200    const storedPause = (await $.store.get(PAUSE_STORE_KEY).catch(() => false)) === true
201    if (storedPause !== (await read($, isPaused))) await update($, isPaused, () => storedPause)
202    if (!storedPause) startLoop($)
203
204    return next(e)
205  })
206
207  on('session.end', ($, e, next) => {
208    // /clear と resume ではプロセスが続き、session.start は来ない。ループは止めない
209    if (e.reason !== 'clear' && e.reason !== 'resume') stopLoop($)
210
211    return next(e)
212  })
213
214  // 返答の text はモデルの会話にも入るので、結果はトーストで知らせて text は返さない
215  on('command.run', { command: COMMAND }, async ($, e) => {
216    const arg = e.args.trim().toLowerCase()
217
218    if (arg === 'reset') {
219      if (game.world !== null) newMatch(game.world)
220      await setPaused($, false)
221      $.ui.toast('Clawd Bros: 新しい試合を始めました')
222
223      return {}
224    }
225
226    let wantPause: boolean
227    if (arg === 'pause') wantPause = true
228    else if (arg === 'resume') wantPause = false
229    else if (arg === '') wantPause = game.loop !== null
230    else {
231      $.ui.toast('使い方: /clawd-bros [pause|resume|reset]')
232
233      return {}
234    }
235    await setPaused($, wantPause)
236    $.ui.toast(wantPause ? 'Clawd Bros: 一時停止しました(/clawd-bros で再開)' : 'Clawd Bros: 再開しました')
237
238    return {}
239  })
240
241  on('prompt.submit', ($, e, next) => {
242    if (game.world !== null) onPrompt(game.world)
243
244    return next(e)
245  })
246
247  on('turn.start', ($, e, next) => {
248    game.isWorking = true
249
250    return next(e)
251  })
252
253  on('turn.complete', ($, e, next) => {
254    game.isWorking = false
255
256    return next(e)
257  })
258
259  // ツール呼び出しを観測するだけ。ゲーム側の失敗でツール実行を止めないよう、
260  // 失敗時は必ず next に流す
261  on('tool.call', async ($, e, next) => {
262    const tool = String(e.tool)
263    try {
264      if (game.world !== null) onToolCall(game.world, tool)
265    } catch (error) {
266      $.ui.log(`clawd-bros: onToolCall failed: ${String(error)}`, { to: 'debug' })
267    }
268    const result = await next(e)
269    try {
270      const isError = result.deny !== undefined || result.isError === true
271      if (game.world !== null) onToolResult(game.world, tool, isError)
272    } catch (error) {
273      $.ui.log(`clawd-bros: onToolResult failed: ${String(error)}`, { to: 'debug' })
274    }
275
276    return result
277  }).catch(($, e, next) => next(e))
278
279  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
280    if (e.props.hasSurvey || (await read($, isPaused))) return next(e)
281
282    if (e.surface === 'terminal') {
283      const columns = Math.min(MAX_COLUMNS, e.props.bodyColumns)
284      const room =
285        e.viewport?.isFullscreen === false
286          ? Math.floor(e.viewport.rows / MAIN_SCREEN_SHARE)
287          : e.props.maxRows
288      const stageRows = Math.min(MAX_ROWS, room - PAD_ROWS - HUD_ROWS)
289      if (columns < MIN_COLUMNS || stageRows < MIN_ROWS) {
290        game.mount = null
291
292        return next(e)
293      }
294      const w = ensureWorld(columns, stageRows)
295      const rows = stageRows + HUD_ROWS
296      const { Box, Client, Raster } = $.ui.resolve(e)
297      game.mount = { requestId: e.requestId, columns, rows }
298      game.denies = 0
299
300      return (
301        <Box flexDirection="column">
302          <Raster
303            key={STAGE_KEY}
304            columns={columns}
305            rows={rows}
306            cells={gridToRasterBase64(drawFrame(w))}
307          />
308          <Client key={PAD_KEY} module="./pad.tsx" props={{ color: P1_COLOR }} height={PAD_ROWS} width={columns} />
309        </Box>
310      )
311    }
312
313    if (e.surface === 'desktop') {
314      // フレーム番号を読んで購読する。タイマーが番号を進めるたびに描き直される
315      await read($, frame)
316      // ターミナルが描いていればそちらのサイズに従い、Svg は viewBox で伸縮させる
317      const columns = Number.isFinite(e.props.bodyColumns)
318        ? Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, e.props.bodyColumns))
319        : 100
320      const m = game.mount
321      const w = m === null ? ensureWorld(columns, MAX_ROWS) : ensureWorld(m.columns, m.rows - HUD_ROWS)
322      game.lastRemoteDraw = w.tick
323      const { Box, Client, Svg } = $.ui.resolve(e)
324
325      return (
326        <Box flexDirection="column">
327          <Svg source={gridToSvg(drawFrame(w))} alt={hudText(w)} />
328          <Client key={PAD_KEY} module="./pad.tsx" props={{ color: P1_COLOR }} height={PAD_ROWS} />
329        </Box>
330      )
331    }
332
333    return next(e)
334  })
335
336  // コントローラー行からのキー入力。P1を手動操作に切り替える
337  on('ui.message', ($, e, next) => {
338    if (e.element === PAD_KEY && game.world !== null) {
339      applyPad(game.world, `${e.surface}:${e.requestId}`, e.data)
340    }
341
342    return next(e)
343  })
344}
345
hooks/engine.ts 1794 lines
1// Clawd Brosのシミュレーション。描画はしない(render.tsがworldを読んで描く)。
2// 座標系・型・色はmodel.tsを参照。worldは破壊的に更新する。
3// Date.now / consoleは使わず、乱数はmodel.tsのrand(world)だけを使う(決定的)。
4
5import {
6  FIGHTER_SPECS,
7  PALETTE,
8  PILOT_IDLE_TICKS,
9  SPRITE_H,
10  SPRITE_W,
11  STOCKS,
12  layout,
13  rand,
14  spriteTopRow,
15} from './model'
16import type {
17  ActiveMove,
18  Effect,
19  Family,
20  Fighter,
21  FighterId,
22  Layout,
23  MoveKind,
24  Pilot,
25  PilotCommand,
26  Pose,
27  World,
28} from './model'
29
30// ---- 物理・進行の定数 ----
31
32const GRAVITY = 0.35
33const JUMP_V = 1.6
34const UPB_V = 1.8
35const WALK = 0.5
36const RUN = 1.0
37const STUN_DECAY = 0.93
38const MAX_DAMAGE = 999
39
40const READY_TICKS = 12
41const GO_TICKS = 8
42const GAME_TICKS = 20
43const WIN_TICKS = 30
44const KO_TICKS = 15
45const RESPAWN_INVINCIBLE = 25
46const RESPAWN_Y = 3
47const DIZZY_TICKS = 18
48const TAUNT_TICKS = 12
49const APPROACH_LIMIT = 15
50/** CPU同士がこれ未満(中心間の列数)まで寄ると、互いに押し合って離れる */
51const PERSONAL_SPACE = 8
52const KO_CREDIT_WINDOW = 100
53
54const BALL_LIFE = 900
55const BALL_COOLDOWN = 600
56const BALL_WINDOW = 80
57const BALL_PICKUP_DELAY = 20
58const BALL_NEED_CALLS = 6
59
60const BEAM_W = 4
61const ASSIST_W = 2
62const GRAB_REACH = 14
63const FINAL_TICKS = 18
64const MAX_EFFECTS = 96
65const MAX_PROJECTILES = 24
66
67/** 手動操作: 1回の押下を押しっぱなし扱いにするティック数(キーリピートの最初の間を埋める) */
68const PILOT_HOLD_TICKS = 4
69/** 手動操作: 同じ向きにこのティック数動き続けたら走りになる */
70const PILOT_RUN_AFTER = 3
71const FAST_FALL_V = 1.0
72
73const DIZZY_YELLOW = 0xffe14a
74const ERR_RED = 0xff5a3c
75
76// ---- 技テーブル ----
77
78type HitDef = { dmg: number; base: number; growth: number; angle: number }
79
80const MOVES: Record<MoveKind, HitDef> = {
81  jab: { dmg: 3, base: 0.3, growth: 0.5, angle: 10 },
82  beam: { dmg: 6, base: 0.5, growth: 0.8, angle: 15 },
83  smash: { dmg: 15, base: 1.0, growth: 2.2, angle: 30 },
84  meteor: { dmg: 13, base: 0.8, growth: 1.8, angle: -60 },
85  dash: { dmg: 9, base: 0.9, growth: 1.6, angle: 40 },
86  grab: { dmg: 9, base: 0.9, growth: 1.7, angle: 45 },
87  assist: { dmg: 8, base: 0.8, growth: 1.4, angle: 35 },
88  special: { dmg: 7, base: 0.7, growth: 1.2, angle: 50 },
89  final: { dmg: 30, base: 2.5, growth: 2.0, angle: 20 },
90}
91
92/** Readの3連打(3,3,5。最後だけbase 0.8) */
93const JAB_COMBO: HitDef[] = [
94  { dmg: 3, base: 0.3, growth: 0.5, angle: 10 },
95  { dmg: 3, base: 0.3, growth: 0.5, angle: 10 },
96  { dmg: 5, base: 0.8, growth: 0.5, angle: 10 },
97]
98
99// ---- ツール分類 ----
100
101const WRITE_SMASH = new Set(['Edit', 'MultiEdit', 'NotebookEdit'])
102const READ_BEAM = new Set([
103  'Grep',
104  'Glob',
105  'LS',
106  'ToolSearch',
107  'LSP',
108  'NotebookRead',
109  'ListMcpResourcesTool',
110  'ReadMcpResourceTool',
111])
112const BASH_SPECIAL = new Set(['BashOutput', 'KillShell', 'KillBash', 'Monitor', 'PowerShell', 'TaskStop'])
113const AGENT_ASSIST = new Set(['Agent', 'Task', 'Workflow'])
114const AGENT_GRAB = new Set(['WebFetch', 'WebSearch'])
115
116export function familyOf(tool: string): Family {
117  if (tool === 'Write' || WRITE_SMASH.has(tool)) return 'write'
118  if (tool === 'Read' || READ_BEAM.has(tool)) return 'read'
119  if (tool === 'Bash' || BASH_SPECIAL.has(tool)) return 'bash'
120  return 'agent'
121}
122
123export function moveOf(tool: string): MoveKind {
124  if (tool === 'Write') return 'meteor'
125  if (WRITE_SMASH.has(tool)) return 'smash'
126  if (tool === 'Read') return 'jab'
127  if (READ_BEAM.has(tool)) return 'beam'
128  if (tool === 'Bash') return 'dash'
129  if (BASH_SPECIAL.has(tool)) return 'special'
130  if (AGENT_ASSIST.has(tool)) return 'assist'
131  if (AGENT_GRAB.has(tool)) return 'grab'
132  return 'special'
133}
134
135// ---- エンジン内部の補助状態 ----
136// model.tsの型にない状態(技の進行・直近の被弾者など)はworld._auxに持つ。
137// worldを複製しても付いてくるよう、WeakMapではなくプロパティにしている。
138
139type MoveState = {
140  /** 走って近づいている最中 */
141  approaching: boolean
142  approachTicks: number
143  /** 描画に見せるポーズ */
144  pose: Pose
145  /** 技ごとの段階(meteor: 0上昇1急降下2後隙 / dash: 0突進2後隙 / grab: 0溜め1引き寄せ2後隙) */
146  phase: number
147  recover: number
148  victimId: FighterId | null
149  fx: Effect | null
150}
151
152type FighterAux = {
153  ms: MoveState | null
154  lastHit: { by: FighterId; tick: number } | null
155  upBUsed: boolean
156  wanderTicks: number
157  wanderDir: 1 | -1
158  /** 引いている最中(相手の方を向いたまま後ろへ歩く)。falseは足場の端から中央へ戻る歩き */
159  wanderBack: boolean
160  /** 直近の歩く向きと、向きの反転を禁じる残りティック */
161  lastSign: number
162  signHold: number
163  dizzyFx: Effect | null
164}
165
166type Aux = { f: FighterAux[] }
167type AuxWorld = World & { _aux?: Aux }
168
169function auxOf(world: World): Aux {
170  const w = world as AuxWorld
171  let a = w._aux
172  if (a === undefined) {
173    a = { f: [] }
174    w._aux = a
175  }
176
177  return a
178}
179
180function fauxOf(world: World, f: Fighter): FighterAux {
181  const a = auxOf(world)
182  let x = a.f[f.id]
183  if (x === undefined) {
184    x = {
185      ms: null,
186      lastHit: null,
187      upBUsed: false,
188      wanderTicks: 0,
189      wanderDir: 1,
190      wanderBack: true,
191      lastSign: 0,
192      signHold: 0,
193      dizzyFx: null,
194    }
195    a.f[f.id] = x
196  }
197
198  return x
199}
200
201// ---- 小さな補助関数 ----
202
203function clamp(v: number, lo: number, hi: number): number {
204  return v < lo ? lo : v > hi ? hi : v
205}
206
207function cxOf(f: Fighter): number {
208  return f.x + SPRITE_W / 2
209}
210
211function isActive(f: Fighter): boolean {
212  return f.stocks > 0 && f.pose !== 'ko' && f.pose !== 'out'
213}
214
215function onStageX(f: Fighter, L: Layout): boolean {
216  const c = cxOf(f)
217
218  return c >= L.stageLeft && c < L.stageRight
219}
220
221function isGrounded(f: Fighter, L: Layout): boolean {
222  return Math.abs(f.y) < 1e-9 && onStageX(f, L)
223}
224
225function isOffStage(f: Fighter, L: Layout): boolean {
226  return !onStageX(f, L) || f.y < -1e-9
227}
228
229function fighterOf(world: World, id: FighterId | null): Fighter | undefined {
230  if (id === null) return undefined
231  for (const f of world.fighters) if (f.id === id) return f
232
233  return undefined
234}
235
236function dirTo(f: Fighter, t: Fighter): 1 | -1 {
237  return cxOf(t) >= cxOf(f) ? 1 : -1
238}
239
240/** 2体の体のあいだの隙間(重なっていれば負) */
241function hgap(a: Fighter, b: Fighter): number {
242  return Math.abs(cxOf(a) - cxOf(b)) - SPRITE_W
243}
244
245function sanitizeSize(n: number, lo: number, hi: number, fallback: number): number {
246  if (!Number.isFinite(n)) return fallback
247
248  return clamp(Math.floor(n), lo, hi)
249}
250
251/** 足場の上にいるまま届く、自発ジャンプの初速。低い帯でも上場外に出ないよう抑える */
252function capV(L: Layout, v: number, y: number): number {
253  const room = Math.max(0.3, L.blastTop - 0.3 - Math.max(0, y))
254
255  return Math.min(v, Math.sqrt(2 * GRAVITY * room))
256}
257
258/**
259 * 横方向のふっとびの倍率。基準は幅100列のステージ。
260 * 広い帯ほど場外が遠いので、同じ%で場外に出るよう比例して強める。
261 */
262function hScale(world: World): number {
263  return clamp(Math.sqrt((world.width + 13) / 113), 0.75, 1.6)
264}
265
266// ---- エフェクト ----
267
268function addEffect(
269  world: World,
270  kind: Effect['kind'],
271  x: number,
272  y: number,
273  ttl: number,
274  color: number,
275  extra?: { text?: string; dir?: 1 | -1; toX?: number },
276): Effect {
277  const e: Effect = { kind, x, y, ttl, maxTtl: ttl, color }
278  if (extra !== undefined) {
279    if (extra.text !== undefined) e.text = extra.text
280    if (extra.dir !== undefined) e.dir = extra.dir
281    if (extra.toX !== undefined) e.toX = extra.toX
282  }
283  world.effects.push(e)
284  if (world.effects.length > MAX_EFFECTS) {
285    // 古い順に、バナー以外を1つ捨てる
286    const i = world.effects.findIndex(x0 => x0.kind !== 'banner')
287    if (i >= 0) world.effects.splice(i, 1)
288  }
289
290  return e
291}
292
293function banner(world: World, text: string, ttl: number, color: number): void {
294  addEffect(world, 'banner', 0, 0, ttl, color, { text })
295}
296
297function topRow(world: World, f: Fighter): number {
298  return spriteTopRow(world, f.y)
299}
300
301function popup(world: World, f: Fighter, text: string, color: number): void {
302  addEffect(world, 'popup', Math.round(cxOf(f)), Math.max(0, topRow(world, f) - 1), 14, color, { text })
303}
304
305function sparkAt(world: World, f: Fighter): void {
306  addEffect(world, 'spark', Math.round(cxOf(f)), topRow(world, f) + 1, 5, PALETTE.spark)
307}
308
309/** エフェクトを即座に取り除く(ttlを0にして残すと1フレーム余計に描かれる) */
310function dropEffect(world: World, e: Effect): void {
311  const i = world.effects.indexOf(e)
312  if (i >= 0) world.effects.splice(i, 1)
313}
314
315function tickEffects(world: World): void {
316  let w = 0
317  for (const e of world.effects) {
318    e.ttl -= 1
319    if (e.ttl > 0) world.effects[w++] = e
320  }
321  world.effects.length = w
322}
323
324// ---- 世界の生成・リセット ----
325
326function makeFighters(world: World): Fighter[] {
327  const L = layout(world.width, world.height)
328  const span = L.stageRight - L.stageLeft
329  const n = FIGHTER_SPECS.length
330
331  return FIGHTER_SPECS.map((spec, i): Fighter => {
332    const center = L.stageLeft + (span * (i + 1)) / (n + 1)
333
334    return {
335      id: i as FighterId,
336      family: spec.family,
337      label: spec.label,
338      color: spec.color,
339      x: center - SPRITE_W / 2,
340      y: 0,
341      vx: 0,
342      vy: 0,
343      facing: i < n / 2 ? 1 : -1,
344      pose: 'idle',
345      poseTicks: 0,
346      stunTicks: 0,
347      damage: 0,
348      stocks: STOCKS,
349      invincibleTicks: 0,
350      move: null,
351      queue: [],
352      jumpsLeft: 1,
353      hasSmashBall: false,
354      kos: 0,
355      targetId: null,
356    }
357  })
358}
359
360function newPilot(): Pilot {
361  return {
362    lastInputTick: -1,
363    left: 0,
364    right: 0,
365    down: 0,
366    heldTicks: 0,
367    jump: false,
368    attack: false,
369    special: false,
370  }
371}
372
373function beginReady(world: World): void {
374  world.phase = 'ready'
375  world.phaseTicks = 0
376  banner(world, 'READY?', READY_TICKS, PALETTE.bannerFg)
377}
378
379export function createWorld(width: number, height: number, seed: number): World {
380  const world: World = {
381    tick: 0,
382    width: sanitizeSize(width, 8, 1000, 100),
383    height: sanitizeSize(height, 4, 100, 10),
384    phase: 'ready',
385    phaseTicks: 0,
386    fighters: [],
387    projectiles: [],
388    effects: [],
389    shake: 0,
390    smashBall: null,
391    activity: 0,
392    recentCalls: [],
393    lastSmashBallTick: -1000,
394    winner: null,
395    matchNo: 1,
396    seed: Number.isFinite(seed) ? seed | 0 : 1,
397    ticker: '',
398    pilot: newPilot(),
399  }
400  world.fighters = makeFighters(world)
401  beginReady(world)
402
403  return world
404}
405
406export function newMatch(world: World): void {
407  world.matchNo += 1
408  world.projectiles = []
409  world.effects = []
410  world.shake = 0
411  world.smashBall = null
412  world.recentCalls = []
413  world.lastSmashBallTick = -1000
414  world.winner = null
415  world.ticker = ''
416  ;(world as AuxWorld)._aux = undefined
417  world.fighters = makeFighters(world)
418  beginReady(world)
419}
420
421export function resizeWorld(world: World, width: number, height: number): void {
422  const w = sanitizeSize(width, 8, 1000, world.width)
423  const h = sanitizeSize(height, 4, 100, world.height)
424  if (w === world.width && h === world.height) return
425  const oldL = layout(world.width, world.height)
426  const ratio = w / world.width
427  world.width = w
428  world.height = h
429  const L = layout(w, h)
430
431  for (const f of world.fighters) {
432    if (f.pose === 'ko' || f.pose === 'out') continue
433    const wasGrounded = Math.abs(f.y) < 1e-9 && onStageX(f, oldL)
434    let c = cxOf(f) * ratio
435    if (wasGrounded) c = clamp(c, L.stageLeft, L.stageRight - 0.01)
436    else c = clamp(c, L.blastLeft + 1 + SPRITE_W / 2, L.blastRight - 1 + SPRITE_W / 2)
437    f.x = c - SPRITE_W / 2
438    f.y = clamp(f.y, L.blastBottom + 1, L.blastTop - 0.5)
439  }
440  for (const p of world.projectiles) p.x *= ratio
441  const b = world.smashBall
442  if (b !== null) {
443    b.x = clamp(b.x * ratio, L.stageLeft + 2, Math.max(L.stageLeft + 2, L.stageRight - 3))
444    b.y = clamp(b.y, 1, Math.max(1.5, L.platformRow - 2))
445  }
446  // セル座標のエフェクトは新しい大きさでは意味を失うので、座標を使わないものだけ残す
447  world.effects = world.effects.filter(e => e.kind === 'banner' || e.kind === 'flash')
448}
449
450// ---- 被弾・場外KO ----
451
452function targetable(v: Fighter): boolean {
453  return isActive(v) && v.invincibleTicks <= 0
454}
455
456function applyHit(
457  world: World,
458  L: Layout,
459  atk: Fighter | null,
460  v: Fighter,
461  def: HitDef,
462  dir: 1 | -1,
463): boolean {
464  if (world.phase !== 'fight' || !targetable(v)) return false
465  const fa = fauxOf(world, v)
466  v.damage = Math.min(MAX_DAMAGE, v.damage + def.dmg)
467  const kb = def.base + (v.damage * def.growth) / 100
468  const rad = (def.angle * Math.PI) / 180
469  let vy = kb * Math.sin(rad)
470  // メテオは地上の相手を下へ叩けないので跳ね返す(bounce)
471  if (def.angle < 0 && v.y <= 0.05 && onStageX(v, L)) vy = Math.abs(vy) * 0.9
472  v.vx = kb * Math.cos(rad) * dir * hScale(world)
473  v.vy = vy
474  v.stunTicks = Math.max(1, Math.round(kb * 4))
475  v.pose = kb > 1.0 ? 'tumble' : 'hurt'
476  v.poseTicks = 0
477  v.facing = dir === 1 ? -1 : 1
478  v.move = null
479  fa.ms = null
480  if (atk !== null) fa.lastHit = { by: atk.id, tick: world.tick }
481  sparkAt(world, v)
482  popup(world, v, `+${def.dmg}%`, v.color)
483  if (kb > 2) world.shake = Math.max(world.shake, 2)
484
485  return true
486}
487
488function exitOf(f: Fighter, L: Layout): 'left' | 'right' | 'top' | 'bottom' | null {
489  if (f.x < L.blastLeft) return 'left'
490  if (f.x > L.blastRight) return 'right'
491  if (f.y > L.blastTop) return 'top'
492  if (f.y < L.blastBottom) return 'bottom'
493
494  return null
495}
496
497function koFighter(world: World, v: Fighter, exit: 'left' | 'right' | 'top' | 'bottom'): void {
498  const fa = fauxOf(world, v)
499  const hit = fa.lastHit
500  let killer: Fighter | undefined
501  if (hit !== null && hit.by !== v.id && world.tick - hit.tick <= KO_CREDIT_WINDOW) {
502    killer = fighterOf(world, hit.by)
503    if (killer !== undefined) killer.kos += 1
504  }
505
506  // 出口のセル座標へkoBurstを出す(向きは内向き)
507  const cx = Math.round(cxOf(v))
508  const midRow = clamp(topRow(world, v) + 1, 0, world.height - 1)
509  let ex = 0
510  let ey = 0
511  let dir: 1 | -1 = 1
512  if (exit === 'left') {
513    ex = 0
514    ey = midRow
515    dir = 1
516  } else if (exit === 'right') {
517    ex = world.width - 1
518    ey = midRow
519    dir = -1
520  } else {
521    ex = clamp(cx, 0, world.width - 1)
522    ey = exit === 'top' ? 0 : world.height - 1
523    dir = ex < world.width / 2 ? 1 : -1
524  }
525  addEffect(world, 'koBurst', ex, ey, 12, v.color, { dir })
526  addEffect(world, 'flash', 0, 0, 1, PALETTE.koHot)
527  world.shake = Math.max(world.shake, 4)
528
529  v.stocks -= 1
530  v.hasSmashBall = false
531  v.move = null
532  v.queue = []
533  v.stunTicks = 0
534  v.vx = 0
535  v.vy = 0
536  v.invincibleTicks = 0
537  v.poseTicks = 0
538  v.pose = v.stocks > 0 ? 'ko' : 'out'
539  fa.ms = null
540  fa.lastHit = null
541  if (fa.dizzyFx !== null) {
542    dropEffect(world, fa.dizzyFx)
543    fa.dizzyFx = null
544  }
545
546  world.ticker =
547    killer !== undefined
548      ? `P${killer.id + 1} ${killer.label} ▸ KO P${v.id + 1}`
549      : `P${v.id + 1} ${v.label} ▸ FELL`
550}
551
552function respawn(world: World, L: Layout, f: Fighter): void {
553  const fa = fauxOf(world, f)
554  f.x = (L.stageLeft + L.stageRight) / 2 - SPRITE_W / 2
555  // 低い帯でも復活台の上で全身が見える高さ(スプライト最上段が0行目以上)にする
556  f.y = Math.max(0, Math.min(RESPAWN_Y, L.blastTop - 1, L.platformRow - SPRITE_H))
557  f.vx = 0
558  f.vy = 0
559  f.damage = 0
560  f.stunTicks = 0
561  f.invincibleTicks = RESPAWN_INVINCIBLE
562  f.move = null
563  f.jumpsLeft = 1
564  f.pose = 'respawn'
565  f.poseTicks = 0
566  f.facing = rand(world) < 0.5 ? 1 : -1
567  fa.ms = null
568  fa.upBUsed = false
569  fa.lastHit = null
570}
571
572// ---- ターゲット選び ----
573
574function validTarget(world: World, f: Fighter, id: FighterId | null): Fighter | null {
575  const t = fighterOf(world, id)
576  if (t === undefined || t.id === f.id || !isActive(t)) return null
577
578  return t
579}
580
581/** いちばん近い相手。無敵でない相手を優先する */
582function nearestTarget(world: World, f: Fighter): Fighter | null {
583  let best: Fighter | null = null
584  let bestScore = Number.POSITIVE_INFINITY
585  for (const t of world.fighters) {
586    if (t.id === f.id || !isActive(t)) continue
587    const score = Math.abs(cxOf(t) - cxOf(f)) + (t.invincibleTicks > 0 ? 1000 : 0)
588    if (score < bestScore) {
589      bestScore = score
590      best = t
591    }
592  }
593
594  return best
595}
596
597function randomTarget(world: World, f: Fighter): Fighter | null {
598  const cands = world.fighters.filter(t => t.id !== f.id && isActive(t))
599  if (cands.length === 0) return null
600
601  return cands[Math.floor(rand(world) * cands.length) % cands.length] ?? null
602}
603
604// ---- 技の開始 ----
605
606/** 近づく必要がある技か(射程外なら走って近づく) */
607function needsApproach(kind: MoveKind): boolean {
608  return kind === 'jab' || kind === 'smash' || kind === 'special' || kind === 'meteor' || kind === 'grab'
609}
610
611/** 体の外側から数えた射程(列)。meteorは相手の頭上まで寄るので、体が重なる側(負)で測る */
612function reachOf(kind: MoveKind): number {
613  switch (kind) {
614    case 'jab':
615      return 2
616    case 'smash':
617    case 'special':
618      return 3
619    case 'meteor':
620      return -3
621    case 'grab':
622      return 12
623    default:
624      return 0
625  }
626}
627
628function startMove(world: World, f: Fighter, kind: MoveKind, tool: string, target: Fighter | null): void {
629  const tgt = target ?? nearestTarget(world, f)
630  const mv: ActiveMove = { kind, tick: 0, targetId: tgt !== null ? tgt.id : null, hitIds: [], tool }
631  f.move = mv
632  const approach = needsApproach(kind) && tgt !== null
633  fauxOf(world, f).ms = {
634    approaching: approach,
635    approachTicks: 0,
636    pose: approach ? 'walk' : 'windup',
637    phase: 0,
638    recover: 0,
639    victimId: null,
640    fx: null,
641  }
642  if (tgt !== null) f.facing = dirTo(f, tgt)
643}
644
645function cancelMove(world: World, f: Fighter): void {
646  f.move = null
647  fauxOf(world, f).ms = null
648}
649
650/** 足場の外へ踏み出さないための横速度の制限(既に場外なら何もしない) */
651function safeVx(f: Fighter, L: Layout, vx: number): number {
652  const c = cxOf(f)
653  if (c < L.stageLeft || c >= L.stageRight) return vx
654  const nc = c + vx
655  if (vx < 0 && nc < L.stageLeft + 0.5) return 0
656  if (vx > 0 && nc > L.stageRight - 1.5) return 0
657
658  return vx
659}
660
661// ---- 当たり判定 ----
662
663/** 前方reach列の判定。facing側へ向けて当てる */
664function hitFront(world: World, L: Layout, f: Fighter, mv: ActiveMove, def: HitDef, reach: number): void {
665  const x0 = f.facing === 1 ? f.x + SPRITE_W - 1 : f.x - reach
666  const x1 = f.facing === 1 ? f.x + SPRITE_W + reach : f.x + 1
667  for (const v of world.fighters) {
668    if (v === f || mv.hitIds.includes(v.id) || !isActive(v)) continue
669    if (Math.abs(v.y - f.y) >= SPRITE_H) continue
670    if (x0 >= v.x + SPRITE_W || x1 <= v.x) continue
671    if (applyHit(world, L, f, v, def, f.facing)) mv.hitIds.push(v.id)
672  }
673}
674
675/** 左右両側reach列の判定。ふっとぶ向きは自分から見て相手のいる側 */
676function hitAround(world: World, L: Layout, f: Fighter, mv: ActiveMove, def: HitDef, reach: number): void {
677  const x0 = f.x - reach
678  const x1 = f.x + SPRITE_W + reach
679  for (const v of world.fighters) {
680    if (v === f || mv.hitIds.includes(v.id) || !isActive(v)) continue
681    if (Math.abs(v.y - f.y) >= SPRITE_H) continue
682    if (x0 >= v.x + SPRITE_W || x1 <= v.x) continue
683    if (applyHit(world, L, f, v, def, dirTo(f, v))) mv.hitIds.push(v.id)
684  }
685}
686
687// ---- 技の進行(各関数は1ティック分を進め、終わったらtrueを返す) ----
688
689function doJab(world: World, L: Layout, f: Fighter, mv: ActiveMove, ms: MoveState, tgt: Fighter | null): boolean {
690  const t = mv.tick
691  f.vx = 0
692  ms.pose = t === 0 ? 'windup' : 'attack'
693  if (mv.tool === '') {
694    if (t === 1) hitFront(world, L, f, mv, MOVES.jab, 2)
695
696    return t >= 4
697  }
698  // Readの3連打。1発ごとに当たり判定をやり直す
699  if (t >= 1 && t <= 9 && (t - 1) % 4 === 0) {
700    const def = JAB_COMBO[(t - 1) / 4]
701    if (tgt !== null && hgap(f, tgt) <= 6) {
702      f.facing = dirTo(f, tgt)
703      // 相手が離れかけていたら、一歩踏み込んで追いかける
704      const g = hgap(f, tgt)
705      if (g > 0.5 && g <= 4 && safeVx(f, L, f.facing * 1.5) !== 0) f.x += f.facing * Math.min(g - 0.5, 1.5)
706    }
707    mv.hitIds = []
708    if (def !== undefined) hitFront(world, L, f, mv, def, 2)
709  }
710
711  return t >= 13
712}
713
714function doSmash(world: World, L: Layout, f: Fighter, mv: ActiveMove, ms: MoveState): boolean {
715  const t = mv.tick
716  ms.pose = t < 4 ? 'windup' : 'attack'
717  f.vx = t === 4 || t === 5 ? safeVx(f, L, f.facing * 0.6) : 0
718  if (t === 4 || t === 5) hitFront(world, L, f, mv, MOVES.smash, 3)
719
720  return t >= 11
721}
722
723function doMeteor(world: World, L: Layout, f: Fighter, mv: ActiveMove, ms: MoveState, tgt: Fighter | null): boolean {
724  if (ms.phase === 0) {
725    if (mv.tick === 0) {
726      if (isGrounded(f, L)) {
727        f.vy = capV(L, JUMP_V, f.y)
728      } else if (f.jumpsLeft > 0) {
729        f.jumpsLeft -= 1
730        f.vy = capV(L, JUMP_V, f.y)
731      } else {
732        ms.phase = 1
733      }
734    }
735    if (ms.phase === 0) {
736      ms.pose = 'jump'
737      let overhead = tgt === null
738      if (tgt !== null) {
739        const dx = cxOf(tgt) - cxOf(f)
740        f.facing = dx >= 0 ? 1 : -1
741        f.vx = safeVx(f, L, clamp(dx / 3, -1.5, 1.5))
742        overhead = Math.abs(dx) < 2.5 && f.vy < 1.2
743      }
744      if (mv.tick >= 1 && (f.vy <= 0.3 || mv.tick >= 8 || overhead)) {
745        ms.phase = 1
746      } else {
747        return false
748      }
749    }
750    if (ms.phase === 1) {
751      // 急降下へ切り替える
752      f.vy = Math.min(f.vy, -2.4)
753      f.vx *= 0.3
754    }
755  }
756
757  if (ms.phase === 1) {
758    ms.pose = 'attack'
759    if (tgt !== null) f.vx = safeVx(f, L, clamp((cxOf(tgt) - cxOf(f)) / 2.5, -1.2, 1.2))
760    for (const v of world.fighters) {
761      if (v === f || mv.hitIds.includes(v.id) || !isActive(v)) continue
762      if (Math.abs(cxOf(v) - cxOf(f)) >= 5.5) continue
763      if (f.y > v.y + 3.2 || f.y < v.y - 1.0) continue
764      if (applyHit(world, L, f, v, MOVES.meteor, dirTo(f, v))) mv.hitIds.push(v.id)
765    }
766    if ((isGrounded(f, L) && mv.tick > 0) || mv.tick > 40) {
767      ms.phase = 2
768      ms.recover = 5
769      f.vx = 0
770    }
771
772    return false
773  }
774
775  ms.pose = 'attack'
776  f.vx = 0
777  ms.recover -= 1
778
779  return ms.recover <= 0
780}
781
782function doDash(world: World, L: Layout, f: Fighter, mv: ActiveMove, ms: MoveState, tgt: Fighter | null): boolean {
783  const t = mv.tick
784  if (t < 2) {
785    ms.pose = 'windup'
786    f.vx = 0
787    if (t === 0 && tgt !== null) f.facing = dirTo(f, tgt)
788
789    return false
790  }
791  ms.pose = 'attack'
792  if (ms.phase === 0) {
793    const vx = safeVx(f, L, f.facing * 2)
794    if (vx === 0) {
795      ms.phase = 2
796      ms.recover = 4
797      f.vx = 0
798
799      return false
800    }
801    f.vx = vx
802    // 残像は進行方向の後ろ側の端に置く(renderがxから後ろへ描く)
803    const back = f.facing === 1 ? Math.round(f.x) : Math.round(f.x) + SPRITE_W - 1
804    addEffect(world, 'trail', back, topRow(world, f) + 1, 4, f.color, { dir: f.facing })
805    let hit = false
806    for (const v of world.fighters) {
807      if (v === f || mv.hitIds.includes(v.id) || !isActive(v)) continue
808      if (Math.abs(v.y - f.y) >= SPRITE_H) continue
809      if (f.x + vx - 1 >= v.x + SPRITE_W || f.x + vx + SPRITE_W + 1 <= v.x) continue
810      if (applyHit(world, L, f, v, MOVES.dash, f.facing)) {
811        mv.hitIds.push(v.id)
812        hit = true
813      }
814    }
815    if (hit || t >= 13) {
816      ms.phase = 2
817      ms.recover = hit ? 5 : 4
818      f.vx = 0
819    }
820
821    return false
822  }
823  f.vx = 0
824  ms.recover -= 1
825
826  return ms.recover <= 0
827}
828
829function spawnProjectile(world: World, kind: 'beam' | 'assist', f: Fighter): void {
830  if (world.projectiles.length >= MAX_PROJECTILES) world.projectiles.shift()
831  if (kind === 'beam') {
832    world.projectiles.push({
833      kind,
834      owner: f.id,
835      x: f.facing === 1 ? f.x + SPRITE_W : f.x - BEAM_W,
836      y: f.y,
837      vx: 2.5 * f.facing,
838      ttl: 140,
839      hitIds: [],
840    })
841  } else {
842    world.projectiles.push({
843      kind,
844      owner: f.id,
845      x: f.facing === 1 ? f.x + SPRITE_W : f.x - ASSIST_W,
846      y: 0,
847      vx: 1.5 * f.facing,
848      ttl: 90,
849      hitIds: [],
850    })
851  }
852}
853
854function doBeam(world: World, f: Fighter, mv: ActiveMove, ms: MoveState, tgt: Fighter | null): boolean {
855  const t = mv.tick
856  f.vx = 0
857  ms.pose = t < 3 ? 'windup' : 'attack'
858  if (t === 0 && tgt !== null) f.facing = dirTo(f, tgt)
859  if (t === 3) spawnProjectile(world, 'beam', f)
860
861  return t >= 8
862}
863
864function doAssist(world: World, f: Fighter, mv: ActiveMove, ms: MoveState, tgt: Fighter | null): boolean {
865  const t = mv.tick
866  f.vx = 0
867  ms.pose = t < 3 ? 'windup' : 'attack'
868  if (t === 0 && tgt !== null) f.facing = dirTo(f, tgt)
869  if (t === 3) spawnProjectile(world, 'assist', f)
870
871  return t >= 9
872}
873
874function doSpecial(world: World, L: Layout, f: Fighter, mv: ActiveMove, ms: MoveState, tgt: Fighter | null): boolean {
875  const t = mv.tick
876  f.vx = 0
877  ms.pose = t < 1 ? 'windup' : 'attack'
878  if (t >= 1 && t <= 6) {
879    // 向きを毎ティック反転して回転して見せる
880    f.facing = t % 2 === 0 ? 1 : -1
881    hitAround(world, L, f, mv, MOVES.special, 3)
882  }
883  if (t >= 9) {
884    if (tgt !== null) f.facing = dirTo(f, tgt)
885
886    return true
887  }
888
889  return false
890}
891
892function findGrabTarget(world: World, f: Fighter): Fighter | null {
893  let best: Fighter | null = null
894  let bestSide = Number.POSITIVE_INFINITY
895  for (const v of world.fighters) {
896    if (v === f || !targetable(v)) continue
897    if (Math.abs(v.y - f.y) >= SPRITE_H + 0.5) continue
898    const side = (cxOf(v) - cxOf(f)) * f.facing
899    if (side < -3 || side - SPRITE_W > GRAB_REACH) continue
900    if (side < bestSide) {
901      bestSide = side
902      best = v
903    }
904  }
905
906  return best
907}
908
909function tetherEnds(f: Fighter, toEdgeX: number): { x: number; toX: number } {
910  return f.facing === 1
911    ? { x: Math.round(f.x) + SPRITE_W, toX: Math.round(toEdgeX) }
912    : { x: Math.round(f.x) - 1, toX: Math.round(toEdgeX) + SPRITE_W - 1 }
913}
914
915function doGrab(world: World, L: Layout, f: Fighter, mv: ActiveMove, ms: MoveState, tgt: Fighter | null): boolean {
916  const t = mv.tick
917  f.vx = 0
918  const row = topRow(world, f) + 1
919
920  if (ms.phase === 0) {
921    ms.pose = t < 3 ? 'windup' : 'attack'
922    if (t === 0 && tgt !== null) f.facing = dirTo(f, tgt)
923    if (t < 3) return false
924    const v = findGrabTarget(world, f)
925    if (v === null) {
926      // 空振り: 最大の長さまで伸ばして戻す
927      const ends = tetherEnds(f, f.facing === 1 ? f.x + SPRITE_W + GRAB_REACH : f.x - GRAB_REACH - SPRITE_W)
928      addEffect(world, 'tether', ends.x, row, 4, f.color, { toX: ends.toX, dir: f.facing })
929      ms.phase = 2
930      ms.recover = 6
931
932      return false
933    }
934    ms.victimId = v.id
935    ms.phase = 1
936    const ends = tetherEnds(f, v.x)
937    ms.fx = addEffect(world, 'tether', ends.x, row, 3, f.color, { toX: ends.toX, dir: f.facing })
938  }
939
940  if (ms.phase === 1) {
941    ms.pose = 'attack'
942    const v = fighterOf(world, ms.victimId)
943    if (v === undefined || !isActive(v) || v.invincibleTicks > 0 || t > 14) {
944      if (ms.fx !== null) dropEffect(world, ms.fx)
945      ms.phase = 2
946      ms.recover = 4
947
948      return false
949    }
950    // 相手を目の前まで引き寄せる
951    const goal = f.facing === 1 ? f.x + SPRITE_W : f.x - SPRITE_W
952    const d = goal - v.x
953    v.x += clamp(d, -3.5, 3.5)
954    v.y += Math.abs(f.y - v.y) < 0.2 ? f.y - v.y : (f.y - v.y) * 0.5
955    v.vx = 0
956    v.vy = 0
957    v.stunTicks = Math.max(v.stunTicks, 3)
958    v.pose = 'hurt'
959    cancelMove(world, v)
960    const ends = tetherEnds(f, v.x)
961    if (ms.fx !== null) {
962      ms.fx.x = ends.x
963      ms.fx.toX = ends.toX
964      ms.fx.y = row
965      ms.fx.ttl = 3
966    }
967    if (Math.abs(d) < 0.6) {
968      // 引き寄せた向きと逆(=自分が向いている側)へ投げる
969      applyHit(world, L, f, v, MOVES.grab, f.facing)
970      if (ms.fx !== null) ms.fx.ttl = 1
971      ms.phase = 2
972      ms.recover = 4
973    }
974
975    return false
976  }
977
978  ms.pose = 'attack'
979  ms.recover -= 1
980
981  return ms.recover <= 0
982}
983
984function doFinal(world: World, L: Layout, f: Fighter, mv: ActiveMove, ms: MoveState): boolean {
985  const t = mv.tick
986  f.vx = 0
987  ms.pose = t < 5 ? 'windup' : 'attack'
988  if (t === 0) {
989    addEffect(world, 'flash', 0, 0, 2, PALETTE.koHot)
990    world.shake = Math.max(world.shake, 4)
991    f.invincibleTicks = Math.max(f.invincibleTicks, FINAL_TICKS + 2)
992    // 敵が多く並んでいる側へ向く(同数なら近い敵の側)
993    let right = 0
994    let left = 0
995    for (const v of world.fighters) {
996      if (v === f || !isActive(v) || Math.abs(v.y - f.y) >= SPRITE_H + 0.5) continue
997      if (cxOf(v) >= cxOf(f)) right += 1
998      else left += 1
999    }
1000    if (right !== left) {
1001      f.facing = right > left ? 1 : -1
1002    } else {
1003      const n = nearestTarget(world, f)
1004      if (n !== null) f.facing = dirTo(f, n)
1005    }
1006  }
1007  if (t === 5) {
1008    const dir = f.facing
1009    const startX = dir === 1 ? Math.round(f.x) + SPRITE_W : Math.round(f.x) - 1
1010    const endX = dir === 1 ? world.width - 1 : 0
1011    addEffect(world, 'laser', startX, topRow(world, f), 12, f.color, { toX: endX, dir })
1012    addEffect(world, 'flash', 0, 0, 2, PALETTE.koHot)
1013    world.shake = Math.max(world.shake, 6)
1014    for (const v of world.fighters) {
1015      if (v === f || !isActive(v)) continue
1016      if (Math.abs(v.y - f.y) >= SPRITE_H + 0.5) continue
1017      if ((cxOf(v) - cxOf(f)) * dir < -3) continue
1018      if (applyHit(world, L, f, v, MOVES.final, dir)) mv.hitIds.push(v.id)
1019    }
1020  }
1021
1022  return t >= FINAL_TICKS
1023}
1024
1025function updateMove(world: World, L: Layout, f: Fighter, fa: FighterAux): void {
1026  const mv = f.move
1027  if (mv === null) return
1028  let ms = fa.ms
1029  if (ms === null) {
1030    ms = { approaching: false, approachTicks: 0, pose: 'attack', phase: 0, recover: 0, victimId: null, fx: null }
1031    fa.ms = ms
1032  }
1033  let tgt = validTarget(world, f, mv.targetId)
1034  if (tgt === null) {
1035    tgt = nearestTarget(world, f)
1036    mv.targetId = tgt !== null ? tgt.id : null
1037  }
1038
1039  if (ms.approaching) {
1040    ms.approachTicks += 1
1041    const reach = reachOf(mv.kind)
1042    const near =
1043      tgt !== null && (mv.kind === 'meteor' ? hgap(f, tgt) <= reach : hgap(f, tgt) <= reach && Math.abs(tgt.y - f.y) < SPRITE_H + 0.5)
1044    if (tgt === null || near || ms.approachTicks > APPROACH_LIMIT) {
1045      ms.approaching = false
1046      f.vx = 0
1047    } else {
1048      const d = dirTo(f, tgt)
1049      f.facing = d
1050      f.vx = safeVx(f, L, d * RUN)
1051      // 足場の端で行き止まりなら、その場で出す
1052      if (f.vx === 0) ms.approaching = false
1053      ms.pose = 'walk'
1054
1055      return
1056    }
1057  }
1058
1059  let done = false
1060  switch (mv.kind) {
1061    case 'jab':
1062      done = doJab(world, L, f, mv, ms, tgt)
1063      break
1064    case 'smash':
1065      done = doSmash(world, L, f, mv, ms)
1066      break
1067    case 'meteor':
1068      done = doMeteor(world, L, f, mv, ms, tgt)
1069      break
1070    case 'dash':
1071      done = doDash(world, L, f, mv, ms, tgt)
1072      break
1073    case 'beam':
1074      done = doBeam(world, f, mv, ms, tgt)
1075      break
1076    case 'assist':
1077      done = doAssist(world, f, mv, ms, tgt)
1078      break
1079    case 'special':
1080      done = doSpecial(world, L, f, mv, ms, tgt)
1081      break
1082    case 'grab':
1083      done = doGrab(world, L, f, mv, ms, tgt)
1084      break
1085    case 'final':
1086      done = doFinal(world, L, f, mv, ms)
1087      break
1088  }
1089  mv.tick += 1
1090  if (done) {
1091    f.move = null
1092    fa.ms = null
1093  }
1094}
1095
1096// ---- CPUの操作 ----
1097
1098const SIGNATURE_MOVES: Record<Family, MoveKind> = {
1099  write: 'smash',
1100  read: 'beam',
1101  bash: 'dash',
1102  agent: 'special',
1103}
1104
1105function aggression(world: World, isWorking: boolean): number {
1106  return Math.min(1, 0.3 + 0.6 * world.activity + (isWorking ? 0.15 : 0))
1107}
1108
1109function groundJump(f: Fighter, L: Layout): void {
1110  f.vy = capV(L, JUMP_V, f.y)
1111}
1112
1113function recoverAI(world: World, L: Layout, f: Fighter, fa: FighterAux): void {
1114  const cx = cxOf(f)
1115  const mid = (L.stageLeft + L.stageRight) / 2
1116  const inside = cx >= L.stageLeft + 1 && cx < L.stageRight - 1
1117  const d: 1 | -1 = mid >= cx ? 1 : -1
1118  const want = inside ? 0 : d * 0.75
1119  f.vx += clamp(want - f.vx, -0.2, 0.2)
1120  if (!inside) f.facing = d
1121  // 足場より下へ落ち始めたら、空中ジャンプ → 上必殺の順で復帰を試みる
1122  if (f.vy < 0 && f.y < 0.3) {
1123    if (f.jumpsLeft > 0) {
1124      f.jumpsLeft -= 1
1125      f.vy = capV(L, JUMP_V, f.y)
1126      sparkAt(world, f)
1127    } else if (!fa.upBUsed) {
1128      fa.upBUsed = true
1129      f.vy = capV(L, UPB_V, f.y)
1130      f.vx = inside ? 0 : d * 0.8
1131      sparkAt(world, f)
1132    }
1133  }
1134}
1135
1136/** 近くの相手から離れる向きの速度。全員が同じ場所に重なって見えないようにする */
1137function separation(world: World, f: Fighter): number {
1138  const cx = cxOf(f)
1139  let s = 0
1140  for (const o of world.fighters) {
1141    if (o === f || !isActive(o) || Math.abs(o.y - f.y) >= SPRITE_H) continue
1142    const d = cx - cxOf(o)
1143    const ad = Math.abs(d)
1144    if (ad >= PERSONAL_SPACE) continue
1145    const dir = ad < 0.01 ? (f.id < o.id ? -1 : 1) : d > 0 ? 1 : -1
1146    s += (dir * (PERSONAL_SPACE - ad)) / PERSONAL_SPACE
1147  }
1148
1149  return s * WALK * 1.5
1150}
1151
1152function aiIdle(world: World, L: Layout, f: Fighter, fa: FighterAux, aggr: number): void {
1153  const grounded = isGrounded(f, L)
1154  if (f.pose === 'taunt' && f.poseTicks < TAUNT_TICKS && grounded) {
1155    f.vx = 0
1156
1157    return
1158  }
1159  const cx = cxOf(f)
1160  const tgt = validTarget(world, f, f.targetId) ?? nearestTarget(world, f)
1161  const toward: 1 | -1 = tgt !== null ? dirTo(f, tgt) : f.facing
1162  const gap = tgt !== null ? hgap(f, tgt) : Number.POSITIVE_INFINITY
1163  let desired = 0
1164  let face: 1 | -1 = tgt !== null ? toward : f.facing
1165
1166  if (fa.wanderTicks > 0) {
1167    fa.wanderTicks -= 1
1168    desired = fa.wanderDir * WALK
1169    if (!fa.wanderBack || tgt === null) face = fa.wanderDir
1170  } else if (tgt !== null) {
1171    if (gap > 2) {
1172      const run = gap > 16 || (gap > 8 && aggr >= 0.45)
1173      desired = toward * (run ? RUN : WALK)
1174    } else if (rand(world) < 0.1) {
1175      // 近づきすぎたら少し引いて間合いを取り直す(相手の方を向いたまま下がる)
1176      fa.wanderTicks = 3 + Math.floor(rand(world) * 6)
1177      fa.wanderDir = toward === 1 ? -1 : 1
1178      fa.wanderBack = true
1179    }
1180  }
1181  const sep = separation(world, f)
1182  if (sep !== 0) {
1183    const base = desired
1184    desired = clamp(desired + sep, -RUN, RUN)
1185    if (Math.abs(desired) < 0.3) {
1186      if (base !== 0) {
1187        // 狙いへ向かう途中で間の相手に押し戻されたら、止まらずにすり抜ける(ときどき飛び越える)
1188        desired = base
1189        if (isGrounded(f, L) && rand(world) < 0.15) groundJump(f, L)
1190      } else {
1191        // もともと止まっていて重なっているなら、押される向きへ歩いて離れる
1192        desired = Math.abs(sep) > 0.05 ? Math.sign(sep) * 0.3 : 0
1193      }
1194    }
1195  }
1196
1197  // 足場の端3列以内では中央へ向きを変える
1198  let edgeTurn = false
1199  if (cx < L.stageLeft + 3 && desired <= 0) {
1200    fa.wanderTicks = 6
hooks/model.ts 265 lines
1// Clawd Bros の共有モデル: engine.ts(シミュレーション)と render.ts(描画)が
2// 同じ座標系・型・色を使うための約束事。ここにロジックは置かない。
3
4/** 0x00RRGGBB。端末既定色は DEFAULT_COLOR(bit24のみ) */
5export const DEFAULT_COLOR = 0x01000000
6
7/** 1ティックの長さ(ms)。物理パラメータはこの周期を前提にする */
8export const TICK_MS = 100
9
10export const SPRITE_W = 9
11export const SPRITE_H = 3
12
13/** ステージの下に足すHUD(各Clawdの%と残機)の行数。drawFrame の Grid はステージ + この行数 */
14export const HUD_ROWS = 1
15
16/** ステージの高さ(HUDを除く)。これ未満ならステージを描かない */
17export const MIN_ROWS = 7
18export const MAX_ROWS = 10
19export const MIN_COLUMNS = 40
20export const MAX_COLUMNS = 240
21
22export const STOCKS = 3
23
24export type FighterId = 0 | 1 | 2 | 3
25export type Family = 'write' | 'read' | 'bash' | 'agent'
26
27/**
28 * 技の種類。ツール呼び出しが引き金になるもの(jab以外)と、CPUが自発的に出す jab。
29 * jab: Read / 自発攻撃, beam: Grep・Glob系の飛び道具, smash: Edit系の横スマ,
30 * meteor: Write の急降下, dash: Bash のダッシュ攻撃, grab: Web系のつかみ投げ,
31 * assist: Agent系のミニClawd召喚, special: その他(MCP・Skill等)の回転攻撃,
32 * final: スマッシュボール取得後の最後の切りふだ
33 */
34export type MoveKind =
35  | 'jab'
36  | 'beam'
37  | 'smash'
38  | 'meteor'
39  | 'dash'
40  | 'grab'
41  | 'assist'
42  | 'special'
43  | 'final'
44
45export type Pose =
46  | 'idle'
47  | 'walk'
48  | 'jump'
49  | 'fall'
50  | 'windup'
51  | 'attack'
52  | 'hurt'
53  | 'tumble'
54  | 'dizzy'
55  | 'taunt'
56  | 'ko'
57  | 'respawn'
58  | 'out'
59
60export type ActiveMove = {
61  kind: MoveKind
62  /** 技を始めてからのティック数 */
63  tick: number
64  targetId: FighterId | null
65  /** 既に当てた相手(多段ヒットを防ぐ) */
66  hitIds: FighterId[]
67  /** 引き金になったツール名(自発攻撃は '') */
68  tool: string
69}
70
71export type Fighter = {
72  id: FighterId
73  family: Family
74  /** 'WRITE' 等。ステータスラインと勝利バナーに使う */
75  label: string
76  color: number
77  /** スプライト左端の列(ステージ座標、小数可) */
78  x: number
79  /** 足元の高さ。足場の上面に立つと 0、上が正。足場の外へ落ちると負 */
80  y: number
81  vx: number
82  vy: number
83  facing: 1 | -1
84  pose: Pose
85  /** 現在のポーズに入ってからのティック数 */
86  poseTicks: number
87  /** ふっとび・ひるみの残りティック。>0 の間は操作不能 */
88  stunTicks: number
89  damage: number
90  stocks: number
91  invincibleTicks: number
92  move: ActiveMove | null
93  /** ツール呼び出しで積まれた技(最大3件) */
94  queue: { kind: MoveKind; tool: string }[]
95  jumpsLeft: number
96  hasSmashBall: boolean
97  kos: number
98  /** CPUの狙い。null なら近い相手 */
99  targetId: FighterId | null
100}
101
102export type Projectile = {
103  kind: 'beam' | 'assist'
104  owner: FighterId
105  /** 左端の列 */
106  x: number
107  /** 足場上面からの高さ(Fighter.y と同じ座標) */
108  y: number
109  vx: number
110  ttl: number
111  hitIds: FighterId[]
112}
113
114/**
115 * 描画専用のエフェクト。x,y はセル座標(x=列, y=行。行は画面上端が0)で、
116 * engine が足場座標から変換して入れる。
117 * spark: ヒット火花, popup: '+12%' 等の浮き文字, koBurst: 場外KOのフレア(看板演出),
118 * flash: 画面全体の白フラッシュ, banner: 'READY?' 'GO!' 'GAME!' 等の中央文字,
119 * trail: ダッシュの残像, tether: つかみのワイヤー(x→toX), laser: 最後の切りふだ,
120 * dizzy: 頭上の星
121 */
122export type Effect = {
123  kind: 'spark' | 'popup' | 'koBurst' | 'flash' | 'banner' | 'trail' | 'tether' | 'laser' | 'dizzy'
124  x: number
125  y: number
126  ttl: number
127  maxTtl: number
128  color: number
129  text?: string
130  dir?: 1 | -1
131  /** tether / laser の終端列 */
132  toX?: number
133}
134
135export type MatchPhase = 'ready' | 'go' | 'fight' | 'game'
136
137/** コントローラー行から届くP1の操作。WASD / < > (と矢印・, .)を register 側でこれに直す */
138export type PilotCommand = 'left' | 'right' | 'jump' | 'down' | 'attack' | 'special'
139
140/** この間入力が無ければP1はCPUに戻る */
141export const PILOT_IDLE_TICKS = 100
142
143/** P1の手動操作の状態。端末はキーを離したことを伝えないので、押下ごとに数ティック押しっぱなし扱いにする */
144export type Pilot = {
145  /** 最後に入力があった tick。-1 なら一度も操作していない */
146  lastInputTick: number
147  /** 左右・下を押しっぱなし扱いにする残りティック */
148  left: number
149  right: number
150  down: number
151  /** 同じ向きに動き続けているティック数(歩き→走りの切り替え用) */
152  heldTicks: number
153  /** 次の操作可能ティックで1回だけ出す入力 */
154  jump: boolean
155  attack: boolean
156  special: boolean
157}
158
159export type World = {
160  tick: number
161  width: number
162  height: number
163  phase: MatchPhase
164  phaseTicks: number
165  fighters: Fighter[]
166  projectiles: Projectile[]
167  effects: Effect[]
168  /** 画面揺れの残りティック */
169  shake: number
170  smashBall: { x: number; y: number; vx: number; vy: number } | null
171  /** 0..1。ツール呼び出しで上がり、時間で下がる。CPUの積極性に効く */
172  activity: number
173  /** 直近のツール呼び出し時刻(tick)。スマッシュボール出現判定に使う */
174  recentCalls: number[]
175  lastSmashBallTick: number
176  winner: FighterId | null
177  matchNo: number
178  /** mulberry32 の状態 */
179  seed: number
180  /** ステータスライン末尾に出す直近の出来事。'P3 BASH ▸ Bash: DASH' 等 */
181  ticker: string
182  /** P1の手動操作 */
183  pilot: Pilot
184}
185
186export type Layout = {
187  /** 足場上面の行(画面上端が0) */
188  platformRow: number
189  /** 足場の左端列(含む) */
190  stageLeft: number
191  /** 足場の右端列(含まない) */
192  stageRight: number
193  /** スプライト左端がこれ未満で左場外KO */
194  blastLeft: number
195  /** スプライト左端がこれ超で右場外KO */
196  blastRight: number
197  /** y がこれ超で上場外KO(画面外上方) */
198  blastTop: number
199  /** y がこれ未満で下場外KO(落下) */
200  blastBottom: number
201}
202
203export function layout(width: number, height: number): Layout {
204  const platformRow = height - 3
205  const stageLeft = Math.round(width * 0.14)
206  return {
207    platformRow,
208    stageLeft,
209    stageRight: width - stageLeft,
210    blastLeft: -SPRITE_W - 2,
211    blastRight: width + 2,
212    blastTop: platformRow - SPRITE_H + 3,
213    blastBottom: -(height + 2),
214  }
215}
216
217/** 足場座標の y(足元の高さ)から、スプライト最上段の行を出す */
218export function spriteTopRow(world: { height: number }, y: number): number {
219  return layout(0, world.height).platformRow - SPRITE_H - Math.round(y)
220}
221
222export const PALETTE = {
223  voidTop: 0x07061a,
224  nebula: 0x1b1046,
225  horizon: 0x2a1e6e,
226  platformBody: 0x3b3f8c,
227  platformEdge: 0x8fe3ff,
228  platformUnder: 0x241f5c,
229  starDim: 0x6c6a9c,
230  starMid: 0xb8b6ff,
231  starBright: 0xffffff,
232  spark: 0xfff4c2,
233  koHot: 0xffffff,
234  koMid: 0xff5a3c,
235  koCool: 0x7a1a10,
236  beam: 0x7fe7ff,
237  bannerFg: 0xffffff,
238  bannerBg: 0x000000,
239} as const
240
241export const FIGHTER_SPECS: readonly { family: Family; label: string; color: number }[] = [
242  { family: 'write', label: 'WRITE', color: 0xd97757 },
243  { family: 'read', label: 'READ', color: 0x5aa9ff },
244  { family: 'bash', label: 'BASH', color: 0xf2c14e },
245  { family: 'agent', label: 'AGENT', color: 0x5bd08a },
246]
247
248/** mulberry32。world.seed を進めて [0,1) を返す */
249export function rand(world: World): number {
250  world.seed = (world.seed + 0x6d2b79f5) | 0
251  let t = world.seed
252  t = Math.imul(t ^ (t >>> 15), t | 1)
253  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
254  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
255}
256
257/** 描画結果。行優先で columns*rows 要素 */
258export type Grid = {
259  columns: number
260  rows: number
261  ch: Uint32Array
262  fg: Uint32Array
263  bg: Uint32Array
264}
265
hooks/render.ts 1095 lines
1// Clawd Brosの描画。Worldを読むだけで、書き換えない。
2// 使う文字は印字可能ASCIIと、ブロック要素 ▀▄█▌▐▖▗▘▙▚▛▜▝▞▟ のみ。
3
4import { isPiloted } from './engine'
5import { DEFAULT_COLOR, HUD_ROWS, PALETTE, SPRITE_H, SPRITE_W, layout, spriteTopRow } from './model'
6import type { Effect, Fighter, Grid, Layout, MoveKind, World } from './model'
7
8// ---- 文字コード ----
9const SPACE = 0x20
10const STAR = 0x2a // *
11const PLUS = 0x2b // +
12const DOT = 0x2e // .
13const UPPER = 0x2580 // ▀
14const LOWER = 0x2584 // ▄
15const FULL = 0x2588 // █
16const WHITE = 0xffffff
17
18// ---- 数値・色のヘルパー ----
19
20function clamp(v: number, lo: number, hi: number): number {
21  return v < lo ? lo : v > hi ? hi : v
22}
23
24/** aからbへt(0..1)だけ寄せる */
25function mix(a: number, b: number, t: number): number {
26  if (t <= 0) return a
27  if (t >= 1) return b
28  const ar = (a >> 16) & 255
29  const ag = (a >> 8) & 255
30  const ab = a & 255
31  const br = (b >> 16) & 255
32  const bg = (b >> 8) & 255
33  const bb = b & 255
34  return (
35    (((ar + (br - ar) * t + 0.5) | 0) << 16) |
36    (((ag + (bg - ag) * t + 0.5) | 0) << 8) |
37    ((ab + (bb - ab) * t + 0.5) | 0)
38  )
39}
40
41/** 明るさをk倍する(0..1で暗く) */
42function scale(c: number, k: number): number {
43  const r = clamp((((c >> 16) & 255) * k + 0.5) | 0, 0, 255)
44  const g = clamp((((c >> 8) & 255) * k + 0.5) | 0, 0, 255)
45  const b = clamp(((c & 255) * k + 0.5) | 0, 0, 255)
46  return (r << 16) | (g << 8) | b
47}
48
49/** 虹色の64段テーブル(毎回計算しない) */
50const RAINBOW = new Uint32Array(64)
51for (let i = 0; i < 64; i++) {
52  const s = 0.65
53  const k = (i / 64) * 6
54  const sector = Math.floor(k)
55  const f = k - sector
56  const p = 1 - s
57  const q = 1 - s * f
58  const t = 1 - s * (1 - f)
59  let r = 1
60  let g = t
61  let b = p
62  switch (sector % 6) {
63    case 1:
64      r = q
65      g = 1
66      b = p
67      break
68    case 2:
69      r = p
70      g = 1
71      b = t
72      break
73    case 3:
74      r = p
75      g = q
76      b = 1
77      break
78    case 4:
79      r = t
80      g = p
81      b = 1
82      break
83    case 5:
84      r = 1
85      g = p
86      b = q
87      break
88    default:
89      break
90  }
91  RAINBOW[i] = (((r * 255 + 0.5) | 0) << 16) | (((g * 255 + 0.5) | 0) << 8) | ((b * 255 + 0.5) | 0)
92}
93
94function rainbowAt(step: number): number {
95  return RAINBOW[Math.floor(step) & 63] ?? WHITE
96}
97
98/** 3値の整数ハッシュ。星の配置とまたたき、火花のばらつきに使う */
99function hash3(a: number, b: number, c: number): number {
100  let h = Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul(b | 0, 0x165667b1) ^ Math.imul(c | 0, 0x2545f491)
101  h = Math.imul(h ^ (h >>> 15), 0x85ebca6b)
102  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35)
103  return (h ^ (h >>> 16)) >>> 0
104}
105
106// ---- セル書き込み(範囲外は黙って捨てる) ----
107
108/** 文字と前景色だけ書く。背景はそのまま */
109function put(g: Grid, c: number, r: number, ch: number, fg: number): void {
110  if (c < 0 || c >= g.columns || r < 0 || r >= g.rows) return
111  const i = r * g.columns + c
112  g.ch[i] = ch
113  g.fg[i] = fg
114}
115
116function putBg(g: Grid, c: number, r: number, ch: number, fg: number, bg: number): void {
117  if (c < 0 || c >= g.columns || r < 0 || r >= g.rows) return
118  const i = r * g.columns + c
119  g.ch[i] = ch
120  g.fg[i] = fg
121  g.bg[i] = bg
122}
123
124function printable(code: number): number {
125  return code >= 0x20 && code <= 0x7e ? code : SPACE
126}
127
128/** ASCII文字列を書く。bgが負なら背景は触らない */
129function putText(g: Grid, c: number, r: number, text: string, fg: number, bg: number): void {
130  if (r < 0 || r >= g.rows) return
131  for (let k = 0; k < text.length; k++) {
132    const code = printable(text.charCodeAt(k))
133    if (bg >= 0) putBg(g, c + k, r, code, fg, bg)
134    else if (code !== SPACE) put(g, c + k, r, code, fg)
135  }
136}
137
138// ---- 背景(グラデーション + 流れる星) ----
139
140function drawBackground(g: Grid, L: Layout, world: World): void {
141  const cols = g.columns
142  const rows = g.rows
143  for (let r = 0; r < rows; r++) {
144    let color: number = PALETTE.voidTop
145    if (r < L.platformRow) {
146      const t = L.platformRow <= 1 ? 1 : r / (L.platformRow - 1)
147      color =
148        t < 0.5
149          ? mix(PALETTE.voidTop, PALETTE.nebula, t * 2)
150          : mix(PALETTE.nebula, PALETTE.horizon, (t - 0.5) * 2)
151    }
152    g.bg.fill(color, r * cols, (r + 1) * cols)
153  }
154
155  // 8ティックごとに1列左へ流れる。星の位置は世界の列で決める
156  const shift = Math.floor(world.tick / 8)
157  const twinkle = Math.floor(world.tick / 3)
158  for (let r = 0; r < rows; r++) {
159    for (let c = 0; c < cols; c++) {
160      const h = hash3(c + shift, r, world.matchNo)
161      if (h % 1000 >= 30) continue
162      const kind = (h >>> 12) % 10
163      const phase = (twinkle + (h >>> 20)) & 3
164      const fg = phase === 0 ? PALETTE.starDim : phase === 2 ? PALETTE.starBright : PALETTE.starMid
165      const i = r * cols + c
166      g.ch[i] = kind < 6 ? DOT : kind < 9 ? PLUS : STAR
167      g.fg[i] = fg
168    }
169  }
170}
171
172// ---- 終点の足場 ----
173
174function drawPlatform(g: Grid, L: Layout, dx: number): void {
175  const pr = L.platformRow
176  const sl = L.stageLeft + dx
177  const sr = L.stageRight + dx
178  const under = PALETTE.platformUnder
179
180  // 上面: 光る縁
181  for (let c = sl; c < sr; c++) {
182    putBg(g, c, pr, UPPER, PALETTE.platformEdge, PALETTE.platformBody)
183  }
184
185  // 1段下: 両端2列ずつ内側。端は半ブロックで斜めに
186  const r1 = pr + 1
187  for (let c = sl + 2; c < sr - 2; c++) putBg(g, c, r1, SPACE, under, under)
188  put(g, sl + 1, r1, 0x2590, under) // ▐
189  put(g, sr - 2, r1, 0x258c, under) // ▌
190
191  // 2段下: もっと細く先細り(上半分だけ)
192  const r2 = pr + 2
193  const k = clamp(Math.round((sr - sl) * 0.2), 5, 36)
194  for (let c = sl + k; c < sr - k; c++) put(g, c, r2, UPPER, under)
195  put(g, sl + k - 1, r2, 0x259d, under) // ▝
196  put(g, sr - k, r2, 0x2598, under) // ▘
197}
198
199// ---- スプライト ----
200
201type Frame = readonly [string, string, string]
202
203const HEAD = ' ▐▛███▜▌ '
204const BODY = '▝▜█████▛▘'
205const HEAD_UP = '▗▐▛███▜▌▖' // 腕を上げた頭
206const BODY_UP = ' ▜█████▛ ' // 腕の先を外した胴
207const LEGS_STAND = '  ▘▘ ▝▝  '
208const LEGS_STRIDE = ' ▗▘   ▝▖ ' // 大股
209const LEGS_STEP = '  ▝▘ ▝▘  ' // そろえて踏み出す
210const LEGS_TUCK = '  ▝▀▀▀▘  ' // 脚をたたむ
211const LEGS_DANGLE = '  ▗▘ ▝▖  ' // ぶらさがる
212const LEGS_CROSS = '  ▝▝ ▘▘  ' // ふらつき・回転
213const LEGS_FUNNEL = '  ▝▜█▛▘  ' // 急降下の先すぼまり
214
215/** 上下反転(ブロック文字の上下を入れ替える) */
216function flipV(code: number): number {
217  switch (code) {
218    case 0x2580:
219      return 0x2584
220    case 0x2584:
221      return 0x2580
222    case 0x2598:
223      return 0x2596
224    case 0x2596:
225      return 0x2598
226    case 0x259d:
227      return 0x2597
228    case 0x2597:
229      return 0x259d
230    case 0x259b:
231      return 0x2599
232    case 0x2599:
233      return 0x259b
234    case 0x259c:
235      return 0x259f
236    case 0x259f:
237      return 0x259c
238    case 0x259e:
239      return 0x259a
240    case 0x259a:
241      return 0x259e
242    default:
243      return code
244  }
245}
246
247/** 左右反転 */
248function flipH(code: number): number {
249  switch (code) {
250    case 0x2598:
251      return 0x259d
252    case 0x259d:
253      return 0x2598
254    case 0x2596:
255      return 0x2597
256    case 0x2597:
257      return 0x2596
258    case 0x2599:
259      return 0x259f
260    case 0x259f:
261      return 0x2599
262    case 0x259b:
263      return 0x259c
264    case 0x259c:
265      return 0x259b
266    case 0x258c:
267      return 0x2590
268    case 0x2590:
269      return 0x258c
270    case 0x259a:
271      return 0x259e
272    case 0x259e:
273      return 0x259a
274    default:
275      return code
276  }
277}
278
279function flipRowV(s: string): string {
280  let out = ''
281  for (let i = 0; i < s.length; i++) out += String.fromCharCode(flipV(s.charCodeAt(i)))
282  return out
283}
284
285const F_BASE: Frame = [HEAD, BODY, LEGS_STAND]
286const F_WALK_A: Frame = [HEAD, BODY, LEGS_STRIDE]
287const F_WALK_B: Frame = [HEAD, BODY, LEGS_STEP]
288const F_JUMP: Frame = [HEAD_UP, BODY_UP, LEGS_TUCK]
289const F_FALL: Frame = [HEAD_UP, BODY_UP, LEGS_DANGLE]
290const F_TAUNT_A: Frame = [HEAD_UP, BODY_UP, LEGS_STAND]
291const F_TAUNT_B: Frame = [HEAD_UP, BODY_UP, LEGS_STEP]
292const F_CROSS: Frame = [HEAD, BODY, LEGS_CROSS]
293const F_METEOR: Frame = [HEAD, BODY, LEGS_FUNNEL]
294const F_ASSIST_R: Frame = [' ▐▛███▜▌▗', '▝▜█████▛ ', LEGS_STAND]
295const F_ASSIST_L: Frame = ['▖▐▛███▜▌ ', ' ▜█████▛▘', LEGS_STAND]
296const F_TUMBLE: Frame = [flipRowV(LEGS_STAND), flipRowV(BODY), flipRowV(HEAD)]
297
298/** テスト用に公開。全フレームはSPRITE_H行、SPRITE_W列 */
299export const SPRITE_FRAMES: readonly Frame[] = [
300  F_BASE,
301  F_WALK_A,
302  F_WALK_B,
303  F_JUMP,
304  F_FALL,
305  F_TAUNT_A,
306  F_TAUNT_B,
307  F_CROSS,
308  F_METEOR,
309  F_ASSIST_R,
310  F_ASSIST_L,
311  F_TUMBLE,
312]
313
314/** 攻撃時に伸ばす腕(右向きの形。左向きは左右反転して使う) */
315const ARM: Record<MoveKind, string> = {
316  jab: '▀▀',
317  beam: '▀▀',
318  smash: '▀▀▀█',
319  meteor: '',
320  dash: '▀▀▀',
321  grab: '▀▀▀▝',
322  assist: '',
323  special: '▀▀',
324  final: '▀▀▀',
325}
326
327function drawRow(g: Grid, s: string, col: number, row: number, fg: number): void {
328  if (row < 0 || row >= g.rows) return
329  for (let j = 0; j < s.length; j++) {
330    const code = s.charCodeAt(j)
331    if (code !== SPACE) put(g, col + j, row, code, fg)
332  }
333}
334
335/** 腕をfacing側へ伸ばす。shown列ぶんだけ描く */
336function drawArm(g: Grid, col0: number, top: number, facing: 1 | -1, kind: MoveKind, shown: number, fg: number): void {
337  const text = ARM[kind]
338  const n = Math.min(text.length, shown)
339  for (let k = 0; k < n; k++) {
340    const code = text.charCodeAt(k)
341    if (facing === 1) put(g, col0 + SPRITE_W + k, top + 1, code, fg)
342    else put(g, col0 - 1 - k, top + 1, flipH(code), fg)
343  }
344}
345
346function bodyColor(world: World, f: Fighter): number {
347  let c = f.color
348  if (f.hasSmashBall || (f.pose === 'attack' && f.move !== null && f.move.kind === 'final')) {
349    c = rainbowAt(world.tick * 2 + f.id * 16)
350  }
351  switch (f.pose) {
352    case 'windup':
353      return mix(c, WHITE, (f.poseTicks & 1) === 0 ? 0.55 : 0.2)
354    case 'hurt':
355      return mix(c, WHITE, (world.tick & 1) === 0 ? 0.45 : 0.1)
356    case 'tumble':
357      return (world.tick & 1) === 0 ? WHITE : c
358    default:
359      return c
360  }
361}
362
363function pickFrame(world: World, f: Fighter, kind: MoveKind): Frame {
364  const tick = world.tick
365  switch (f.pose) {
366    case 'walk':
367      return ((tick >> 1) & 1) === 0 ? F_WALK_A : F_WALK_B
368    case 'jump':
369      return F_JUMP
370    case 'fall':
371      return F_FALL
372    case 'taunt':
373      return ((tick >> 1) & 1) === 0 ? F_TAUNT_A : F_TAUNT_B
374    case 'tumble':
375      return (tick & 3) === 3 ? F_BASE : F_TUMBLE
376    case 'dizzy':
377      return Math.floor(tick / 3) % 2 === 0 ? F_BASE : F_CROSS
378    case 'attack':
379      switch (kind) {
380        case 'meteor':
381          return F_METEOR
382        case 'assist':
383          return f.facing === 1 ? F_ASSIST_R : F_ASSIST_L
384        case 'smash':
385          return F_WALK_A
386        case 'dash':
387        case 'special':
388        case 'final':
389          return (tick & 1) === 0 ? F_WALK_A : F_CROSS
390        default:
391          return F_BASE
392      }
393    default:
394      return F_BASE
395  }
396}
397
398/** 頭上を回る星。位相は0が左、1が奥、2が右、3が手前 */
399function drawDizzyStars(g: Grid, cx: number, row: number, tick: number, color: number): void {
400  const ph = (tick >> 1) & 3
401  for (let s = 0; s < 2; s++) {
402    switch ((ph + s * 2) & 3) {
403      case 0:
404        put(g, cx - 2, row, STAR, color)
405        break
406      case 1:
407        put(g, cx, row - 1, STAR, color)
408        break
409      case 2:
410        put(g, cx + 2, row, STAR, color)
411        break
412      default:
413        put(g, cx, row, STAR, color)
414        break
415    }
416  }
417}
418
419const DIZZY_YELLOW = 0xffe14a
420
421function drawFighter(g: Grid, world: World, f: Fighter, dx: number, dizzyFx: boolean): void {
422  const pose = f.pose
423  if (pose === 'ko' || pose === 'out') return
424  let col0 = Math.round(f.x) + dx
425  const top = spriteTopRow(world, f.y)
426
427  // 画面の上へ全身が出たら、最上段に '^' と番号で居場所だけ示す
428  if (top + SPRITE_H - 1 < 0) {
429    const cx = col0 + 4
430    put(g, cx - 1, 0, 0x5e, f.color) // ^
431    put(g, cx, 0, 0x50, f.color) // P
432    put(g, cx + 1, 0, 0x31 + f.id, f.color) // 1..4
433
434    return
435  }
436
437  // respawnと無敵中は1ティックおきに点滅。復活台は消さない
438  if (pose === 'respawn') {
439    for (let k = 1; k <= 7; k++) put(g, col0 + k, top + SPRITE_H, UPPER, WHITE)
440  }
441  if ((pose === 'respawn' || f.invincibleTicks > 0) && (world.tick & 1) === 1) return
442
443  // ひるみは1列揺れる(のけぞる側へ)
444  if (pose === 'hurt' && (world.tick & 1) === 0) col0 -= f.facing
445
446  const color = bodyColor(world, f)
447  const kind: MoveKind = f.move !== null ? f.move.kind : 'jab'
448  const frame = pickFrame(world, f, kind)
449  drawRow(g, frame[0], col0, top, color)
450  drawRow(g, frame[1], col0, top + 1, color)
451  drawRow(g, frame[2], col0, top + 2, color)
452
453  if (pose === 'attack') {
454    const arm = mix(color, WHITE, 0.35)
455    const shown = 2 + f.poseTicks
456    if (kind === 'special' || kind === 'final') {
457      drawArm(g, col0, top, 1, kind, shown, arm)
458      drawArm(g, col0, top, -1, kind, shown, arm)
459    } else {
460      drawArm(g, col0, top, f.facing, kind, shown, arm)
461    }
462  } else if (pose === 'dizzy' && !dizzyFx) {
463    drawDizzyStars(g, col0 + 4, top - 1, world.tick, DIZZY_YELLOW)
464  }
465}
466
467function drawLabel(g: Grid, world: World, f: Fighter, dx: number): void {
468  if (f.pose === 'ko' || f.pose === 'out') return
469  const row = spriteTopRow(world, f.y) - 1
470  if (row < 0 || row >= g.rows) return
471  const col = Math.round(f.x) + dx + 3
472  if (f.id === 0 && isPiloted(world)) {
473    // 手動操作中のP1は反転表示の 'YOU' で目立たせる
474    for (const [i, ch] of [0x59, 0x4f, 0x55].entries()) putBg(g, col - 1 + i, row, ch, PALETTE.bannerBg, f.color)
475
476    return
477  }
478  put(g, col, row, 0x50, f.color) // P
479  put(g, col + 1, row, 0x31 + f.id, f.color) // 1..4
480}
481
482// ---- 飛び道具・スマッシュボール ----
483
484function drawProjectiles(g: Grid, world: World, dx: number): void {
485  const tick = world.tick
486  for (const p of world.projectiles) {
487    const col = Math.round(p.x) + dx
488    const top = spriteTopRow(world, p.y)
489    let owner: Fighter | undefined
490    for (const f of world.fighters) {
491      if (f.id === p.owner) {
492        owner = f
493        break
494      }
495    }
496    if (p.kind === 'beam') {
497      const dir = p.vx > 0 ? 1 : p.vx < 0 ? -1 : owner !== undefined ? owner.facing : 1
498      const text = dir === 1 ? '-==>' : '<==-'
499      const head = mix(PALETTE.beam, WHITE, 0.55)
500      for (let k = 0; k < 4; k++) {
501        const isHead = dir === 1 ? k === 3 : k === 0
502        put(g, col + k, top + 1, text.charCodeAt(k), isHead ? head : PALETTE.beam)
503      }
504    } else {
505      // ミニClawd。足場の上を走るので足元の行に置く
506      const color = owner !== undefined ? owner.color : WHITE
507      const flip = (tick & 1) === 1
508      put(g, col, top + 2, flip ? 0x2599 : 0x259f, color) // ▙ / ▟
509      put(g, col + 1, top + 2, flip ? 0x259f : 0x2599, color)
510    }
511  }
512}
513
514function drawSmashBall(g: Grid, world: World, dx: number): void {
515  const sb = world.smashBall
516  if (sb === null) return
517  const col = Math.round(sb.x) + dx
518  const row = spriteTopRow(world, sb.y) + 1
519  const color = rainbowAt(world.tick * 3)
520  put(g, col, row, 0x2590, color) // ▐
521  put(g, col + 1, row, 0x258c, color) // ▌
522}
523
524// ---- エフェクト ----
525
526/** KOフレアの扇を組み立てる作業領域(半ブロック単位のビット。1が上半分、2が下半分) */
527let koScratch = new Uint8Array(0)
528
529function markHalf(m: Uint8Array, cols: number, rows: number, col: number, h: number): void {
530  const row = h >> 1
531  if (row < 0 || row >= rows || col < 0 || col >= cols) return
532  const i = row * cols + col
533  m[i] = (m[i] ?? 0) | ((h & 1) === 0 ? 1 : 2)
534}
535
536/** 出口から先端へ: 白 → 赤 → 暗赤。時間が進むほど全体が冷めて暗くなる */
537function koColor(f: number, p: number): number {
538  const ff = clamp(f * 0.9 + (1 - p) * 0.5, 0, 1)
539  const c =
540    ff < 0.3 ? mix(PALETTE.koHot, PALETTE.koMid, ff / 0.3) : mix(PALETTE.koMid, PALETTE.koCool, (ff - 0.3) / 0.7)
541  return scale(c, 0.5 + 0.5 * p)
542}
543
544const RAY_SLOPE = [0.45, 1.1] // 列あたりの半行
545const RAY_MIN_P = [0.25, 0.55]
546const RAY_LEN = [0.7, 0.45]
547
548function drawKoBurst(g: Grid, e: Effect, dx: number, tick: number): void {
549  const cols = g.columns
550  const rows = g.rows
551  const maxT = e.maxTtl > 0 ? e.maxTtl : 1
552  const p = clamp(e.ttl / maxT, 0, 1)
553  const elapsed = maxT - e.ttl
554  const dir: 1 | -1 = e.dir ?? (e.x < cols / 2 ? 1 : -1)
555  const ox = clamp(Math.round(e.x) + dx, 0, cols - 1)
556  const oy = clamp(Math.round(e.y), 0, rows - 1)
557
558  // 出口の端に全高の柱。数ティックだけ
559  if (elapsed < 4) {
560    const pillar = scale(PALETTE.koMid, 1 - Math.max(0, elapsed) * 0.18)
561    for (let r = 0; r < rows; r++) put(g, ox, r, FULL, pillar)
562  }
563
564  const n = cols * rows
565  if (koScratch.length < n) koScratch = new Uint8Array(n)
566  const m = koScratch
567  m.fill(0, 0, n)
568
569  const lenMax = clamp(Math.round(cols * 0.42), 12, 96)
570  const lc = Math.max(3, Math.round(lenMax * (0.18 + 0.82 * Math.pow(p, 0.6))))
571
572  // 中心の光線(全面)
573  for (let i = 0; i < lc; i++) {
574    const col = ox + dir * i
575    if (col < 0 || col >= cols) break
576    m[oy * cols + col] = 3
577  }
578  // 中心の上下の細い行('▄'が上、'▀'が下で中心行に接する)
579  const l1 = Math.round(lc * (0.55 + 0.25 * p))
580  const core = p > 0.5 ? Math.round(3 * p) : 0
581  for (let i = 0; i < l1; i++) {
582    const col = ox + dir * i
583    if (col < 0 || col >= cols) break
584    const full = i < core ? 3 : -1
585    if (oy - 1 >= 0) m[(oy - 1) * cols + col] = (m[(oy - 1) * cols + col] ?? 0) | (full === 3 ? 3 : 2)
586    if (oy + 1 < rows) m[(oy + 1) * cols + col] = (m[(oy + 1) * cols + col] ?? 0) | (full === 3 ? 3 : 1)
587  }
588  // 斜めの光線。半ブロック解像度で扇状に広げる
589  for (let si = 0; si < 2; si++) {
590    if (p <= (RAY_MIN_P[si] ?? 1)) continue
591    const slope = RAY_SLOPE[si] ?? 0.5
592    const len = Math.round(lc * (RAY_LEN[si] ?? 0.5))
593    for (let sign = -1; sign <= 1; sign += 2) {
594      let prev = sign > 0 ? 2 * oy + 1 : 2 * oy
595      for (let i = 1; i <= len; i++) {
596        const col = ox + dir * i
597        if (col < 0 || col >= cols) break
598        const off = Math.round(slope * i)
599        const h = sign > 0 ? 2 * oy + 1 + off : 2 * oy - off
600        if (h === prev) {
601          markHalf(m, cols, rows, col, h)
602        } else {
603          const lo = Math.min(prev, h)
604          const hi = Math.max(prev, h)
605          for (let hh = lo; hh <= hi; hh++) if (hh !== prev) markHalf(m, cols, rows, col, hh)
606        }
607        prev = h
608      }
609    }
610  }
611
612  // 書き出し。光線にならなかったセルの一部にまばらな'*'
613  const sparkBase = e.color !== 0 ? e.color : PALETTE.koMid
614  const sparkColor = scale(sparkBase, 0.5 + 0.5 * p)
615  const sparkRange = p > 0.6 ? 3 : 2
616  for (let i = 0; i < lc; i++) {
617    const col = ox + dir * i
618    if (col < 0 || col >= cols) break
619    const color = koColor(i / lc, p)
620    const sparkMod = 6 + Math.floor((6 * i) / lc)
621    for (let r = 0; r < rows; r++) {
622      const v = m[r * cols + col] ?? 0
623      if (v !== 0) {
624        put(g, col, r, v === 3 ? FULL : v === 1 ? UPPER : LOWER, color)
625      } else if (p > 0.3 && i >= 2 && i < lc * 0.95) {
626        const d = r > oy ? r - oy : oy - r
627        if (d >= 2 && d <= sparkRange && hash3(col * 31 + tick, r, ox) % sparkMod === 0) {
628          // 足場やファイターは壊さず、空きセルと星の上にだけ散らす
629          const under = g.ch[r * cols + col] ?? SPACE
630          if (under === SPACE || under === DOT || under === PLUS || under === STAR) put(g, col, r, STAR, sparkColor)
631        }
632      }
633    }
634  }
635}
636
637function drawEffect(g: Grid, world: World, e: Effect, dx: number): void {
638  const tick = world.tick
639  const maxT = e.maxTtl > 0 ? e.maxTtl : 1
640  const p = clamp(e.ttl / maxT, 0, 1) // 1 → 0
641  const elapsed = maxT - e.ttl
642  const x = Math.round(e.x) + dx
643  const y = Math.round(e.y)
644  switch (e.kind) {
645    case 'spark': {
646      const prog = 1 - p
647      put(g, x, y, prog < 1 / 3 ? STAR : prog < 2 / 3 ? PLUS : DOT, PALETTE.spark)
648      break
649    }
650    case 'popup': {
651      const text = e.text ?? ''
652      const rise = Math.floor(Math.max(0, elapsed) / 3)
653      const color = p < 0.25 ? scale(e.color, 0.6) : e.color
654      putText(g, x - (text.length >> 1), y - rise, text, color, -1)
655      break
656    }
657    case 'trail': {
658      const dir = e.dir ?? 1
659      const n = 2 + Math.ceil(3 * p)
660      const base = 0.35 + 0.35 * p
661      for (let k = 1; k <= n; k++) {
662        put(g, x - dir * k, y, 0x2d, scale(e.color, base * (1 - 0.12 * k))) // -
663      }
664      break
665    }
666    case 'tether': {
667      const tx = Math.round(e.toX ?? e.x) + dx
668      const lo = Math.min(x, tx)
669      const hi = Math.max(x, tx)
670      for (let c = lo; c <= hi; c++) put(g, c, y, 0x7e, e.color) // ~
671      break
672    }
673    case 'laser': {
674      const tx = Math.round(e.toX ?? e.x) + dx
675      const lo = Math.min(x, tx)
676      const hi = Math.max(x, tx)
677      const phase = tick % 3
678      const color = phase === 0 ? WHITE : phase === 1 ? 0xffe14a : e.color
679      // 終わりぎわは細く絞る
680      const r0 = p < 0.2 ? 1 : 0
681      const r1 = p < 0.2 ? 1 : 2
682      for (let r = r0; r <= r1; r++) {
683        for (let c = lo; c <= hi; c++) put(g, c, y + r, FULL, color)
684      }
685      break
686    }
687    case 'dizzy': {
688      drawDizzyStars(g, x, y, tick, e.color !== 0 ? e.color : DIZZY_YELLOW)
689      break
690    }
691    case 'banner': {
692      const text = e.text ?? ''
693      const row = clamp(Math.max(layout(g.columns, g.rows).platformRow - 5, 0), 0, g.rows - 1)
694      const c0 = Math.floor((g.columns - text.length) / 2) + dx
695      putBg(g, c0 - 1, row, SPACE, PALETTE.bannerFg, PALETTE.bannerBg)
696      putText(g, c0, row, text, PALETTE.bannerFg, PALETTE.bannerBg)
697      putBg(g, c0 + text.length, row, SPACE, PALETTE.bannerFg, PALETTE.bannerBg)
698      break
699    }
700    case 'koBurst':
701      drawKoBurst(g, e, dx, tick)
702      break
703    default:
704      break // flashはdrawFrameで処理する
705  }
706}
707
708// ---- HUD(ステージ下の%と残機) ----
709
710const HUD_BG = 0x100c2a
711const HUD_DIM = 0x6c6a9c
712/** スマブラのように、%が上がるほど白→黄→橙→赤→暗赤へ */
713const DAMAGE_STOPS: readonly (readonly [number, number])[] = [
714  [0, 0xffffff],
715  [50, 0xffe14a],
716  [100, 0xff9a3c],
717  [150, 0xff4a3c],
718  [200, 0xb81d1d],
719]
720const STOCK_ICON = [0x259f, 0x2599] // ▟▙: 小さなClawdの頭
721
722function damageColor(damage: number): number {
723  const d = Math.max(0, damage)
724  for (let k = 1; k < DAMAGE_STOPS.length; k++) {
725    const hi = DAMAGE_STOPS[k]
726    const lo = DAMAGE_STOPS[k - 1]
727    if (hi === undefined || lo === undefined) break
728    if (d <= hi[0]) return mix(lo[1], hi[1], (d - lo[0]) / (hi[0] - lo[0]))
729  }
730
731  return DAMAGE_STOPS[DAMAGE_STOPS.length - 1]?.[1] ?? WHITE
732}
733
734/** row 行目に4人分のパネルを等幅で並べる。幅が足りなければ名前・残機アイコンから省く */
735function drawHud(g: Grid, world: World, row: number): void {
736  for (let c = 0; c < g.columns; c++) putBg(g, c, row, SPACE, HUD_DIM, HUD_BG)
737  const count = world.fighters.length
738  if (count === 0) return
739  const panel = Math.floor(g.columns / count)
740  const piloted = isPiloted(world)
741  for (const f of world.fighters) {
742    const x0 = f.id * panel + 1
743    const limit = f.id * panel + panel - 1
744    let x = x0
745    const text = (s: string, fg: number, bg: number = HUD_BG) => {
746      for (let k = 0; k < s.length && x < limit; k++) putBg(g, x++, row, printable(s.charCodeAt(k)), fg, bg)
747    }
748    if (f.id === 0 && piloted) text(' YOU ', PALETTE.bannerBg, f.color)
749    else text(panel >= 22 ? `P${f.id + 1} ${f.label}` : `P${f.id + 1}`, f.color)
750    x += 1
751    if (f.stocks <= 0) {
752      text('OUT', HUD_DIM)
753      continue
754    }
755    text(`${Math.floor(f.damage)}%`, damageColor(f.damage))
756    x += 1
757    if (panel >= 18) {
758      // アイコン同士は1列空けて並べる(続けると1つの塊に見える)
759      for (let s = 0; s < f.stocks && x + 1 < limit; s++) {
760        for (const ch of STOCK_ICON) putBg(g, x++, row, ch, f.color, HUD_BG)
761        x += 1
762      }
763    } else {
764      text(`x${f.stocks}`, f.color)
765    }
766  }
767}
768
769// ---- 公開API: フレーム生成 ----
770
771/** ステージ(world.height 行)の下に HUD_ROWS 行のHUDを付けたフレーム */
772export function drawFrame(world: World): Grid {
773  const stage = drawStage(world)
774  const columns = stage.columns
775  const rows = stage.rows + HUD_ROWS
776  const n = columns * rows
777  const g: Grid = {
778    columns,
779    rows,
780    ch: new Uint32Array(n).fill(SPACE),
781    fg: new Uint32Array(n),
782    bg: new Uint32Array(n),
783  }
784  g.ch.set(stage.ch)
785  g.fg.set(stage.fg)
786  g.bg.set(stage.bg)
787  drawHud(g, world, stage.rows)
788
789  return g
790}
791
792function drawStage(world: World): Grid {
793  const columns = Math.max(1, Math.floor(world.width) || 1)
794  const rows = Math.max(1, Math.floor(world.height) || 1)
795  const n = columns * rows
796  const g: Grid = {
797    columns,
798    rows,
799    ch: new Uint32Array(n).fill(SPACE),
800    fg: new Uint32Array(n),
801    bg: new Uint32Array(n),
802  }
803  const L = layout(columns, rows)
804  const dx = world.shake > 0 ? ((world.tick & 1) === 0 ? -1 : 1) : 0
805
806  let flash = 0
807  let hasDizzyFx = false
808  for (const e of world.effects) {
809    if (e.kind === 'flash') {
810      const maxT = e.maxTtl > 0 ? e.maxTtl : 1
811      flash = Math.max(flash, 0.2 + 0.3 * clamp(e.ttl / maxT, 0, 1)) // 最初は元色と白の中間、だんだん戻る
812    } else if (e.kind === 'dizzy') {
813      hasDizzyFx = true
814    }
815  }
816
817  // 背景と足場
818  drawBackground(g, L, world)
819  drawPlatform(g, L, dx)
820
821  // ファイター(スプライトを先に、ラベルは重なっても読めるよう最後)
822  for (const f of world.fighters) drawFighter(g, world, f, dx, hasDizzyFx)
823  for (const f of world.fighters) drawLabel(g, world, f, dx)
824
825  // 飛び道具・スマッシュボール
826  drawProjectiles(g, world, dx)
827  drawSmashBall(g, world, dx)
828
829  // フラッシュ(背景を白寄りにする。この上にエフェクトを重ねる)
830  if (flash > 0) {
831    const bg = g.bg
832    let lastIn = -1
833    let lastOut = 0
834    for (let i = 0; i < n; i++) {
835      const v = bg[i] ?? 0
836      if (v !== lastIn) {
837        lastIn = v
838        lastOut = mix(v, WHITE, flash)
839      }
840      bg[i] = lastOut
841    }
842  }
843
844  // エフェクト
845  for (const e of world.effects) drawEffect(g, world, e, dx)
846  return g
847}
848
849// ---- 公開API: Raster ----
850
851const B64_CODES = new Uint8Array(64)
852{
853  const table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
854  for (let i = 0; i < 64; i++) B64_CODES[i] = table.charCodeAt(i)
855}
856
857function bytesToBase64(b: Uint8Array): string {
858  const len = b.length
859  const out = new Uint8Array(Math.ceil(len / 3) * 4)
860  let j = 0
861  let i = 0
862  for (; i + 2 < len; i += 3) {
863    const v = ((b[i] ?? 0) << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0)
864    out[j++] = B64_CODES[(v >> 18) & 63] ?? 0
865    out[j++] = B64_CODES[(v >> 12) & 63] ?? 0
866    out[j++] = B64_CODES[(v >> 6) & 63] ?? 0
867    out[j++] = B64_CODES[v & 63] ?? 0
868  }
869  const rest = len - i
870  if (rest === 1) {
871    const v = (b[i] ?? 0) << 16
872    out[j++] = B64_CODES[(v >> 18) & 63] ?? 0
873    out[j++] = B64_CODES[(v >> 12) & 63] ?? 0
874    out[j++] = 0x3d
875    out[j++] = 0x3d
876  } else if (rest === 2) {
877    const v = ((b[i] ?? 0) << 16) | ((b[i + 1] ?? 0) << 8)
878    out[j++] = B64_CODES[(v >> 18) & 63] ?? 0
879    out[j++] = B64_CODES[(v >> 12) & 63] ?? 0
880    out[j++] = B64_CODES[(v >> 6) & 63] ?? 0
881    out[j++] = 0x3d
882  }
883  // fromCharCodeの引数が増えすぎないよう分割して文字列にする
884  let s = ''
885  for (let k = 0; k < out.length; k += 8192) {
886    s += String.fromCharCode.apply(null, out.subarray(k, k + 8192) as unknown as number[])
887  }
888  return s
889}
890
891/** 行優先で各セル[codePoint, fg, bg]をu32リトルエンディアンで並べ、標準padded base64にする */
892export function gridToRasterBase64(grid: Grid): string {
893  const n = grid.columns * grid.rows
894  const bytes = new Uint8Array(n * 12)
895  let o = 0
896  for (let i = 0; i < n; i++) {
897    const a = grid.ch[i] ?? 0
898    const f = grid.fg[i] ?? 0
899    const b = grid.bg[i] ?? 0
900    bytes[o] = a & 255
901    bytes[o + 1] = (a >>> 8) & 255
902    bytes[o + 2] = (a >>> 16) & 255
903    bytes[o + 3] = (a >>> 24) & 255
904    bytes[o + 4] = f & 255
905    bytes[o + 5] = (f >>> 8) & 255
906    bytes[o + 6] = (f >>> 16) & 255
907    bytes[o + 7] = (f >>> 24) & 255
908    bytes[o + 8] = b & 255
909    bytes[o + 9] = (b >>> 8) & 255
910    bytes[o + 10] = (b >>> 16) & 255
911    bytes[o + 11] = (b >>> 24) & 255
912    o += 12
913  }
914  return bytesToBase64(bytes)
915}
916
917// ---- 公開API: SVG(デスクトップ用。1セル8×16px) ----
918
919const CELL_W = 8
920const CELL_H = 16
921const SVG_LIMIT = 120000
922
923const HEX2: string[] = []
924for (let i = 0; i < 256; i++) HEX2.push((i < 16 ? '0' : '') + i.toString(16))
925
926function hex(c: number, fallback: string): string {
927  if ((c & DEFAULT_COLOR) !== 0) return fallback // 端末既定色(bit24)は固定色で代用
928  return (HEX2[(c >> 16) & 255] ?? '00') + (HEX2[(c >> 8) & 255] ?? '00') + (HEX2[c & 255] ?? '00')
929}
930
931/** ブロック要素U+2580..259Fの4象限ビット(1が左上、2が右上、4が左下、8が右下)。-1は使わない文字 */
932const BLOCK_MASK = new Int8Array(32).fill(-1)
933BLOCK_MASK[0x00] = 3 // ▀
934BLOCK_MASK[0x04] = 12 // ▄
935BLOCK_MASK[0x08] = 15 // █
936BLOCK_MASK[0x0c] = 5 // ▌
937BLOCK_MASK[0x10] = 10 // ▐
938BLOCK_MASK[0x16] = 4 // ▖
939BLOCK_MASK[0x17] = 8 // ▗
940BLOCK_MASK[0x18] = 1 // ▘
941BLOCK_MASK[0x19] = 13 // ▙
942BLOCK_MASK[0x1a] = 9 // ▚
943BLOCK_MASK[0x1b] = 7 // ▛
944BLOCK_MASK[0x1c] = 11 // ▜
945BLOCK_MASK[0x1d] = 2 // ▝
946BLOCK_MASK[0x1e] = 6 // ▞
947BLOCK_MASK[0x1f] = 14 // ▟
948
949function rectEl(x: number, y: number, w: number, h: number, fill: string): string {
950  return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" fill="#' + fill + '"/>'
951}
952
953function escapeChar(code: number): string {
954  return code === 0x26 ? '&amp;' : code === 0x3c ? '&lt;' : code === 0x3e ? '&gt;' : String.fromCharCode(code)
955}
956
957/** 0は重要な文字、1は細部('.' '+' '*')、2は星(細部のうち星の色のもの。最初に捨てる) */
958function textClass(code: number, fg: number): number {
959  if (code === DOT || code === PLUS || code === STAR) {
960    return fg === PALETTE.starDim || fg === PALETTE.starMid || fg === PALETTE.starBright ? 2 : 1
961  }
962  return 0
963}
964
965export function gridToSvg(grid: Grid): string {
966  const cols = grid.columns
967  const rows = grid.rows
968  const width = cols * CELL_W
969  const height = rows * CELL_H
970  const head =
971    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' +
972    width +
973    ' ' +
974    height +
975    '" width="' +
976    width +
977    '" height="' +
978    height +
979    '" shape-rendering="crispEdges">'
980  const tail = '</svg>'
981
982  const bgEls: string[] = []
983  const blockEls: string[] = []
984  const textEls: string[][] = [[], [], []]
985
986  for (let r = 0; r < rows; r++) {
987    const base = r * cols
988    const y = r * CELL_H
989
990    // 背景: 同色の連続セルを1つのrectに
991    let c = 0
992    while (c < cols) {
993      const color = grid.bg[base + c] ?? 0
994      let e = c + 1
995      while (e < cols && grid.bg[base + e] === color) e++
996      bgEls.push(rectEl(c * CELL_W, y, (e - c) * CELL_W, CELL_H, hex(color, '000000')))
997      c = e
998    }
999
1000    // 文字・ブロック
1001    c = 0
1002    while (c < cols) {
1003      const code = grid.ch[base + c] ?? SPACE
1004      const fg = grid.fg[base + c] ?? 0
1005      if (code === SPACE) {
1006        c++
1007        continue
1008      }
1009      const x = c * CELL_W
1010      const mask = code >= 0x2580 && code <= 0x259f ? (BLOCK_MASK[code - 0x2580] ?? -1) : -1
1011      if (mask === 3 || mask === 12 || mask === 15) {
1012        // 全幅のブロックは同じ文字・同色の連続をまとめる
1013        let e = c + 1
1014        while (e < cols && grid.ch[base + e] === code && grid.fg[base + e] === fg) e++
1015        const hh = mask === 15 ? CELL_H : CELL_H / 2
1016        const yy = mask === 12 ? y + CELL_H / 2 : y
1017        blockEls.push(rectEl(x, yy, (e - c) * CELL_W, hh, hex(fg, 'cccccc')))
1018        c = e
1019      } else if (mask > 0) {
1020        const fill = hex(fg, 'cccccc')
1021        const hw = CELL_W / 2
1022        const hh = CELL_H / 2
1023        if ((mask & 5) === 5) blockEls.push(rectEl(x, y, hw, CELL_H, fill))
1024        else {
1025          if ((mask & 1) !== 0) blockEls.push(rectEl(x, y, hw, hh, fill))
1026          if ((mask & 4) !== 0) blockEls.push(rectEl(x, y + hh, hw, hh, fill))
1027        }
1028        if ((mask & 10) === 10) blockEls.push(rectEl(x + hw, y, hw, CELL_H, fill))
1029        else {
1030          if ((mask & 2) !== 0) blockEls.push(rectEl(x + hw, y, hw, hh, fill))
1031          if ((mask & 8) !== 0) blockEls.push(rectEl(x + hw, y + hh, hw, hh, fill))
1032        }
1033        c++
1034      } else if (code > SPACE && code <= 0x7e) {
1035        // 印字可能ASCII: 同色・同種の連続をまとめる
1036        const cls = textClass(code, fg)
1037        let e = c + 1
1038        let s = escapeChar(code)
1039        while (e < cols) {
1040          const nc = grid.ch[base + e] ?? SPACE
1041          if (nc <= SPACE || nc > 0x7e) break
1042          const nf = grid.fg[base + e] ?? 0
1043          if (nf !== fg || textClass(nc, nf) !== cls) break
1044          s += escapeChar(nc)
1045          e++
1046        }
1047        const run = e - c
1048        const el =
1049          '<text x="' +
1050          x +
1051          '" y="' +
1052          (y + 12) +
1053          '" font-family="monospace" font-size="14" fill="#' +
1054          hex(fg, 'cccccc') +
1055          '"' +
1056          (run > 1 ? ' textLength="' + run * CELL_W + '" lengthAdjust="spacing"' : '') +
1057          '>' +
1058          s +
1059          '</text>'
1060        const bucket = textEls[cls]
1061        if (bucket !== undefined) bucket.push(el)
1062        c = e
1063      } else {
1064        c++ // 描かない文字
1065      }
1066    }
1067  }
1068
1069  // 上限(Svgのsourceは131072文字)に収める。背景、ブロック、重要な文字、細部、星の順に入れ、入りきらない後ろ側は捨てる
1070  const out: string[] = [head]
1071  let used = head.length + tail.length
1072  const add = (els: string[], partial: boolean): void => {
1073    let sum = 0
1074    for (const s of els) sum += s.length
1075    if (used + sum <= SVG_LIMIT) {
1076      for (const s of els) out.push(s)
1077      used += sum
1078      return
1079    }
1080    if (!partial) return
1081    for (const s of els) {
1082      if (used + s.length > SVG_LIMIT) break
1083      out.push(s)
1084      used += s.length
1085    }
1086  }
1087  add(bgEls, true)
1088  add(blockEls, true)
1089  add(textEls[0] ?? [], false)
1090  add(textEls[1] ?? [], false)
1091  add(textEls[2] ?? [], false)
1092  out.push(tail)
1093  return out.join('')
1094}
1095
hooks/pad.tsx 98 lines
1// コントローラー行: 帯の最下段に置く Client のサーフェスモジュール。
2// クリックでフォーカスを受け取り、WASD / < > (矢印・, . も可)をフックモジュールへ post する。
3// このモジュールは描画スレッドで動く($ は無い)。
4
5import type { ClientKeyEvent, ClientModule } from 'claude-code'
6
7type Command = 'left' | 'right' | 'jump' | 'down' | 'attack' | 'special'
8
9type PadProps = { color: string }
10
11type PadState = {
12  counts: Record<Command, number>
13  /** 最後のキーから経った 500ms の数 */
14  idle: number
15}
16
17/** エンジンが手動操作をやめてCPUに戻すまでの時間(10秒)と揃える */
18const IDLE_STEPS = 20
19
20const KEYS: Readonly<Record<string, Command>> = {
21  a: 'left',
22  left: 'left',
23  d: 'right',
24  right: 'right',
25  w: 'jump',
26  up: 'jump',
27  ' ': 'jump',
28  space: 'jump',
29  s: 'down',
30  down: 'down',
31  '<': 'attack',
32  ',': 'attack',
33  '>': 'special',
34  '.': 'special',
35}
36
37function commandOf(event: ClientKeyEvent): Command | undefined {
38  if (event.ctrl || event.meta) return undefined
39  const key = event.key.length === 1 ? event.key.toLowerCase() : event.key
40
41  return KEYS[key]
42}
43
44const Pad: ClientModule<PadProps, PadState> = (props, surface) => {
45  const { Box, Text } = surface.elements
46  let state = surface.state
47  if (state === undefined) {
48    // 初回だけキー受付とタイマーを仕掛ける。s を正として持ち、描き直しのたびに写しを渡す
49    const s: PadState = {
50      counts: { left: 0, right: 0, jump: 0, down: 0, attack: 0, special: 0 },
51      idle: IDLE_STEPS,
52    }
53    surface.onKey(event => {
54      const cmd = commandOf(event)
55      if (cmd === undefined) return
56      s.counts[cmd] += 1
57      const wasIdle = s.idle >= IDLE_STEPS
58      s.idle = 0
59      // 同じフレームの post は後のもので置き換わるので、累計回数を送って取りこぼしを防ぐ
60      surface.post({ counts: { ...s.counts }, last: cmd })
61      if (wasIdle) surface.setState({ ...s, counts: { ...s.counts } })
62    })
63    surface.every(500, () => {
64      if (s.idle >= IDLE_STEPS) return
65      s.idle += 1
66      if (s.idle === IDLE_STEPS) surface.setState({ ...s, counts: { ...s.counts } })
67    })
68    state = { ...s, counts: { ...s.counts } }
69    surface.setState(state)
70  }
71
72  const help = 'A/D 移動  W ジャンプ  S 急降下  < 攻撃  > 必殺'
73  if (state.idle < IDLE_STEPS) {
74    return (
75      <Box>
76        <Text inverse color={props.color} bold>
77          {' YOU '}
78        </Text>
79        <Text wrap="truncate"> {help}  (Esc で終了、10秒さわらないとCPUに戻る)</Text>
80      </Box>
81    )
82  }
83
84  return (
85    <Box>
86      <Text color={props.color} bold>
87        [P1を操作]
88      </Text>
89      <Text dimColor wrap="truncate">
90        {' '}
91        ここをクリックしてから {help}
92      </Text>
93    </Box>
94  )
95}
96
97export default Pad
98
types/index.d.ts 13 lines
1export type ClawdBrosState = {
2  /** 非ターミナル面(デスクトップ)の再描画用フレーム番号 */
3  frame: number
4  /** /clawd-bros で一時停止中か */
5  isPaused: boolean
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'clawd-bros': { frame: number; isPaused: boolean }
11  }
12}
13