SLOPSHOPPER

tomoshibi

灯火迷宮: Claudeの応答を待つ間だけ進む、セーブ付きの育成型ダンジョンRPG

newpanebandcommandtoastnetwork
v0.3.0MITupdated 2026-10-15jnk0vc/tomoshibi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tomoshibi
│ ┃ 灯火迷宮 ✕ › fix the failing auth test and add an audit log call │ ┃ 灯 地下1階 苔むす石段・最深は地下1階 │ ┃ ▄▀▀▀▀▀▄ ▄ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀ ▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▄▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▄▀▀▀▀▀▀▀▄ ▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▕ @◦ ◦ ◦ ◦ ▼ ▏ 通路を進んでいる │ ┃ 灯は消えている。Claudeが応答を作っている間だ ✻ Worked for 42s · done 4:20 PM │ ┃ け探索が進む。 │ ┃ › /tomoshibi │ ┃ Lv1 経験 0/16 │ ┃ ──────────────────────────────────────── │ ┃ 体力 50/50 攻撃 8 防御 3 金 0 │ ┃ 撃破0・敗走0・探索0分 │ ┃ │ ┃ 記録 │ ┃ › 灯を手に、苔むす石段を下りはじめた。 │ ┃ │ ┃ 装備 │ ┃ 武器 木の棒 [ 鍛える(25金) ] │ ┃ 防具 布の服 [ 鍛える(25金) ] │ ┃ 灯 灯火迷宮 地下1階・Lv1 Claudeの応答を待つ間、灯がともって探索が進む 開く ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
灯 灯火迷宮 地下1階・Lv1 Claudeの応答を待つ間、灯がともって探索が進む 開く
Pane · 灯火迷宮
灯 地下1階 苔むす石段・最深は地下1階 ▄▀▀▀▀▀▄ ▄ ▀▀▀▀▀▀▀▀▀ ▀▀▀ ▀▀▀▀▀▀▀▀▀▄▀▀▀ ▄▀▀▀▀▀▀▀▄ ▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▕ @◦ ◦ ◦ ◦ ▼ ▏ 通路を進んでいる 灯は消えている。Claudeが応答を作っている間だけ探索が進む。 Lv1 経験 0/16 ──────────────────────────────────────── 体力 50/50 攻撃 8 防御 3 金 0 撃破0・敗走0・探索0分 記録 › 灯を手に、苔むす石段を下りはじめた。 装備 武器 木の棒 [ 鍛える(25金) ] 防具 布の服 [ 鍛える(25金) ] 育成方針 レベルが上がるたびに3ポイント振る [ 攻撃 ] [ 防御 ] [ 体力 ] [ 均等 ] 進路 [ 浅い階へ ] [ 深い階へ ] [ 階段を見つけたら降りる ] 入力欄の ランキング 参加すると、名前・Lv・最深階・総経験値・撃破数・探索時間をラ ンキングサーバーへ送る。 名前: 12文字まで ⏎ 参加する
README

灯火迷宮(tomoshibi)

Claudeが応答を作っている間だけ灯がともり、勇者が地下へ潜っていく放置型ダンジョンRPGです。Claude Codeのmod(function hooksのプラグイン)として動きます。

  • 応答待ちの間は1.5秒ごとに戦闘が進み、入力欄の上の帯にドット絵の勇者と敵、階、Lv、体力、直近の出来事が出ます。
  • 待っていない間は帯が1行になり、前回の待ち時間で得た経験値・金・撃破数を出します。
  • 進行は自動で保存され、セッションをまたいで続きから遊べます。
  • ローグライクではありません。敗れても経験値・金・装備は失わず、2階上へ戻って休むだけです。
  • レベルと階に上限はなく、10階ごとに景色と敵が変わります。100階を越えると同じ景色を「弐」「参」と深さを増して巡り直します。

勇者・焚き火と地下1〜50階の敵

地下51〜100階の敵

勇者と焚き火、10の景色に5体ずつ(うち1体は10階ごとに現れる階の主)の敵を、16x16ドットのファミコン風に描いています。端末では半角ブロック、デスクトップアプリではSVGで描きます。

入れ方

ターミナルのClaude Codeのプロンプトで次を打ちます。

/plugin install tomoshibi --marketplace jnk0vc/tomoshibi

マーケットプレイスの追加を聞かれたらyで答え、入れる範囲(ユーザー単位など)を選びます。デスクトップアプリのCodeタブではこのコマンドを使えませんが、ターミナルでユーザー単位に入れたmodはデスクトップアプリのセッションでも読み込まれます。

Claude Code 2.1.295で動作を確認しています。modの仕組み自体が先行公開中の機能なので、ほかの版では動かない場合があります。

遊び方

操作内容
Claudeに何か頼む応答を待つ間だけ探索が進む
/tomoshibi詳しい画面(ペイン)を開く・閉じる。応答中でもすぐ開く
帯の「開く」ペインを開く

ペインでは次の操作ができます。キーはペインにフォーカスがあるときに効きます。

キー操作
w武器を鍛える(金を使う)
a防具を鍛える(金を使う)
u / d浅い階・深い階へ移る(深い側は到達済みの階まで)
m「階段を見つけたら降りる」と「この階で稼ぐ」を切り替える

育成方針(攻撃・防御・体力・均等)を選ぶと、レベルが上がるたびに3ポイントがその方針で振られます。勇者の剣と鎧の色は、装備を10段階鍛えるごとに変わります。

ランキング(任意)

参加すると、名前・Lv・最深階・総経験値・撃破数・探索時間をランキングサーバーへ送り、Lv・最深階・総経験の順位表を見られます。記録を送るのは、ペインで名前を入れて参加したあとだけです。抜けるとサーバー上の記録も消えます。参加していなくても、ペインを開くと順位表を読み込みます。

既定では、作者が公開しているサーバー(https://tomoshibi-ranking.jankovic.workers.dev、Cloudflare WorkersとD1)につながります。サーバーのコードとデプロイ手順はserver/にあります。ランキングの通信を一切したくない場合は、プラグイン設定のrankingUrlを空にします。自分で立てたサーバーを使う場合は、そのURLを入れます。

開発

パス内容
hooks/register.tsxフック、タイマー、帯とペインの描画、保存、ランキングの通信
hooks/game.tsゲームの進行(戦闘、レベル、装備、階)。$に触れない純粋関数
hooks/sprites.tsドット絵を端末用のRasterとデスクトップ用のSvgに変換する
hooks/art.ts16x16のドット絵データ
hooks/ranking.tsランキングの入力検証と応答の検証
types/index.d.ts$.stateに置く値の型
tests/claude plugin testで動くテスト
server/ランキングサーバー(Cloudflare WorkersとD1)。テストはserver/でpnpm test(vitest)
claude plugin validate .
claude plugin test .

開発中のmodを手元のフォルダから読み込むときは、claude --plugin-dir <このフォルダ>で起動します。

ライセンス

MIT License。全文はLICENSEにあります。

Source 6 files
hooks/register.tsx 763 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { Focus, Game, LogKind, Ranking, RankBy, RankEntry } from '../types'
5import {
6  armorName,
7  biomeAt,
8  buyArmor,
9  buyWeapon,
10  encountersOn,
11  expNeed,
12  fmt,
13  heroStats,
14  isBossFloor,
15  loadGame,
16  moveFloor,
17  newGame,
18  playTime,
19  setAuto,
20  setFocus,
21  startRun,
22  tick,
23  TICK_MS,
24  upgradeCost,
25  weaponName,
26} from './game'
27import {
28  BOARD_LIMIT,
29  endpoint,
30  failureText,
31  loadPlayer,
32  newPlayer,
33  normalizeName,
34  parseBoard,
35  RANK_LABEL,
36  scoreBody,
37  SUBMIT_INTERVAL_MS,
38} from './ranking'
39import type { Player } from './ranking'
40import { artFor, blankArt, heroRecolor, rasterCells, svgSource } from './sprites'
41import type { SpriteOptions } from './sprites'
42
43const PANE = 'tomoshibi'
44const TITLE = '灯火迷宮'
45const SAVE_KEY = 'save'
46/** 応答待ちが長いときも、この間隔(tick数)で途中保存する */
47const SAVE_EVERY = 20
48
49const GAME_REF = { plugin: 'tomoshibi', key: 'game' } as const
50const GAME = atom(GAME_REF, newGame(1))
51const BAND_HIDDEN = atom({ plugin: 'tomoshibi', key: 'isBandHidden' } as const, false)
52const LIT = atom({ plugin: 'tomoshibi', key: 'isLit' } as const, false)
53const INITIAL_RANKING: Ranking = { isJoined: false, name: '', by: 'level', board: null, error: null, isBusy: false }
54const RANKING = atom({ plugin: 'tomoshibi', key: 'ranking' } as const, INITIAL_RANKING)
55const PLAYER_KEY = 'player'
56const USER_AGENT = 'tomoshibi-mod/0.3.0'
57const RANK_ORDER: readonly RankBy[] = ['level', 'deepest', 'exp']
58
59// ランキングサーバーのURL。設定(userConfig)から読み、モジュールが読み直されるたびに入れ直す
60let rankingUrl = ''
61
62type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
63
64/** ドット絵の描き先。端末は半角ブロックのRaster、それ以外の面はSvg */
65type Canvas =
66  | { kind: 'raster'; Raster: Elements['terminal']['Raster'] }
67  | { kind: 'svg'; Svg: Elements['desktop']['Svg']; px: number }
68
69const KIND_COLOR: Record<LogKind, string | undefined> = {
70  info: undefined,
71  gain: undefined,
72  level: 'success',
73  boss: 'warning',
74  loss: 'error',
75}
76
77const FOCUS_LABEL: readonly (readonly [Focus, string])[] = [
78  ['atk', '攻撃'],
79  ['def', '防御'],
80  ['hp', '体力'],
81  ['even', '均等'],
82]
83
84// 応答待ちの間だけ動くタイマー。リロードで消えるのでモジュール変数でよい
85let ticker: Timer | undefined
86
87/** 保存する。ストアに別セッションの新しいセーブがあれば、上書きせずにそちらへ乗り換える */
88async function save($: EngineInterface): Promise<void> {
89  const stored = loadGame(await $.store.get(SAVE_KEY))
90  let isBehind = false
91  const game = await update($, GAME, g => {
92    isBehind = stored !== null && stored.rev > g.rev
93    return isBehind && stored !== null ? stored : { ...g, rev: g.rev + 1 }
94  })
95  if (!isBehind) await $.store.set(SAVE_KEY, game)
96}
97
98async function advance($: EngineInterface): Promise<void> {
99  const game = await update($, GAME, tick)
100  if (game.ticks % SAVE_EVERY === 0) await save($)
101}
102
103function light($: EngineInterface): void {
104  if (ticker !== undefined) return
105  ticker = $.clock.every(TICK_MS, () => {
106    advance($).catch((error: unknown) => {
107      $.ui.log(`tomoshibi: 探索を進められなかった: ${String(error)}`, { to: 'debug' })
108    })
109  })
110}
111
112function douse(): void {
113  ticker?.cancel()
114  ticker = undefined
115}
116
117/** 別のセッションで進んだセーブがあれば、そちらに乗り換える */
118async function adoptNewerSave($: EngineInterface): Promise<void> {
119  const saved = loadGame(await $.store.get(SAVE_KEY))
120  if (saved === null) return
121  await update($, GAME, g => (saved.rev > g.rev ? saved : g))
122}
123
124async function act($: EngineInterface, change: (g: Game) => Game | null, failure?: (g: Game) => string): Promise<void> {
125  // 操作は最新のセーブの上に積む(放置していたセッションの古い状態に積まない)
126  await adoptNewerSave($)
127  let refused: Game | undefined
128  // update は版が食い違うとやり直すので、判定も毎回やり直す
129  await update($, GAME, g => {
130    refused = undefined
131    const next = change(g)
132    if (next !== null) return next
133    refused = g
134    return g
135  })
136  if (refused !== undefined && failure !== undefined) {
137    $.ui.toast(failure(refused))
138    return
139  }
140  await save($)
141}
142
143// ---- 描画 ----
144
145function bar(T: Ui, ratio: number, width: number, color: string): RenderElement {
146  const { Box, Text } = T
147  const filled = Math.max(0, Math.min(width, Math.round(ratio * width)))
148  return (
149    <Box flexDirection="row">
150      <Text color={color}>{'━'.repeat(filled)}</Text>
151      <Text dimColor>{'─'.repeat(width - filled)}</Text>
152    </Box>
153  )
154}
155
156/** 階の通路。@が勇者、◦がまだ会っていない敵、◆が階の主、▼が下り階段 */
157function corridor(T: Ui, g: Game, isLit: boolean): RenderElement {
158  const { Box, Text } = T
159  const total = encountersOn()
160  const cells: RenderElement[] = [<Text dimColor>▕ </Text>]
161  const hero = <Text color={isLit ? 'claude' : undefined} bold={isLit}>@</Text>
162  for (let i = 0; i < total; i++) {
163    const isBossSlot = isBossFloor(g.floor) && i === total - 1
164    if (i === g.step) {
165      cells.push(hero)
166      if (g.foe !== null) {
167        cells.push(<Text color={g.foe.isBoss ? 'warning' : 'error'} bold>{g.foe.glyph}</Text>)
168        cells.push(<Text> </Text>)
169        continue
170      }
171    }
172    if (i < g.step) cells.push(<Text dimColor>· </Text>)
173    else if (isBossSlot) cells.push(<Text color="warning">◆ </Text>)
174    else cells.push(<Text dimColor>◦ </Text>)
175  }
176  if (g.step >= total) cells.push(hero)
177  cells.push(<Text>▼</Text>)
178  cells.push(<Text dimColor> ▏</Text>)
179  return <Box flexDirection="row">{cells}</Box>
180}
181
182function encounter(T: Ui, g: Game): RenderElement {
183  const { Box, Text } = T
184  if (g.phase === 'rest') return <Text dimColor>灯の下で休んでいる</Text>
185  if (g.foe === null) return <Text dimColor>通路を進んでいる</Text>
186  return (
187    <Box flexDirection="row" gap={1}>
188      <Text color={g.foe.isBoss ? 'warning' : undefined} bold={g.foe.isBoss} wrap="truncate">
189        {g.foe.name}
190      </Text>
191      <Text dimColor>Lv{g.foe.level}</Text>
192      {bar(T, g.foe.hp / g.foe.maxHp, 8, 'error')}
193    </Box>
194  )
195}
196
197function lastLines(T: Ui, g: Game, count: number): RenderElement[] {
198  const { Text } = T
199  const lines = g.log.slice(-count)
200  if (lines.length === 0) return [<Text dimColor> </Text>]
201  return lines.map(line => (
202    <Text color={KIND_COLOR[line.kind]} dimColor={line.kind === 'info'} wrap="truncate">
203      › {line.text}
204    </Text>
205  ))
206}
207
208function sprite(canvas: Canvas, key: string, rows: readonly string[], options: SpriteOptions, alt: string): RenderElement {
209  if (canvas.kind === 'raster') {
210    const { Raster } = canvas
211    return <Raster key={key} columns={16} rows={8} cells={rasterCells(rows, options)} />
212  }
213  const { Svg } = canvas
214  return <Svg source={svgSource(rows, options)} alt={alt} width={canvas.px} height={canvas.px} />
215}
216
217/** 左に主人公、右に敵(休息中は焚き火、通路を進む間は空き)。灯がともっている間は交互に1ドット揺れる */
218function scene(T: Ui, canvas: Canvas, g: Game, isLit: boolean): RenderElement | null {
219  const heroArt = artFor('@')
220  if (heroArt === undefined) return null
221  const { Box } = T
222  const isOdd = isLit && g.ticks % 2 === 1
223  const hero = sprite(
224    canvas,
225    'hero',
226    heroArt,
227    { recolor: heroRecolor(g.weapon, g.armor), isLifted: isOdd && g.phase !== 'rest' },
228    `勇者(${weaponName(g.weapon)}、${armorName(g.armor)})`,
229  )
230  const foe =
231    g.phase === 'rest'
232      ? sprite(canvas, 'foe', artFor('焚') ?? blankArt(), { isMirrored: isOdd }, '焚き火')
233      : g.foe === null
234        ? sprite(canvas, 'foe', blankArt(), {}, '誰もいない通路')
235        : sprite(canvas, 'foe', artFor(g.foe.glyph) ?? blankArt(), { isLifted: isLit && !isOdd }, g.foe.name)
236  return (
237    <Box flexDirection="row" gap={1}>
238      {hero}
239      {foe}
240    </Box>
241  )
242}
243
244/** 「攻撃 120」のようなラベルと値の組。間はレイアウトの間隔で空ける */
245function stat(T: Ui, label: string, value: string, color?: string): RenderElement {
246  const { Box, Text } = T
247  return (
248    <Box flexDirection="row" gap={1}>
249      <Text dimColor>{label}</Text>
250      <Text color={color} wrap="truncate">
251        {value}
252      </Text>
253    </Box>
254  )
255}
256
257function runSummary(g: Game): string {
258  if (g.run.kills === 0) return '前回の待ち時間では、まだ敵を倒していない'
259  const parts = [`前回の待ち時間で撃破${fmt(g.run.kills)}`, `経験+${fmt(g.run.exp)}`, `金+${fmt(g.run.gold)}`]
260  if (g.run.levels > 0) parts.push(`Lv+${g.run.levels}`)
261  if (g.run.floorsDown > 0) parts.push(`${g.run.floorsDown}階降りた`)
262  return parts.join('・')
263}
264
265async function openPane($: EngineInterface): Promise<void> {
266  const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
267  if (!opened.isPlaced) {
268    $.ui.toast(`灯火迷宮を開けなかった: ${opened.reason}`)
269    return
270  }
271  await fetchBoard($)
272}
273
274// ---- ランキング ----
275
276/** フックの外で走らせたい通信を、時計の発火として切り離して動かす */
277function later($: EngineInterface, what: string, work: () => Promise<void>): void {
278  $.clock.after(0, () => {
279    work().catch((error: unknown) => {
280      $.ui.log(`tomoshibi: ${what}に失敗した: ${String(error)}`, { to: 'debug' })
281    })
282  })
283}
284
285async function request(
286  $: EngineInterface,
287  method: 'GET' | 'POST' | 'DELETE',
288  path: string,
289  body?: string,
290): Promise<{ status: number; ok: boolean; text: string } | string> {
291  const url = endpoint(rankingUrl, path)
292  if (url === null) return 'ランキングサーバーのURLが正しくない(https://で始まるURLにする)。'
293  try {
294    // User-Agentを付けないと、CloudflareのBrowser Integrity Checkに弾かれることがある(error code 1010)
295    const headers: Record<string, string> = { 'user-agent': USER_AGENT }
296    if (body !== undefined) headers['content-type'] = 'application/json'
297    const init = body === undefined ? { method, headers } : { method, headers, body }
298    return await $.http.fetch(url, init)
299  } catch (error) {
300    $.ui.log(`tomoshibi: ${method} ${path}: ${String(error)}`, { to: 'debug' })
301    return 'ランキングサーバーにつながらない。通信できる状態で更新する。'
302  }
303}
304
305async function readPlayer($: EngineInterface): Promise<Player> {
306  return loadPlayer(await $.store.get(PLAYER_KEY)) ?? newPlayer()
307}
308
309async function fetchBoard($: EngineInterface): Promise<void> {
310  if (rankingUrl === '') return
311  const player = await readPlayer($)
312  const { by } = await read($, RANKING)
313  await update($, RANKING, r => ({ ...r, isBusy: true }))
314  const me = player.isJoined ? `&me=${player.id}` : ''
315  const res = await request($, 'GET', `/v1/leaderboard?by=${by}&limit=${BOARD_LIMIT}${me}`)
316  if (typeof res === 'string') {
317    await update($, RANKING, r => ({ ...r, isBusy: false, error: res }))
318    return
319  }
320  const board = res.ok ? parseBoard(res.text) : null
321  const error = board !== null ? null : res.ok ? 'ランキングの応答が読めなかった。' : failureText(res.status)
322  await update($, RANKING, r => ({ ...r, isBusy: false, board: board ?? r.board, error }))
323}
324
325/** 記録を送る。isForcedでなければ、前回からSUBMIT_INTERVAL_MS経っていないときは送らない */
326async function submitScore($: EngineInterface, isForced: boolean): Promise<void> {
327  if (rankingUrl === '') return
328  const player = loadPlayer(await $.store.get(PLAYER_KEY))
329  if (player === null || !player.isJoined) return
330  const now = await $.clock.now()
331  if (!isForced && now - player.lastSentAt < SUBMIT_INTERVAL_MS) return
332  const res = await request($, 'POST', '/v1/scores', scoreBody(player, await read($, GAME)))
333  if (typeof res === 'string') {
334    await update($, RANKING, r => ({ ...r, error: res }))
335    return
336  }
337  if (res.ok || res.status === 429) {
338    const latest = loadPlayer(await $.store.get(PLAYER_KEY)) ?? player
339    await $.store.set(PLAYER_KEY, { ...latest, lastSentAt: now })
340  }
341  await update($, RANKING, r => ({ ...r, error: res.ok ? null : failureText(res.status) }))
342  if (res.ok) await fetchBoard($)
343}
344
345/** 名前を決めて参加する。参加中なら名前を変える */
346async function join($: EngineInterface, raw: string): Promise<void> {
347  const name = normalizeName(raw)
348  if (name === null) {
349    await update($, RANKING, r => ({ ...r, error: '名前は1〜12文字にする(制御文字は使えない)。' }))
350    return
351  }
352  const player = await readPlayer($)
353  await $.store.set(PLAYER_KEY, { ...player, name, isJoined: true })
354  await update($, RANKING, r => ({ ...r, isJoined: true, name, error: null }))
355  await submitScore($, true)
356}
357
358/** ランキングから抜ける。サーバーの記録も消す */
359async function leave($: EngineInterface): Promise<void> {
360  const player = loadPlayer(await $.store.get(PLAYER_KEY))
361  if (player === null) return
362  if (rankingUrl !== '') {
363    const res = await request($, 'DELETE', '/v1/scores', JSON.stringify({ playerId: player.id, token: player.token }))
364    if (typeof res === 'string' || (!res.ok && res.status !== 404)) {
365      const error = typeof res === 'string' ? res : failureText(res.status)
366      await update($, RANKING, r => ({ ...r, error }))
367      return
368    }
369  }
370  await $.store.set(PLAYER_KEY, { ...player, isJoined: false })
371  await update($, RANKING, r => ({ ...r, isJoined: false, board: null, error: null }))
372}
373
374async function chooseBoard($: EngineInterface, by: RankBy): Promise<void> {
375  await update($, RANKING, r => ({ ...r, by }))
376  await fetchBoard($)
377}
378
379function statText(by: RankBy, entry: RankEntry): string {
380  if (by === 'deepest') return `地下${fmt(entry.deepest)}階`
381  if (by === 'exp') return `経験${fmt(entry.totalExp)}`
382  return `Lv${fmt(entry.level)}`
383}
384
385function rankingSection(
386  $: EngineInterface,
387  T: Ui,
388  Input: Elements['terminal']['Input'] | null,
389  ranking: Ranking,
390): RenderElement {
391  const { Box, Text, Button } = T
392  const board = ranking.board !== null && ranking.board.by === ranking.by ? ranking.board : null
393  const nameField = (key: string, label: string, submitLabel: string) =>
394    Input === null ? (
395      <Text dimColor>名前の入力はターミナルかデスクトップでできる。</Text>
396    ) : (
397      <Input
398        key={key}
399        label={label}
400        placeholder="12文字まで"
401        value={ranking.isJoined ? ranking.name : undefined}
402        submitLabel={submitLabel}
403        onSubmit={value => join($, value)}
404      />
405    )
406
407  let body: RenderElement
408  if (rankingUrl === '') {
409    body = <Text dimColor>ランキングサーバーが設定されていない。プラグイン設定のrankingUrlにURLを入れると参加できる。</Text>
410  } else if (!ranking.isJoined) {
411    body = (
412      <Box flexDirection="column">
413        <Text dimColor>参加すると、名前・Lv・最深階・総経験値・撃破数・探索時間をランキングサーバーへ送る。</Text>
414        {nameField('rank-join', '名前', '参加する')}
415      </Box>
416    )
417  } else {
418    const isMeShown = board?.entries.some(entry => entry.isYou) ?? false
419    body = (
420      <Box flexDirection="column">
421        <Box flexDirection="row" gap={1} flexWrap="wrap">
422          {RANK_ORDER.map(by => (
423            <Button
424              key={`rank-${by}`}
425              label={RANK_LABEL[by]}
426              variant={ranking.by === by ? 'primary' : 'secondary'}
427              dimColor={ranking.by !== by}
428              onPress={() => chooseBoard($, by)}
429            />
430          ))}
431          <Button key="rank-refresh" label="更新" onPress={() => fetchBoard($)} />
432        </Box>
433        {board === null ? (
434          <Text dimColor>{ranking.isBusy ? '読み込み中' : '「更新」で最新の順位を読み込む。'}</Text>
435        ) : (
436          <Box flexDirection="column">
437            {board.entries.map(entry => (
438              <Box flexDirection="row" gap={1}>
439                <Text color={entry.isYou ? 'claude' : undefined} bold={entry.isYou}>
440                  {`${entry.rank}位`.padStart(4)}
441                </Text>
442                <Text color={entry.isYou ? 'claude' : undefined} bold={entry.isYou} wrap="truncate">
443                  {entry.name}
444                </Text>
445                <Text dimColor>{statText(board.by, entry)}</Text>
446              </Box>
447            ))}
448            <Text dimColor>
449              {board.me !== null && !isMeShown
450                ? `${ranking.name}は${board.me.rank}位(${fmt(board.players)}人中)`
451                : `${fmt(board.players)}人が参加中`}
452            </Text>
453          </Box>
454        )}
455        {nameField('rank-rename', '名前を変える', '変える')}
456        <Button key="rank-leave" plain dimColor label="ランキングから抜ける(記録も消す)" onPress={() => leave($)} />
457      </Box>
458    )
459  }
460
461  return (
462    <Box flexDirection="column">
463      <Box flexDirection="row" gap={1}>
464        <Text bold>ランキング</Text>
465        {ranking.isBusy && <Text dimColor>通信中</Text>}
466      </Box>
467      {body}
468      {ranking.error !== null && <Text color="error">{ranking.error}</Text>}
469    </Box>
470  )
471}
472
473export const register: Register = (on, options) => {
474  rankingUrl = typeof options.rankingUrl === 'string' ? options.rankingUrl.trim() : ''
475
476  on('session.start', async ($, e, next) => {
477    const held = await $.state.get(GAME_REF)
478    if (held.version === 0) {
479      const saved = loadGame(await $.store.get(SAVE_KEY))
480      const game = saved ?? newGame(await $.clock.now())
481      await update($, GAME, () => game)
482    }
483    await $.command.register({
484      name: 'tomoshibi',
485      description: '灯火迷宮(Claudeの応答待ちに進むダンジョンRPG)を開く・閉じる',
486      // 探索が進むのは応答中なので、応答中に打ってもすぐ開けるようにする
487      immediate: true,
488    })
489    const player = loadPlayer(await $.store.get(PLAYER_KEY))
490    await update($, RANKING, r => ({ ...r, isJoined: player?.isJoined ?? false, name: player?.name ?? '', isBusy: false }))
491    // 応答中にモジュールが読み直されたときは、灯をともし直す
492    if (await read($, LIT)) light($)
493    return next(e)
494  })
495
496  on('turn.start', async ($, e, next) => {
497    await adoptNewerSave($)
498    await update($, GAME, startRun)
499    await update($, LIT, () => true)
500    light($)
501    return next(e)
502  })
503
504  on('turn.complete', async ($, e, next) => {
505    // サブエージェントのターン終了では消さない(turn.startもメインにしか来ない)
506    if (e.agentId === undefined) {
507      douse()
508      await update($, LIT, () => false)
509      await save($)
510      later($, 'ランキングへの送信', () => submitScore($, false))
511    }
512    return next(e)
513  })
514
515  on('session.end', async ($, e, next) => {
516    douse()
517    await save($)
518    return next(e)
519  })
520
521  on('command.run', { command: 'tomoshibi' }, async $ => {
522    const pane = (await $.ui.panes()).find(one => one.id === PANE)
523    if (pane?.isShown === true) {
524      await $.ui.close({ id: PANE })
525      return {}
526    }
527    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
528    if (opened.isPlaced) later($, 'ランキングの読み込み', () => fetchBoard($))
529    return opened.isPlaced ? {} : { text: `灯火迷宮を開けなかった: ${opened.reason}` }
530  })
531
532  // 入力欄の上の帯: 応答待ちの間はドット絵の戦闘と戦況を、待っていない間は1行で前回の成果を出す
533  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
534    if (e.props.hasSurvey || (await read($, BAND_HIDDEN))) return next(e)
535    const g = await read($, GAME)
536    const isLit = await read($, LIT)
537    const T = $.ui.resolve(e)
538    const { Box, Text, Button } = T
539    const { maxHp } = heroStats(g)
540    // 端末はドット絵が16列x8行を取るので、幅と高さに余裕があるときだけ出す
541    const canvas: Canvas | null =
542      e.surface === 'terminal'
543        ? e.props.bodyColumns >= 84 && e.props.maxRows >= 8
544          ? { kind: 'raster', Raster: $.ui.resolve(e).Raster }
545          : null
546        : e.props.bodyColumns >= 60
547          ? { kind: 'svg', Svg: $.ui.resolve(e).Svg, px: 64 }
548          : null
549    const battle = canvas === null ? null : scene(T, canvas, g, isLit)
550    const textColumns = e.props.bodyColumns - (battle === null ? 0 : 36)
551    const isWide = textColumns >= 80
552
553    if (!isLit) {
554      // 幅が足りないときは成果の文だけを切り詰め、ほかは縮めない
555      return (
556        <Box flexDirection="row" gap={2}>
557          <Box flexDirection="row" gap={2} flexShrink={0}>
558            <Text dimColor>灯</Text>
559            {e.props.bodyColumns >= 50 && <Text bold>{TITLE}</Text>}
560            <Text dimColor>
561              地下{g.floor}階・Lv{g.level}
562            </Text>
563          </Box>
564          <Box flexShrink={1} flexGrow={1}>
565            <Text dimColor wrap="truncate">
566              {g.run.ticks > 0 ? runSummary(g) : 'Claudeの応答を待つ間、灯がともって探索が進む'}
567            </Text>
568          </Box>
569          <Box flexShrink={0}>
570            <Button key="open" label="開く" plain onPress={() => openPane($)} />
571          </Box>
572        </Box>
573      )
574    }
575
576    const status = (
577      <Box flexDirection="column" flexGrow={1}>
578        <Box flexDirection="row" gap={1}>
579          <Box flexDirection="row" gap={1} flexShrink={0}>
580            <Text color="claude" bold>灯</Text>
581            <Text bold>地下{g.floor}階</Text>
582          </Box>
583          <Box flexShrink={1} flexGrow={1}>
584            <Text dimColor wrap="truncate">
585              {biomeAt(g.floor).name}
586            </Text>
587          </Box>
588          <Box flexDirection="row" gap={1} flexShrink={0}>
589            <Text bold>Lv{g.level}</Text>
590            <Text dimColor>経</Text>
591            {bar(T, g.exp / expNeed(g.level), 8, 'suggestion')}
592            <Text dimColor>体</Text>
593            {bar(T, g.hp / maxHp, 8, 'error')}
594            <Text dimColor>
595              {fmt(g.hp)}/{fmt(maxHp)}
596            </Text>
597            {isWide && <Text color="warning">金{fmt(g.gold)}</Text>}
598          </Box>
599        </Box>
600        <Box flexDirection="row" gap={2}>
601          <Box flexShrink={0}>{corridor(T, g, true)}</Box>
602          <Box flexShrink={1} flexGrow={1}>
603            {encounter(T, g)}
604          </Box>
605          <Box flexShrink={0}>
606            <Button key="open" label="開く" plain dimColor onPress={() => openPane($)} />
607          </Box>
608        </Box>
609        {lastLines(T, g, battle === null ? 1 : e.surface === 'terminal' ? 6 : 2)}
610      </Box>
611    )
612    if (battle === null) return status
613    return (
614      <Box flexDirection="row" gap={2}>
615        {battle}
616        {status}
617      </Box>
618    )
619  })
620
621  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
622    const g = await read($, GAME)
623    const isLit = await read($, LIT)
624    const isBandHidden = await read($, BAND_HIDDEN)
625    const ranking = await read($, RANKING)
626    const T = $.ui.resolve(e)
627    const Input = e.surface === 'mobile' ? null : $.ui.resolve(e).Input
628    const { Box, Text, Button } = T
629    const hero = heroStats(g)
630    const canvas: Canvas =
631      e.surface === 'terminal'
632        ? { kind: 'raster', Raster: $.ui.resolve(e).Raster }
633        : { kind: 'svg', Svg: $.ui.resolve(e).Svg, px: 96 }
634    const battle = scene(T, canvas, g, isLit)
635    const need = expNeed(g.level)
636    const barWidth = Math.max(10, Math.min(40, e.props.bodyColumns - 4))
637    const weaponCost = upgradeCost(g.weapon)
638    const armorCost = upgradeCost(g.armor)
639    const shortBy = (cost: number) => (game: Game) => `金が足りない(あと${fmt(cost - game.gold)})`
640    // 記録は戦況のすぐ下に置くので、ペインが低くても最新の数行は見える
641    const LOG_ROWS = 5
642
643    return (
644      <Box flexDirection="column" gap={1}>
645        <Box flexDirection="column">
646          <Box flexDirection="row" gap={1}>
647            <Text color={isLit ? 'claude' : undefined} dimColor={!isLit} bold={isLit}>
648              灯
649            </Text>
650            <Text bold>地下{g.floor}階</Text>
651            <Text dimColor wrap="truncate">
652              {biomeAt(g.floor).name}・最深は地下{g.deepest}階
653            </Text>
654          </Box>
655          {battle}
656          <Box flexDirection="row" gap={2}>
657            {corridor(T, g, isLit)}
658            {encounter(T, g)}
659          </Box>
660          {!isLit && <Text dimColor>灯は消えている。Claudeが応答を作っている間だけ探索が進む。</Text>}
661        </Box>
662
663        <Box flexDirection="column">
664          <Box flexDirection="row" gap={2}>
665            <Text bold>Lv{g.level}</Text>
666            {stat(T, '経験', `${fmt(g.exp)}/${fmt(need)}`)}
667          </Box>
668          {bar(T, g.exp / need, barWidth, 'suggestion')}
669          <Box flexDirection="row" gap={2} flexWrap="wrap">
670            {stat(T, '体力', `${fmt(g.hp)}/${fmt(hero.maxHp)}`, 'error')}
671            {stat(T, '攻撃', fmt(hero.atk))}
672            {stat(T, '防御', fmt(hero.def))}
673            {stat(T, '金', fmt(g.gold), 'warning')}
674          </Box>
675          <Text dimColor>
676            撃破{fmt(g.kills)}・敗走{fmt(g.defeats)}・探索{playTime(g.ticks)}
677          </Text>
678        </Box>
679
680        <Box flexDirection="column">
681          <Text bold>記録</Text>
682          {g.log.slice(-LOG_ROWS).map(line => (
683            <Text color={KIND_COLOR[line.kind]} dimColor={line.kind === 'info'} wrap="truncate">
684              › {line.text}
685            </Text>
686          ))}
687        </Box>
688
689        <Box flexDirection="column">
690          <Text bold>装備</Text>
691          <Box flexDirection="row" gap={1}>
692            {stat(T, '武器', weaponName(g.weapon))}
693            <Button
694              key="weapon"
695              hotkey="w"
696              dimColor={g.gold < weaponCost}
697              label={`鍛える(${fmt(weaponCost)}金)`}
698              onPress={() => act($, buyWeapon, shortBy(weaponCost))}
699            />
700          </Box>
701          <Box flexDirection="row" gap={1}>
702            {stat(T, '防具', armorName(g.armor))}
703            <Button
704              key="armor"
705              hotkey="a"
706              dimColor={g.gold < armorCost}
707              label={`鍛える(${fmt(armorCost)}金)`}
708              onPress={() => act($, buyArmor, shortBy(armorCost))}
709            />
710          </Box>
711        </Box>
712
713        <Box flexDirection="column">
714          <Box flexDirection="row" gap={1}>
715            <Text bold>育成方針</Text>
716            <Text dimColor>レベルが上がるたびに3ポイント振る</Text>
717          </Box>
718          <Box flexDirection="row" gap={1} flexWrap="wrap">
719            {FOCUS_LABEL.map(([focus, label]) => (
720              <Button
721                key={`focus-${focus}`}
722                label={label}
723                variant={g.focus === focus ? 'primary' : 'secondary'}
724                dimColor={g.focus !== focus}
725                onPress={() => act($, game => setFocus(game, focus))}
726              />
727            ))}
728          </Box>
729        </Box>
730
731        <Box flexDirection="column">
732          <Text bold>進路</Text>
733          <Box flexDirection="row" gap={1} flexWrap="wrap">
734            <Button key="up" hotkey="u" label="浅い階へ" dimColor={g.floor <= 1} onPress={() => act($, game => moveFloor(game, -1))} />
735            <Button
736              key="down"
737              hotkey="d"
738              label="深い階へ"
739              dimColor={g.floor >= g.deepest}
740              onPress={() => act($, game => moveFloor(game, 1))}
741            />
742            <Button
743              key="auto"
744              hotkey="m"
745              label={g.isAuto ? '階段を見つけたら降りる' : 'この階で稼ぐ'}
746              onPress={() => act($, game => setAuto(game, !game.isAuto))}
747            />
748            <Button
749              key="band"
750              plain
751              dimColor
752              label={isBandHidden ? '入力欄の上に戦況を出す' : '入力欄の上の戦況を隠す'}
753              onPress={() => update($, BAND_HIDDEN, hidden => !hidden)}
754            />
755          </Box>
756        </Box>
757
758        {rankingSection($, T, Input, ranking)}
759      </Box>
760    )
761  })
762}
763
hooks/game.ts 495 lines
1// 灯火迷宮のゲームロジック。$に触れない純粋関数だけを置き、register.tsxから呼ぶ。
2import type { Focus, Foe, Game, LogKind, RunSummary } from '../types'
3
4export const TICK_MS = 1500
5const LOG_LIMIT = 40
6const ENCOUNTERS_PER_FLOOR = 4
7const RETREAT_FLOORS = 2
8const POINTS_PER_LEVEL = 3
9/** 敵と敵の合間にこれを下回っていたら、先へ進む前に休む */
10const LOW_HP_RATIO = 0.35
11
12type Species = { glyph: string; name: string }
13type Biome = { name: string; foes: readonly [Species, Species, Species, Species]; boss: Species }
14
15// 10階ごとに景色が変わる。100階を越えると同じ並びを「弐」「参」…の深さで巡り直す。
16const BIOMES: readonly Biome[] = [
17  {
18    name: '苔むす石段',
19    foes: [
20      { glyph: '鼠', name: '大ネズミ' },
21      { glyph: '蝠', name: '洞窟コウモリ' },
22      { glyph: '粘', name: '苔スライム' },
23      { glyph: '蜘', name: '穴グモ' },
24    ],
25    boss: { glyph: '王', name: 'ネズミの王' },
26  },
27  {
28    name: '骨の回廊',
29    foes: [
30      { glyph: '骸', name: '骸骨兵' },
31      { glyph: '霊', name: '鬼火' },
32      { glyph: '屍', name: 'さまよう屍' },
33      { glyph: '鴉', name: '墓守ガラス' },
34    ],
35    boss: { glyph: '冠', name: '骨冠の騎士' },
36  },
37  {
38    name: '地下水脈',
39    foes: [
40      { glyph: '蛙', name: '盲目ガエル' },
41      { glyph: '蛭', name: '大ヒル' },
42      { glyph: '蟹', name: '鉄甲ガニ' },
43      { glyph: '鰐', name: '水路ワニ' },
44    ],
45    boss: { glyph: '蛇', name: '水脈の大蛇' },
46  },
47  {
48    name: '打ち捨てられた坑道',
49    foes: [
50      { glyph: '坑', name: '亡霊坑夫' },
51      { glyph: '蟲', name: '岩喰い虫' },
52      { glyph: '石', name: '石像兵' },
53      { glyph: '土', name: '大モグラ' },
54    ],
55    boss: { glyph: '巨', name: '坑道の巨人' },
56  },
57  {
58    name: '菌糸の森',
59    foes: [
60      { glyph: '茸', name: '歩く茸' },
61      { glyph: '蛾', name: '燐光蛾' },
62      { glyph: '胞', name: '胞子袋' },
63      { glyph: '蔦', name: '絞め蔦' },
64    ],
65    boss: { glyph: '母', name: '菌糸の母' },
66  },
67  {
68    name: '溶岩の裂け目',
69    foes: [
70      { glyph: '蜥', name: '火トカゲ' },
71      { glyph: '炎', name: '炎の精' },
72      { glyph: '亀', name: '熔岩ガメ' },
73      { glyph: '灰', name: '灰の獣' },
74    ],
75    boss: { glyph: '竜', name: '赤竜の仔' },
76  },
77  {
78    name: '凍てつく書庫',
79    foes: [
80      { glyph: '書', name: '呪われた書' },
81      { glyph: '氷', name: '氷霊' },
82      { glyph: '司', name: '書庫の番人' },
83      { glyph: '狼', name: '霜狼' },
84    ],
85    boss: { glyph: '賢', name: '凍れる賢者' },
86  },
87  {
88    name: '鏡の迷路',
89    foes: [
90      { glyph: '鏡', name: '鏡像' },
91      { glyph: '影', name: '影法師' },
92      { glyph: '面', name: '仮面兵' },
93      { glyph: '幻', name: '幻術師' },
94    ],
95    boss: { glyph: '双', name: '双子の鏡像' },
96  },
97  {
98    name: '鎖の牢獄',
99    foes: [
100      { glyph: '鎖', name: '鎖の亡者' },
101      { glyph: '獄', name: '獄卒' },
102      { glyph: '牢', name: '牢鬼' },
103      { glyph: '刑', name: '処刑人' },
104    ],
105    boss: { glyph: '典', name: '典獄長' },
106  },
107  {
108    name: '星なき大空洞',
109    foes: [
110      { glyph: '虚', name: '虚ろなもの' },
111      { glyph: '眼', name: '浮かぶ眼' },
112      { glyph: '触', name: '這い寄る触手' },
113      { glyph: '翼', name: '堕ちた翼' },
114    ],
115    boss: { glyph: '淵', name: '深淵の目' },
116  },
117]
118
119// [体力, 攻撃, 防御]の倍率。0:標準、1:素早いが脆い、2:硬い、3:力自慢
120const SLOT_SHAPE: readonly (readonly [number, number, number])[] = [
121  [1, 1, 1],
122  [0.75, 1.2, 0.7],
123  [1.4, 0.75, 1.3],
124  [1, 1.25, 0.95],
125]
126
127const KANJI_DIGITS = ['', '', '弐', '参', '肆', '伍', '陸', '漆', '捌', '玖', '拾']
128
129const WEAPONS = ['木の棒', '錆びた剣', '鉄の剣', '鋼の剣', '銀の剣', '魔鋼の剣', '竜骨の剣', '星鉄の剣', '灯火の剣']
130const ARMORS = ['布の服', '革の鎧', '鎖帷子', '鉄の鎧', '鋼の鎧', '銀の鎧', '竜鱗の鎧', '星鉄の鎧', '灯火の衣']
131
132export function emptyRun(): RunSummary {
133  return { ticks: 0, kills: 0, exp: 0, gold: 0, levels: 0, floorsDown: 0 }
134}
135
136export function newGame(seed: number): Game {
137  return {
138    v: 1,
139    rev: 0,
140    seed: seed >>> 0 || 1,
141    level: 1,
142    exp: 0,
143    totalExp: 0,
144    gold: 0,
145    hp: heroBase(1).maxHp,
146    points: { atk: 0, def: 0, hp: 0 },
147    focus: 'even',
148    weapon: 0,
149    armor: 0,
150    floor: 1,
151    deepest: 1,
152    step: 0,
153    phase: 'walk',
154    foe: null,
155    isAuto: true,
156    kills: 0,
157    defeats: 0,
158    ticks: 0,
159    run: emptyRun(),
160    log: [{ text: '灯を手に、苔むす石段を下りはじめた。', kind: 'info' }],
161  }
162}
163
164// ---- 数値 ----
165
166function heroBase(level: number): { maxHp: number; atk: number; def: number } {
167  const l = level - 1
168  return { maxHp: 50 + 14 * l, atk: 8 + 2.4 * l, def: 3 + 1.1 * l }
169}
170
171export function weaponAtk(w: number): number {
172  return 3 * w + Math.floor((w * w) / 25)
173}
174
175export function armorDef(a: number): number {
176  return 2 * a + Math.floor((a * a) / 40)
177}
178
179export function armorHp(a: number): number {
180  return 8 * a + Math.floor((a * a) / 8)
181}
182
183export function heroStats(g: Game): { maxHp: number; atk: number; def: number } {
184  const base = heroBase(g.level)
185  return {
186    maxHp: Math.round(base.maxHp + 8 * g.points.hp + armorHp(g.armor)),
187    atk: Math.round(base.atk + 2 * g.points.atk + weaponAtk(g.weapon)),
188    def: Math.round(base.def + 1.5 * g.points.def + armorDef(g.armor)),
189  }
190}
191
192export function expNeed(level: number): number {
193  return Math.round(10 + 6 * level ** 1.6)
194}
195
196export function upgradeCost(level: number): number {
197  return Math.round(25 * 1.15 ** level)
198}
199
200export function biomeAt(floor: number): { name: string; index: number } {
201  const zone = Math.floor((floor - 1) / 10)
202  const index = zone % BIOMES.length
203  const lap = Math.floor(zone / BIOMES.length) + 1
204  const biome = BIOMES[index] ?? BIOMES[0]!
205  const suffix = lap === 1 ? '' : ` ${KANJI_DIGITS[lap] ?? String(lap)}`
206  return { name: biome.name + suffix, index }
207}
208
209export function isBossFloor(floor: number): boolean {
210  return floor % 10 === 0
211}
212
213export function encountersOn(): number {
214  return ENCOUNTERS_PER_FLOOR
215}
216
217export function makeFoe(floor: number, slot: number, isBoss: boolean): Foe {
218  const biome = BIOMES[biomeAt(floor).index] ?? BIOMES[0]!
219  const species = isBoss ? biome.boss : (biome.foes[slot] ?? biome.foes[0])
220  const [hpK, atkK, defK] = isBoss ? [4, 1.35, 1.2] : (SLOT_SHAPE[slot] ?? SLOT_SHAPE[0]!)
221  const m = floor
222  const maxHp = Math.round((18 + 9 * m + 0.06 * m * m) * hpK)
223  const reward = isBoss ? 8 : (hpK + atkK) / 2
224  return {
225    name: species.name,
226    glyph: species.glyph,
227    level: floor,
228    hp: maxHp,
229    maxHp,
230    atk: Math.round((5 + 2 * m + 0.012 * m * m) * atkK),
231    def: Math.round((2 + m + 0.008 * m * m) * defK),
232    exp: Math.round((4 + 2.2 * m + 0.02 * m * m) * reward),
233    gold: Math.round((2 + 1.2 * m + 0.01 * m * m) * reward * (isBoss ? 1.25 : 1)),
234    isBoss,
235  }
236}
237
238// ---- 乱数(セーブに種を持つので、同じセーブからは同じ展開になる) ----
239
240function nextRandom(d: Game): number {
241  let t = (d.seed = (d.seed + 0x6d2b79f5) >>> 0)
242  t = Math.imul(t ^ (t >>> 15), t | 1)
243  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
244  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
245}
246
247function damage(atk: number, def: number, roll: number): number {
248  return Math.max(1, Math.round(((atk * atk) / (atk + def)) * (0.9 + 0.2 * roll)))
249}
250
251// ---- 進行 ----
252
253function draft(g: Game): Game {
254  return {
255    ...g,
256    points: { ...g.points },
257    run: { ...g.run },
258    foe: g.foe === null ? null : { ...g.foe },
259    log: [...g.log],
260  }
261}
262
263function say(d: Game, text: string, kind: LogKind): void {
264  d.log.push({ text, kind })
265  if (d.log.length > LOG_LIMIT) d.log.splice(0, d.log.length - LOG_LIMIT)
266}
267
268function spendPoints(d: Game, focus: Focus): void {
269  if (focus === 'even') {
270    d.points.atk += 1
271    d.points.def += 1
272    d.points.hp += 1
273    return
274  }
275  d.points[focus] += POINTS_PER_LEVEL
276}
277
278function gainExp(d: Game, amount: number): void {
279  d.exp += amount
280  d.totalExp += amount
281  d.run.exp += amount
282  while (d.exp >= expNeed(d.level)) {
283    d.exp -= expNeed(d.level)
284    d.level += 1
285    d.run.levels += 1
286    spendPoints(d, d.focus)
287    d.hp = heroStats(d).maxHp
288    say(d, `Lv${d.level}に上がった。傷がふさがった。`, 'level')
289  }
290}
291
292function descend(d: Game): void {
293  const before = biomeAt(d.floor).name
294  d.floor += 1
295  d.step = 0
296  d.run.floorsDown += 1
297  if (d.floor > d.deepest) d.deepest = d.floor
298  const after = biomeAt(d.floor).name
299  say(d, after === before ? `地下${d.floor}階へ降りた。` : `地下${d.floor}階、${after}に出た。`, 'info')
300}
301
302function walk(d: Game): void {
303  if (d.hp < heroStats(d).maxHp * LOW_HP_RATIO) {
304    d.phase = 'rest'
305    say(d, '灯の下で息を整える。', 'info')
306    return
307  }
308  if (d.step >= ENCOUNTERS_PER_FLOOR) {
309    if (d.isAuto) {
310      descend(d)
311    } else {
312      d.step = 0
313      say(d, `地下${d.floor}階をもう一巡する。`, 'info')
314    }
315    return
316  }
317  const isBoss = isBossFloor(d.floor) && d.step === ENCOUNTERS_PER_FLOOR - 1
318  const slot = Math.floor(nextRandom(d) * 4)
319  d.foe = makeFoe(d.floor, slot, isBoss)
320  d.phase = 'fight'
321  say(d, isBoss ? `階の主、${d.foe.name}が立ちはだかる。` : `${d.foe.name}が現れた。`, isBoss ? 'boss' : 'info')
322}
323
324function fight(d: Game, foe: Foe): void {
325  const hero = heroStats(d)
326  const isCritical = nextRandom(d) < 0.08
327  const dealt = Math.round(damage(hero.atk, foe.def, nextRandom(d)) * (isCritical ? 1.8 : 1))
328  foe.hp = Math.max(0, foe.hp - dealt)
329
330  if (foe.hp === 0) {
331    d.kills += 1
332    d.run.kills += 1
333    d.gold += foe.gold
334    d.run.gold += foe.gold
335    d.step += 1
336    d.foe = null
337    d.phase = 'walk'
338    d.hp = Math.min(hero.maxHp, d.hp + Math.round(hero.maxHp * 0.08))
339    say(d, `${foe.name}を倒した。経験+${fmt(foe.exp)}、金+${fmt(foe.gold)}`, foe.isBoss ? 'boss' : 'gain')
340    gainExp(d, foe.exp)
341    return
342  }
343
344  d.hp -= damage(foe.atk, hero.def, nextRandom(d))
345  if (d.hp > 0) return
346
347  d.defeats += 1
348  d.hp = 1
349  d.foe = null
350  d.phase = 'rest'
351  d.step = 0
352  d.floor = Math.max(1, d.floor - RETREAT_FLOORS)
353  say(d, `${foe.name}に敗れた。地下${d.floor}階まで退いて休む。`, 'loss')
354}
355
356function rest(d: Game): void {
357  const { maxHp } = heroStats(d)
358  d.hp = Math.min(maxHp, d.hp + Math.ceil(maxHp * 0.25))
359  if (d.hp < maxHp) return
360  d.phase = 'walk'
361  say(d, '灯を掲げ直し、探索を再開した。', 'info')
362}
363
364/** 1tick進める。Claudeの応答待ちの間だけTICK_MSごとに呼ばれる */
365export function tick(g: Game): Game {
366  const d = draft(g)
367  d.ticks += 1
368  d.run.ticks += 1
369  if (d.phase === 'rest') rest(d)
370  else if (d.phase === 'fight' && d.foe !== null) fight(d, d.foe)
371  else walk(d)
372  return d
373}
374
375// ---- プレイヤーの操作 ----
376
377export function buyWeapon(g: Game): Game | null {
378  const cost = upgradeCost(g.weapon)
379  if (g.gold < cost) return null
380  const d = draft(g)
381  d.gold -= cost
382  d.weapon += 1
383  say(d, `武器を鍛えた。${weaponName(d.weapon)}`, 'gain')
384  return d
385}
386
387export function buyArmor(g: Game): Game | null {
388  const cost = upgradeCost(g.armor)
389  if (g.gold < cost) return null
390  const d = draft(g)
391  const before = heroStats(d).maxHp
392  d.gold -= cost
393  d.armor += 1
394  d.hp += heroStats(d).maxHp - before
395  say(d, `防具を鍛えた。${armorName(d.armor)}`, 'gain')
396  return d
397}
398
399export function setFocus(g: Game, focus: Focus): Game {
400  return { ...g, focus }
401}
402
403export function setAuto(g: Game, isAuto: boolean): Game {
404  return { ...g, isAuto }
405}
406
407/** 浅い階・深い階へ移る。深い側は到達済みの最深階まで */
408export function moveFloor(g: Game, delta: number): Game {
409  const floor = Math.min(g.deepest, Math.max(1, g.floor + delta))
410  if (floor === g.floor) return g
411  const d = draft(g)
412  d.floor = floor
413  d.step = 0
414  d.foe = null
415  if (d.phase === 'fight') d.phase = 'walk'
416  say(d, `地下${floor}階へ移った。`, 'info')
417  return d
418}
419
420export function startRun(g: Game): Game {
421  return { ...g, run: emptyRun() }
422}
423
424// ---- 表示用 ----
425
426const UNITS: readonly (readonly [number, string])[] = [
427  [1e20, '垓'],
428  [1e16, '京'],
429  [1e12, '兆'],
430  [1e8, '億'],
431  [1e4, '万'],
432]
433
434/** 1万未満は桁区切り、それ以上は12.3万や4.56億のように縮める */
435export function fmt(n: number): string {
436  const value = Math.floor(n)
437  for (const [size, unit] of UNITS) {
438    if (value >= size) {
439      const scaled = value / size
440      const digits = scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2
441      return `${Number(scaled.toFixed(digits))}${unit}`
442    }
443  }
444  return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
445}
446
447function gear(names: readonly string[], level: number): string {
448  const tier = Math.min(names.length - 1, Math.floor(level / 10))
449  const plus = level - tier * 10
450  return plus === 0 ? names[tier]! : `${names[tier]!}+${plus}`
451}
452
453export function weaponName(level: number): string {
454  return gear(WEAPONS, level)
455}
456
457export function armorName(level: number): string {
458  return gear(ARMORS, level)
459}
460
461/** 遊んだ時間を「3時間12分」の形で */
462export function playTime(ticks: number): string {
463  const minutes = Math.floor((ticks * TICK_MS) / 60000)
464  const h = Math.floor(minutes / 60)
465  const m = minutes % 60
466  return h > 0 ? `${h}時間${m}分` : `${m}分`
467}
468
469// ---- 保存データの読み込み ----
470
471function isCount(value: unknown): value is number {
472  return typeof value === 'number' && Number.isFinite(value) && value >= 0
473}
474
475/** $.storeから読んだ値を検証する。壊れていればnull(呼び出し側で新規ゲームにする) */
476export function loadGame(saved: unknown): Game | null {
477  if (typeof saved !== 'object' || saved === null) return null
478  const s = saved as Partial<Game>
479  if (s.v !== 1) return null
480  const counts = [s.rev, s.seed, s.level, s.exp, s.totalExp, s.gold, s.hp, s.weapon, s.armor, s.floor, s.deepest, s.step, s.kills, s.defeats, s.ticks]
481  if (!counts.every(isCount)) return null
482  if (typeof s.points !== 'object' || s.points === null || ![s.points.atk, s.points.def, s.points.hp].every(isCount)) return null
483  if (!Array.isArray(s.log)) return null
484  const fresh = newGame(1)
485  return {
486    ...fresh,
487    ...s,
488    focus: s.focus === 'atk' || s.focus === 'def' || s.focus === 'hp' ? s.focus : 'even',
489    phase: s.phase === 'fight' || s.phase === 'rest' ? s.phase : 'walk',
490    foe: s.phase === 'fight' && typeof s.foe === 'object' ? (s.foe ?? null) : null,
491    isAuto: s.isAuto !== false,
492    run: { ...emptyRun(), ...(s.run ?? {}) },
493  } as Game
494}
495
hooks/ranking.ts 134 lines
1// ランキングサーバーとのやり取りに使う純粋関数。通信そのものはregister.tsxが$.http.fetchで行う。
2import type { Board, Game, RankBy, RankEntry } from '../types'
3
4/** 応答待ちが終わるたびに送るが、この間隔より短ければ送らない(サーバー側は20秒) */
5export const SUBMIT_INTERVAL_MS = 60_000
6export const BOARD_LIMIT = 10
7const NAME_MAX = 12
8
9export const RANK_LABEL: Readonly<Record<RankBy, string>> = { level: 'Lv', deepest: '最深階', exp: '総経験' }
10
11/** $.storeに置くプレイヤー情報。tokenは記録を書き換えるための秘密で、$.stateには出さない */
12export type Player = { id: string; token: string; name: string; isJoined: boolean; lastSentAt: number }
13
14function randomBytes(length: number): Uint8Array {
15  return crypto.getRandomValues(new Uint8Array(length))
16}
17
18function base64Url(bytes: Uint8Array): string {
19  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
20  let out = ''
21  let buffer = 0
22  let bits = 0
23  for (const byte of bytes) {
24    buffer = (buffer << 8) | byte
25    bits += 8
26    while (bits >= 6) {
27      bits -= 6
28      out += alphabet[(buffer >> bits) & 63]
29    }
30  }
31  if (bits > 0) out += alphabet[(buffer << (6 - bits)) & 63]
32  return out
33}
34
35export function newPlayer(): Player {
36  return { id: crypto.randomUUID().toLowerCase(), token: base64Url(randomBytes(32)), name: '', isJoined: false, lastSentAt: 0 }
37}
38
39const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
40const TOKEN = /^[A-Za-z0-9_-]{32,128}$/
41
42/** $.storeから読んだ値を検証する。壊れていればnull */
43export function loadPlayer(saved: unknown): Player | null {
44  if (typeof saved !== 'object' || saved === null) return null
45  const p = saved as Partial<Player>
46  if (typeof p.id !== 'string' || !UUID.test(p.id)) return null
47  if (typeof p.token !== 'string' || !TOKEN.test(p.token)) return null
48  return {
49    id: p.id,
50    token: p.token,
51    name: typeof p.name === 'string' ? p.name : '',
52    isJoined: p.isJoined === true,
53    lastSentAt: typeof p.lastSentAt === 'number' && Number.isFinite(p.lastSentAt) ? p.lastSentAt : 0,
54  }
55}
56
57/** サーバーと同じ規則で名前を整える。前後の空白を除き、空白の連続を1つにし、1〜12文字で制御文字なし。不正ならnull */
58export function normalizeName(raw: string): string | null {
59  const name = raw.trim().replace(/\s+/g, ' ')
60  const chars = [...name]
61  if (chars.length < 1 || chars.length > NAME_MAX) return null
62  if (/[\p{Cc}\p{Cf}]/u.test(name)) return null
63  return name
64}
65
66/** http:は手元で動かすサーバー(localhost)にだけ許し、それ以外はhttps:に限る */
67export function endpoint(base: string, path: string): string | null {
68  let url: URL
69  try {
70    url = new URL(base)
71  } catch {
72    return null
73  }
74  const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'
75  if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLocal)) return null
76  return `${url.origin}${url.pathname.replace(/\/+$/, '')}${path}`
77}
78
79export function scoreBody(player: Player, game: Game): string {
80  return JSON.stringify({
81    playerId: player.id,
82    token: player.token,
83    name: player.name,
84    level: game.level,
85    deepest: game.deepest,
86    totalExp: Math.floor(game.totalExp),
87    kills: game.kills,
88    ticks: game.ticks,
89  })
90}
91
92function isCount(value: unknown): value is number {
93  return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
94}
95
96function parseEntry(value: unknown): RankEntry | null {
97  if (typeof value !== 'object' || value === null) return null
98  const v = value as Record<string, unknown>
99  if (!isCount(v.rank) || !isCount(v.level) || !isCount(v.deepest) || !isCount(v.totalExp)) return null
100  if (typeof v.name !== 'string') return null
101  // 表示前に制御文字を落とし、長さも抑える(サーバーが壊れていても画面を崩さない)
102  const name = [...v.name.replace(/[\p{Cc}\p{Cf}]/gu, '')].slice(0, NAME_MAX).join('')
103  return { rank: v.rank, name, level: v.level, deepest: v.deepest, totalExp: v.totalExp, isYou: v.isYou === true }
104}
105
106/** GET /v1/leaderboard の応答を検証する。形が違えばnull */
107export function parseBoard(text: string): Board | null {
108  let data: unknown
109  try {
110    data = JSON.parse(text)
111  } catch {
112    return null
113  }
114  if (typeof data !== 'object' || data === null) return null
115  const d = data as Record<string, unknown>
116  if (d.by !== 'level' && d.by !== 'deepest' && d.by !== 'exp') return null
117  if (!Array.isArray(d.entries) || !isCount(d.players)) return null
118  const entries = d.entries.map(parseEntry)
119  if (entries.some(entry => entry === null)) return null
120  const me = d.me === null || d.me === undefined ? null : parseEntry({ ...(d.me as object), isYou: true })
121  return { by: d.by, entries: entries as RankEntry[], me, players: d.players }
122}
123
124/** 通信の失敗を、プレイヤーが次に何をすればよいか分かる文にする */
125export function failureText(status: number): string {
126  if (status === 403) return 'この記録は別の端末で登録されたもので、書き換えられない。'
127  if (status === 422) return '記録が不自然と判定され、受け付けられなかった。'
128  if (status === 429) return '送信の間隔が短すぎる。少し待ってから更新する。'
129  if (status === 400 || status === 415) return '送った内容が受け付けられなかった。名前を見直す。'
130  if (status === 404) return 'ランキングに記録が見つからない。'
131  if (status >= 500) return 'ランキングサーバーで問題が起きている。時間をおいて更新する。'
132  return `ランキングサーバーが想定外の応答を返した(${status})。`
133}
134
hooks/sprites.ts 157 lines
1// 16x16のドット絵と、その描画データ(端末のRaster用セル列、デスクトップのSvg)を作る。$には触れない。
2import { ART } from './art'
3
4export const SPRITE_SIZE = 16
5
6/** ドット絵の1文字が指す色。nullは透明。W/A/aは主人公の装備段階で塗り替える */
7const PALETTE: Readonly<Record<string, number | null>> = {
8  '.': null,
9  k: 0x1a1220,
10  w: 0xfcfcfc,
11  g: 0xbcbcbc,
12  G: 0x7c7c7c,
13  s: 0xfcbc94,
14  S: 0xc8845c,
15  r: 0xe83828,
16  R: 0x881400,
17  o: 0xf87858,
18  y: 0xf8b800,
19  Y: 0xfce0a8,
20  b: 0xa0602c,
21  B: 0x583010,
22  e: 0x00a844,
23  E: 0x005820,
24  l: 0xb8f818,
25  c: 0x3cbcfc,
26  C: 0x0058f8,
27  n: 0x0820a0,
28  p: 0x9078fc,
29  P: 0xd800cc,
30  v: 0x4428bc,
31  i: 0xa4e4fc,
32  W: 0xbcbcbc,
33  A: 0x4c6ccc,
34  a: 0x283c88,
35}
36
37// 武器・防具の名前(game.ts)と同じ10段階ごとの並び。木の棒→錆→鉄→鋼→銀→魔鋼→竜骨→星鉄→灯火
38const BLADE = [0xa0602c, 0xb86848, 0x9ca0a8, 0xd0d8e8, 0xfcfcfc, 0x9078fc, 0xfce0a8, 0x3cbcfc, 0xf8b800]
39// 布の服→革→鎖帷子→鉄→鋼→銀→竜鱗→星鉄→灯火の衣。[地の色, 影の色]
40const ARMOR: readonly (readonly [number, number])[] = [
41  [0xb8a888, 0x887858],
42  [0xa0602c, 0x583010],
43  [0x9ca0a8, 0x5c6068],
44  [0x7c8494, 0x484c58],
45  [0x4c6ccc, 0x283c88],
46  [0xd8dce8, 0x8c90a0],
47  [0x00a844, 0x005820],
48  [0x3c7cfc, 0x0820a0],
49  [0xe83828, 0x881400],
50]
51
52export type Recolor = Readonly<Record<string, number>>
53
54/** 装備の強化段階から、主人公の剣と鎧の塗り替えを決める */
55export function heroRecolor(weapon: number, armor: number): Recolor {
56  const blade = BLADE[Math.min(BLADE.length - 1, Math.floor(weapon / 10))]!
57  const [main, shade] = ARMOR[Math.min(ARMOR.length - 1, Math.floor(armor / 10))]!
58  return { W: blade, A: main, a: shade }
59}
60
61export type SpriteOptions = {
62  /** 1ドット上に浮かせる(待機中の上下の揺れ) */
63  isLifted?: boolean
64  /** 左右反転(焚き火の揺らぎ) */
65  isMirrored?: boolean
66  recolor?: Recolor
67}
68
69const EMPTY: readonly string[] = Array.from({ length: SPRITE_SIZE }, () => '.'.repeat(SPRITE_SIZE))
70
71export function artFor(glyph: string): readonly string[] | undefined {
72  return ART[glyph]
73}
74
75export function blankArt(): readonly string[] {
76  return EMPTY
77}
78
79function pixels(rows: readonly string[], options: SpriteOptions): (number | null)[][] {
80  let grid = rows.map(row => [...row])
81  if (options.isMirrored) grid = grid.map(row => [...row].reverse())
82  if (options.isLifted) grid = [...grid.slice(1), [...'.'.repeat(SPRITE_SIZE)]]
83  return grid.map(row =>
84    row.map(key => {
85      const recolored = options.recolor?.[key]
86      if (recolored !== undefined) return recolored
87      return PALETTE[key] ?? null
88    }),
89  )
90}
91
92const DEFAULT_COLOR = 0x01000000
93const UPPER_HALF = 0x2580
94const LOWER_HALF = 0x2584
95const SPACE = 0x20
96const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
97
98function toBase64(bytes: Uint8Array): string {
99  let out = ''
100  for (let i = 0; i < bytes.length; i += 3) {
101    const a = bytes[i]!
102    const b = bytes[i + 1]
103    const c = bytes[i + 2]
104    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
105    out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
106    out += b === undefined ? '=' : BASE64[(n >> 6) & 63]!
107    out += c === undefined ? '=' : BASE64[n & 63]!
108  }
109  return out
110}
111
112/** 端末のRaster用。1セルに上下2ドットを半角ブロックで詰め、16列x8行にする */
113export function rasterCells(rows: readonly string[], options: SpriteOptions = {}): string {
114  const grid = pixels(rows, options)
115  const cellRows = SPRITE_SIZE / 2
116  const view = new DataView(new ArrayBuffer(SPRITE_SIZE * cellRows * 12))
117  let offset = 0
118  for (let y = 0; y < cellRows; y++) {
119    for (let x = 0; x < SPRITE_SIZE; x++) {
120      const top = grid[y * 2]?.[x] ?? null
121      const bottom = grid[y * 2 + 1]?.[x] ?? null
122      const cell =
123        top === null && bottom === null
124          ? [SPACE, DEFAULT_COLOR, DEFAULT_COLOR]
125          : top === null
126            ? [LOWER_HALF, bottom ?? DEFAULT_COLOR, DEFAULT_COLOR]
127            : [UPPER_HALF, top, bottom ?? DEFAULT_COLOR]
128      for (const word of cell) {
129        view.setUint32(offset, word, true)
130        offset += 4
131      }
132    }
133  }
134  return toBase64(new Uint8Array(view.buffer))
135}
136
137function hex(color: number): string {
138  return `#${color.toString(16).padStart(6, '0')}`
139}
140
141/** デスクトップなどのSvg用。同じ色が横に続くドットは1つの矩形にまとめる */
142export function svgSource(rows: readonly string[], options: SpriteOptions = {}): string {
143  const grid = pixels(rows, options)
144  const rects: string[] = []
145  grid.forEach((row, y) => {
146    let x = 0
147    while (x < row.length) {
148      const color = row[x] ?? null
149      let end = x + 1
150      while (end < row.length && row[end] === color) end++
151      if (color !== null) rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${hex(color)}"/>`)
152      x = end
153    }
154  })
155  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SPRITE_SIZE} ${SPRITE_SIZE}" shape-rendering="crispEdges">${rects.join('')}</svg>`
156}
157
hooks/art.ts 941 lines
1// 16x16のドット絵。キーはゲーム内で敵を表す漢字(主人公は@、焚き火は焚)。色はsprites.tsのPALETTEを参照
2// scratchpadのgen-art.mjsで書き出したもの
3export const ART: Readonly<Record<string, readonly string[]>> = {
4  '@': [
5    '....kkkkk.......',
6    '...kbbbbbk...k..',
7    '..kbbbbbbbk.kWk.',
8    '..kbbbbsssk.kWk.',
9    '..kBbbssksk.kWk.',
10    '..kBSsssssskkWk.',
11    '...kBssssk..kWk.',
12    '..kaAAAAAAk.kWk.',
13    '.ksaAAAAaAAkyyyk',
14    '.kkkaAAAAaAAsssk',
15    'kYYYkBBBBBBkkBk.',
16    'kYoYkaAAAAak.k..',
17    'kyoykaakaak.....',
18    'kyyykBBkBBk.....',
19    '.kkkkBBkBBBk....',
20    '....kkkkkkkk....',
21  ],
22  '焚': [
23    '................',
24    '.......k........',
25    '......krk.......',
26    '...k.krork.k....',
27    '..krkkrorkkrk...',
28    '..krkkroorkrrk..',
29    '..krrkroyorork..',
30    '..kroroyYyoork..',
31    '..krooyYYYyork..',
32    '...kroyyyyork...',
33    '..kkkroooorkkk..',
34    '.kbbbkrrrrkbbbk.',
35    '..kkbbbkkbbbkk..',
36    '...kkkbBBbkkk...',
37    '..kbbBkkkkBbbk..',
38    '...kkk....kkk...',
39  ],
40  '鼠': [
41    '................',
42    '................',
43    '................',
44    '................',
45    '................',
46    '.....kk.........',
47    '....kGGk.kkk.k..',
48    '...kGSSGkGGGkSk.',
49    '...kGSGGGGGGkkSk',
50    '..kGGGGGGGGGGkSk',
51    '.kGrGGGGGGGGGkSk',
52    'kSGGGGGGGGGGGkSk',
53    '.kGGggggggGGGkSk',
54    '..kGGgggggGGGSk.',
55    '..kSSkkkkkSSkk..',
56    '...kk.....kk....',
57  ],
58  '蝠': [
59    '................',
60    '................',
61    '................',
62    '................',
63    '................',
64    '.k....k..k....k.',
65    'kgk..kGkkGk..kgk',
66    'kbgk.kGGGGk.kgbk',
67    'kbbgkGrGGrGkgbbk',
68    'kbbbgGGGGGGgbbbk',
69    'kbbbbkGggGkbbbbk',
70    'kbkbbbkGGkbbbkbk',
71    'kk.kbkbkkbkbk.kk',
72    '....k.k..k.k....',
73    '................',
74    '................',
75  ],
76  '粘': [
77    '................',
78    '................',
79    '......k...k.....',
80    '.....klk.klk....',
81    '....klEkkElk....',
82    '....kkEeEeEkk...',
83    '...keeEeeeEeek..',
84    '..keeeeeeeeeEek.',
85    '.keleeeeeeeeeeek',
86    'kelleeeeeeeeeeek',
87    'kelereereeeeeeek',
88    'keeereereeeeeeek',
89    'keeeeeeeeeeeeeek',
90    'kEeeeeeeeeeeeeEk',
91    'kEEEEEEEEEEEEEEk',
92    '.kkkkkkkkkkkkkk.',
93  ],
94  '蜘': [
95    '................',
96    '................',
97    '...k........k...',
98    '..kGk......kGk..',
99    '.kGkGk..kkkGkGk.',
100    '.kGkGk.kbbbGkGk.',
101    '.kGkkGkbbebbkGk.',
102    '.kGkrbkebbebkGk.',
103    '.kGrbbkbeebbkGk.',
104    '.kGkbbkbbbebkGk.',
105    'kGk.kbbbbbbk.kGk',
106    'kGkkGkkGkkGk.kGk',
107    'kGkkGkkGk.kGkkGk',
108    'kGkGk.kGk.kGkkGk',
109    'kGkGkkGk...kGkGk',
110    '.k.k..k.....k.k.',
111  ],
112  '王': [
113    '...kykykyk.kk...',
114    '...kyyyyykkGGk..',
115    '...kyyryykGggGk.',
116    '..kGGGGGGGGggGk.',
117    '.kGGrGGGGGGGGGk.',
118    'krGGGGGGGGGGGk..',
119    '.kgGGGGGGGGeek..',
120    '.kkgGGGGGeeeek..',
121    'krkGgggGGeeeek..',
122    'kyGGgggGGeeeeek.',
123    'kyGGgggGGeeeeek.',
124    'kykGgggGGeeeeeek',
125    'kykGgggGGeeeeeek',
126    'kykGGGGGGeeeeeek',
127    'kykGGkGGyyyyyyyk',
128    '.k.kk.kkkkkkkkk.',
129  ],
130  '骸': [
131    '.....kkkkk...k..',
132    '....kwwwwwk.kwk.',
133    '...kwwwwwwgkkwk.',
134    '...kckwckwgkkwk.',
135    '...kkwkkwwgkkwk.',
136    '....kwkwkgk.kgk.',
137    '.....kkwkk.kvvvk',
138    '....kgwwwwwwwvk.',
139    'kkkkkkwwwwgkkkk.',
140    'kvvvvkkkwkk.....',
141    'kvccvkwwwwgk....',
142    'kvvvvkkkwkk.....',
143    '.kvvk.kgwwwgk...',
144    '..kk.kwk.kwk....',
145    '....kwwkkwwk....',
146    '....kkkkkkkk....',
147  ],
148  '霊': [
149    '........k.......',
150    '.......kck......',
151    '......kcck..k...',
152    '....k.kcick.kck.',
153    '...kck.kcicckck.',
154    '...kcckciiicck..',
155    '....kciiwiiiCk..',
156    '...kciwwwwwiCCk.',
157    '..kcikwwkwwwiCk.',
158    '..kcikwwkwwwiCk.',
159    '..kciwwwwwwwiCk.',
160    '...kciwkwwwicCk.',
161    '....kcciiiccCCk.',
162    '.....kkCccCCCCCk',
163    '.......kkkkkkkk.',
164    '................',
165  ],
166  '屍': [
167    '................',
168    '......kkkkk.....',
169    '.....keeeeEk....',
170    '....keeeeeeEk...',
171    '....kckekkeEk...',
172    '....keeeeeEEk...',
173    '....kkEkkEEk....',
174    '..kkkkkeeeEkk...',
175    '.keeEkvvvvvvvk..',
176    '..kkkvvvvkvvvk..',
177    '.keeEkvvvevvvk..',
178    '..kkkvvvvvvkvk..',
179    '......kvvkvkvk..',
180    '......kEek.kEk..',
181    '.....keEk..keEk.',
182    '....kkkk..kkkkk.',
183  ],
184  '鴉': [
185    '................',
186    '.........kk.....',
187    '........kvvk..k.',
188    '.......kvvnk.kvk',
189    '.......kvnnkkvnk',
190    '...kkk.kvnnkvnnk',
191    '..kvvvkkvvnnvnk.',
192    '.kvvcvkvvvvnnk..',
193    'kggvvvvvvvvnnkk.',
194    '.kkkvvvvvvvnnnnk',
195    '...kkvvvvvnnnnnk',
196    '.....kkvvnnnkkk.',
197    '.......kkkkk....',
198    '......kgk.kgk...',
199    '.......k...k....',
200    '................',
201  ],
202  '冠': [
203    '..k...k.k.k.k...',
204    '.kck.kwkwkwkwk..',
205    '.kwk.kgwgcgwgk..',
206    '.kck.kwwwwwwgk..',
207    '.kck.kckwckwgk..',
208    '.kck.kkwkwwgk...',
209    'kgggk.kwkwkkkk..',
210    '.kvkkvvvkkvvvnk.',
211    '.kvvvvgvvvvgvnnk',
212    '.kgkkvvvcvvvnvnk',
213    '..k..kvcccvvnvnk',
214    '.....kvvcvvnvnnk',
215    '....kgvvvvvgnvnk',
216    '....kvvkkkvvkknk',
217    '....kgvk.kgvk.k.',
218    '...kkkkk.kkkkk..',
219  ],
220  '蛙': [
221    '................',
222    '................',
223    '................',
224    '...kk...........',
225    '..kwwk..........',
226    '.kwggwkkkk......',
227    '.kwggwiiiikk....',
228    'kiiwwiiiiiickk..',
229    'kiiiiiiiiiiicck.',
230    '.kkkkkkiiiiiicck',
231    '.kwwwwwiiwiiicck',
232    '.kwwwwkiwiiiicck',
233    '..kiik.kiiiiccck',
234    '.kkiik.kkkkcccck',
235    'kiiiikkiiiiiicck',
236    '.kkkk..kkkkkkkk.',
237  ],
238  '蛭': [
239    '................',
240    '................',
241    '.....kkkkkk.....',
242    '....kppppppkkk..',
243    '...kpvvnvvnvvpk.',
244    '..kpvvnvvnvvnvpk',
245    '.kpvvnnnnnnvvvpk',
246    '.kpvnkkkkknvvvpk',
247    'kpvvnk....knnnnk',
248    'knnnnk....kpvvpk',
249    'kpvvpk....kpvvpk',
250    'krkkrk....knnnnk',
251    '.krrk.....kpvvpk',
252    '..kk.....kpvvvpk',
253    '.krk....krrrrrrk',
254    '..k......kkkkkk.',
255  ],
256  '蟹': [
257    '................',
258    '..k..k..........',
259    '.kwkkwk.........',
260    'kwgGkgGk........',
261    'kggGkgGkk..k....',
262    'kgGGgGGkckkckk..',
263    '.kgGGGkkGkkGkgk.',
264    '..kGGkgggggggGgk',
265    '...kGgwggwgggGGk',
266    '...kgwgGgggGgGGk',
267    '..kggggggggGGGGk',
268    '...kGGGGGGGGGGk.',
269    '..kGkkGkGkGkkGk.',
270    '.kGkkGkkGkkGkkGk',
271    '.kGkkGkkGkkGkkGk',
272    '..k..k..k..k..k.',
273  ],
274  '鰐': [
275    '................',
276    '................',
277    '................',
278    '................',
279    '..............k.',
280    '....kk.k.k.k.kEk',
281    '..kkEEkEkEkEkEek',
282    '.keeyeeEeEeEeeek',
283    'keeeeeeeeeeeeeEk',
284    'kYkYkYkeeeeeeEEk',
285    '.kYkYkYeeeeeEEk.',
286    'keeeeeeYYYYYEk..',
287    '.kkEEekeEkkEeEk.',
288    '..kEekkEek.kEek.',
289    '.kEEEkEEEkkEEEk.',
290    '..kkk.kkk..kkk..',
291  ],
292  '蛇': [
293    '...k.k.k........',
294    '..kykykykk......',
295    '..kyyyyykik.....',
296    '.kCCCCCCCcik....',
297    'kCyyCCCCCncik...',
298    'kCCCCCCCCCnck...',
299    'kikikiCCCCnckk..',
300    '.kikikcCCCCncik.',
301    '.kccccCCCCCnckk.',
302    '..kkkkcCCCCCncik',
303    '.....kcCCCCCnck.',
304    '....kcCCCCCCncik',
305    '.kkkcCCCCCCnnck.',
306    'kiiicCCCCCnniiik',
307    'kicciiiiiiiiccik',
308    '.kkkkkkkkkkkkkk.',
309  ],
310  '坑': [
311    '....kk..........',
312    '...kGGk..kkkk...',
313    '..kGkk..kyyyyk..',
314    '.kGk...kyyyyyyk.',
315    'kGkGk.kwyyyyyyyk',
316    'kGkkGk.kwwwwwwik',
317    '.k..kGkkwkwwkwik',
318    '.....kGkwkwwkwik',
319    '.....kwwwwwwwwik',
320    '.....kwkiwkkwwik',
321    '......kkiwwwwiik',
322    '........kiwwwiik',
323    '........kiiwiik.',
324    '.........kikik..',
325    '..........k.k...',
326    '................',
327  ],
328  '蟲': [
329    '....kkkkk.......',
330    '..kkoooook......',
331    '.kooooooookk....',
332    'koooooooooRok...',
333    'kwRwRwRooooRok..',
334    '.kGGGRRoooRok...',
335    '.kGGGRRoooRok...',
336    'kRwRwRwoooRook..',
337    'kooooooooRoook..',
338    '.kRooooooRoook..',
339    '..kkRRooooRook..',
340    '....kkRoooRoook.',
341    '....kGGRoooRoGk.',
342    '...kGGGGGGGGGGGk',
343    '..kGGwGGGGGwGGGk',
344    '...kkkkkkkkkkkk.',
345  ],
346  '石': [
347    '..k......k......',
348    '.kwk....kwk.....',
349    'kwgwk..kgggk....',
350    '.kGk..kgggGGk...',
351    '.kGk.kggggGGGk..',
352    '.kGk.krrkgGGGk..',
353    '.kGk.kgggggGGk..',
354    '.kGk.kkgggGGkk..',
355    '.kGkkwggggGGGGk.',
356    'kgggwggkggGGGGGk',
357    'kgggwgggkgGGkGGk',
358    '.kGkkggggGGGkGGk',
359    '.kGkkggGkGGGkkk.',
360    '.kGkkgGGkgGGkkk.',
361    'kGGGGGGGGGGGGGGk',
362    '.kkkkkkkkkkkkkk.',
363  ],
364  '土': [
365    '................',
366    '................',
367    '.....kkkkkk.....',
368    '...kkbbbbbBkk...',
369    '..kbbbbbbBBBBk..',
370    '.kbbkbbbBBBBBBk.',
371    'kkbbbbbBBBBBBBBk',
372    'koobbbBBBBBBBBBk',
373    'kookbbBBBBBBBBBk',
374    'kkkYYkobBBBBBBBk',
375    'kYYYYkoobBBBBBBk',
376    '.kkkkkobBBBBBBBk',
377    'kYYYYkoobBBBBBBk',
378    '.kkkkkbBBBBBBBk.',
379    '...koobBkkkBook.',
380    '...kkkkk..kkkk..',
381  ],
382  '巨': [
383    '....k.k....k....',
384    '...kikik..kik.k.',
385    '..kicicikkicikik',
386    '..kggggggkGcGGck',
387    '.kggggggggGGGGGk',
388    '.kyygyyggGGGGGGk',
389    '.kggggggGgGGGGGk',
390    '..kgkkkgGGGGGGGk',
391    '.kkggggGGGgGGGGk',
392    'kggkgggGGGGgkGGk',
393    'kgggkggGkGGkgGGk',
394    'kggggkgGGGGkgGGk',
395    'kggggkgGGkGGkGGk',
396    'kggggkgGGkGGkGGk',
397    '.kGGkkGGGkGGGkk.',
398    '..kk..kkk.kkk...',
399  ],
400  '茸': [
401    '................',
402    '.....kkkkkk.....',
403    '...kkPPllPPkk...',
404    '..kPPllPPPPPPk..',
405    '.kPPPlPPPPllPPk.',
406    'kPPPPPPPPPllPPPk',
407    'kPllPPPPPPPPPPPk',
408    'kSSSSSSSSSSSSSSk',
409    '.kkkYYYYYYYSkkk.',
410    '...kYkYkYYYSk...',
411    '..kYYkYkYYYSYk..',
412    '...kYYYYYYYSk...',
413    '...kYkkYYYSSk...',
414    '...kYSkkkkYSk...',
415    '..kYYSk.kYYSk...',
416    '...kkk...kkk....',
417  ],
418  '蛾': [
419    '................',
420    '...k........k...',
421    '.kkkk......kkkk.',
422    'kPPPkk.kk.kkPPPk',
423    'klllPPkvvkPPlllk',
424    'kllllPPvvPPllllk',
425    'klPlllEvvElllPlk',
426    '.klllEEvvEElllk.',
427    '..kklEEvvEElkk..',
428    '..klllEvvElllk..',
429    '.klPllEvvEllPlk.',
430    '..klllkvvklllk..',
431    '...kllkkkkllk...',
432    '...klk....klk...',
433    '....k......k....',
434    '................',
435  ],
436  '胞': [
437    '...l...l....l...',
438    '......kkkkl.....',
439    '.....kpllpk.....',
440    '.....kkppkk.....',
441    '...kkppppppkk...',
442    '..kpppvppppppk..',
443    '.kpwwpppppppvpk.',
444    'kpwwppppplppppvk',
445    'kpppppvppppppvvk',
446    'kppkpkppppvpppvk',
447    'kppkpkpppppplvvk',
448    'kppppppvppppppvk',
449    'kpplkkpppppvppvk',
450    '.kppppppppppvvk.',
451    '.kvvvvvvvvvvvvk.',
452    '..kkkkkkkkkkkk..',
453  ],
454  '蔦': [
455    '.....k.kk.k.....',
456    '....kPkPPkPk....',
457    '...kPPPPPPPPk...',
458    '..kPPPlllPPPPk..',
459    '...kPPkllPPPk...',
460    '..kPPPPPPPPPPk..',
461    '...kkPkeEkPkk...',
462    '..kkkkkeEkk.....',
463    '.keeek.kEek.kk..',
464    'kekkkekkeEkkeek.',
465    'kekeek.kEeeeEEek',
466    'keekk.keEkkkeek.',
467    '.keekkeEk...kk..',
468    '..keeeEkkkkk....',
469    '...keEeEEeeEk...',
470    '....kkkkkkkk....',
471  ],
472  '母': [
473    '.....k.kk.k.....',
474    '....kYkYYkYk....',
475    '..kkPYYYYYYPkk..',
476    '.kPPPPPPPPPPPPk.',
477    'kPPlPPPPPPPlPPPk',
478    'kPllPPPpPPPPPPPk',
479    'kPPPPPPPPPPllPPk',
480    'kvvvvvvvvvvvvvvk',
481    '.kkkvvvvvvvvkkk.',
482    '.l.kllvllvvvk.l.',
483    '...kvvvvvvvvk...',
484    'l.kYYYYYYYYYYk..',
485    '.kYvYYvYYvYYvYkl',
486    'kYYvYYvYYvYYvYYk',
487    'kYYkYYkYYkYYkYYk',
488    '.kk.kk.kk.kk.kk.',
489  ],
490  '蜥': [
491    '................',
492    '............k...',
493    '...........kyk..',
494    '..........kyyk..',
495    '..........kyyyk.',
496    '.........kyyoyk.',
497    '.........kyooyk.',
498    '..kkk.....kyoyk.',
499    '.krrrk.k.k.krrk.',
500    'krryrrkokokkrRk.',
501    'krrrrrrrrrrkrRk.',
502    '.kkkrrrrrrrrrRk.',
503    'krrrRyyyyyyRRk..',
504    '.kkrRkkkkkrRk...',
505    '.krRRk..krRRk...',
506    '..kkk....kkk....',
507  ],
508  '炎': [
509    '..........k.....',
510    '......k..krk..k.',
511    '.....krkkrrk.krk',
512    '....krrkrrokkrrk',
513    '...krrorkroorrk.',
514    '..krroorroooork.',
515    '.krrooyyooyoork.',
516    '.krooyyyyyyyork.',
517    'krroykyykyyyork.',
518    'krroykyykyyoork.',
519    'krroyyyyyyyork..',
520    '.krooykkyyoork..',
521    '..kroooyooorrk..',
522    '...krroooorkk...',
523    '....kkkkkkk.....',
524    '................',
525  ],
526  '亀': [
527    '........k.......',
528    '.......kyk......',
529    '.......kyyk.....',
530    '......kyoykk....',
531    '.....kkGooGGkk..',
532    '....kGGGGoGGGGk.',
533    '...kGGGGGoGGGGGk',
534    '...kGGoGGoGGoGGk',
535    '..kkGGGooGooGGGk',
536    '.krrGGGGGoGGGGGk',
537    'kryrGoGGGoGGGoGk',
538    'krrrGGooGGGooGGk',
539    '.krrooooooooook.',
540    '..krrkkkkkkrrk..',
541    '.krrrk...krrrk..',
542    '..kkk.....kkk...',
543  ],
544  '灰': [
545    '................',
546    '................',
547    '....k.k......k..',
548    '...kGkGkk.k.kyk.',
549    '...kGgGkokokoGk.',
550    '..kGgggGGoGoGGk.',
551    '.kggyggGGGGGGGk.',
552    'kgggggGgggogGGk.',
553    '.kkgGGgggggggGk.',
554    '..kGGkgoggggGGk.',
555    '...kkkGGGGGGGGk.',
556    '....kGgGkkkGgGk.',
557    '....kGgGk.kGgGk.',
558    '....kGgGk.kGgGk.',
559    '...kGGgGkkGGgGk.',
560    '....kkkk..kkkk..',
561  ],
562  '竜': [
563    '....k..k........',
564    '...kYkkYk...k...',
565    '...kYooYk..kRkk.',
566    '..krrrrrrkkRRkRk',
567    '.krrrrkyrkRoRRRk',
568    'krrrrrrrrkRooRRk',
569    'krkrrrrrrRoooRRk',
570    'krrrrrrrkRooRoRk',
571    '.kYkYkrrrRoRooRk',
572    '..krrrrrrrRRRRk.',
573    '...krYYYrrrrrk..',
574    '..krrYYYYrrrrk..',
575    '.krrkYYYYrrrrRk.',
576    '..kkkYYYYrrrrrRk',
577    '...krrrkrrrkkrRk',
578    '....kkk.kkk..kk.',
579  ],
580  '書': [
581    '................',
582    '.kk..........kk.',
583    'kvvkk......kkvvk',
584    'kvpwwkk..kkwwpvk',
585    'kvpwwwwkkwwwwpvk',
586    'kvpyywwwkwyywpvk',
587    'kvpkywwwkwkywpvk',
588    'kvpwwwwwkwwwwpvk',
589    'kvpkkkkkkkkkkpvk',
590    'kvpwkwkwkwkwkpvk',
591    'kvvwwwwwkwwwwvvk',
592    '.kvvvvwwkwwvvvk.',
593    '..kkkkvvvvvkkk..',
594    '......kkkkk.....',
595    '................',
596    '................',
597  ],
598  '氷': [
599    '........k.......',
600    '..k....kwk......',
601    '.kik..kwick.....',
602    '.kck.kwiicck....',
603    '..k.kwiiiccck...',
604    '...kwiiiicccCk..',
605    '..kwikiikccccCk.',
606    '.kwiikiikcccCCCk',
607    '..kiiiiiicccCCk.',
608    '...kciikkcccCk..',
609    '....kccccccCkk..',
610    '.....kcccCCkkik.',
611    '......kcCCk.kck.',
612    '.......kCk...k..',
613    '........k.......',
614    '................',
615  ],
616  '司': [
617    '.......kkk......',
618    '......knnnk.....',
619    '.....knCCnnk....',
620    '....knCnnnnnk...',
621    '.....kkkknnnnk..',
622    '.....kykyknnnk..',
623    '.....kkkknnnnk..',
624    '..kkkknnnnnnnnk.',
625    '.kyyyknCnnnnnnk.',
626    '.kykyCCCnnnnnnk.',
627    '.kyyyknCnnnnnnk.',
628    '..kykknCnnnnnnnk',
629    '..kyyknCnnnnnnnk',
630    '..kykkCCnnnnnnnk',
631    '..kyykiiiiiiiiik',
632    '...kk.kkkkkkkkk.',
633  ],
634  '狼': [
635    '.....k.k........',
636    '....kwkwk.......',
637    '....kwiwik......',
638    '...kwwwwwik.....',
639    '.kkwwCwwwikk....',
640    'kCwwwwwwwiick...',
641    '.kkkkwwwwiiikk..',
642    '.kwwwiwwwwiiick.',
643    '..kkiiwwwwwiiik.',
644    '...kiiwwwwwwwiik',
645    '....kwwiiwwwwwik',
646    '....kwikiwwwwwik',
647    '....kwikiwwwwick',
648    '....kwikiiiiicck',
649    '...kwwikwwwcccck',
650    '....kkk.kkkkkkk.',
651  ],
652  '賢': [
653    '..k.......kk....',
654    '.kik.....kvvk...',
655    'kiwik...kvpvvk..',
656    'kiccik.kvppvvk..',
657    '.kiik.kvppvvvvk.',
658    '.kikkkvppvvvvvvk',
659    '.kikiiiiiiiiiiik',
660    '.kikikcckcckvvik',
661    '.kikkwwwwwwwvvk.',
662    'kpvvvwwwwwwvvpvk',
663    '.kikvwwwwwwvvpvk',
664    '.kikvvwwwwvvvpvk',
665    '.kikvvvwwvvvvpvk',
666    '.kikvvvvwvvvvpvk',
667    '.kivvvvvvvvvvvvk',
668    '..kkkkkkkkkkkkk.',
669  ],
670  '鏡': [
671    '..........k.....',
672    '.........kwk....',
673    '..k.....kwickk..',
674    '.kik...kwiickck.',
675    'kiwk..kwiiickcck',
676    '.kik.kwiwiickkk.',
677    '..k.kwiwiiicck..',
678    '...kikPkwiicck..',
679    '..kiikPkiiicck..',
680    '...kiiiiwiicck..',
681    '....kiiwiiicck..',
682    '.....kiwiicckck.',
683    '......kicckkcck.',
684    '.......kck..kk..',
685    '........k.......',
686    '................',
687  ],
688  '影': [
689    '.......k..k.....',
690    '......kvkkvk....',
691    '.....kvvvvvvk...',
692    '....kvvvvvvnk...',
693    '....kPvPvvnnk...',
694    '....kvwvwvnnk...',
695    '....kkvvvnnk....',
696    '.kkkvvvvvnnnk...',
697    'kvvvvvvvvnnnnk..',
698    'kvkvkvvvvnnnnk..',
699    '.k.kkvvvvvnnnk..',
700    '.....kvvvvnnnk..',
701    '.....kvvvnnnnk..',
702    '..kkkkvvnnnnnnk.',
703    '.knnnnnvnnnnnnnk',
704    '..kkkkkkkkkkkkk.',
705  ],
706  '面': [
707    '...........k....',
708    '......kkk.kwk...',
709    '.....kvvvkwcwk..',
710    '....kvvvvvkck...',
711    '...kwwwwvvkpk...',
712    '....kwkwvvkpk...',
713    '...kwwwwvvkpk...',
714    '....kwwvvkkpk...',
715    '..kkpvvvvvvpk...',
716    '.kccckpvvvvpk...',
717    'kcwccckvvvvpk...',
718    'kccwcckvvvvpk...',
719    'kcccwckvvvvpk...',
720    '.kccckvvkvvpk...',
721    '..kkkvvvkvvpk...',
722    '.....kkk.kkk....',
723  ],
724  '幻': [
725    '..........kkk...',
726    '.........kvvvk..',
727    '..kk....kvPPvkk.',
728    '.kppk..kvPPPvkwk',
729    'kpwwpk.kvPPPvkk.',
730    'kpwwpkkvPPPPPvk.',
731    '.kppkppppppppppk',
732    '..kpkkkwkwkkkkk.',
733    '..kpkPPkkkPPvk..',
734    '..kppPPPPPPPvk..',
735    '..kpkpPPPPPPvk..',
736    '..kpkPpPPPPPvvk.',
737    '..kpkPPpPPPPPvk.',
738    '..kpkPPPpPPPPvvk',
739    '..kpkppppppppvvk',
740    '...k.kkkkkkkkkk.',
741  ],
742  '双': [
743    '..k.k.k..k.k.k..',
744    '.kykykykkykykyk.',
745    '.kyyyyykkyyyyyk.',
746    '.kiiiiikknnnnnk.',
747    '.kwwiiikknnnvvk.',
748    '.kwkwiikknnvyvk.',
749    '.kwwwiikknnvvvk.',
750    '.kkwwikkkknvvkk.',
751    'kykiiiiinnnnnkyk',
752    'kywkiiiinnnnkvyk',
753    'kwkwiiiinnnnvkvk',
754    'kwkiiiiinnnnnkvk',
755    'kwkwiiiinnnnvkvk',
756    'kwkiiiiinnnnnkvk',
757    'kwkwwwiinnvvvkvk',
758    '.k.kkkkkkkkkk.k.',
759  ],
760  '鎖': [
761    '................',
762    '......kkkk......',
763    '.....kBBBBk.....',
764    '....kBBBBBBk....',
765    '...kBgggBBBBk...',
766    '...kgrgrgBBBk...',
767    '...kggkggBBBBk..',
768    '...kBgggBBBBBBk.',
769    '..kBkggggggBBk..',
770    '.kGGgggkgkggk...',
771    '.kGGkkggkgkgk...',
772    '..kgk.kBBBBBk...',
773    '.kGGk.kBBBBBk...',
774    'kGgGGkkggkggk...',
775    'kGGGGkkggkggk...',
776    '.kkkk..kk.kk....',
777  ],
778  '獄': [
779    '......kkkk......',
780    '.....kGGGGk.....',
781    '....kGggggGk....',
782    '...kGGGGGGGGk...',
783    '...kSkSSkSSGk...',
784    '..kkSSSSSSSbk...',
785    '.kgkSgkkgSSbSk..',
786    'kgggkSSSSSSbbbk.',
787    'kgGgkSSSSSSSSbbk',
788    '.kGkSSSSSSSSSbbk',
789    '.kGSSSSbSSSSSbbk',
790    '.kGkkbbbbbbbbbk.',
791    '.kGkkSSSSSSSSbk.',
792    '.kGkkSSSkkkSSbk.',
793    '.kGkSSSSkkSSbbk.',
794    '..k.kkkk..kkkk..',
795  ],
796  '牢': [
797    '....k.....k.....',
798    '...kYk...kYk....',
799    '...kYYkkkYYk....',
800    '....krrrrrBBk...',
801    '...krYrrYrBBBk..',
802    '...krrrrrrrBBk..',
803    '...krkYkYkrBBk..',
804    '....krrrrrBBk...',
805    '..kkrrrrrrrrk...',
806    '.kYrrkrrrrrrrk..',
807    '..kYkkrrRRrrrk..',
808    '...k.kYBYBYrk...',
809    '.....kYBYBYRk...',
810    '.....krrkkrRk...',
811    '....krrrkkrRRk..',
812    '.....kkk..kkk...',
813  ],
814  '刑': [
815    '........k.......',
816    '..kk...kRk......',
817    '.kggk.kRRRk.....',
818    'kgggkkRRRRRk....',
819    'kgggbkRgRgRk....',
820    'kRggbkRRRRRRk...',
821    '.kggbRRRRRRRRk..',
822    '..kkbSSSSSSSSk..',
823    '..kSbSSSSSSSSSk.',
824    '...kbSSSSSSSSSk.',
825    '...kbkSSSSSSSk..',
826    '...kbkbbbbbbbk..',
827    '...kbkRRRRRRRk..',
828    '...kbkRRkkRRRk..',
829    '...kbkRRkkRRRk..',
830    '....k.kk..kkk...',
831  ],
832  '典': [
833    '.....kkkkkk.....',
834    '....kRRRRRRk....',
835    '...kRRRyyRRRk...',
836    '.kkGGGGGGGGGGk..',
837    'kyyykgggggggk...',
838    'kykykrgrggggk...',
839    'kyyykgggggggk...',
840    '.kykkkgGGGgkkk..',
841    '.kykyRRRgRRRyyk.',
842    '.kygggRRRRRRRyyk',
843    '.kykRRRRyRRRRRRk',
844    '.kykRGRGRGRGRRRk',
845    'kyykRRRRyRRRRRRk',
846    '.kykRRRRRRRRRRRk',
847    'kyykRRRRyRRRRRRk',
848    '.kk.kkkkkkkkkkk.',
849  ],
850  '虚': [
851    '................',
852    '......kkkk......',
853    '.....kppppk.....',
854    '....kpvvvvpk....',
855    '...kpvkkkkvvk...',
856    '...kvkklkklvk...',
857    '...kvkkkkkkvk...',
858    '..kpvvkkkkvvvk..',
859    '.kpvvvvvvvvvvvk.',
860    'kpvvvvvkkkkvvvk.',
861    'kpkpkvvk..kvnvk.',
862    '.k.kkvvk..kvnvvk',
863    '...kvvvkkkkvnvvk',
864    '..kvnvvvvvvvnvnk',
865    '..kvkvnkvnkvnkvk',
866    '...k.kk.kk.kk.k.',
867  ],
868  '眼': [
869    '................',
870    '......kkkk......',
871    '....kkwwwwkk....',
872    '...kwwwwwwwwk...',
873    '..kwwwwwwwrwpk..',
874    '.kwPPPwwwwrwwpk.',
875    '.kPPkPPwwwwrwpk.',
876    '.kPkwkPwwrwwwpk.',
877    '.kPPkPPwwwrwwpk.',
878    '.kwPPPwwwwwwppk.',
879    '..kwwwwwwwwppk..',
880    '...kpwwwwpppk...',
881    '....kkrrprkk....',
882    '......krkkrk....',
883    '.......k..k.....',
884    '................',
885  ],
886  '触': [
887    '................',
888    '...kkkk.........',
889    '..kPPPPk...kkk..',
890    '.kPvvvPPk.kPPPk.',
891    '.kPpkkvPPkPvvPPk',
892    '.kPPk.kPPkPpkvPk',
893    '..kpk.kPPkPPkPPk',
894    '...k..kvPPkkkPPk',
895    '..kk..kpPPkkPPk.',
896    '.kPPk.kvPPkPPvk.',
897    'kPvPPkkpPPPPvk..',
898    'kPpkPPkvPPPvvk..',
899    '.kpkkPPpPPvvkk..',
900    '.knnnnnnnnnnnnk.',
901    'knnnnnnnnnnnnnnk',
902    '.kkkkkkkkkkkkkk.',
903  ],
904  '翼': [
905    '....kkk.kk......',
906    '...kyyykyyk.....',
907    '....kkkkkk..kk..',
908    '...kyyyyk..knnk.',
909    '..kyywwyykknnvnk',
910    '..kykwwyyknnvnnk',
911    '...kwwwyknnvnvnk',
912    '...kwvvvknvnvnk.',
913    '..kwwvvvnnvnnk..',
914    '.kwkkvvvvnnnnk..',
915    '..k.kvvvvvnnknk.',
916    '....kvvvvvnknk..',
917    '.....kvvvkk.knk.',
918    '......kvk....k..',
919    '.......k........',
920    '................',
921  ],
922  '淵': [
923    '..k...k..k...k..',
924    '.kPk.kPkkPk.kPk.',
925    '..kPkPvvvvPkPk..',
926    '.k.kvvvvvvvvvk..',
927    'kPkvvnnnnnnnvvk.',
928    '.kvvnwwwwwwnnvk.',
929    '.kvnwyyywwwwnvPk',
930    'kvvwyykyywwwnvvk',
931    'kvvwyykyywwwnvvk',
932    '.kvnwyyywwwwnvk.',
933    '.kvvnwwwwwwnnvPk',
934    'kPkvvnnnnnnnvvk.',
935    '.kkvvvvvvvvvvk..',
936    '.kvnvkvnvkvnvk..',
937    'kvnvkkvnkkvnnvk.',
938    '.kkk..kk..kkkk..',
939  ],
940}
941
types/index.d.ts 97 lines
1/** レベルアップ時のステータスポイントの振り先 */
2export type Focus = 'atk' | 'def' | 'hp' | 'even'
3
4/** walk:通路を進む、fight:交戦中、rest:敗走後の休息 */
5export type Phase = 'walk' | 'fight' | 'rest'
6
7export type Foe = {
8  name: string
9  /** 通路に描く1文字(漢字) */
10  glyph: string
11  level: number
12  hp: number
13  maxHp: number
14  atk: number
15  def: number
16  exp: number
17  gold: number
18  isBoss: boolean
19}
20
21export type LogKind = 'gain' | 'level' | 'loss' | 'boss' | 'info'
22
23export type LogLine = { text: string; kind: LogKind }
24
25/** 直近の待ち時間(1ターン)で得たもの */
26export type RunSummary = {
27  ticks: number
28  kills: number
29  exp: number
30  gold: number
31  levels: number
32  floorsDown: number
33}
34
35export type Game = {
36  v: 1
37  /** 保存のたびに増える。別セッションの新しいセーブを見分けるのに使う */
38  rev: number
39  seed: number
40  level: number
41  /** 現在レベル内で貯まった経験値 */
42  exp: number
43  /** 累計経験値 */
44  totalExp: number
45  gold: number
46  hp: number
47  points: { atk: number; def: number; hp: number }
48  focus: Focus
49  weapon: number
50  armor: number
51  floor: number
52  deepest: number
53  /** 階内で何体目の敵まで進んだか */
54  step: number
55  phase: Phase
56  foe: Foe | null
57  /** true:階段を見つけたら次の階へ、false:同じ階で稼ぐ */
58  isAuto: boolean
59  kills: number
60  defeats: number
61  /** 探索した総tick数(遊んだ時間) */
62  ticks: number
63  run: RunSummary
64  log: LogLine[]
65}
66
67/** ランキングの並べ方。level:レベル、deepest:最深階、exp:総経験値 */
68export type RankBy = 'level' | 'deepest' | 'exp'
69
70export type RankEntry = {
71  rank: number
72  name: string
73  level: number
74  deepest: number
75  totalExp: number
76  isYou: boolean
77}
78
79export type Board = { by: RankBy; entries: RankEntry[]; me: RankEntry | null; players: number }
80
81/** ペインに描くランキングの状態。秘密のtokenは$.storeにだけ置き、ここには入れない */
82export type Ranking = {
83  isJoined: boolean
84  name: string
85  by: RankBy
86  board: Board | null
87  /** 直近の失敗。成功すると消える */
88  error: string | null
89  isBusy: boolean
90}
91
92declare module 'claude-code' {
93  interface PluginState {
94    tomoshibi: { game: Game; isBandHidden: boolean; isLit: boolean; ranking: Ranking }
95  }
96}
97