SLOPSHOPPER

すこしタコ

Claude が作業している間の待ち時間に「すこしタコ」が遊べます

newpanebandguardtoastprocess
v0.0.9MITupdated 2026-10-06TaichiAkimoto/sukoshi-tako
A shopper browsing a rack in a slop shop
README

sukoshi-tako(すこしタコ)

Claude が作業している間の待ち時間に「すこしタコ」が遊べる Claude Code プラグインです。Mac の Claude Code なら、ターミナルでもデスクトップアプリでも使えます。Claude の作業が終わると、ゲームの画面の上に知らせが出ます。

「すこしタコ」は、体を塗って景色に溶け込む 3D のかくれんぼです。このプラグインでは全世界モードが遊べます。世界のどこかの誰かが隠れた場所を探しに行くか、自分が隠れて誰かに探してもらいます。相手と同じ時間に遊ぶ必要はありません。

A Claude Code plugin that lets you play "Sukoshi Tako", a 3D camouflage hide-and-seek, while Claude works: in a window of its own, or inside the terminal where the terminal can show pictures. It tells you when Claude is done. macOS only for now.

入れ方

  1. プラグインを入れます。Claude Code に、次の 2 行を 1 行ずつ打ちます。
   /plugin marketplace add TaichiAkimoto/sukoshi-tako
   /plugin install tako@sukoshi-tako
  1. オンにします。
   /tako:play

初回だけ、ゲーム本体(約 7 MB)を取得します。

オフにするには /tako:off です。ターミナルでもデスクトップアプリでも、コマンドは同じです。

必要なもの

  • macOS(ゲーム本体は Apple silicon と Intel の両方に対応。動作を確かめたのは macOS 27 の Apple silicon だけです)
  • Claude Code 2.1.287 以降。ターミナルの Claude Code でも、Claude デスクトップアプリ(Code タブの Local のセッション)でも使えます

ゲームは別のウィンドウで開き、Claude Code の側にはメニューと操作の説明が出ます。動作を確かめたのは、macOS 標準の「ターミナル」と、Claude デスクトップアプリ 2.19675.0 です。

絵をターミナルの中に出せるターミナル(Ghostty と kitty)では、別のウィンドウではなく、Claude の会話の横にゲームが出ます。この場合は、Claude Code のテーマ(/theme)とターミナルの配色を、明るいもの同士か暗いもの同士で揃えてください(食い違うと文字が読めません)。

動き方

  • /tako:play を打つと、メニューが出ます。コースを選ぶと、ゲームのウィンドウが開きます。
  • 操作のしかたは、ゲームのウィンドウの左下に出ます。
  • Claude の作業が終わったり、権限の確認や質問が出たりすると、ゲームのウィンドウの上に「Claude の作業が終わりました」と出ます。ゲームは止まりません(いくつものセッションを同時に動かしていても遊び続けられます)。
  • ゲームのウィンドウを閉じるか、「やめる」を押すと終わります。

絵をターミナルの中に出せるターミナルでは、次のように動きます。

  • オンの間は、Claude が 2 秒以上作業するとゲームが開きます。作業が終わると 3 秒数えて閉じます。
  • 権限の確認や質問が出たら、すぐに閉じます。答えると、また開きます。
  • 自分で閉じたら、その作業の間は開きません。
  • ターミナルが狭くて自動で開けないときは、入力欄の上に出るボタン(1 を押す)で開けます。

操作

キーとマウスは、ゲームのウィンドウで操作します。ゲームがターミナルの中に出ている場合は、絵をクリックするとキーがゲームに届き、Esc で入力欄に戻ります。

| キー | 探す | 隠れる | | :- | :- | :- | | 十字キー / W A S D | 移動 | 移動 | | ドラッグ / I J K L | 狙う | 右ドラッグか I J K L で視点 | | クリック / Space | 撃つ(押し続けで連射) | 左ドラッグで体を塗る | | 1 | — | 色を拾う(次のクリックで拾う) | | 2 | — | ブラシを替える | | 3 | — | ポーズを替える | | 4 | — | うつす(景色の色で塗る)のオン・オフ | | 5 | — | 全身に景色をうつす | | Q / E | — | 体の向き | | R / F | 上 / 下 | 上 / 下 | | C | 背後へ戻す | 背後へ戻す | | V | カメラの距離 | — | | Enter | さがし終わり | ここに隠れる |

ゲームがターミナルの中に出ている場合、ターミナルはキーを離したことを知らせないので、キーは自動リピートが止まるまで押されている扱いになります。

何に接続するか

  • ゲーム本体の取得: このリポジトリの Releases から curl で取得します。プラグインに書かれた SHA-256 と一致したときだけ展開して実行します。
  • 遊んでいる間: すこしタコのサーバー(Cloudflare Workers)に HTTPS で接続します。送るのは、匿名の識別子(この Mac で作った乱数)、遊んだコース、隠れた位置と塗った模様、見つけた相手、遊び方の計測(どの場面を通ったか)です。
  • 名前、メールアドレス、Claude との会話、作業中のプロジェクトの内容は送りません。
  • 匿名の識別子は ~/Library/Application Support/sukoshi-tako/ に保存します。このフォルダを消すと、別の人として始まります。

公開している範囲

このリポジトリにあるのは、プラグイン(JavaScript と設定)だけです。ゲーム本体はビルド済みの実行ファイルとして配っていて、ソースコードは公開していません。

ライセンスとクレジット

  • プラグイン(このリポジトリのファイル): MIT。LICENSE を見てください。
  • ゲーム本体(配布している実行ファイルとその中の素材): 再配布や改変の許可は付けていません。
  • キャラクターの 3D モデル: CC-BY-4.0。作者と出典は CREDITS.md にあります。
  • 家具の 3D モデル: CC0。
  • GLTFKit2: MIT。
  • 仕組みは intermission(Claude の作業中に Doom を遊ぶプラグイン)を参考にしました。
Source 3 files
hooks/register.js 813 lines
1// sukoshi-tako: Claude が作業している間、ペインで「すこしタコ」を遊べるようにする。
2// 作業が終わるか、Claude があなたを必要としたら、すぐに戻す。
3
4import {
5  classifyEngineEnd,
6  commandAction,
7  commandNames,
8  devEngineRequest,
9  DROP_IN_DELAY_MS,
10  canShowPixels,
11  checksumMatches,
12  engineEndAction,
13  engineEndNotice,
14  failureNotice,
15  outputTail,
16  fetchPlan,
17  idlePointer,
18  initialState,
19  inputFileText,
20  inputPathFor,
21  isEngineUp,
22  isStartStillWanted,
23  isPlayingPhase,
24  launchRequest,
25  NEEDS_TERMINAL,
26  newEngineSession,
27  PANE_HEIGHT,
28  PANE_WIDTH,
29  paneOpenArgs,
30  removeFileArgv,
31  paneView,
32  parseEngineAssets,
33  parseEngineLine,
34  platformKey,
35  playCommandPlan,
36  playOutcomeText,
37  START_WAIT_MS,
38  playMode,
39  pushEvent,
40  reduce,
41  releasedPointer,
42  resolveEngineAsset,
43  resolveEngineOverride,
44  devMarkerPath,
45  splitLines,
46  surfaceOrGuess,
47} from './logic.js'
48
49const PANE = 'sukoshi-tako'
50// plugin.json の name。ファイルで宣言したコマンドは <この名前>:play、<この名前>:off で届く
51const PLUGIN_NAME = 'tako'
52const TITLE = 'すこしタコ'
53// 遊んでいない間もエンジンは止めずに待たせる。これだけ離れたら終了させる
54const AWAY_STOP_MS = 5 * 60 * 1000
55
56// 出し入れの状態(logic.js の reduce が進める)
57let state = initialState()
58let timer = null
59let awayTimer = null
60
61// 動いている子プロセスの出力、いちばん新しいフレーム、入力ファイルのパス
62let engine = null
63let frame = null
64let inputPath = null
65let isStarting = false
66// オフにした・ペインを閉じたら進める。準備の途中だった起動は、進んでいたら取りやめる
67let startToken = 0
68// 進行中のゲーム本体の取得と、ペインに出すその進み具合
69let download = null
70let downloadStatus = null
71// エンジンが伝えてきた場面・コース一覧・状態表示
72let scene = 'starting'
73let sceneDetail = ''
74let stages = []
75let hud = ''
76let failure = null
77// failure の種類。'noBuild' = この OS の本体がまだ無い(ウィンドウの話をしない)
78let failureKind = null
79// プラグインが本体を止めた(Windows では、止めた子も終了コードで返るので、自分の記録で見分ける)
80let stopRequested = false
81// ペインから受けた入力と、メニューで押した単発の操作
82let keys = []
83let pointer = idlePointer()
84let events = []
85let eventSeq = 0
86// メニューで先に選んだ「探す / 隠れる」
87let menuMode = null
88// このターミナルで絵が出せるか。最初の起動時に調べる
89let hasPixels = null
90// このセッションの OS と CPU。1 回だけ調べる
91let platformCache = null
92let isPlatformKnown = false
93// SUKOSHI_TAKO_DEBUG=1 のとき、ターミナルから届いたままのキー名と、押下中のキーを画面に出す
94let isDebug = false
95let lastKey = ''
96
97function cancelTimer() {
98  timer?.cancel()
99  timer = null
100}
101
102async function writeInput($) {
103  if (!inputPath) return
104  await $.fs.write(
105    inputPath,
106    inputFileText({ isPlaying: isPlayingPhase(state.phase), keys, pointer, events, mode: state.mode }),
107  )
108}
109
110async function sendEvent($, name) {
111  eventSeq += 1
112  events = pushEvent(events, eventSeq, name)
113  await writeInput($)
114}
115
116// logic.js の reduce を 1 歩進め、出てきた副作用を順に実行する
117async function dispatch($, action) {
118  const { state: next, effects } = reduce(state, action)
119  state = next
120  for (const effect of effects) {
121    switch (effect) {
122      case 'armDropIn':
123        cancelTimer()
124        timer = $.clock.after(DROP_IN_DELAY_MS, () => void dispatch($, { type: 'dropInDue' }))
125        break
126      case 'cancelTimer':
127        cancelTimer()
128        break
129      case 'open': {
130        const opened = await $.ui.open(paneOpenArgs(PANE, TITLE))
131        // 置けなかったペインを待たせておくと、ずっと後で急に出てしまう
132        if (!opened.isPlaced) await $.ui.close({ id: PANE })
133        await dispatch($, { type: 'opened', isPlaced: opened.isPlaced })
134        if (opened.isPlaced) await startPlaying($)
135        break
136      }
137      case 'close':
138        await $.ui.close({ id: PANE })
139        break
140      case 'startCountdown':
141        cancelTimer()
142        timer = $.clock.every(1000, () => void dispatch($, { type: 'countdownTick' }))
143        break
144      case 'redraw':
145        $.ui.invalidate('ui.render')
146        break
147    }
148  }
149  await writeInput($)
150}
151
152async function startPlaying($) {
153  awayTimer?.cancel()
154  awayTimer = null
155  if (engine) await writeInput($)
156  // 取得や起動の途中でもう一度呼ばれても、二重には起動しない
157  else if (!isStarting) void runEngine($)
158}
159
160// ペインが閉じた。エンジンは休ませ、しばらく戻らなければ止める
161async function goAway($) {
162  frame = null
163  keys = []
164  pointer = idlePointer()
165  // 取得や起動の準備の途中で閉じられた。見る人がいないので、準備が終わっても起動しない
166  if (!engine && isStarting) startToken += 1
167  if (!engine) return
168  await writeInput($)
169  // ウィンドウ方式: ゲームは自分のウィンドウで動き続ける。ペインを閉じても止めない
170  // (終わらせるのは、ゲームのウィンドウを閉じる・「やめる」・/tako:off)
171  if (state.mode === 'window') return
172  awayTimer?.cancel()
173  awayTimer = $.clock.after(AWAY_STOP_MS, () => void stopEngine())
174}
175
176async function stopEngine() {
177  awayTimer?.cancel()
178  awayTimer = null
179  startToken += 1
180  // ループを抜けると子プロセスが止まる
181  if (engine) {
182    stopRequested = true
183    await engine.return()
184  }
185}
186
187async function checkPixels($) {
188  if (hasPixels !== null) return hasPixels
189  hasPixels = canShowPixels({
190    termProgram: await $.env.get('TERM_PROGRAM'),
191    term: await $.env.get('TERM'),
192    kittyWindowId: await $.env.get('KITTY_WINDOW_ID'),
193  })
194  return hasPixels
195}
196
197// このセッションの OS と CPU。1 回だけ調べる(uname は毎回呼ばない)
198async function platformFor($) {
199  if (!isPlatformKnown) {
200    platformCache = await detectPlatform($)
201    // 調べるのに失敗した(null)ときは覚えない。次にもう一度調べる
202    isPlatformKnown = platformCache !== null
203  }
204  return platformCache
205}
206
207// OS の判定。Windows は uname が無いので環境変数を先に見る
208async function detectPlatform($) {
209  const envOS = await $.env.get('OS')
210  if (envOS === 'Windows_NT') {
211    return platformKey({ envOS, processorArchitecture: await $.env.get('PROCESSOR_ARCHITECTURE') })
212  }
213  try {
214    return platformKey({
215      unameS: (await $.process.run(['uname', '-s'])).stdout.trim(),
216      unameM: (await $.process.run(['uname', '-m'])).stdout.trim(),
217    })
218  } catch {
219    return null
220  }
221}
222
223// プラグインの版ごとに、その版用のゲーム本体を 1 回だけ取得する。
224// 戻り値は { platform, asset, plan }。展開済みで engine.json と同じなら取り直さない。
225function ensureEngine($) {
226  download ??= downloadEngine($).finally(() => {
227    download = null
228  })
229  return download
230}
231
232async function downloadEngine($) {
233  const root = $.plugin.root
234  const showStatus = (text) => {
235    downloadStatus = text
236    $.ui.invalidate('ui.render')
237  }
238  const platform = await platformFor($)
239  const assets = parseEngineAssets(await $.fs.read(root + '/engine.json').catch(() => ''))
240  // この OS の本体が engine.json にまだ無いときも、落ちずに理由の分かる案内を出す
241  const found = resolveEngineAsset(assets, platform)
242  if (!found.ok) throw Object.assign(new Error(found.text), { failureKind: found.kind })
243  const asset = found.asset
244  // 取得元の上書きは開発用。どこから取っても、下の照合を通らなければ実行しない
245  const url = (await $.env.get('SUKOSHI_TAKO_ENGINE_URL')) ?? asset.url
246  const plan = fetchPlan({ platform, root, url })
247  if (!plan) throw new Error('このフォルダの場所では、ゲーム本体を取得できません。')
248  // 展開済みで、engine.json と同じものなら取り直さない
249  if ((await $.fs.exists(plan.engineDir)) && (await $.fs.exists(plan.marker))) {
250    if ((await $.fs.read(plan.marker)).trim() === asset.sha256) return { platform, asset, plan }
251  }
252  showStatus('ゲーム本体を取得しています(約 7 MB)…')
253  const fetched = await $.process.run(plan.download, { timeoutMs: 10 * 60 * 1000 })
254  if (fetched.exitCode !== 0) throw new Error('ゲーム本体を取得できませんでした(取得の終了コード ' + fetched.exitCode + ')。')
255  const sum = await $.process.run(plan.hash)
256  if (sum.exitCode !== 0 || !checksumMatches(sum.stdout, asset.sha256)) {
257    await $.process.run(plan.removeArchive)
258    throw new Error('取得したファイルが想定と違うため、実行しません。')
259  }
260  // 展開が途中で失敗しても「展開済み」と見なされないよう、印を先に無効にする
261  if (await $.fs.exists(plan.marker)) await $.fs.write(plan.marker, '')
262  await $.process.run(plan.removeOld)
263  await $.process.run(plan.makeDist)
264  const unpacked = await $.process.run(plan.unpack)
265  await $.process.run(plan.removeArchive)
266  if (unpacked.exitCode !== 0) throw new Error('取得したファイルを展開できませんでした(展開の終了コード ' + unpacked.exitCode + ')。')
267  await $.fs.write(plan.marker, asset.sha256 + '\n')
268  showStatus(null)
269  return { platform, asset, plan }
270}
271
272// 開発中は SUKOSHI_TAKO_ENGINE でローカルのビルドを指す("fake" なら同梱の偽エンジン)。
273// SHA-256 の照合を迂回できてしまうので、SUKOSHI_TAKO_DEV=1 の許可があり、絶対パスのときだけ使う
274// (それ以外の指定は黙って無視し、通常どおり取得して照合する)。
275async function engineRequest($, id) {
276  const root = $.plugin.root
277  const mode = state.mode === 'window' ? 'window' : 'pane'
278  const platform = await platformFor($)
279  const override = resolveEngineOverride(
280    await $.env.get('SUKOSHI_TAKO_ENGINE'),
281    await $.env.get('SUKOSHI_TAKO_DEV'),
282    platform,
283    // 印のファイルが、このプラグインのフォルダの中にあるときだけ(環境変数だけでは効かせない)
284    await $.fs.exists(devMarkerPath(root)).catch(() => false),
285  )
286  if (override) {
287    if (override !== 'fake' && !(await $.fs.exists(override))) {
288      throw new Error('SUKOSHI_TAKO_ENGINE の先にファイルがありません。')
289    }
290    return devEngineRequest({ override, platform, mode, root, id, inputPath })
291  }
292  // 本体は、取得のときに照合した配布物だけを起動する。素材のパックは展開先で渡す
293  const ensured = await ensureEngine($)
294  const request = launchRequest({
295    platform: ensured.platform,
296    mode,
297    root,
298    asset: ensured.asset,
299    engineDir: ensured.plan.engineDir,
300    id,
301    inputPath,
302  })
303  if (!request) throw new Error('ゲーム本体の場所が分かりません。')
304  return request
305}
306
307// /tako:play が待っている「始まったか、失敗したか」。分かった時点で 1 回だけ答える
308let startWaiters = []
309
310function settleStart(outcome) {
311  const waiters = startWaiters
312  startWaiters = []
313  for (const settle of waiters) settle(outcome)
314}
315
316// 上限まで待って分からなければ、その時点の様子(まだ準備中 / 起動したが無言)で答える
317function waitForStart($) {
318  return new Promise((resolve) => {
319    let isDone = false
320    let timer = null
321    const settle = (outcome) => {
322      if (isDone) return
323      isDone = true
324      timer?.cancel()
325      resolve(outcome)
326    }
327    startWaiters.push(settle)
328    timer = $.clock.after(START_WAIT_MS, () => settle({ kind: engine ? 'silent' : 'waiting' }))
329  })
330}
331
332// 失敗をチャットにも 1 行出す(ペインが見えていない人にも届くように)。モデルには読ませない。
333// チャットに書けない場所では、通知で出す
334async function tellInChat($, text) {
335  if (!text) return
336  try {
337    await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
338  } catch {
339    $.ui.toast(text, { timeoutMs: 15000 })
340  }
341}
342
343async function runEngine($) {
344  // await の前に立てる。待っている間にもう一度呼ばれても、二重には起動しない
345  isStarting = true
346  const id = Math.random().toString(36).slice(2, 6)
347  const token = startToken
348  let request
349  let platform = null
350  try {
351    platform = await platformFor($)
352    const tmp = platform === 'win32-x64' ? await $.env.get('TEMP') : await $.env.get('TMPDIR')
353    inputPath = inputPathFor(tmp, id, platform)
354    if (!inputPath) throw new Error('一時フォルダの場所が分かりません。')
355    scene = 'starting'
356    failure = null
357    failureKind = null
358    // 前のエンジンの時代に溜まった単発操作・キー・ボタンは、新しいエンジンに渡さない
359    ;({ keys, pointer, events } = newEngineSession())
360    request = await engineRequest($, id)
361  } catch (error) {
362    inputPath = null
363    scene = 'error'
364    failure = error.message
365    failureKind = error.failureKind ?? null
366    downloadStatus = null
367    $.ui.invalidate('ui.render')
368    settleStart({ kind: 'failed', text: failureNotice(failure) })
369    await tellInChat($, failureNotice(failure))
370    return
371  } finally {
372    isStarting = false
373  }
374  // 準備の間にオフにされた・ペインを閉じられたなら、起動しない
375  if (!isStartStillWanted(token, startToken)) {
376    inputPath = null
377    scene = 'menu'
378    downloadStatus = null
379    settleStart({ kind: 'cancelled' })
380    return
381  }
382  await writeInput($)
383  stopRequested = false
384  engine = $.process.spawn(request)
385  let pending = ''
386  // 終わり方({ code, signal })。止められたときは無い
387  let result
388  // 本体が 1 行でも出したか、標準出力以外に最後に何を出したか(落ちたときに知らせる)
389  let sawOutput = false
390  let tail = ''
391  // 本体が何かを見せるところまで行ったか(行く前に正常終了したら、失敗として知らせる)
392  let wasUp = false
393  try {
394    for (;;) {
395      const step = await engine.next()
396      if (step.done) {
397        result = step.value
398        break
399      }
400      const { stream, text } = step.value
401      if (stream !== 'stdout') {
402        tail = outputTail(tail, text)
403        continue
404      }
405      sawOutput = true
406      const split = splitLines(pending, text)
407      pending = split.pending
408      for (const line of split.lines) {
409        const message = parseEngineLine(line)
410        // 決まった形でない行(使い方の案内など)も、落ちたときに添えられるよう覚えておく
411        if (!message) tail = outputTail(tail, line + '\n')
412        handleEngineLine($, message)
413        // /tako:play が待っていれば、ここで答える(1 回だけ。待つ人がいなければ何もしない)
414        if (message?.type === 'fatal') settleStart({ kind: 'failed', text: failureNotice(message.reason) })
415        else if (isEngineUp(message)) {
416          wasUp = true
417          settleStart({ kind: 'started' })
418        }
419      }
420    }
421  } catch (error) {
422    $.ui.log('すこしタコを起動できませんでした: ' + error, { to: 'debug' })
423    failure = 'ゲームを起動できませんでした。'
424    const spawnNotice = failureNotice(failure + '(' + String(error?.message ?? error).slice(0, 200) + ')')
425    settleStart({ kind: 'failed', text: spawnNotice })
426    await tellInChat($, spawnNotice)
427  } finally {
428    // 流し読みが途中で失敗しても、子を残さない(for await なら自動で呼ばれる後始末を、手動のループでも行う)
429    await engine?.return?.().catch(() => {})
430    // 入力ファイルを /tmp に残さない
431    const path = inputPath
432    engine = null
433    frame = null
434    inputPath = null
435    const removal = path ? removeFileArgv(path, platform) : null
436    if (removal) await $.process.run(removal).catch(() => {})
437  }
438  // 人が閉じた・プラグインが止めた・落ちた、の見分け(Windows では止めた子も終了コードで返る)。
439  // 画面の扱いは Mac と同じで、OS によらない。見分けの結果は診断のログにだけ残す
440  const kind = classifyEngineEnd({ result, stopRequested, hasFatal: failureKind === 'fatal' })
441  $.ui.log('すこしタコの本体が終わりました: ' + kind + ' ' + JSON.stringify(result ?? null), { to: 'debug' })
442  // 止めていないのに落ちた。失敗として扱い(黙ってペインを閉じない)、チャットにも知らせる
443  // 改行なしで終わった最後の行(pending)も、添える出力に入れる
444  const endNotice = failure ? null : engineEndNotice({ kind, result, sawOutput, tail: outputTail(tail, pending), wasUp })
445  if (endNotice) {
446    failure = endNotice.split('\n')[0].replace(/^すこしタコ: /, '')
447    scene = 'error'
448    $.ui.invalidate('ui.render')
449    settleStart({ kind: 'failed', text: endNotice })
450    await tellInChat($, endNotice)
451  }
452  // 待っている人がまだいれば(出す前に人が閉じた・止めた)、終わったことを答える
453  settleStart({ kind: stopRequested ? 'cancelled' : 'ended' })
454  const action = engineEndAction({ mode: state.mode, hasFailure: Boolean(failure), isPlaying: isPlayingPhase(state.phase) })
455  if (action === 'closePane') {
456    // ウィンドウ方式で、ゲームのウィンドウを人が閉じた。失敗ではないので、ペインも静かに閉じる
457    scene = 'starting'
458    await $.ui.close({ id: PANE }).catch(() => {})
459  } else if (action === 'markEnded') {
460    // 遊んでいる最中に終わったなら、エンジンが落ちたか自分で終了した
461    scene = 'error'
462    failure = failure ?? 'ゲームが終了しました。'
463    $.ui.invalidate('ui.render')
464  }
465}
466
467function handleEngineLine($, message) {
468  if (!message) return
469  switch (message.type) {
470    case 'frame': {
471      // ウィンドウ方式では絵は本体のウィンドウ。ペインには出さない
472      if (state.mode === 'window') break
473      const isFirst = frame === null
474      frame = message.name
475      if (isFirst) $.ui.invalidate('ui.render')
476      else $.ui.blit({ requestId: PANE, key: 'view', source: shmSource(frame) }).catch(() => {})
477      break
478    }
479    case 'hud':
480      if (message.text === hud) break
481      hud = message.text
482      $.ui.invalidate('ui.render')
483      break
484    case 'state':
485      scene = message.state
486      sceneDetail = message.detail
487      // 場面が変わったら、前の場面の絵は捨てる(次のフレームで描き直す)
488      if (scene !== 'hide' && scene !== 'seek') {
489        frame = null
490        // 入力部品も消えるので、押しっぱなしのまま残さない(離した扱いにして伝える)
491        keys = []
492        pointer = releasedPointer(pointer)
493        void writeInput($)
494      }
495      if (scene === 'menu') menuMode = null
496      $.ui.invalidate('ui.render')
497      break
498    case 'stages':
499      stages = message.stages
500      $.ui.invalidate('ui.render')
501      break
502    case 'fatal':
503      scene = 'error'
504      failure = message.reason
505      failureKind = 'fatal'
506      $.ui.invalidate('ui.render')
507      void tellInChat($, failureNotice(failure))
508      break
509  }
510}
511
512// 表示先と OS で、遊び方を決める(ペイン / ウィンドウ / 遊べない)
513async function modeFor($, surface) {
514  return playMode({
515    surface,
516    hasPixels: await checkPixels($),
517    platform: await platformFor($),
518  })
519}
520
521// いまのセッションが描いている表示先から、遊び方を決め直す。
522// 起動直後やコマンドの時点では、表示先がまだ付いていないことがある。そのときは null を返し、
523// 遊び方は変えない(ペインを描くときに drawPane が決める)。
524async function refreshMode($, fallbackSurface) {
525  let surface = fallbackSurface ?? null
526  try {
527    const surfaces = await $.session.surfaces()
528    if (surfaces.length > 0) surface = surfaces[0]
529  } catch {
530    // 表示先が読めないときは、渡されたものだけで決める
531  }
532  surface = surfaceOrGuess(surface, await checkPixels($))
533  if (surface === null) return null
534  const mode = await modeFor($, surface)
535  if (mode !== state.mode) await dispatch($, { type: 'setMode', mode })
536  return mode
537}
538
539function chooseMode($, mode) {
540  menuMode = mode
541  $.ui.invalidate('ui.render')
542}
543
544async function chooseStage($, stageId) {
545  // エンジンは番号順に処理する: どちらで遊ぶか → コース → 決定
546  eventSeq += 1
547  events = pushEvent(events, eventSeq, menuMode)
548  eventSeq += 1
549  events = pushEvent(events, eventSeq, 'stage:' + stageId)
550  await sendEvent($, 'confirm_stage')
551}
552
553function pressOption($, option) {
554  if (option.action === 'stage') void chooseStage($, option.stageId)
555  else if (option.action === 'mode') chooseMode($, option.mode)
556}
557
558function pressButton($, button) {
559  if (button.action === 'event') void sendEvent($, button.event)
560  else if (button.action === 'close') {
561    // 自分でやめた。ゲームのウィンドウを残さない(残すと「Claude が呼んでいます」のまま 5 分居座る)
562    void stopEngine()
563    void $.ui.close({ id: PANE })
564  }
565}
566
567// autoFocus は「true か、付けない」のどちらかでなければ描画が拒否される
568function buttonProps(button) {
569  return {
570    key: button.key,
571    label: button.label,
572    hotkey: button.hotkey,
573    ...(button.autoFocus ? { autoFocus: true } : {}),
574  }
575}
576
577function shmSource(name) {
578  return { shm: name, format: 'rgb', width: PANE_WIDTH, height: PANE_HEIGHT }
579}
580
581// paneView の平たい記述を、その表示先の要素に写す
582function renderView($, elements, view) {
583  const { Box, Text, Button, Image, Client } = elements
584  switch (view.kind) {
585    case 'message':
586      return Text({ children: [view.text] })
587    case 'menu':
588      return Box({
589        flexDirection: 'column',
590        gap: 1,
591        children: [
592          Text({ bold: true, children: [view.title] }),
593          ...view.options.map((option) => Button({ ...buttonProps(option), onPress: () => pressOption($, option) })),
594        ],
595      })
596    case 'game':
597      return Box({
598        flexDirection: 'column',
599        children: [
600          Image({ key: 'view', source: shmSource(frame), columns: view.image.columns, rows: view.image.rows, alt: NEEDS_TERMINAL }),
601          // 絵の上に重ねて、クリックとキーをゲームへ渡す
602          Box({
603            position: 'absolute',
604            top: 0,
605            left: 0,
606            children: [Client({ key: 'input', module: './input.js', width: view.image.columns, height: view.image.rows })],
607          }),
608          // 操作の説明は絵のすぐ下。数えている間は、残りをそこに出す
609          ...(view.status
610            ? [Text({ bold: true, children: [view.status.text] })]
611            : view.help.map((line, index) => Text({ key: 'help' + index, dimColor: true, children: [line] }))),
612          ...(view.hud ? [Text({ children: [view.hud] })] : []),
613          ...(view.debug ? [Text({ dimColor: true, children: [view.debug] })] : []),
614        ],
615      })
616    case 'windowGame':
617      // ウィンドウ方式のペイン: 絵も入力部品も出さず、案内とボタンだけ
618      return Box({
619        flexDirection: 'column',
620        gap: 1,
621        children: [
622          Text({ children: [view.note] }),
623          ...(view.hud ? [Text({ children: [view.hud] })] : []),
624          Text({ dimColor: true, children: [view.help] }),
625          ...view.keys.map((line, index) => Text({ key: 'keys' + index, dimColor: true, children: [line] })),
626          ...view.buttons.map((button) => Button({ ...buttonProps(button), onPress: () => pressButton($, button) })),
627        ],
628      })
629    case 'outcome':
630      return Box({
631        flexDirection: 'column',
632        gap: 1,
633        children: [
634          Text({ bold: true, children: [view.title] }),
635          ...(view.detail ? [Text({ children: [view.detail] })] : []),
636          Button({ ...buttonProps(view.button), onPress: () => sendEvent($, 'back_to_hub') }),
637        ],
638      })
639    case 'error':
640      return Box({
641        flexDirection: 'column',
642        gap: 1,
643        children: [
644          Text({ children: [view.text] }),
645          ...(view.guidance ? [Text({ children: [view.guidance] })] : []),
646          Button({ ...buttonProps(view.button), onPress: () => retry($) }),
647        ],
648      })
649    default:
650      return Text({ children: [view.text ?? NEEDS_TERMINAL] })
651  }
652}
653
654async function drawPane($, e) {
655  const elements = $.ui.resolve(e)
656  const mode = await modeFor($, e.surface)
657  if (mode !== state.mode) await dispatch($, { type: 'setMode', mode })
658  const view = paneView(
659    {
660      ...state,
661      platform: await platformFor($),
662      scene,
663      sceneDetail,
664      hud,
665      stages,
666      menuMode,
667      failure,
668      failureKind,
669      downloadStatus,
670      isDebug,
671      keys,
672      lastKey,
673      hasFrame: frame !== null,
674      columns: e.props?.bodyColumns,
675      rows: e.props?.scroll?.bodyRows,
676    },
677    mode,
678  )
679  return renderView($, elements, view)
680}
681
682async function retry($) {
683  if (engine) await sendEvent($, 'retry')
684  else void runEngine($)
685}
686
687export function register(on) {
688  on('session.start', async ($, e, next) => {
689    const isOn = (await $.store.get('isOn')) === true
690    state = { ...initialState(), isOn }
691    isDebug = (await $.env.get('SUKOSHI_TAKO_DEBUG')) === '1'
692    // 表示先が分かるときは、ここで遊び方を決めておく(デスクトップでは後から付くこともある)
693    await refreshMode($, e.surface)
694    return next(e)
695  })
696
697  for (const name of commandNames(PLUGIN_NAME)) {
698    on('command.run', { command: name }, async ($, e) => {
699      if (commandAction(name, e.args) === 'off') {
700        await $.store.set('isOn', false)
701        await dispatch($, { type: 'setOn', isOn: false })
702        await stopEngine()
703        return { text: 'すこしタコをオフにしました。' }
704      }
705      await $.store.set('isOn', true)
706      await dispatch($, { type: 'setOn', isOn: true })
707      // 表示先がいま付いているかを確かめ直す(デスクトップでは起動時に無いことがある)
708      const plan = playCommandPlan({ mode: await refreshMode($), platform: await platformFor($) })
709      if (!plan.start) return { text: plan.text }
710      if (plan.waitsForPane) {
711        // 絵をペインの中に出す方式。自分で開いたペインは、狭いターミナルでも置かれる
712        await $.ui.open(paneOpenArgs(PANE, TITLE))
713        await dispatch($, { type: 'openedByPerson' })
714        await startPlaying($)
715        return {}
716      }
717      // ウィンドウ方式(表示先がまだ分からないときも含む)。ゲームはペインが無くても始められるので、
718      // ペインが開くのを待たない。開けなかった・見えていないときも、チャットの 1 行は必ず返る
719      if (plan.mode !== state.mode) await dispatch($, { type: 'setMode', mode: plan.mode })
720      await dispatch($, { type: 'openedByPerson' })
721      void $.ui.open(paneOpenArgs(PANE, TITLE)).catch(() => {})
722      if (engine) {
723        await startPlaying($)
724        return { text: playOutcomeText({ kind: 'running' }) }
725      }
726      // 始まったか失敗したかが分かるまで待って、返事に載せる(あとから足す知らせは、
727      // デスクトップアプリのチャットに出ない)
728      const outcome = waitForStart($)
729      void startPlaying($)
730      return { text: playOutcomeText(await outcome) }
731    })
732  }
733
734  on('turn.start', async ($, e, next) => {
735    // ウィンドウ方式では自動で開かない(開くのは /tako:play とペインのボタンだけ)。
736    // それでも印は付けておく。止めていたゲームを次の作業で動かすため
737    // 表示先が分からないうちは開かない(絵が出せるターミナルは、今までどおり開く)
738    const mode = await refreshMode($)
739    if (state.isOn && mode !== null && mode !== 'none') await dispatch($, { type: 'turnStart' })
740    return next(e)
741  })
742
743  on('turn.complete', async ($, e, next) => {
744    // サブエージェントの完了では戻さない
745    if (e.agentId) return next(e)
746    await dispatch($, { type: 'turnComplete', isAborted: e.isAborted === true })
747    return next(e)
748  })
749
750  on('tool.check', async ($, e, next) => {
751    const result = await next(e)
752    // 確認が人ではなく分類器に回ることもあるが、本物の確認を見逃すほうが高くつく
753    if (e.tool_use_id && result.decision === 'ask') await dispatch($, { type: 'needsYou' })
754    return result
755  })
756
757  on('tool.call', async ($, e, next) => {
758    if (e.tool === 'AskUserQuestion') await dispatch($, { type: 'needsYou' })
759    const result = await next(e)
760    await dispatch($, { type: 'toolRan' })
761    return result
762  })
763
764  on('ui.close', async ($, e, next) => {
765    if (e.id !== PANE) return next(e)
766    await dispatch($, { type: 'paneClosed', byPerson: e.origin?.kind === 'person' })
767    await goAway($)
768    return next(e)
769  })
770
771  on('ui.message', async ($, e) => {
772    if (e.element !== 'input') return {}
773    keys = Array.isArray(e.data.keys) ? e.data.keys : []
774    pointer = { ...idlePointer(), ...e.data.pointer }
775    if (isDebug && e.data.lastKey !== lastKey) {
776      lastKey = e.data.lastKey
777      $.ui.invalidate('ui.render')
778    }
779    await writeInput($)
780    return {}
781  })
782
783  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
784    // 帯はターミナルだけのもの。ウィンドウ方式では出さない
785    if (state.mode !== 'pane' || state.phase !== 'offered') return next(e)
786    const { Box, Button } = $.ui.resolve(e)
787    // 他のプラグインが帯に出しているものは残す
788    const others = await next(e)
789    return Box({
790      flexDirection: 'column',
791      children: [
792        Button({
793          key: 'play',
794          label: 'Claude の作業中にすこしタコを遊ぶ',
795          hotkey: '1',
796          plain: true,
797          onPress: async () => {
798            await $.ui.open(paneOpenArgs(PANE, TITLE))
799            await dispatch($, { type: 'openedByPerson' })
800            await startPlaying($)
801          },
802        }),
803        ...(others ? [others] : []),
804      ],
805    })
806  })
807
808  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
809    if (e.requestId !== PANE) return next(e)
810    return drawPane($, e)
811  })
812}
813
hooks/logic.js 893 lines
1// sukoshi-tako の、$ に触らない部分。register.js が使い、テストが直接呼ぶ。
2
3export const DROP_IN_DELAY_MS = 2000
4export const COUNTDOWN_SECONDS = 3
5// 入力ファイルの 3 行目に残す単発操作の数。エンジンは番号で処理済みを見分ける
6export const EVENTS_KEPT = 8
7
8// ゲーム画面の大きさ(本体と共有する画素の数)
9export const PANE_WIDTH = 640
10export const PANE_HEIGHT = 360
11// ウィンドウ方式のゲームのウィンドウ(開いた後は人が大きさを変えられる)
12export const WINDOW_WIDTH = 1280
13export const WINDOW_HEIGHT = 720
14
15export function engineSize(mode) {
16  return mode === 'window'
17    ? { width: WINDOW_WIDTH, height: WINDOW_HEIGHT }
18    : { width: PANE_WIDTH, height: PANE_HEIGHT }
19}
20
21// ---- 出し入れの状態機械 ----
22//
23//   idle       遊んでいない(Claude が作業中かどうかは問わない)
24//   waiting    Claude が作業中。待ち時間が過ぎたら入る
25//   offered    ターミナルが狭くて自動では開けなかった。帯のボタンで開ける
26//   playing    ペインが開いていて遊んでいる
27//   countdown  Claude の作業が終わった。数え終わったら閉じる
28//   paused     ウィンドウ方式で Claude の番。ペインは開いたまま、本体は止めている
29
30export function initialState() {
31  return { phase: 'idle', isOn: false, isTurnRunning: false, isDismissed: false, countdown: 0, mode: 'pane' }
32}
33
34// 返すのは次の状態と、register.js が実行する副作用の名前の並び。
35//   armDropIn / cancelTimer / open / close / startCountdown / redraw
36//
37// mode は 'pane'(ペインの中に絵を出す)/ 'window'(本体が別ウィンドウを出す)。
38// ウィンドウ方式は自動で開かず、Claude の作業が終わってもペインを閉じない
39// (遊んでいるかを 0 にして、本体に一時停止を知らせるだけ)。ペイン方式の動きは変えない。
40export function reduce(state, action) {
41  const next = { ...state }
42  const effects = []
43  const canArm = () =>
44    next.mode === 'pane' && next.isOn && next.isTurnRunning && !next.isDismissed && next.phase === 'idle'
45  const arm = () => {
46    if (!canArm()) return
47    next.phase = 'waiting'
48    effects.push('armDropIn')
49  }
50  const pullOut = () => {
51    effects.push('cancelTimer')
52    if (next.phase === 'playing' || next.phase === 'countdown' || next.phase === 'paused') effects.push('close')
53    else effects.push('redraw')
54    next.phase = 'idle'
55  }
56
57  switch (action.type) {
58    case 'setMode':
59      next.mode = action.mode
60      break
61    case 'setOn':
62      next.isOn = action.isOn
63      if (!action.isOn && next.phase !== 'idle') pullOut()
64      break
65    case 'turnStart':
66      next.isTurnRunning = true
67      next.isDismissed = false
68      if (next.phase === 'countdown') {
69        // 続けて次の作業が始まったので、そのまま遊ぶ
70        next.phase = 'playing'
71        effects.push('cancelTimer', 'redraw')
72      } else if (next.mode === 'window' && next.phase === 'paused') {
73        // 止めていたゲームを、Claude が続きを始めたらまた動かす
74        next.phase = 'playing'
75        effects.push('redraw')
76      }
77      arm()
78      break
79    case 'dropInDue':
80      if (next.phase === 'waiting') effects.push('open')
81      break
82    case 'opened':
83      // 自動で開こうとした結果。置けなければ帯で誘う
84      if (next.phase !== 'waiting') break
85      next.phase = action.isPlaced ? 'playing' : 'offered'
86      effects.push('redraw')
87      break
88    case 'openedByPerson':
89      next.phase = 'playing'
90      effects.push('cancelTimer', 'redraw')
91      break
92    case 'turnComplete':
93      next.isTurnRunning = false
94      if (next.mode === 'window') {
95        // ペインは閉じず、本体も止めない。遊んでいるかを 0 にして一時停止を知らせる
96        if (next.phase === 'playing') {
97          next.phase = 'paused'
98          effects.push('redraw')
99        }
100      } else if (next.phase === 'waiting' || next.phase === 'offered') {
101        pullOut()
102      } else if (next.phase === 'playing') {
103        if (action.isAborted) {
104          pullOut()
105        } else {
106          next.phase = 'countdown'
107          next.countdown = COUNTDOWN_SECONDS
108          effects.push('startCountdown', 'redraw')
109        }
110      }
111      break
112    case 'countdownTick':
113      if (next.phase !== 'countdown') break
114      next.countdown -= 1
115      if (next.countdown > 0) effects.push('redraw')
116      else pullOut()
117      break
118    case 'needsYou':
119      if (next.mode === 'window') {
120        // Claude が確認を出す。ペインは閉じずに、遊んでいるかを 0 にする
121        if (next.phase === 'playing') {
122          next.phase = 'paused'
123          effects.push('redraw')
124        }
125      } else if (next.phase !== 'idle') {
126        // プロンプトを見せるために即座に引く
127        pullOut()
128      }
129      break
130    case 'toolRan':
131      if (next.mode === 'window') {
132        // 確認に答えて Claude が続きを始めたら、止めていたゲームをまた動かす
133        if (next.phase === 'paused') {
134          next.phase = 'playing'
135          effects.push('redraw')
136        }
137      } else {
138        // 確認に答えて Claude が続きを始めたら、また入る
139        arm()
140      }
141      break
142    case 'paneClosed':
143      if (action.byPerson && next.isTurnRunning) next.isDismissed = true
144      next.phase = 'idle'
145      effects.push('cancelTimer')
146      break
147  }
148  return { state: next, effects }
149}
150
151export function isPlayingPhase(phase) {
152  return phase === 'playing' || phase === 'countdown'
153}
154
155// ---- 絵の大きさ ----
156
157// ターミナルのセルは縦が横の約 2 倍
158export function rowsFor(columns, width, height) {
159  return Math.max(1, Math.round((columns * height) / width / 2))
160}
161
162// ペインに頼む大きさ(頼みであって、置き場所が狭ければ小さくなる)。横に並ぶときは幅、
163// 入力欄の上に出るときは高さが効く
164export const PANE_WANTS = { columns: 120, rows: 40 }
165
166export function paneOpenArgs(id, title) {
167  return { id, title, focus: true, ...PANE_WANTS }
168}
169
170// 絵はペインの幅いっぱい。ただし下に出す行(操作の説明と HUD)が隠れない高さまでにする
171export function imageCells(bodyColumns, bodyRows, rowsBelow) {
172  let columns = Math.min(255, Math.max(0, bodyColumns ?? 0))
173  if (Number.isFinite(bodyRows)) {
174    const room = Math.max(1, bodyRows - rowsBelow)
175    columns = Math.min(columns, Math.floor((room * 2 * PANE_WIDTH) / PANE_HEIGHT))
176  }
177  return { columns, rows: rowsFor(columns, PANE_WIDTH, PANE_HEIGHT) }
178}
179
180// ---- 入力ファイル ----
181
182export function idlePointer() {
183  return { isLeftDown: false, isRightDown: false, x: 0, y: 0, leftDowns: 0, leftUps: 0, rightDowns: 0, rightUps: 0 }
184}
185
186// エンジンと共有する約束。3 行で、必ず改行で終わる。
187// window 方式(本体のウィンドウがキーを直接受ける)では、キーとマウスを載せず、
188// 1 行目は遊んでいるかだけ、2 行目は押されていないマウスにする。
189export function inputFileText({ isPlaying, keys, pointer, events, mode }) {
190  if (mode === 'window') {
191    const first = isPlaying ? '1' : '0'
192    const second = 'P 0 0 0.0000 0.0000 0 0 0 0'
193    const third = ['E', ...events.map((event) => event.id + ':' + event.name)].join(' ')
194    return first + '\n' + second + '\n' + third + '\n'
195  }
196  const p = pointer
197  const first = [isPlaying ? '1' : '0', ...(isPlaying ? [...keys].sort() : [])].join(' ')
198  const second = [
199    'P',
200    isPlaying && p.isLeftDown ? 1 : 0,
201    isPlaying && p.isRightDown ? 1 : 0,
202    clamp01(p.x).toFixed(4),
203    clamp01(p.y).toFixed(4),
204    p.leftDowns,
205    p.leftUps,
206    p.rightDowns,
207    p.rightUps,
208  ].join(' ')
209  const third = ['E', ...events.map((event) => event.id + ':' + event.name)].join(' ')
210  return first + '\n' + second + '\n' + third + '\n'
211}
212
213function clamp01(value) {
214  return Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0))
215}
216
217// 新しいエンジンを起動するときの、入力の初期状態。
218// エンジンは「処理済みの単発操作の番号」を 0 から数え直すので、前のエンジンの時代に溜まった
219// 操作(探す・コース・決定)を渡すと、何も選んでいないのに前回のコースが始まる。
220export function newEngineSession() {
221  return { keys: [], pointer: idlePointer(), events: [] }
222}
223
224// 絶対パスかどうか。Windows はドライブ文字(C:\ か C:/)が要る
225export function isAbsolutePath(path, platform) {
226  if (typeof path !== 'string') return false
227  if (platform === 'win32-x64') return /^[A-Za-z]:[\\/]/.test(path)
228  return path.startsWith('/')
229}
230
231// 入力ファイルの置き場所。ほかのユーザーも書ける /tmp ではなく、ユーザーごとの一時フォルダ
232// (macOS の TMPDIR、自分だけが読み書きできる)に置く。絶対パスでなければ /tmp に戻す。
233// Windows は %TEMP% の絶対パスが分かったときだけ置く(戻し先の /tmp は無い)。
234export function inputPathFor(tmpdir, id, platform) {
235  if (platform === 'win32-x64') {
236    if (!isAbsolutePath(tmpdir, 'win32-x64')) return null
237    return tmpdir.replace(/[\\/]+$/, '') + '\\sukoshi-tako-' + id + '.input'
238  }
239  const base = typeof tmpdir === 'string' && tmpdir.startsWith('/') ? tmpdir.replace(/\/+$/, '') : '/tmp'
240  return base + '/sukoshi-tako-' + id + '.input'
241}
242
243// 開発用の SUKOSHI_TAKO_ENGINE。SHA-256 の照合を通らずに任意の実行ファイルを起動できてしまうので、
244// (1) プラグインのフォルダの中に印のファイルがあり、(2) SUKOSHI_TAKO_DEV=1 で、(3) 絶対パス(または fake)の
245// ときだけ受け付ける。環境変数だけでは効かない: 環境変数は、開いたリポジトリの設定から入りうるため。
246// 印のファイルは、開発する人が自分のプラグインのフォルダに置く(配る物には入っていない)。
247export function devMarkerPath(root) {
248  return root + '/dev/allow-local-engine'
249}
250
251export function resolveEngineOverride(override, devFlag, platform, hasDevMarker) {
252  if (hasDevMarker !== true) return null
253  if (devFlag !== '1' || typeof override !== 'string') return null
254  if (override === 'fake') return 'fake'
255  return isAbsolutePath(override, platform) ? override : null
256}
257
258// 場面がゲームでなくなったとき。ペインの入力部品が消えると「離した」が届かず、ゲームは押しっぱなしの
259// まま(連射し続ける)になる。押していたボタンに、離した回数を 1 つ足して離した扱いにする。
260export function releasedPointer(pointer) {
261  return {
262    ...pointer,
263    isLeftDown: false,
264    isRightDown: false,
265    leftUps: pointer.leftUps + (pointer.isLeftDown ? 1 : 0),
266    rightUps: pointer.rightUps + (pointer.isRightDown ? 1 : 0),
267  }
268}
269
270export function pushEvent(events, id, name) {
271  return [...events, { id, name }].slice(-EVENTS_KEPT)
272}
273
274// ---- エンジンの標準出力 ----
275
276// かたまりで届く出力を行に分ける。最後の、まだ改行の来ていない分は持ち越す
277export function splitLines(pending, text) {
278  const lines = (pending + text).split('\n')
279  const rest = lines.pop()
280  return { lines, pending: rest }
281}
282
283export function parseEngineLine(line) {
284  // Windows の標準出力は \r\n で届くことがある。行末の \r が語に残ると、場面が「知らない語」になる
285  line = line.replace(/\r$/, '')
286  if (!line.startsWith('@')) return null
287  const space = line.indexOf(' ')
288  const tag = space === -1 ? line : line.slice(0, space)
289  const rest = space === -1 ? '' : line.slice(space + 1)
290  switch (tag) {
291    case '@frame': {
292      const name = rest.split(' ')[0]
293      return name ? { type: 'frame', name } : null
294    }
295    case '@hud':
296      return { type: 'hud', text: rest }
297    case '@state': {
298      const [state, ...detail] = rest.split(' ')
299      return state ? { type: 'state', state, detail: detail.join(' ') } : null
300    }
301    case '@stages':
302      try {
303        const stages = JSON.parse(rest)
304        if (!Array.isArray(stages)) return null
305        return { type: 'stages', stages: stages.filter((s) => s && typeof s.id === 'string' && typeof s.name === 'string') }
306      } catch {
307        return null
308      }
309    case '@fatal':
310      return { type: 'fatal', reason: rest }
311    default:
312      return null
313  }
314}
315
316// ---- ゲーム本体の取得 ----
317
318// engine.json: この版のプラグインが取りに行くゲーム本体と、その SHA-256。
319// 形が違えば null(その場合は何も取りに行かない)。
320export function parseEngineManifest(text) {
321  try {
322    const { version, url, sha256 } = JSON.parse(text)
323    if (typeof version !== 'string' || typeof url !== 'string' || typeof sha256 !== 'string') return null
324    if (!/^(https|file):\/\//.test(url)) return null
325    if (!/^[0-9a-f]{64}$/.test(sha256)) return null
326    return { version, url, sha256 }
327  } catch {
328    return null
329  }
330}
331
332// 配布物の中の実行ファイルへの相対パス。外へ出る形は受け付けない。
333function isEntryPath(value) {
334  if (typeof value !== 'string' || value === '') return false
335  if (value.startsWith('/') || value.includes('\\') || value.includes(':')) return false
336  if (value.split('/').includes('..')) return false
337  return true
338}
339
340// engine.json の配布物の一覧。assets(OS と CPU ごとの url / sha256 / entry と、
341// 任意の windowEntry)を読み、darwin が採れなければ最上位の url / sha256 を
342// darwin として補う(古い形との互換)。形が違えば null。条件を満たさない項目は捨てる。
343export function parseEngineAssets(text) {
344  const manifest = parseEngineManifest(text)
345  if (!manifest) return null
346  const assets = JSON.parse(text).assets
347  const found = {}
348  if (assets && typeof assets === 'object') {
349    for (const platform of ['darwin', 'win32-x64', 'linux-x64']) {
350      const item = assets[platform]
351      if (!item || typeof item !== 'object') continue
352      if (typeof item.url !== 'string' || !/^(https|file):\/\//.test(item.url)) continue
353      if (typeof item.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(item.sha256)) continue
354      // entry は配布物の中の、実行ファイルへの相対パス。外へ出る形は受け付けない
355      const entry = item.entry
356      if (!isEntryPath(entry)) continue
357      const foundItem = { url: item.url, sha256: item.sha256, entry }
358      // windowEntry は、ウィンドウ方式で起動する実行ファイル(任意)。形が違うときは
359      // 無いものとして entry を使う(ウィンドウが開けなくてもペイン方式は動く)
360      if (isEntryPath(item.windowEntry)) foundItem.windowEntry = item.windowEntry
361      found[platform] = foundItem
362    }
363  }
364  // 古い形(最上位だけ)でも、新しい形で darwin が抜けていても、Mac はこれで動く
365  if (!found.darwin) {
366    found.darwin = { url: manifest.url, sha256: manifest.sha256, entry: 'sukoshi-tako-engine/ChameleonPane' }
367  }
368  return found
369}
370
371// 出力のどこかにある、16 進 64 文字の語が、engine.json の値と同じか。行や位置を決め打ちしないので、
372// shasum -a 256 / sha256sum / Windows の certutil -hashfile(1 行目が説明、2 行目がハッシュ、
373// 3 行目が完了)のどれでも読める。
374export function checksumMatches(output, expected) {
375  const actual = String(output ?? '')
376    .split(/\s+/)
377    .find((word) => /^[0-9a-f]{64}$/i.test(word))
378  return actual !== undefined && actual.toLowerCase() === expected.toLowerCase()
379}
380
381// OS ごとの「取得 → 照合 → 展開」の一連を argv で組み立てる。シェルは通さない。
382// Windows も Windows 10 以降に入っている curl.exe / certutil / tar.exe だけで行う。
383// 知らない OS は null(何も取りに行かない)。
384export function fetchPlan({ platform, root, url }) {
385  if (platform === 'darwin' || platform === 'linux-x64') {
386    const archive = root + '/engine.tar.gz'
387    const distDir = root + '/dist'
388    const engineDir = distDir + '/sukoshi-tako-engine'
389    return {
390      archive,
391      distDir,
392      engineDir,
393      marker: distDir + '/engine.sha256',
394      download: ['curl', '-fsSL', '--retry', '2', '-o', archive, url],
395      hash: platform === 'darwin' ? ['shasum', '-a', '256', archive] : ['sha256sum', archive],
396      removeArchive: ['rm', '-f', archive],
397      removeOld: ['rm', '-rf', engineDir],
398      makeDist: ['mkdir', '-p', distDir],
399      unpack: ['tar', '-xzf', archive, '-C', distDir],
400    }
401  }
402  if (platform === 'win32-x64') {
403    // del / rmdir / mkdir は cmd /c 経由(= シェル)。cmd が読み替える文字が場所にあるなら取りに行かない
404    if (/[&|<>^%"!()]/.test(root)) return null
405    // cmd は / をオプションの印と読むので、区切りは \ にそろえる。末尾の区切りは落とす
406    const base = windowsRoot(root)
407    const archive = base + '\\engine.tar.gz'
408    const distDir = base + '\\dist'
409    const engineDir = distDir + '\\sukoshi-tako-engine'
410    return {
411      archive,
412      distDir,
413      engineDir,
414      marker: distDir + '\\engine.sha256',
415      download: ['curl.exe', '-fsSL', '--retry', '2', '-o', archive, url],
416      hash: ['certutil', '-hashfile', archive, 'SHA256'],
417      removeArchive: ['cmd', '/c', 'del', '/f', '/q', archive],
418      removeOld: ['cmd', '/c', 'rmdir', '/s', '/q', engineDir],
419      makeDist: ['cmd', '/c', 'mkdir', distDir],
420      unpack: ['tar.exe', '-xzf', archive, '-C', distDir],
421    }
422  }
423  return null
424}
425
426// Windows のパスの区切りを \ にそろえ、末尾の区切りを落とす
427function windowsRoot(root) {
428  return root.replace(/\//g, '\\').replace(/\\+$/, '')
429}
430
431// 入力ファイルの後始末。Windows に rm は無い
432// cmd の特殊文字が場所に入っていたら、cmd に渡さない(取得の fetchPlan と同じ検査)。そのときは null(後始末を諦める)。
433export function removeFileArgv(path, platform) {
434  if (platform !== 'win32-x64') return ['rm', '-f', path]
435  if (/[&|<>^%"!()]/.test(path)) return null
436  return ['cmd', '/c', 'del', '/f', '/q', path.replace(/\//g, '\\')]
437}
438
439// 取得や起動の準備を始めたときの番号と、今の番号。オフにしたりペインを閉じたりすると番号が進む。
440// 進んでいたら、準備が終わっても本体は起動しない
441export function isStartStillWanted(tokenAtStart, tokenNow) {
442  return tokenAtStart === tokenNow
443}
444
445// 配布物の中の実行ファイルの場所
446export function enginePathFor(root, platform, entry) {
447  if (platform === 'darwin' || platform === 'linux-x64') return root + '/dist/' + entry
448  if (platform === 'win32-x64') return windowsRoot(root) + '\\dist\\' + entry.replace(/\//g, '\\')
449  return null
450}
451
452// ウィンドウ方式で起動する実行ファイル。windowEntry があればそれを、無ければ entry を使う
453export function engineEntryFor(asset, mode) {
454  if (!asset) return null
455  if (mode === 'window' && typeof asset.windowEntry === 'string' && asset.windowEntry !== '') return asset.windowEntry
456  return asset.entry
457}
458
459// 素材のパックの場所。本体は起動時にこれを読む(SUKOSHI_TAKO_PACK)
460export function packPathFor(engineDir, platform) {
461  if (typeof engineDir !== 'string' || engineDir === '') return null
462  const separator = platform === 'win32-x64' ? '\\' : '/'
463  return engineDir.replace(/[\\/]+$/, '') + separator + 'assets.pack'
464}
465
466// engine.json の配布物の一覧(parseEngineAssets の戻り値)から、この OS の分を選ぶ。
467// ない OS は、落ちずに理由の分かる案内にする。kind: noBuild = この OS の本体がまだ無い /
468// unsupported = 対応していない OS・CPU / manifest = engine.json が読めない
469export function resolveEngineAsset(assets, platform) {
470  if (!assets) return { ok: false, kind: 'manifest', text: 'engine.json が読めません。' }
471  if (platform !== 'darwin' && platform !== 'win32-x64' && platform !== 'linux-x64') {
472    return { ok: false, kind: 'unsupported', text: NEEDS_OTHER_ENV }
473  }
474  const asset = assets[platform]
475  if (!asset) return { ok: false, kind: 'noBuild', text: noBuildText(platform) }
476  return { ok: true, asset }
477}
478
479// 本体に渡す環境変数。共有メモリの名前(FRAMES)は Mac の本体だけが使う
480export function engineEnv({ platform, mode, id, inputPath, packPath }) {
481  const size = engineSize(mode)
482  return {
483    // macOS の共有メモリ名は 30 文字まで: "/tk" + 4 文字 + "-" + 連番
484    ...(platform === 'darwin' ? { SUKOSHI_TAKO_FRAMES: '/tk' + id + '-' } : {}),
485    SUKOSHI_TAKO_INPUT: inputPath,
486    SUKOSHI_TAKO_WIDTH: String(size.width),
487    SUKOSHI_TAKO_HEIGHT: String(size.height),
488    SUKOSHI_TAKO_MODE: mode,
489    ...(packPath ? { SUKOSHI_TAKO_PACK: packPath } : {}),
490  }
491}
492
493// 取得・照合・展開が済んだ本体の起動の組み立て。置き場所が分からなければ null
494export function launchRequest({ platform, mode, root, asset, engineDir, id, inputPath }) {
495  const exe = enginePathFor(root, platform, engineEntryFor(asset, mode))
496  if (!exe) return null
497  return {
498    argv: engineArgv(exe, platform),
499    env: engineEnv({ platform, mode, id, inputPath, packPath: packPathFor(engineDir, platform) }),
500  }
501}
502
503// 本体の起動の引数。Windows / Linux の本体は、run を付けないと使い方を 1 行出して正常終了する
504// (2026-10-06 Windows: /tako:play の直後に「すこしタコは終わりました。」)。Mac の本体は引数を取らない
505export function engineArgv(exe, platform) {
506  return platform === 'darwin' ? [exe] : [exe, 'run']
507}
508
509// 開発用の上書き(SUKOSHI_TAKO_ENGINE。resolveEngineOverride を通った値)の起動の組み立て
510export function devEngineRequest({ override, platform, mode, root, id, inputPath }) {
511  const env = engineEnv({ platform, mode, id, inputPath, packPath: null })
512  if (override === 'fake') return { argv: ['python3', '-u', root + '/dev/fake_engine.py'], env }
513  if (!override) return null
514  if (platform !== 'darwin') return { argv: engineArgv(override, platform), env }
515  // 手元の Mac のビルドは、隣のフレームワークを自分では見つけられない
516  const folder = override.slice(0, override.lastIndexOf('/'))
517  return { argv: [override], env: { ...env, DYLD_FRAMEWORK_PATH: folder + ':' + folder + '/PackageFrameworks' } }
518}
519
520// 本体が終わったときの解釈。Windows では、止めた子も signal ではなく終了コードで返るので、
521// コードだけでは見分けられない。止めたかどうかは自分の記録(stopRequested)で決める。
522//   fatal    @fatal を受けた(理由は画面に出ている)
523//   stopped  プラグインが止めた(オフ・やめる・5 分離れた)
524//   closed   止めていないのに正常に終わった(人がウィンドウを閉じた。入力ファイルが消えた場合も)
525//   crashed  止めていないのに 0 以外の終了・signal・結果なし
526export function classifyEngineEnd({ result, stopRequested, hasFatal }) {
527  if (hasFatal) return 'fatal'
528  if (stopRequested) return 'stopped'
529  if (result && result.code === 0 && !result.signal) return 'closed'
530  return 'crashed'
531}
532
533// 終わったあとにペインをどうするか(Mac で今やっていることそのまま。OS に依らない)。
534//   closePane   ウィンドウ方式で失敗が無い: 静かにペインも閉じる
535//   markEnded   遊んでいる最中に終わった: 「ゲームが終了しました」を出す
536//   none        何もしない
537export function engineEndAction({ mode, hasFailure, isPlaying }) {
538  if (mode === 'window' && !hasFailure) return 'closePane'
539  return isPlaying ? 'markEnded' : 'none'
540}
541
542// ---- 失敗をチャットにも知らせる ----
543//
544// 失敗の理由はペインに出すが、ペインが見えていない人には届かない(Windows のデスクトップアプリ、
545// 2026-10-06: 「始めます」の後、ウィンドウが出ないまま何も言わなかった)。同じ文をチャットにも 1 行出す。
546
547// 取得や起動の準備で失敗したときの 1 行。失敗が無ければ null
548export function failureNotice(failure) {
549  return failure ? 'すこしタコ: ' + failure : null
550}
551
552// 本体が出した文字の終わりだけを覚えておく(落ちたときに添える)
553export function outputTail(previous, text, max = 400) {
554  return (previous + text).slice(-max)
555}
556
557// 本体が勝手に終わったときの 1 行。人が閉じた・プラグインが止めた・@fatal で理由を出した後は null。
558//   sawOutput  本体が 1 行でも出していたか(出す前に落ちたなら、ウィンドウは開いていない)
559//   tail       本体が最後に出した文字(標準エラー)。頼まれて見る人のために添える
560//   wasUp      本体が何かを見せるところまで行ったか。行く前に正常終了したなら、人が閉じたのではない
561//              (2026-10-06 Windows: 使い方を 1 行出して終了コード 0。「終わりました」だけでは理由が分からなかった)
562export function engineEndNotice({ kind, result, sawOutput, tail, wasUp }) {
563  const endedEarly = kind === 'closed' && wasUp === false
564  if (kind !== 'crashed' && !endedEarly) return null
565  const how = !result ? '終了の記録なし' : result.signal ? 'シグナル ' + result.signal : '終了コード ' + result.code
566  const what = endedEarly
567    ? 'ゲームが始まる前に終わりました'
568    : sawOutput
569      ? 'ゲームが途中で終わりました'
570      : 'ゲームのウィンドウを開けませんでした'
571  const last = String(tail ?? '').trim()
572  return 'すこしタコ: ' + what + '(' + how + ')。' + (last ? '\n本体の最後の出力: ' + last : '')
573}
574
575// /tako:play の返事。始まったか失敗したかが分かってから答える(分かるまでの上限は START_WAIT_MS)。
576// デスクトップアプリのチャットに確実に出せるのはコマンドの返事だけなので、理由はここに載せる。
577//   started    本体が最初の 1 行を出した(= 起動した)
578//   failed     取得・照合・展開・起動のどこかで失敗した、または出す前に落ちた(text が理由)
579//   waiting    上限まで待っても、まだ取得や展開の途中
580//   silent     起動はしたが、上限まで待っても本体が何も出さない
581//   running    もう動いている
582//   cancelled  待っている間にオフにした・ペインを閉じた
583//   ended      待っている間に、人がウィンドウを閉じた
584export const START_WAIT_MS = 60 * 1000
585
586// 本体が「何かを見せている」と言えるか。読み込み中の知らせだけでは、まだ起動したと言わない
587// (ウィンドウを作れずに、そのあと落ちることがある)
588export function isEngineUp(message) {
589  if (!message) return false
590  if (message.type === 'state') return message.state !== 'loading' && message.state !== 'starting'
591  return message.type === 'frame' || message.type === 'hud' || message.type === 'stages'
592}
593
594export function playOutcomeText(outcome) {
595  switch (outcome?.kind) {
596    case 'started':
597      return 'すこしタコを始めました。ゲームは別のウィンドウで開いています(ほかのウィンドウの後ろに隠れていることがあります)。'
598    case 'failed':
599      return outcome.text || 'すこしタコ: ゲームを始められませんでした。'
600    case 'waiting':
601      return 'すこしタコを準備しています(ゲーム本体の取得に時間がかかっています)。1 分ほど待ってもウィンドウが出ないときは、もう一度 /tako:play を送ってください。'
602    case 'silent':
603      return 'すこしタコのゲーム本体を起動しましたが、まだ応答がありません。ウィンドウが出ないときは、もう一度 /tako:play を送ると理由が出ます。'
604    case 'running':
605      return 'すこしタコは、もう別のウィンドウで動いています。'
606    case 'cancelled':
607      return 'すこしタコを止めました。'
608    case 'ended':
609      return 'すこしタコは終わりました。'
610    default:
611      return PLAY_STARTING_IN_WINDOW
612  }
613}
614
615// ---- メニュー ----
616
617// コース選択のボタンの中身(onPress は register.js が足す)。
618// autoFocus は「true か、付けない」のどちらかでなければ描画が拒否される。
619export function stageButtonProps(stages) {
620  return stages.slice(0, 9).map((stage, index) => ({
621    key: 'stage-' + stage.id,
622    label: stage.name,
623    hotkey: String(index + 1),
624    ...(index === 0 ? { autoFocus: true } : {}),
625  }))
626}
627
628// ---- ターミナルの見分け ----
629
630// 絵が出るのは kitty の画像の仕組みを持つ Ghostty と kitty だけ
631// コマンドは commands/play.md と commands/off.md で宣言する。デスクトップアプリは実行時に登録した
632// コマンドを一覧に載せないので、ターミナルでも同じ <プラグイン名>:<コマンド名> だけを使う。
633export function commandNames(pluginName) {
634  return [pluginName + ':play', pluginName + ':off']
635}
636
637export function commandAction(name, args) {
638  if (String(name ?? '').endsWith(':off')) return 'off'
639  return String(args ?? '').trim().toLowerCase() === 'off' ? 'off' : 'on'
640}
641
642export function canShowPixels({ termProgram, term, kittyWindowId }) {
643  if ((termProgram ?? '').toLowerCase() === 'ghostty') return true
644  if ((term ?? '').includes('kitty') || (term ?? '').includes('ghostty')) return true
645  return Boolean(kittyWindowId)
646}
647
648// ---- 遊べる環境の見分け ----
649
650// OS と CPU から、使う配布物の名前を決める。判定できない環境は null(何も取りに行かない)。
651// Windows は環境変数 OS を先に見る(uname が無い)。Darwin は配布物がユニバーサルなので CPU を問わない。
652export function platformKey({ envOS, unameS, unameM, processorArchitecture }) {
653  if (envOS === 'Windows_NT') {
654    return typeof processorArchitecture === 'string' && processorArchitecture.toLowerCase() === 'amd64'
655      ? 'win32-x64'
656      : null
657  }
658  const system = typeof unameS === 'string' ? unameS.trim() : ''
659  if (system === 'Darwin') return 'darwin'
660  if (system === 'Linux' && (unameM === 'x86_64' || unameM === 'amd64')) return 'linux-x64'
661  return null
662}
663
664// その表示先と OS で、どの遊び方ができるか。
665//   pane    ターミナルのペインの中に絵を出す(今までの Mac)
666//   window  ゲーム本体が別ウィンドウを出す
667//   none    遊べない(案内だけ出す)
668// Windows / Linux の新しい本体は絵をペインに出さない(ウィンドウ方式だけ)ので、ターミナルでも
669// デスクトップでも 'window'。画素が出るかどうかは Mac のターミナルの話で、答えに効かない。
670// 実際に動くかは、その OS の配布物が engine.json にあるかで決まる(無ければ取得の段で案内する)。
671// どの表示先で描いているかがまだ分からないとき(起動直後、コマンドの時点)の読み。
672// 絵が出せるターミナルなら、今までどおりターミナルとして扱う。それ以外は分からないまま返し、
673// ペインを描くときに決める。
674export function surfaceOrGuess(surface, hasPixels) {
675  if (surface) return surface
676  return hasPixels === true ? 'terminal' : null
677}
678
679export function playMode({ surface, hasPixels, platform }) {
680  if (platform === 'win32-x64' || platform === 'linux-x64') {
681    return surface === 'terminal' || surface === 'desktop' ? 'window' : 'none'
682  }
683  if (platform !== 'darwin') return 'none'
684  // ターミナル: 絵を出せる(Ghostty、kitty)ならペインの中、出せないなら別ウィンドウ
685  if (surface === 'terminal') return hasPixels === true ? 'pane' : 'window'
686  if (surface === 'desktop') return 'window'
687  return 'none'
688}
689
690// ---- ペインに何を描くか ----
691//
692// paneView は「何を描くか」の平たい記述を返し、register.js はそれを要素に写すだけにする。
693// 判定は全部ここに置いて、テストで固定できるようにする。
694
695export const NEEDS_TERMINAL = 'すこしタコは、Mac のターミナルか Claude デスクトップアプリの Claude Code で遊べます。'
696// Windows / Linux で、ターミナルでもデスクトップアプリでもない場所(スマホ、エディタの中など)
697export const NEEDS_LOCAL_SESSION =
698  'すこしタコは、ターミナルか Claude デスクトップアプリの Claude Code(Local のセッション)から開いてください。'
699// 対応していない OS・CPU
700export const NEEDS_OTHER_ENV =
701  'お使いのパソコンの種類には対応していません。すこしタコは Mac で遊べます。'
702// ウィンドウ方式のペインに出す案内
703export const WINDOW_NOTE = 'ゲームは別のウィンドウに出ています。'
704export const WINDOW_HELP = 'キーとマウスは、ゲームのウィンドウで操作します。ウィンドウを閉じると終わります。'
705// 本体が起動できなかった・@fatal を出したときの案内(Local 以外のセッションでは開けない)
706export const WINDOW_UNAVAILABLE =
707  'ゲームのウィンドウを開けませんでした。この Mac の上で動いている Claude Code から開いてください(Claude デスクトップアプリでは Local のセッション)。'
708
709export const WINDOW_UNAVAILABLE_WINDOWS =
710  'ゲームのウィンドウを開けませんでした。このセッションの種類では遊べません。Local のセッションで開いてください(Claude デスクトップアプリでは、新しいセッションを Local で作ります)。'
711export const WINDOW_UNAVAILABLE_LINUX =
712  'ゲームのウィンドウを開けませんでした。このセッションの種類では遊べません。Local のセッションで開いてください。Linux では、X11 という画面の仕組みが動いている環境が必要です。'
713
714export function windowUnavailableText(platform) {
715  if (platform === 'win32-x64') return WINDOW_UNAVAILABLE_WINDOWS
716  if (platform === 'linux-x64') return WINDOW_UNAVAILABLE_LINUX
717  return WINDOW_UNAVAILABLE
718}
719
720export function unavailableText(platform) {
721  if (platform === 'darwin') return NEEDS_TERMINAL
722  if (platform === 'win32-x64' || platform === 'linux-x64') return NEEDS_LOCAL_SESSION
723  return NEEDS_OTHER_ENV
724}
725
726// ウィンドウ方式で始めるときに、チャットへ返す 1 行。絵も進み具合もペインと別ウィンドウに出るので、
727// どちらも見えていない人にも「始まっている」「どこを見ればよいか」が伝わるようにする
728export const PLAY_STARTING_IN_WINDOW =
729  'すこしタコを始めます。ゲームは別のウィンドウで開きます(最初の 1 回は、準備に 1 分ほどかかります)。' +
730  '出ないときは、横の「すこしタコ」の画面に理由が出ます。'
731
732// /tako:play を受けたときに何をするか。
733//   mode は playMode の結果。表示先がまだ分からないとき(デスクトップアプリなど)は null。
734// 表示先が分からなくても、別ウィンドウで遊べる OS なら、その場で始める。ペインが描かれるのを
735// 待つと、ペインが出ない・見えないときに何も起きないまま止まる(Windows、2026-10-06)。
736//   waitsForPane  ペインが開くのを待ってから始める(絵をペインの中に出す方式だけ)
737export function playCommandPlan({ mode, platform }) {
738  if (mode === 'pane') return { start: true, mode: 'pane', waitsForPane: true, text: null }
739  const canUseWindow = platform === 'darwin' || platform === 'win32-x64' || platform === 'linux-x64'
740  if (mode === 'window' || (mode === null && canUseWindow)) {
741    return { start: true, mode: 'window', waitsForPane: false, text: PLAY_STARTING_IN_WINDOW }
742  }
743  return { start: false, mode: 'none', waitsForPane: false, text: unavailableText(platform) }
744}
745
746// この OS 向けのゲーム本体が engine.json に無いときの案内。
747// Windows / Linux への対応は取りやめた(2026-10-06)ので、「準備中」とは言わない
748export function noBuildText(platform) {
749  const name = platform === 'win32-x64' ? 'Windows' : platform === 'linux-x64' ? 'Linux' : 'この OS'
750  return name + ' 向けのゲーム本体はありません。すこしタコは Mac で遊べます。'
751}
752
753// 絵のすぐ下に出す。1 行に詰めると狭いペインで折り返して絵から離れるので、短い行に分ける
754const HELP = {
755  seek: [
756    '移動 十字キー/WASD · 狙う ドラッグ/IJKL · 上下 R/F',
757    '撃つ クリック/Space(押し続けで連射)',
758    '背後 C · 距離 V · さがし終わり Enter',
759  ],
760  hide: [
761    '移動 十字キー/WASD · 視点 右ドラッグ/IJKL · 上下 R/F',
762    '塗る 左ドラッグ · 体の向き Q/E',
763    '1 色を拾う · 2 ブラシ · 3 ポーズ · 4 うつす · 5 全身にうつす',
764    'ここに隠れる Enter',
765  ],
766}
767
768// 本体のポーズ名 → アプリの中での呼び名
769const POSE_NAMES = {
770  stand: '立ち',
771  idle: '立ち',
772  crouch: 'しゃがみ',
773  sit: '座り',
774  crossLegged: 'あぐら',
775  allFours: '四つん這い',
776  prone: '伏せる',
777  lieDown: '横たわる',
778  sideLie: '横向き寝',
779  curled: '丸まる',
780  bridge: 'ブリッジ',
781  wallFlat: '壁ぴた',
782}
783
784// 本体が送る状態の行(key=value; key=value)を、人が読める形にする。色のコードとボタンの
785// 名前は出さない(見ても何もできない)。知らない形の行はそのまま出す。
786export function readableHud(text) {
787  const line = String(text ?? '')
788  if (!line.includes('=')) return line
789  const shown = []
790  for (const field of line.split('; ')) {
791    const at = field.indexOf('=')
792    if (at < 0) continue
793    const key = field.slice(0, at)
794    const value = field.slice(at + 1)
795    if (key === '色' || key === '確定') continue
796    if (key === 'ポーズ') shown.push('ポーズ: ' + (POSE_NAMES[value] ?? value))
797    else if (key === '探す') shown.push(...value.split(' / ').filter((part) => /^(みつけた|のこり)/.test(part)))
798    else shown.push(key + ': ' + value)
799  }
800  return shown.join(' · ')
801}
802
803// state は、出し入れの状態(reduce)に、今の場面や表示用の値を重ねたもの。
804// mode は playMode の戻り値。'none' は遊べないので案内だけを返す。
805export function paneView(state, mode) {
806  if (mode !== 'pane' && mode !== 'window') {
807    return { kind: 'unavailable', text: unavailableText(state.platform) }
808  }
809  switch (state.scene) {
810    case 'starting':
811    case 'loading':
812      return { kind: 'message', text: state.downloadStatus ?? '読み込んでいます…' }
813    case 'menu':
814      return menuView(state)
815    case 'hide':
816    case 'seek':
817      return gameView(state, mode)
818    case 'published':
819    case 'result':
820      return {
821        kind: 'outcome',
822        title: state.scene === 'published' ? '世界に隠れました' : '結果',
823        detail: state.sceneDetail ?? '',
824        button: { key: 'hub', label: 'ハブへ戻る', hotkey: '1', autoFocus: true },
825      }
826    default:
827      return {
828        kind: 'error',
829        // 本体の @fatal や起動の失敗(failure)が先。なければ本体が @state error で伝えた文
830        text: state.failure ?? (state.scene === 'error' && state.sceneDetail ? state.sceneDetail : 'うまく動いていません。'),
831        // ウィンドウ方式で開けなかったときは、Local のセッションで開くよう案内する。
832        // その OS の本体がまだ無いだけのときは、ウィンドウの話はしない
833        // 本体が動いていて @state error で伝えた失敗(少し待ってね、保存できなかった、など)にも付けない
834        guidance:
835          mode === 'window' && state.failureKind !== 'noBuild' && state.failure ? windowUnavailableText(state.platform) : null,
836        button: { key: 'retry', label: 'もう一度', hotkey: '1', autoFocus: true },
837      }
838  }
839}
840
841function menuView(state) {
842  if (state.menuMode === null || state.menuMode === undefined) {
843    return {
844      kind: 'menu',
845      title: '全世界モード',
846      options: [
847        { key: 'seek', label: '誰かを探しに行く', hotkey: '1', autoFocus: true, action: 'mode', mode: 'world_seek' },
848        { key: 'hide', label: '世界に隠れる', hotkey: '2', action: 'mode', mode: 'world_hide' },
849      ],
850    }
851  }
852  const stages = Array.isArray(state.stages) ? state.stages : []
853  return {
854    kind: 'menu',
855    title: state.menuMode === 'world_seek' ? 'どのコースを探しますか' : 'どのコースで隠れますか',
856    options: [
857      ...stageButtonProps(stages).map((props, index) => ({ ...props, action: 'stage', stageId: stages[index].id })),
858      { key: 'back', label: '戻る', hotkey: '0', action: 'mode', mode: null },
859    ],
860  }
861}
862
863function gameView(state, mode) {
864  if (mode === 'window') {
865    // ウィンドウ方式: 絵は本体のウィンドウ。ペインには文字とボタンだけを出す
866    return {
867      kind: 'windowGame',
868      note: WINDOW_NOTE,
869      hud: readableHud(state.hud),
870      help: WINDOW_HELP,
871      keys: HELP[state.scene] ?? [],
872      buttons: [
873        { key: 'menu', label: 'メニューへ', hotkey: '1', autoFocus: true, action: 'event', event: 'back_to_hub' },
874        { key: 'quit', label: 'やめる', hotkey: '2', action: 'close' },
875      ],
876    }
877  }
878  if (!state.hasFrame) return { kind: 'message', text: '読み込んでいます…' }
879  const help = HELP[state.scene] ?? []
880  return {
881    kind: 'game',
882    image: imageCells(state.columns, state.rows, help.length + 1 + (state.isDebug ? 1 : 0)),
883    help,
884    hud: readableHud(state.hud),
885    debug: state.isDebug
886      ? '届いたキー: ' + JSON.stringify(state.lastKey ?? '') + ' · 押下中: ' + (state.keys ?? []).join(' ')
887      : null,
888    status: state.phase === 'countdown'
889      ? { text: 'Claude の作業が終わりました · あと ' + state.countdown, style: 'bold' }
890      : null,
891  }
892}
893
hooks/input.js 95 lines
1// ゲームの絵の上でキーとマウスを受け、「いま押されているもの」を register.js へ送る。
2//
3// ターミナルはキーを押したことは知らせるが、離したことは知らせない。そこで
4// 「自動リピートが止まったら離した」とみなす: 1 回押しただけなら最初のリピートが
5// 来るまで長めに、リピートが流れている間は短く待つ。マウスのボタンは離したことも届く。
6
7const HOLD_AFTER_PRESS_MS = 550
8const HOLD_WHILE_REPEATING_MS = 120
9const POST_EVERY_MS = 30
10
11// ターミナルが返すキーを、エンジンと約束した名前に直す。扱わないキーは null
12// スペースと Enter は、ターミナルによって文字で届くことも名前で届くこともある。
13export function keyName(key) {
14  const lower = key.toLowerCase()
15  if (key === ' ' || lower === 'space') return 'space'
16  if (key === '\r' || key === '\n' || lower === 'return' || lower === 'enter') return 'return'
17  if (lower === 'up' || lower === 'down' || lower === 'left' || lower === 'right') return lower
18  return key.length === 1 ? lower : null
19}
20
21export function notePress(presses, name, now) {
22  const last = presses.get(name)
23  presses.set(name, { at: now, isRepeating: !!last && now - last.at < HOLD_AFTER_PRESS_MS })
24}
25
26// いま押されているとみなすキー。期限の切れたものは presses から消す
27export function heldKeys(presses, now) {
28  const held = []
29  for (const [name, press] of presses) {
30    const holdMs = press.isRepeating ? HOLD_WHILE_REPEATING_MS : HOLD_AFTER_PRESS_MS
31    if (now - press.at < holdMs) held.push(name)
32    else presses.delete(name)
33  }
34  return held.sort()
35}
36
37// セルの位置を、絵の左上を 0、右下を 1 とする割合に直す
38export function fraction(event, columns, rows) {
39  const x = (event.fine?.x ?? event.x + 0.5) / Math.max(1, columns)
40  const y = (event.fine?.y ?? event.y + 0.5) / Math.max(1, rows)
41  return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) }
42}
43
44export function notePointer(pointer, event, columns, rows) {
45  if (event.type === 'enter' || event.type === 'leave') return
46  const at = fraction(event, columns, rows)
47  pointer.x = at.x
48  pointer.y = at.y
49  const isLeft = event.button === 'left'
50  const isRight = event.button === 'right'
51  if (event.type === 'down') {
52    if (isLeft) { pointer.isLeftDown = true; pointer.leftDowns += 1 }
53    if (isRight) { pointer.isRightDown = true; pointer.rightDowns += 1 }
54  }
55  if (event.type === 'up') {
56    if (isLeft) { pointer.isLeftDown = false; pointer.leftUps += 1 }
57    if (isRight) { pointer.isRightDown = false; pointer.rightUps += 1 }
58  }
59}
60
61export default function GameInput(props, surface) {
62  if (surface.state === undefined) {
63    const input = {
64      presses: new Map(), // キー名 -> { at, isRepeating }
65      pointer: { isLeftDown: false, isRightDown: false, x: 0, y: 0, leftDowns: 0, leftUps: 0, rightDowns: 0, rightUps: 0 },
66      lastKey: '',
67      posted: '',
68    }
69
70    surface.onKey((e) => {
71      // 届いたままのキー。SUKOSHI_TAKO_DEBUG のときに register.js が画面に出す
72      input.lastKey = e.key
73      const name = keyName(e.key)
74      if (name !== null) notePress(input.presses, name, Date.now())
75    })
76
77    surface.onPointer((e) => {
78      notePointer(input.pointer, e, surface.columns, surface.rows)
79    })
80
81    surface.every(POST_EVERY_MS, () => {
82      const message = { keys: heldKeys(input.presses, Date.now()), pointer: { ...input.pointer }, lastKey: input.lastKey }
83      const text = JSON.stringify(message)
84      if (text === input.posted) return
85      input.posted = text
86      surface.post(message)
87    })
88
89    surface.setState(input)
90  }
91
92  const { Box } = surface.elements
93  return Box({ width: '100%', height: '100%' })
94}
95