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

Claudeが応答を作っている間だけ灯がともり、勇者が地下へ潜っていく放置型ダンジョンRPGです。Claude Codeのmod(function hooksのプラグイン)として動きます。
![]()
![]()
勇者と焚き火、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.ts | 16x16のドット絵データ |
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にあります。
hooks/register.tsx 763 lines1import { 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}
763hooks/game.ts 495 lines1// 灯火迷宮のゲームロジック。$に触れない純粋関数だけを置き、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}
495hooks/ranking.ts 134 lines1// ランキングサーバーとのやり取りに使う純粋関数。通信そのものは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}
134hooks/sprites.ts 157 lines1// 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}
157hooks/art.ts 941 lines1// 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}
941types/index.d.ts 97 lines1/** レベルアップ時のステータスポイントの振り先 */
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