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


Claude Codeのプロンプト上の帯を「終点」ステージにして、Clawd 4体が大乱闘するmodです。Claudeがツールを呼ぶたびに対応するClawdが技を出し、各Clawdのダメージ%と残機は帯の最下段のHUDに出ます。P1はキーボードで自分で操作することもできます。
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(青) | Read | 3連ジャブ |
Grep Glob LS ToolSearch LSP など | ビーム(飛び道具) | |
| P3 BASH(黄) | Bash | ダッシュ攻撃 |
BashOutput KillShell Monitor など | 回転攻撃 | |
| P4 AGENT(緑) | Agent Task Workflow | ミニClawdを召喚して走らせる |
WebFetch WebSearch | つかみ投げ(遠くからワイヤーで引き寄せる) | |
MCPツール・Skill・その他すべて | 回転攻撃 |
帯の最下段の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(WRITE)に入ります。
| キー | 動き |
|---|---|
A / D(← →も可) | 左右に移動。押し続けると走る |
W(↑・スペースも可) | ジャンプ。空中でもう1回跳べて、その後は上必殺で復帰 |
S(↓も可) | 空中で急降下 |
<(,も可) | 弱攻撃(ジャブ) |
>(.も可) | 必殺技(横スマッシュ)。スマッシュボールを持っていれば最後の切りふだ |
Escでキー入力をプロンプトへ戻せます。10秒キーを押さなければ、P1はCPUに戻ります。| コマンド | 動作 |
|---|---|
/clawd-bros | 一時停止と再開を切り替える |
/clawd-bros pause / /clawd-bros resume | 一時停止する / 再開する |
/clawd-bros reset | 新しい試合を始める |
一時停止は、Claude Codeを起動し直しても続きます。結果はトーストで知らせ、会話には何も残しません。帯は[-](またはctrl+x ctrl+a)で畳めます。
Raster)として描き、10fpsで$.ui.blitにより差し替えます。その下にP1操作用の行(Client)を1行置きます。▀▄█▌▐▖▗▘▙▚▛▜▝▞▟)だけです。曖昧幅の文字は使っていないので、日本語環境のターミナルでも列がずれません。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 .で行えます。
hooks/register.tsx 345 lines1import { 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}
345hooks/engine.ts 1794 lines1// 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 = 6hooks/model.ts 265 lines1// 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}
265hooks/render.ts 1095 lines1// 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 ? '&' : code === 0x3c ? '<' : code === 0x3e ? '>' : 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}
1095hooks/pad.tsx 98 lines1// コントローラー行: 帯の最下段に置く 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
98types/index.d.ts 13 lines1export 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