SLOPSHOPPER

session-radar

在側邊面板即時列出本機所有 Claude Code session 的狀態

newpanecommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-radar
│ ┃ Claude Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ 0 個 session · 0 個工作中 · 藍色為本視窗 │ ┃ 找不到任何 session。 ⏺ 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. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /sessions │ ┃ ⎿ session-radar: Claude Sessions 面板已開啟。 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ʚɞ … ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claude Sessions
0 個 session · 0 個工作中 · 藍色為本視窗 找不到任何 session。 ʚɞ ▄▀▀▀▄ ▄▄ ▄ ▄▀▄ ▄▀▀▀▄ ▄▀▄ ▄▀▀▀▄ ▀▀▀▀▀▀▀ ▀▀▀▀▄▀▀▀▄ ▀▀▀▀▀▀▀ ▄▀▀▀▄ ▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▄▀▀▀▄ ▀▀▀▀▀ ▄▀▀▀▄ ▀▀▀▀▀ ▄▀▀ ▀▀ ▀ ▄▀▄ ▀ ▄▀▄ ▄▀▄▄▄▀▄▄▀▄▄▀▄▀▄▄▄▀▄▄▀▄▄▀▄▀▄▄▄▀▄▄▀▄▄▀▄▀▄▄▄▀▄▄▀▄▄▀▄▀▄▄▄▀▄▄
README

likephp-mods

兩個 Claude Code mods(以 function hooks 寫成的 plugin)。

這是個人作品,與 Anthropic 無關。Mods 是 Claude Code 的 early access 功能,需要 Claude Code 2.1.287 以上版本;API 可能隨版本變動。

安裝

在 Claude Code 裡輸入:

/plugin marketplace add likephp-github/likephp-mods
/plugin install token-weather@likephp-mods
/plugin install session-radar@likephp-mods

兩個 mod 互相獨立,可以只裝其中一個。

token-weather

在輸入框上方顯示兩行:

☂ 陣雨 45%  90k/200k  ▂▃▄▅  上一輪 +8.3k
[Opus 5.5] │ my-project (main) │ 5h ██▌░░░░░ 32% (3h 41m後重置) │ 7d 18% │ ⏱ 18m │ $1.23
  • 第一行:context 用量的天氣(☀ 晴 <25%、☁ 多雲、☂ 陣雨、☇ 暴風雨、↯ 即將壓縮 ≥90%)、已用/總 token、最近 12 次的走勢、上一輪增量
  • 第二行:模型、專案與 git 分支、5 小時/7 天用量限制與重置時間、session 時長、費用
  • /token-weather:切換顯示;也可 /token-weather on|off|status

session-radar

側邊面板列出本機所有正在執行的 Claude Code session(讀取 ~/.claude/sessions/*.json,每 3 秒更新):

7 個 session · 1 個工作中 · 藍色為本視窗
● my-project-f8 工作中 3秒
○ 2: api-server-d8 閒置 12分
◆ 1: web-fb shell 2時

跳到其他 session:輸入 /sessions 2 就把面板上編號 2 的 session 所在的終端機分頁帶到最前面;也可以用滑鼠點面板上的 session 名稱(全螢幕版面)。

  • 數字依 session 啟動先後編號(最多 9 個),清單重新排序時不會變;本視窗那一行沒有編號
  • 支援 tmux、iTerm2、Terminal.app。session 在 tmux 裡時會先切到它的 pane;那個 tmux session 沒有連線中的 client 時,本視窗在 tmux 裡就直接切過去,否則提示 tmux attach -t <名稱>
  • 第一次使用時 macOS 會詢問是否允許控制 iTerm2/Terminal.app;拒絕後可到「系統設定 › 隱私權與安全性 › 自動化」重新允許

面板底部有一組像素角色演出本 session 的狀態,角色與背景由主題決定。狀態只有三種,與主題無關:

  • working:執行中
  • resting:回覆結束後 30 秒內
  • idle:閒置超過 30 秒

每個主題在三個狀態各有自己的動作。角色體型隨本 session 的 context 用量變大變小(100% 時為面板容得下最大尺寸的一半,最多 1.5 倍;最小 0.5 倍);面板比角色窄時不畫角色。

主題

/sessions theme 依序切換到下一個主題,/sessions theme tiger 或 /sessions theme puma 直接指定。選擇會寫進 ~/.claude/session-radar.json(例如 { "theme": "puma" },保留檔案裡的其他欄位),之後每個 session 都沿用;沒有這個檔案時用小老虎。

小老虎(tiger,預設)

老虎住在綠色的樹林裡:身後是一排樹,腳下是一行草地。樹固定高度、不隨老虎縮放,可以當比例尺看出老虎變胖了多少。老虎走動時背景以視差方式往反方向捲動(樹走得慢、草跟老虎同速),老虎停下來抓蝴蝶或睡覺時背景也停住。面板太矮放不下樹時只畫草地。

狀態動作
working<br>執行中小老虎 working:巡邏<br>巡邏:左右來回走
resting<br>回覆結束後 30 秒內小老虎 resting:抓蝴蝶<br>抓蝴蝶:甩尾、壓低身體、跳起來拍掌邊的蝴蝶
idle<br>閒置 30 秒後小老虎 idle:睡覺<br>睡覺:呼吸起伏、抖尾巴、抖耳朵、打呼
綠雲與愛心(puma)

綠雲帶著愛心住在閃爍的星空裡:沒有地面,角色腳底貼著面板底部;星星(·、✦、✧)位置固定,約一半會閃爍,不會蓋在角色或對話框上。最小倍數同樣從 0.5 倍起算,縮得太小看不到綠雲的黑眼珠時會自動稍微放大。

狀態動作
working<br>執行中綠雲與愛心 working:走路<br>走路:12 格走路圖循環,左右來回走,愛心跟在後面
resting<br>回覆結束後 30 秒內綠雲與愛心 resting:♥ 與名言<br>♥+名言:停在原地,頭頂冒出 ♥,上面隨機出現一句名言
idle<br>閒置 30 秒後綠雲與愛心 idle:吃丸子<br>吃丸子:一顆顆吃掉手上的三色丸子(吃到剩 2 顆時頭頂冒出 nom),吃完換新的一串

resting 的名言每進入一次 resting 隨機挑一句,這段期間不換;前後加「」,對話框自動換行,最多 3 行,面板太矮時縮成 1 行或只留 ♥:

  1. 我們不要灰心,我們也不應該喪志,為什麼?因為我來了。
  2. 人類的讚歌就是勇氣的讚歌,我們會把勇氣繼續傳承下去。
  3. 如果有人說懷抱希望是一種錯誤,那麼我會每一次都反駁他。無論幾次,我都會堅定地說。
  4. 未來的事情無人知,但就是因為這樣,可能性才會如此強大。
  5. 我本來就不是以外表取勝。
  6. 洋流是溫暖的,可以帶來漁獲,不會燙傷人。

動作圖由 scripts/gen-gifs.ts 以 mod 內實際的像素圖與動畫邏輯產生;終端機裡以半格字元繪製,蝴蝶、打呼、♥、星星與 nom 為文字符號。小老虎的圖是 40 欄寬、1 倍;綠雲與愛心的圖用原圖大小(1 倍)以看清楚臉,面板放寬到 64 欄。GIF 不畫中文,名言的位置以 … 代替。

/sessions:開關面板(不會自動開啟);/sessions 1~/sessions 9:跳到該編號的 session;/sessions theme [tiger|puma]:切換主題。面板在全螢幕版面下會停靠在右側。

安全性

Mods 不在沙盒裡執行,安裝前請先閱讀原始碼。這兩個 mod 只讀取:

  • token-weather:本 session 的用量資訊、git 的 .git/HEAD
  • session-radar:~/.claude/sessions/*.json(不讀取同目錄的 .key 檔)、~/.claude/session-radar.json(選的主題)

都不會連網。唯一會寫入的檔案是 session-radar 在你執行 /sessions theme 時寫的 ~/.claude/session-radar.json。

session-radar 只有在你按下某個 session 時,才會執行 ps(查它的 tty)、tmux(找 pane、切換)與 osascript(選取 iTerm2/Terminal.app 的分頁);不會啟動沒在執行的終端機 app。

開發

claude plugin validate ./token-weather
claude plugin test ./token-weather

session-radar 的動作圖(docs/images/<主題>-<狀態>.gif)由腳本產生,每一格都用面板同一套動畫邏輯算出;改了主題的角色、背景或動作後重新產生:

node scripts/gen-gifs.ts            # 重新產生每個主題 × 每個狀態的 GIF(需要 Node 24,不需安裝套件)
node scripts/gen-puma.ts            # 從原圖重新產生綠雲與愛心的走路圖與預覽圖(docs/images/puma-preview.png)
node --test 'scripts/*.test.ts'     # 腳本的測試

License

MIT

Source 15 files
hooks/register.tsx 257 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Peer, Tiger } from '../types'
5import { jumpTo, messageFor } from './jump'
6import type { Run } from './jump'
7import { ago, hotkeys, look, parsePeer, parseSessionsArgs, peerForKey, sortPeers } from './sessions'
8import { paneFrame } from './frame'
9import { factorFor, joinCells, stamp, step, toCells } from './sprite'
10import { DEFAULT_THEME, stateFor, themeFor } from './theme'
11import { chooseTheme, parseThemeSetting, SETTINGS_PATH, withThemeSetting } from './choice'
12
13const PANE = 'session-radar'
14const TITLE = 'Claude Sessions'
15const POLL_MS = 3000
16const FRAME_MS = 400
17
18const peers = atom({ plugin: 'session-radar', key: 'peers' } as const, [])
19const selfId = atom({ plugin: 'session-radar', key: 'selfId' } as const, '')
20const checkedAt = atom({ plugin: 'session-radar', key: 'checkedAt' } as const, 0)
21const tiger = atom({ plugin: 'session-radar', key: 'tiger' } as const, {
22  isWorking: false,
23  idleSince: 0,
24  frame: 0,
25  x: 0,
26  facing: 1,
27} as Tiger)
28const themeName = atom({ plugin: 'session-radar', key: 'theme' } as const, DEFAULT_THEME as string)
29
30/** 跳到 peer 所在的終端機;失敗時用 toast 告訴使用者下一步,細節寫到 debug log。 */
31/** 跳到 peer 所在的終端機;回傳給使用者看的提示(成功時 undefined),錯誤細節寫到 debug log。 */
32async function jump($: EngineInterface, peer: Peer, inTmux: boolean): Promise<string | undefined> {
33  const run: Run = argv => $.process.run(argv)
34  const outcome = await jumpTo(peer.pid, run, { inTmux })
35  if (outcome.kind === 'error') $.ui.log(`session-radar 切換到 ${peer.name} 失敗:${outcome.detail}`, { to: 'debug' })
36  return messageFor(outcome)
37}
38
39/** 按下面板上的名稱:失敗時用 toast 提示。 */
40async function jumpFromPane($: EngineInterface, peer: Peer, inTmux: boolean) {
41  const message = await jump($, peer, inTmux)
42  if (message !== undefined) $.ui.toast(message)
43}
44
45/** 設定檔的內容;不存在或讀不到時 undefined。 */
46const readSettings = ($: EngineInterface, home: string) => $.fs.read(`${home}/${SETTINGS_PATH}`).catch(() => undefined)
47
48/** /sessions theme:切換當前面板的主題並寫進設定檔,回傳給使用者看的一行。 */
49async function switchTheme($: EngineInterface, home: string, name: string | undefined): Promise<string> {
50  const choice = chooseTheme(await read($, themeName), name)
51  if (choice.kind === 'unknown') return `沒有「${choice.name}」這個主題;可用的主題:${choice.names.join('、')}。`
52
53  await update($, themeName, () => choice.name)
54  const path = `${home}/${SETTINGS_PATH}`
55  try {
56    await $.fs.write(path, withThemeSetting(await readSettings($, home), choice.name))
57  } catch (err) {
58    $.ui.log(`session-radar 寫入 ${path} 失敗:${String(err)}`, { to: 'debug' })
59    return `主題已切換為 ${choice.name}(設定檔寫入失敗,只在這個 session 有效)。`
60  }
61  return `主題已切換為 ${choice.name}。`
62}
63
64async function scan($: EngineInterface, home: string) {
65  const dir = `${home}/.claude/sessions`
66  const entries = await $.fs.list(dir).catch(() => [])
67  const found: Peer[] = []
68
69  for (const entry of entries) {
70    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
71    const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => '')
72    const peer = parsePeer(text)
73    if (peer !== undefined) found.push(peer)
74  }
75
76  const now = await $.clock.now()
77  await update($, peers, () => sortPeers(found))
78  await update($, checkedAt, () => now)
79}
80
81/** 動畫的一格:工作中往前走一步,其他狀態只推進動作的節奏。面板沒開就不動。 */
82async function tick($: EngineInterface, room: number, stride: number) {
83  const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
84  if (!isOpen) return
85
86  await update($, tiger, t => {
87    const walked = t.isWorking ? step({ x: t.x, facing: t.facing }, room, stride) : t
88    return { ...t, x: walked.x, facing: walked.facing, frame: t.frame + 1 }
89  })
90}
91
92export const register: Register = on => {
93  let home = ''
94  let inTmux = false
95  let room = 0
96  let stride = 1
97
98  on('session.start', async ($, e, next) => {
99    home = (await $.env.get('HOME')) ?? ''
100    inTmux = ((await $.env.get('TMUX')) ?? '') !== ''
101    const id = await $.session.id()
102    const saved = parseThemeSetting(await readSettings($, home)) ?? DEFAULT_THEME
103    await update($, themeName, () => saved)
104    await update($, selfId, () => id)
105    await $.command.register({
106      name: 'sessions',
107      description: '開關側邊面板:本機所有 Claude session 的狀態;/sessions 1~9 跳到該編號的 session;/sessions theme [名稱] 切換主題',
108    })
109    await scan($, home)
110    $.clock.every(POLL_MS, () => scan($, home))
111    const { context } = await $.session.usage()
112    await update($, tiger, t => ({ ...t, isWorking: false, percent: context.percent }))
113    $.clock.every(FRAME_MS, () => tick($, room, stride))
114    return next(e)
115  })
116
117  on('prompt.submit', async ($, e, next) => {
118    await update($, tiger, t => ({ ...t, isWorking: true }))
119
120    return next(e)
121  })
122
123  on('turn.complete', async ($, e, next) => {
124    if (e.agentId === undefined) {
125      const t = await $.clock.now()
126      // 每次進入 resting 抽一次亂數,主題用它挑這段期間不變的內容(puma 的名言)
127      await update($, tiger, cat => ({ ...cat, isWorking: false, idleSince: t, seed: Math.random() }))
128    }
129
130    return next(e)
131  })
132
133  on('session.measure', async ($, e, next) => {
134    const { percent } = e.context
135    if (percent !== undefined) {
136      await update($, tiger, t => ({ ...t, percent }))
137    }
138
139    return next(e)
140  })
141
142  on('command.run', { command: 'sessions' }, async ($, e) => {
143    const cmd = parseSessionsArgs(e.args)
144
145    if (cmd.kind === 'usage') {
146      return { text: '用法:/sessions 開關面板;/sessions 1~9 跳到面板上該編號的 session;/sessions theme 輪流切換主題,/sessions theme <名稱> 指定主題。' }
147    }
148
149    if (cmd.kind === 'theme') return { text: await switchTheme($, home, cmd.name) }
150
151    if (cmd.kind === 'jump') {
152      await scan($, home)
153      const peer = peerForKey(await read($, peers), await read($, selfId), cmd.key)
154      if (peer === undefined) return { text: `沒有編號 ${cmd.key} 的 session。` }
155      return { text: (await jump($, peer, inTmux)) ?? `已切換到 ${peer.name}。` }
156    }
157
158    const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
159
160    if (isOpen) {
161      await $.ui.close({ id: PANE })
162
163      return { text: 'Claude Sessions 面板已關閉。' }
164    }
165
166    await scan($, home)
167    await $.ui.open({ id: PANE, title: TITLE })
168
169    return { text: 'Claude Sessions 面板已開啟。' }
170  })
171
172  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173    const { Box, Button, Text } = $.ui.resolve(e)
174    const list = await read($, peers)
175    const me = await read($, selfId)
176    const now = await read($, checkedAt)
177    const busy = list.filter(p => p.status === 'busy').length
178    const keys = hotkeys(list, me)
179    const cat = await read($, tiger)
180    const theme = themeFor(await read($, themeName))
181    const columns = e.props.bodyColumns
182    const factor = factorFor(cat.percent, columns, theme)
183    const state = stateFor(cat.isWorking, cat.idleSince, await $.clock.now())
184    const listRows = 1 + Math.max(1, list.length)
185    const frame = paneFrame({
186      theme,
187      state,
188      x: cat.x,
189      facing: cat.facing,
190      frame: cat.frame,
191      factor,
192      columns,
193      rows: e.props.scroll.bodyRows - listRows,
194      seed: cat.seed,
195    })
196    room = frame.room
197    stride = frame.stride
198    const cells = frame.marks.reduce(
199      (c, m) => stamp(c, frame.sprite, m.line, m.col, m.text, m.color, theme.palette),
200      toCells(frame.sprite, theme.palette),
201    )
202    const lines = joinCells(cells)
203    // 把場景推到面板最底:可見列數扣掉清單與場景佔的列數,剩下的補空行
204    const gap = Math.max(1, e.props.scroll.bodyRows - listRows - lines.length)
205
206    return (
207      <Box flexDirection="column">
208        <Text dimColor>
209          {list.length} 個 session · {busy} 個工作中 · 藍色為本視窗
210        </Text>
211        {list.length === 0 && <Text dimColor>找不到任何 session。</Text>}
212        {list.map(peer => {
213          const l = look(peer.status)
214          const isMe = peer.sessionId === me
215          const key = keys[peer.sessionId]
216          const detail = (
217            <Text dimColor wrap="truncate-end">
218              {' '}
219              {l.label} {ago(peer.since, now)}
220              {peer.kind === 'bg' ? ' 背景' : ''}
221            </Text>
222          )
223
224          // 本視窗那一行只是看的;其他 session 的名稱是按鈕,按了跳過去
225          return isMe ? (
226            <Text key={peer.sessionId} wrap="truncate-end">
227              <Text color={l.color}>{l.icon}</Text>{' '}
228              <Text bold color="blue">
229                {peer.name}
230              </Text>
231              {detail}
232            </Text>
233          ) : (
234            <Box key={peer.sessionId} flexDirection="row">
235              <Text color={l.color}>{l.icon} </Text>
236              <Button key={`jump-${peer.sessionId}`} plain hotkey={key} label={peer.name} onPress={() => jumpFromPane($, peer, inTmux)} />
237              {detail}
238            </Box>
239          )
240        })}
241        {Array.from({ length: gap }, (_, i) => (
242          <Text key={`gap${i}`}> </Text>
243        ))}
244        {lines.map((runs, row) => (
245          <Text key={`t${row}`} wrap="truncate-end">
246            {runs.map((r, i) => (
247              <Text key={`r${row}-${i}`} color={r.fg} backgroundColor={r.bg}>
248                {r.text}
249              </Text>
250            ))}
251          </Text>
252        ))}
253      </Box>
254    )
255  })
256}
257
hooks/jump.ts 153 lines
1/**
2 * 跳到某個 session 所在的終端機:依它的 tty 依序問 tmux、iTerm2、Terminal.app。
3 * 外部指令一律透過注入的 run 執行(argv,不經 shell),方便用假結果測試。
4 */
5
6export type RunResult = { exitCode: number; stdout: string; stderr: string }
7export type Run = (argv: readonly string[]) => Promise<RunResult>
8
9export type Outcome =
10  | { kind: 'done' }
11  | { kind: 'notFound' }
12  | { kind: 'attach'; session: string }
13  | { kind: 'denied'; app: string }
14  | { kind: 'error'; detail: string }
15
16/** 依序詢問的終端機 app(AppleScript 名稱)。 */
17const APPS = ['iTerm2', 'Terminal'] as const
18type App = (typeof APPS)[number]
19
20/** 在 app 裡找 tty 相符的分頁,選取後帶到最前面;找到回 "found"。tty 由 argv 傳入。 */
21const FOCUS: Record<App, string[]> = {
22  iTerm2: [
23    'on run argv',
24    'tell application "iTerm2"',
25    'repeat with w in windows',
26    'repeat with t in tabs of w',
27    'repeat with s in sessions of t',
28    'if tty of s is (item 1 of argv) then',
29    'select w',
30    'tell t to select',
31    'tell s to select',
32    'activate',
33    'return "found"',
34    'end if',
35    'end repeat',
36    'end repeat',
37    'end repeat',
38    'end tell',
39    'return ""',
40    'end run',
41  ],
42  Terminal: [
43    'on run argv',
44    'tell application "Terminal"',
45    'repeat with w in windows',
46    'repeat with t in tabs of w',
47    'if tty of t is (item 1 of argv) then',
48    'set selected of t to true',
49    'set index of w to 1',
50    'activate',
51    'return "found"',
52    'end if',
53    'end repeat',
54    'end repeat',
55    'end tell',
56    'return ""',
57    'end run',
58  ],
59}
60
61const script = (lines: readonly string[]): string[] => lines.flatMap(line => ['-e', line])
62
63/** macOS 拒絕 Apple events(使用者沒允許自動化)的錯誤碼。 */
64const NOT_AUTHORIZED = '-1743'
65
66/** ps 輸出的 tty(ttys001)轉成 /dev/ttys001;沒有終端機(??)回 undefined。 */
67export const ttyOf = (psOut: string): string | undefined => {
68  const name = psOut.trim()
69  if (name === '' || name.startsWith('?')) return undefined
70  return name.startsWith('/dev/') ? name : `/dev/${name}`
71}
72
73type Pane = { tty: string; session: string; window: string; pane: string }
74
75const PANE_FORMAT = '#{pane_tty} #{session_name} #{window_id} #{pane_id}'
76
77/** 解析 tmux list-panes -a 的輸出(欄位見 PANE_FORMAT)。session 名稱可能含空白。 */
78export const parsePanes = (out: string): Pane[] =>
79  out
80    .split('\n')
81    .map(line => line.trim().split(' '))
82    .filter(parts => parts.length >= 4)
83    .map(parts => ({
84      tty: parts[0] ?? '',
85      session: parts.slice(1, -2).join(' '),
86      window: parts.at(-2) ?? '',
87      pane: parts.at(-1) ?? '',
88    }))
89
90/** 依序問正在執行的終端機 app;沒找到回 undefined。 */
91const focusTty = async (run: Run, tty: string): Promise<Outcome | undefined> => {
92  for (const app of APPS) {
93    const running = await run(['osascript', '-e', `application "${app}" is running`])
94    if (running.stdout.trim() !== 'true') continue
95    const found = await run(['osascript', ...script(FOCUS[app]), tty])
96    if (found.stderr.includes(NOT_AUTHORIZED)) return { kind: 'denied', app }
97    if (found.exitCode !== 0) return { kind: 'error', detail: found.stderr.trim() }
98    if (found.stdout.trim() === 'found') return { kind: 'done' }
99  }
100  return undefined
101}
102
103/** 目標在 tmux 裡:選到它的 pane,再把看得到它的畫面帶到前面。 */
104const jumpInTmux = async (run: Run, target: Pane, inTmux: boolean): Promise<Outcome> => {
105  for (const argv of [
106    ['tmux', 'select-window', '-t', target.window],
107    ['tmux', 'select-pane', '-t', target.pane],
108  ]) {
109    const r = await run(argv)
110    if (r.exitCode !== 0) return { kind: 'error', detail: r.stderr.trim() }
111  }
112  const clients = await run(['tmux', 'list-clients', '-t', target.session, '-F', '#{client_tty}'])
113  const client = clients.exitCode === 0 ? ttyOf(clients.stdout.split('\n')[0] ?? '') : undefined
114  if (client !== undefined) return (await focusTty(run, client)) ?? { kind: 'notFound' }
115  if (!inTmux) return { kind: 'attach', session: target.session }
116  const r = await run(['tmux', 'switch-client', '-t', target.pane])
117  return r.exitCode === 0 ? { kind: 'done' } : { kind: 'error', detail: r.stderr.trim() }
118}
119
120/** 跳到 pid 這個 session 所在的終端機。inTmux:本視窗是否在 tmux 裡。 */
121export const jumpTo = async (pid: number, run: Run, env: { inTmux: boolean }): Promise<Outcome> => {
122  try {
123    const ps = await run(['ps', '-o', 'tty=', '-p', String(pid)])
124    const tty = ps.exitCode === 0 ? ttyOf(ps.stdout) : undefined
125    if (tty === undefined) return { kind: 'notFound' }
126
127    // 沒裝 tmux 時指令根本無法啟動,當成 tmux 裡找不到
128    const panes = await run(['tmux', 'list-panes', '-a', '-F', PANE_FORMAT]).catch(() => ({ exitCode: 1, stdout: '', stderr: '' }))
129    const target = panes.exitCode === 0 ? parsePanes(panes.stdout).find(p => p.tty === tty) : undefined
130    if (target !== undefined) return await jumpInTmux(run, target, env.inTmux)
131
132    return (await focusTty(run, tty)) ?? { kind: 'notFound' }
133  } catch (err) {
134    return { kind: 'error', detail: err instanceof Error ? err.message : String(err) }
135  }
136}
137
138/** 給使用者看的提示;成功時不提示。 */
139export const messageFor = (o: Outcome): string | undefined => {
140  switch (o.kind) {
141    case 'done':
142      return undefined
143    case 'notFound':
144      return '找不到這個 session 所在的終端機(支援 tmux、iTerm2、Terminal.app)'
145    case 'attach':
146      return `這個 session 在未連線的 tmux session「${o.session}」,請執行 tmux attach -t ${o.session}`
147    case 'denied':
148      return `沒有控制 ${o.app} 的權限,請到「系統設定 › 隱私權與安全性 › 自動化」允許`
149    case 'error':
150      return `切換失敗:${o.detail.split('\n')[0] ?? ''}`
151  }
152}
153
hooks/sessions.ts 94 lines
1import type { Peer } from '../types'
2
3export type Look = { icon: string; label: string; color: string }
4
5const LOOKS: Record<string, Look> = {
6  busy: { icon: '●', label: '工作中', color: 'yellow' },
7  idle: { icon: '○', label: '閒置', color: 'green' },
8  shell: { icon: '◆', label: 'shell', color: 'cyan' },
9  waiting: { icon: '◐', label: '等待回應', color: 'magenta' },
10}
11
12export const look = (status: string): Look =>
13  LOOKS[status] ?? { icon: '·', label: status || '未知', color: 'gray' }
14
15const asString = (v: unknown, fallback: string): string => (typeof v === 'string' ? v : fallback)
16const asNumber = (v: unknown, fallback: number): number => (typeof v === 'number' ? v : fallback)
17
18/** 解析 ~/.claude/sessions/<pid>.json;格式不符(例如寫入到一半)回 undefined。 */
19export const parsePeer = (text: string): Peer | undefined => {
20  let raw: unknown
21  try {
22    raw = JSON.parse(text)
23  } catch {
24    return undefined
25  }
26  if (typeof raw !== 'object' || raw === null) return undefined
27  const d = raw as Record<string, unknown>
28  if (typeof d.pid !== 'number' || typeof d.sessionId !== 'string') return undefined
29
30  return {
31    pid: d.pid,
32    sessionId: d.sessionId,
33    name: asString(d.name, `pid ${d.pid}`),
34    status: asString(d.status, ''),
35    kind: asString(d.kind, ''),
36    cwd: asString(d.cwd, ''),
37    since: asNumber(d.statusUpdatedAt, asNumber(d.updatedAt, asNumber(d.startedAt, 0))),
38    startedAt: asNumber(d.startedAt, 0),
39  }
40}
41
42/** 工作中的排最前面,其次依最近狀態變動時間。 */
43export const sortPeers = (peers: readonly Peer[]): Peer[] =>
44  [...peers].sort((a, b) => {
45    const busy = Number(b.status === 'busy') - Number(a.status === 'busy')
46    return busy !== 0 ? busy : b.since - a.since
47  })
48
49/**
50 * 本視窗以外的 session 依啟動時間由舊到新編號 1~9,當作 hotkey。
51 * 與清單排序無關,session 還在時編號就不變。
52 */
53export const hotkeys = (peers: readonly Peer[], selfId: string): Record<string, string> =>
54  Object.fromEntries(
55    peers
56      .filter(p => p.sessionId !== selfId)
57      .sort((a, b) => a.startedAt - b.startedAt || a.pid - b.pid)
58      .slice(0, 9)
59      .map((p, i) => [p.sessionId, String(i + 1)]),
60  )
61
62/** 依 hotkey 編號找回 session。 */
63export const peerForKey = (peers: readonly Peer[], selfId: string, key: string): Peer | undefined => {
64  const keys = hotkeys(peers, selfId)
65  return peers.find(p => keys[p.sessionId] === key)
66}
67
68export type SessionsCommand =
69  | { kind: 'toggle' }
70  | { kind: 'jump'; key: string }
71  | { kind: 'theme'; name?: string }
72  | { kind: 'usage' }
73
74/** /sessions 的參數:空的開關面板,1~9 跳到該編號的 session,theme [名稱] 切換主題(名稱由呼叫端檢查)。 */
75export const parseSessionsArgs = (args: string): SessionsCommand => {
76  const a = args.trim()
77  if (a === '') return { kind: 'toggle' }
78  if (/^[1-9]$/.test(a)) return { kind: 'jump', key: a }
79  const theme = /^theme(?:\s+(\S+))?$/.exec(a)
80  if (theme === null) return { kind: 'usage' }
81  return theme[1] === undefined ? { kind: 'theme' } : { kind: 'theme', name: theme[1] }
82}
83
84export const ago = (since: number, now: number): string => {
85  const s = Math.max(0, Math.round((now - since) / 1000))
86  if (s < 60) return `${s}秒`
87  const m = Math.floor(s / 60)
88  if (m < 60) return `${m}分`
89  const h = Math.floor(m / 60)
90  if (h < 24) return `${h}時`
91  return `${Math.floor(h / 24)}天`
92}
93
94
hooks/frame.ts 55 lines
1/**
2 * 面板底部一格畫面的計算:依狀態取主題的動作、決定角色位置、合成場景、算出文字符號的位置。
3 * 面板繪製與 README 動作圖的產生腳本共用這裡,兩邊看到的是同一套動畫。
4 */
5import { mirror, sized, spriteWidth } from './sprite'
6import type { Sprite } from './sprite'
7import type { State, Theme } from './theme'
8
9/** 蓋在場景上的文字符號:第 line 行(文字行)、第 col 欄。 */
10export type Mark = { line: number; col: number; text: string; color: string }
11
12export type FrameInput = {
13  theme: Theme
14  state: State
15  x: number
16  facing: 1 | -1
17  frame: number
18  /** 角色的倍數(factorFor 算出來的)。 */
19  factor: number
20  columns: number
21  /** 場景最多能佔幾行文字;放不下完整背景時由主題決定怎麼省略。 */
22  rows: number
23  /** 進入 resting 時抽的亂數(0 ≤ seed < 1),主題用來挑這段 resting 期間不變的內容(例如名言)。 */
24  seed?: number
25}
26
27export type Frame = {
28  sprite: Sprite
29  marks: Mark[]
30  /** 角色實際畫出的 x。 */
31  x: number
32  /** 角色最上面那一行文字。 */
33  tigerLine: number
34  /** 角色可走的範圍與每步欄數,給走路的計時器用。 */
35  room: number
36  stride: number
37}
38
39export const paneFrame = (input: FrameInput): Frame => {
40  const fitted = input.theme.fit?.(input.factor) ?? input.factor
41  const o = fitted === input.factor ? input : { ...input, factor: fitted }
42  const act = o.theme.acts[o.state]
43  const pose = sized(act.pose(o.frame), o.factor)
44  const width = spriteWidth(pose)
45  const room = Math.max(0, o.columns - width)
46  const stride = Math.max(1, Math.round(o.factor))
47  const x = act.place?.(o, width) ?? Math.min(o.x, room)
48  // 背景位移跟著角色實際畫出的 x
49  const scene = o.theme.compose({ pose: o.facing === 1 ? pose : mirror(pose), x, columns: o.columns, rows: o.rows })
50  const tigerLine = Math.floor(scene.top / 2)
51  const marks = act.marks?.(o, { x, width, top: scene.top, line: tigerLine }) ?? []
52
53  return { sprite: scene.sprite, marks, x, tigerLine, room, stride }
54}
55
hooks/sprite.ts 127 lines
1/** 像素圖的共用工具:縮放、翻轉、轉成半格字元、在畫面上寫字。與主題無關。 */
2import { charWidth } from './text'
3
4/** 像素圖:每個字元是一個像素,'.' 為透明;顏色由主題的調色盤決定。 */
5export type Sprite = readonly string[]
6
7export const spriteWidth = (s: Sprite): number => s[0]?.length ?? 0
8
9/** 整數倍放大(最近鄰),保持像素風。 */
10export const scale = (s: Sprite, k: number): Sprite =>
11  s.flatMap(row => {
12    const wide = [...row].map(px => px.repeat(k)).join('')
13    return Array.from({ length: k }, () => wide)
14  })
15
16/** 左右翻轉,讓角色往左走。 */
17export const mirror = (s: Sprite): Sprite => s.map(row => [...row].reverse().join(''))
18
19/** 最近鄰縮放到指定像素寬高,可放大也可縮小。 */
20export const resize = (s: Sprite, width: number, height: number): Sprite => {
21  const srcH = s.length
22  const srcW = spriteWidth(s)
23  return Array.from({ length: height }, (_, y) => {
24    const row = s[Math.min(srcH - 1, Math.floor((y * srcH) / height))] ?? ''
25    return Array.from({ length: width }, (_, x) => row[Math.min(srcW - 1, Math.floor((x * srcW) / width))] ?? '.').join('')
26  })
27}
28
29export const MAX_FACTOR = 3
30/** 整體縮小比例:依用量算出的大小再乘上這個值。 */
31export const SIZE_SCALE = 0.5
32
33/**
34 * 上下文 100% 時的倍數:面板能容納的整數倍(最多 3 倍)再乘上 SIZE_SCALE。
35 * 其他用量依比例縮小,最小為主題的最小倍數;還沒有用量時用最小值。
36 * width 是角色 1 倍時的像素寬。
37 */
38export const factorFor = (percent: number | undefined, columns: number, size: { width: number; minFactor: number }): number => {
39  const full = Math.max(1, Math.min(MAX_FACTOR, Math.floor(columns / size.width)))
40  if (percent === undefined) return size.minFactor
41  const ratio = Math.min(100, Math.max(0, percent)) / 100
42  return Math.max(size.minFactor, full * ratio * SIZE_SCALE)
43}
44
45/** 依倍數縮放姿勢,寬高至少 1 像素。 */
46export const sized = (s: Sprite, factor: number): Sprite =>
47  resize(s, Math.max(1, Math.round(spriteWidth(s) * factor)), Math.max(1, Math.round(s.length * factor)))
48
49export type Run = { text: string; fg?: string; bg?: string }
50
51/** 每兩列像素合成一列半格字元(▀ ▄),每格一個 Run;palette 是主題的調色盤。 */
52export const toCells = (s: Sprite, palette: Record<string, string>): Run[][] => {
53  const lines: Run[][] = []
54  for (let y = 0; y < s.length; y += 2) {
55    const top = s[y] ?? ''
56    const bottom = s[y + 1] ?? ''
57    lines.push(
58      [...top].map((px, x) => {
59        const t = palette[px]
60        const b = palette[bottom[x] ?? '.']
61        if (t === undefined && b === undefined) return { text: ' ' }
62        if (t === undefined) return { text: '▄', fg: b }
63        return b === undefined ? { text: '▀', fg: t } : { text: '▀', fg: t, bg: b }
64      }),
65    )
66  }
67  return lines
68}
69
70/** 相同顏色、相同字元的相鄰格併成一段。 */
71export const joinCells = (lines: readonly (readonly Run[])[]): Run[][] =>
72  lines.map(cells => {
73    const runs: Run[] = []
74    for (const cell of cells) {
75      const last = runs[runs.length - 1]
76      if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg && last.text[0] === cell.text) {
77        last.text += cell.text
78      } else {
79        runs.push({ ...cell })
80      }
81    }
82    return runs
83  })
84
85export const toRuns = (s: Sprite, palette: Record<string, string>): Run[][] => joinCells(toCells(s, palette))
86
87/**
88 * 在第 line 行第 at 欄寫字;底色用該格下半像素的顏色,空白不蓋背景。
89 * 寬字元(中文)佔兩格:字放在第一格,第二格清成空字串;放不下整個字就不畫。
90 */
91export const stamp = (
92  cells: readonly (readonly Run[])[],
93  s: Sprite,
94  line: number,
95  at: number,
96  text: string,
97  color: string,
98  palette: Record<string, string>,
99): Run[][] =>
100  cells.map((row, l) => {
101    if (l !== line) return [...row]
102    const out = [...row]
103    let col = at
104    for (const ch of text) {
105      const width = charWidth(ch)
106      const fits = col >= 0 && col + width <= out.length
107      if (ch !== ' ' && fits) {
108        const bg = palette[s[line * 2 + 1]?.[col] ?? '.']
109        out[col] = bg === undefined ? { text: ch, fg: color } : { text: ch, fg: color, bg }
110        if (width === 2) out[col + 1] = { text: '' }
111      }
112      col += width
113    }
114    return out
115  })
116
117export type Walker = { x: number; facing: 1 | -1 }
118
119/** 往前走一步,碰到邊界就轉身。 */
120export const step = (w: Walker, room: number, stride: number): Walker => {
121  const max = Math.max(0, room)
122  const next = w.x + w.facing * stride
123  if (next > max) return { x: max, facing: -1 }
124  if (next < 0) return { x: 0, facing: 1 }
125  return { x: next, facing: w.facing }
126}
127
hooks/theme.ts 64 lines
1/**
2 * 面板底部的主題:一組角色、背景,與它們在各個狀態下的動作。
3 * 狀態與主題無關;主題決定每個狀態要演什麼。
4 */
5import type { FrameInput, Mark } from './frame'
6import type { Sprite } from './sprite'
7import { puma } from './puma'
8import { tiger } from './tiger'
9
10/** session 的活動程度:工作中、回覆結束後 30 秒內、閒置超過 30 秒。 */
11export type State = 'working' | 'resting' | 'idle'
12
13/** 閒置多久後從 resting 進入 idle。 */
14export const NAP_AFTER_MS = 30_000
15
16export const stateFor = (isWorking: boolean, idleSince: number, now: number): State => {
17  if (isWorking) return 'working'
18  return idleSince > 0 && now - idleSince < NAP_AFTER_MS ? 'resting' : 'idle'
19}
20
21/** 背景與角色合成後的整格畫面。 */
22export type Scene = {
23  sprite: Sprite
24  /** 角色最上面一列像素在場景裡的列數。 */
25  top: number
26}
27
28/** 角色在這一格實際的位置:x、寬度(欄)、最上面一列像素、最上面一行文字。 */
29export type Placed = { x: number; width: number; top: number; line: number }
30
31/** 主題在某個狀態下的動作。 */
32export type Act = {
33  /** 第 frame 格的姿勢:原始大小、面向右。 */
34  pose: (frame: number) => Sprite
35  /** 角色實際畫在哪一欄;沒有時夾在可走範圍內。width 是縮放後的寬度。 */
36  place?: (o: FrameInput, width: number) => number
37  /** 蓋在場景上的文字符號;沒有時不畫。 */
38  marks?: (o: FrameInput, at: Placed) => Mark[]
39}
40
41export type Theme = {
42  name: string
43  acts: Record<State, Act>
44  /** 背景合成:把擺好的角色疊到背景上;rows 是場景最多能佔幾行文字。 */
45  compose: (o: { pose: Sprite; x: number; columns: number; rows: number }) => Scene
46  /** 像素字元 → 色碼,涵蓋角色與背景。 */
47  palette: Record<string, string>
48  /** 角色 1 倍時的像素寬,給 factorFor 算體型。 */
49  width: number
50  /** 體型的最小倍數。 */
51  minFactor: number
52  /** 依面板算出的倍數再由主題微調(例如保證縮小後還看得到眼睛);沒有時照用。 */
53  fit?: (factor: number) => number
54}
55
56export const THEMES = { tiger, puma } satisfies Record<string, Theme>
57
58export type ThemeName = keyof typeof THEMES
59
60export const DEFAULT_THEME: ThemeName = 'tiger'
61
62/** 以名稱取得主題;不認得的名稱用預設主題。 */
63export const themeFor = (name: string): Theme => (Object.hasOwn(THEMES, name) ? THEMES[name as ThemeName] : THEMES[DEFAULT_THEME])
64
hooks/choice.ts 43 lines
1/**
2 * 使用者選的主題:/sessions theme 的切換規則,與 ~/.claude/session-radar.json 的讀寫。
3 * 主題名稱一律從登錄表推導,新增主題時這裡不用改。
4 */
5import { THEMES } from './theme'
6import type { ThemeName } from './theme'
7
8/** 依登錄表順序的主題名稱,/sessions theme 依這個順序輪流。 */
9export const THEME_NAMES = Object.keys(THEMES) as ThemeName[]
10
11/** 設定檔相對於家目錄的位置。 */
12export const SETTINGS_PATH = '.claude/session-radar.json'
13
14export type ThemeChoice = { kind: 'chosen'; name: string } | { kind: 'unknown'; name: string; names: string[] }
15
16/** 沒指定名稱就換到清單中的下一個(最後一個之後回到第一個);指定了就檢查名稱在不在清單裡。 */
17export const chooseTheme = (current: string, name: string | undefined, names: readonly string[] = THEME_NAMES): ThemeChoice => {
18  if (name === undefined) return { kind: 'chosen', name: names[(names.indexOf(current) + 1) % names.length] ?? current }
19  return names.includes(name) ? { kind: 'chosen', name } : { kind: 'unknown', name, names: [...names] }
20}
21
22/** 設定檔內容轉成物件;不存在、壞掉或不是物件時回 undefined。 */
23const parseObject = (text: string | undefined): Record<string, unknown> | undefined => {
24  if (text === undefined) return undefined
25  let raw: unknown
26  try {
27    raw = JSON.parse(text)
28  } catch {
29    return undefined
30  }
31  return typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined
32}
33
34/** 從設定檔內容讀出主題名稱;檔案不存在(undefined)、壞掉、或名稱不在清單裡時回 undefined。 */
35export const parseThemeSetting = (text: string | undefined, names: readonly string[] = THEME_NAMES): string | undefined => {
36  const theme = parseObject(text)?.theme
37  return typeof theme === 'string' && names.includes(theme) ? theme : undefined
38}
39
40/** 把主題寫進設定檔內容,保留其他欄位;原內容不能用時只寫主題。 */
41export const withThemeSetting = (text: string | undefined, name: string): string =>
42  `${JSON.stringify({ ...parseObject(text), theme: name }, null, 2)}\n`
43
hooks/text.ts 65 lines
1/** 文字在終端機上的顯示寬度與換行:中日韓全形字佔 2 欄,其他佔 1 欄。與主題無關。 */
2
3/** 佔 2 欄的字元範圍(East Asian Wide / Fullwidth 的常用區段)。 */
4const WIDE: readonly (readonly [number, number])[] = [
5  [0x1100, 0x115f], // 韓文字母
6  [0x2e80, 0x303e], // 部首、CJK 標點(「」。、)
7  [0x3041, 0x33ff], // 假名、注音、CJK 相容字元
8  [0x3400, 0x4dbf], // CJK 擴充 A
9  [0x4e00, 0x9fff], // CJK 統一漢字
10  [0xa000, 0xa4cf], // 彝文
11  [0xac00, 0xd7a3], // 韓文音節
12  [0xf900, 0xfaff], // CJK 相容漢字
13  [0xfe30, 0xfe4f], // CJK 相容標點
14  [0xff00, 0xff60], // 全形英數與標點(,?!)
15  [0xffe0, 0xffe6], // 全形符號
16  [0x20000, 0x3fffd], // CJK 擴充 B 以後
17]
18
19/** 單一字元(code point)的顯示寬度。 */
20export const charWidth = (ch: string): 1 | 2 => {
21  const code = ch.codePointAt(0) ?? 0
22  return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
23}
24
25/** 字串的顯示寬度。 */
26export const displayWidth = (text: string): number => [...text].reduce((sum, ch) => sum + charWidth(ch), 0)
27
28export const ELLIPSIS = '…'
29
30/** 不可放在行首的標點。 */
31const NO_LINE_START = ',。、?!;:」』)…'
32
33/**
34 * 以字為單位換行:每行顯示寬度不超過 width,寬字元不會被切成兩半。
35 * 超過 maxLines 行時只留前 maxLines 行,最後一行以 … 結尾(必要時再拿掉字讓出位置)。
36 * width 放不下任何一個字時回傳空陣列。
37 */
38export const wrapText = (text: string, width: number, maxLines: number): string[] => {
39  if (maxLines <= 0 || [...text].some(ch => charWidth(ch) > width)) return []
40  const lines: string[] = []
41  let line = ''
42  for (const ch of text) {
43    if (displayWidth(line + ch) > width) {
44      // 行首禁則:標點不能開頭,把前面的字一起帶到下一行
45      let carry = ch
46      while (NO_LINE_START.includes(carry[0] ?? '') && [...line].length > 1) {
47        carry = [...line].slice(-1).join('') + carry
48        line = [...line].slice(0, -1).join('')
49      }
50      lines.push(line)
51      line = carry
52      continue
53    }
54    line += ch
55  }
56  if (line !== '') lines.push(line)
57  if (lines.length <= maxLines) return lines
58
59  const kept = lines.slice(0, maxLines)
60  let last = kept[maxLines - 1] ?? ''
61  while (last !== '' && displayWidth(last + ELLIPSIS) > width) last = [...last].slice(0, -1).join('')
62  kept[maxLines - 1] = last + ELLIPSIS
63  return kept
64}
65
hooks/puma.ts 171 lines
1/**
2 * 綠雲與愛心主題(puma):工作中左右來回走,回覆結束後頭頂冒出 ♥ 與一句名言,閒置久了吃三色丸子。
3 * 背景是閃爍的星空,沒有地面;角色腳底貼著面板底部。
4 */
5import { DANGO } from './dango'
6import type { FrameInput, Mark } from './frame'
7import { PUMA_PALETTE, PUMA_WALK } from './puma-sprites'
8import { overlay } from './scene'
9import { mirror, sized, spriteWidth } from './sprite'
10import type { Sprite } from './sprite'
11import { starField, starMarks } from './stars'
12import { displayWidth, wrapText } from './text'
13import type { Act, Placed, Theme } from './theme'
14
15/** 名言(原文照用)。 */
16export const QUOTES: readonly string[] = [
17  '我們不要灰心,我們也不應該喪志,為什麼?因為我來了。',
18  '人類的讚歌就是勇氣的讚歌,我們會把勇氣繼續傳承下去。',
19  '如果有人說懷抱希望是一種錯誤,那麼我會每一次都反駁他。無論幾次,我都會堅定地說。',
20  '未來的事情無人知,但就是因為這樣,可能性才會如此強大。',
21  '我本來就不是以外表取勝。',
22  '洋流是溫暖的,可以帶來漁獲,不會燙傷人。',
23]
24
25/** 這段 resting 的名言:由進入 resting 時抽的亂數決定,期間不換;沒有亂數時用第一句。 */
26export const quoteFor = (seed: number | undefined): string =>
27  QUOTES[Math.min(QUOTES.length - 1, Math.max(0, Math.floor((seed ?? 0) * QUOTES.length)))] ?? ''
28
29/** 名言最多幾行。 */
30export const QUOTE_LINES = 3
31
32/** 原圖朝左;主題的姿勢一律面向右,所以先翻轉。 */
33const faceRight = (s: Sprite): Sprite => mirror(s)
34
35/** 走路圖(面向右)。 */
36export const WALK: readonly Sprite[] = PUMA_WALK.map(faceRight)
37
38/** 停下來時的站姿:固定用走路的第 1 格(朝左原圖)。 */
39const STAND = PUMA_WALK[0] ?? []
40
41/** 丸子疊在原圖(朝左)的哪個位置:竹籤尾端插在前面那隻黃色手的上緣。 */
42export const DANGO_AT = { x: 2, y: 7 }
43
44/** 丸子每個階段維持幾格。 */
45export const DANGO_FRAMES = 4
46
47/** 第 frame 格丸子吃到第幾階段:0~3 依序剩 3、2、1、0 顆,之後換新的一串。 */
48export const dangoStage = (frame: number): number => Math.floor(frame / DANGO_FRAMES) % DANGO.length
49
50/** 吃丸子的姿勢(面向右):站姿的手上疊著這個階段的丸子。 */
51export const eatPose = (frame: number): Sprite =>
52  faceRight(overlay(STAND, DANGO[dangoStage(frame)] ?? [], DANGO_AT.x, DANGO_AT.y))
53
54/**
55 * 白色眼睛區塊裡有黑色像素:某個黑色像素左右或上下都是眼白。
56 * 縮到最小時眼白整塊變白(眼珠被取樣掉)就不成立。
57 */
58export const hasPupil = (s: Sprite): boolean =>
59  s.some((row, y) =>
60    [...row].some(
61      (px, x) => px === 'X' && ((row[x - 1] === 'I' && row[x + 1] === 'I') || (s[y - 1]?.[x] === 'I' && s[y + 1]?.[x] === 'I')),
62    ),
63  )
64
65/** 最小倍數的起點;實際值會為了看得到黑眼珠往上調。 */
66export const BASE_MIN_FACTOR = 0.5
67
68/** 調高倍數時每次加多少。 */
69const FIT_STEP = 0.01
70
71const fitted = new Map<number, number>()
72
73/**
74 * 保底:縮放後 12 格走路圖都還看得到黑眼珠。縮放是最近鄰取樣,
75 * 某些倍數剛好會把眼珠取樣掉;這時往上加到 12 格都看得到為止。
76 * 檢查的是面板實際縮放的姿勢(面向右的版本),先翻轉再縮放取到的像素不一樣。
77 */
78export const eyeSafe = (factor: number): number => {
79  const cached = fitted.get(factor)
80  if (cached !== undefined) return cached
81  let f = factor
82  for (let i = 0; i < 100 && !WALK.every(p => hasPupil(sized(p, f))); i++) f = Math.round((f + FIT_STEP) * 1000) / 1000
83  fitted.set(factor, f)
84  return f
85}
86
87export const MIN_FACTOR = eyeSafe(BASE_MIN_FACTOR)
88
89/** 頭頂最多留幾行給對話框:名言 3 行加 ♥ 一行。 */
90export const HEAD_LINES = QUOTE_LINES + 1
91
92const evenUp = (n: number): number => n + (n % 2)
93
94/** 角色在場景裡佔的範圍(文字行):最上面有顏色的那一行到場景底部。 */
95type Figure = { head: number; bottom: number; drawn: boolean }
96
97const figureOf = (pose: Sprite, o: FrameInput, at: Placed): Figure => {
98  const sizedPose = sized(pose, o.factor)
99  const firstRow = Math.max(0, sizedPose.findIndex(row => /[^.]/.test(row)))
100  return {
101    head: Math.floor((at.top + firstRow) / 2),
102    bottom: Math.ceil((at.top + sizedPose.length) / 2),
103    drawn: at.width <= o.columns,
104  }
105}
106
107/** 頭頂的文字:一行一個 Mark,每行以角色為中心,碰到面板邊緣往內推。 */
108const centered = (o: FrameInput, at: Placed, line: number, text: string, color: string): Mark => {
109  const width = displayWidth(text)
110  const center = at.x + Math.floor(at.width / 2)
111  const col = Math.max(0, Math.min(center - Math.floor(width / 2), o.columns - width))
112  return { line, col, text, color }
113}
114
115/**
116 * 對話框:頭頂上一行是 ♥,名言在 ♥ 上面。
117 * 頭頂的行數不夠時依序讓出:名言 3 行 → 1 行 → 不顯示名言只留 ♥ → 什麼都不畫。
118 */
119export const bubble = (o: FrameInput, at: Placed, head: number): Mark[] => {
120  if (head < 1) return []
121  const heart = centered(o, at, head - 1, '♥', 'redBright')
122  const room = head - 1
123  const maxLines = room >= QUOTE_LINES ? QUOTE_LINES : room >= 1 ? 1 : 0
124  const lines = wrapText(`「${quoteFor(o.seed)}」`, o.columns, maxLines)
125  const top = head - 1 - lines.length
126  return [...lines.map((text, i) => centered(o, at, top + i, text, 'white')), heart]
127}
128
129/** 星空:避開角色與頭頂文字佔用的格子;文字左右各多留一格,星星不會黏在字旁邊。 */
130const sky = (o: FrameInput, at: Placed, fig: Figure, over: readonly Mark[]): Mark[] => {
131  const used = new Set(over.flatMap(m => Array.from({ length: displayWidth(m.text) + 2 }, (_, i) => `${m.line},${m.col - 1 + i}`)))
132  const onFigure = (line: number, col: number) => fig.drawn && line >= fig.head && col >= at.x && col < at.x + at.width
133  return starMarks(starField(o.columns, fig.bottom), o.frame, (line, col) => onFigure(line, col) || used.has(`${line},${col}`))
134}
135
136/** 動作:pose 之外,符號一律是「頭頂文字+避開它們的星空」。 */
137const act = (pose: (frame: number) => Sprite, overhead: (o: FrameInput, at: Placed, head: number) => Mark[]): Act => ({
138  pose,
139  marks: (o, at) => {
140    const fig = figureOf(pose(o.frame), o, at)
141    const over = fig.drawn ? overhead(o, at, fig.head) : []
142    return [...sky(o, at, fig, over), ...over]
143  },
144})
145
146/** 吃到剩 2 顆時頭頂冒出 nom。 */
147const nom = (o: FrameInput, at: Placed, head: number): Mark[] =>
148  dangoStage(o.frame) === 1 && head >= 1 ? [centered(o, at, head - 1, 'nom', 'yellow')] : []
149
150export const puma: Theme = {
151  name: 'puma',
152  acts: {
153    working: act(frame => WALK[frame % WALK.length] ?? [], () => []),
154    resting: act(() => faceRight(STAND), bubble),
155    idle: act(eatPose, nom),
156  },
157  // 星空不畫在像素圖上;場景只有角色,腳底貼底,頭頂最多留 HEAD_LINES 行。面板比角色窄時不畫角色。
158  compose: o => {
159    const body = evenUp(o.pose.length)
160    const head = Math.max(0, Math.min(HEAD_LINES, o.rows - body / 2))
161    const height = head * 2 + body
162    const top = height - o.pose.length
163    const empty: Sprite = Array.from({ length: height }, () => '.'.repeat(o.columns))
164    return { sprite: spriteWidth(o.pose) <= o.columns ? overlay(empty, o.pose, o.x, top) : empty, top }
165  },
166  palette: PUMA_PALETTE,
167  width: spriteWidth(STAND),
168  minFactor: MIN_FACTOR,
169  fit: factor => eyeSafe(Math.max(MIN_FACTOR, factor)),
170}
171
hooks/tiger.ts 211 lines
1/** 小老虎主題(預設):8-bit 老虎、樹林與草地背景,工作中巡邏、閒置時抓蝴蝶、久了睡覺。 */
2import type { FrameInput, Mark } from './frame'
3import { composeScene, fitsTrees } from './scene'
4import { spriteWidth } from './sprite'
5import type { Sprite } from './sprite'
6import type { Placed, Theme } from './theme'
7
8/** 老虎與樹林草地的顏色。 */
9export const PALETTE: Record<string, string> = {
10  O: '#f08a24', // 橘色毛
11  K: '#3b2410', // 深色條紋
12  W: '#f5efe2', // 白色肚子、嘴邊
13  E: '#111111', // 眼睛
14  N: '#ff8fa3', // 鼻子
15  G: '#2e7d32', // 樹冠
16  L: '#43a047', // 樹冠亮面
17  B: '#6d4c41', // 樹幹
18  g: '#7cc242', // 草
19  H: '#4e9a2f', // 草的暗面
20}
21
22const BODY = [
23  '............OO..OO',
24  '............OOKKOO',
25  'K...........OEOOEO',
26  '.O..OOOOOOOOOWNNWO',
27  '..OOKOOKOOKOOOWWW.',
28  '...OKOOKOOKOOOO...',
29  '...OWWWWWWWWWOO...',
30]
31
32export const WALK: readonly Sprite[] = [
33  [
34    ...BODY,
35    '...O.O......O.O...',
36    '..O...O....O...O..',
37    '..W...W....W...W..',
38  ],
39  [
40    ...BODY,
41    '....OO......OO....',
42    '....OO......OO....',
43    '....WW......WW....',
44  ],
45]
46
47/** 站著抓蝴蝶:0 四腳站好,1 舉起前掌。 */
48export const PLAY: readonly Sprite[] = [
49  WALK[1] ?? [],
50  [
51    ...BODY.slice(0, 4),
52    '..OOKOOKOOKOOOWWWW',
53    '...OKOOKOOKOOOO.OO',
54    '...OWWWWWWWWWOOO..',
55    '....OO......O.....',
56    '....OO......O.....',
57    '....WW......W.....',
58  ],
59]
60
61/** 尾巴往後平甩:把翹起的尾巴改成水平。 */
62const swish = (s: Sprite): Sprite =>
63  s.map((row, y) => (y === 2 ? '.' + row.slice(1) : y === 3 ? '..' + row.slice(2) : y === 4 ? 'KO' + row.slice(2) : row))
64
65const STAND = PLAY[0] ?? []
66
67/** 壓低身體準備撲:整隻往下一列,腿縮短。 */
68const CROUCH: Sprite = [
69  '..................',
70  ...BODY,
71  '....OO......OO....',
72  '....WW......WW....',
73]
74
75/** 跳起來拍:舉起前掌,四腳離地一列。 */
76const POUNCE: Sprite = [
77  ...(PLAY[1] ?? []).slice(0, 7),
78  '....OO......O.....',
79  '....WW......W.....',
80  '..................',
81]
82
83/**
84 * 抓蝴蝶時每一步的姿勢,與蝴蝶的飛行步驟一一對應:
85 * 盯著看甩尾 → 壓低身體扭屁股 → 跳起來拍 → 落地舉掌 → 甩尾看牠飛走。
86 */
87export const PLAY_STEPS: readonly Sprite[] = [
88  STAND,
89  swish(STAND),
90  CROUCH,
91  swish(CROUCH),
92  POUNCE,
93  PLAY[1] ?? STAND,
94  swish(STAND),
95  STAND,
96]
97
98export const SLEEP: Sprite = [
99  '............OO..OO',
100  '............OOKKOO',
101  '..OOOOOOOOOOOKOOKO',
102  '.OOKOOKOOKOOOWNNWO',
103  'OKOOOOOOOOOOOOWWW.',
104  '..OOOOOOOOOOWWWW..',
105]
106
107/** 把某一列換成新內容。 */
108const withRow = (s: Sprite, y: number, row: string): Sprite => s.map((r, i) => (i === y ? row : r))
109
110/** 吸氣:背部鼓起一列。 */
111const inhale = (s: Sprite): Sprite => withRow(s, 1, '....OOOOOOO.OOKKOO')
112
113/** 尾巴尖翹起來抖一下。 */
114const flick = (s: Sprite): Sprite =>
115  withRow(withRow(s, 3, 'KOOKOOKOOKOOOWNNWO'), 4, '.OOOOOOOOOOOOOWWW.')
116
117/** 後面那隻耳朵抖一下(折下去)。 */
118const twitch = (s: Sprite): Sprite => withRow(s, 0, '............OO....')
119
120/** 睡覺動作一輪的格數(每格 0.4 秒)。 */
121export const SLEEP_CYCLE = 16
122
123/**
124 * 第 frame 格的睡姿:每 1.2 秒一吸一吐,
125 * 每輪中段尾巴抖兩格,接近尾聲耳朵抖一格。
126 */
127export const sleepPose = (frame: number): Sprite => {
128  const step = frame % SLEEP_CYCLE
129  const breath = Math.floor(frame / 3) % 2 === 1 ? inhale(SLEEP) : SLEEP
130  const tail = step === 8 || step === 9 ? flick(breath) : breath
131  return step === 13 ? twitch(tail) : tail
132}
133
134/** 老虎的最小倍數。 */
135export const MIN_FACTOR = 0.5
136
137const ZZZ = ['z', 'z Z', 'z Z z', '']
138
139export const snore = (frame: number): string => ZZZ[Math.floor(frame / 2) % ZZZ.length] ?? ''
140
141/** 前掌在原始圖裡的像素列(第 6 列,從 0 起算第 5 列)。 */
142export const PAW_ROW = 5
143
144/**
145 * 蝴蝶一輪飛行的每一步(每步一格動畫):從頭頂飛下來、停到掌邊被拍到、再逃走。
146 * row 為 'above' 時在老虎頭頂那一行;否則是相對前掌那一行的列差。
147 * col 是離老虎身體前緣的欄數,hit 表示這一步被拍到、老虎舉起前掌。
148 */
149const FLIGHT: readonly { row: 'above' | number; col: number; hit?: boolean }[] = [
150  { row: 'above', col: 5 },
151  { row: 'above', col: 3 },
152  { row: -1, col: 3 },
153  { row: -1, col: 1 },
154  { row: 0, col: 0, hit: true },
155  { row: 0, col: 1, hit: true },
156  { row: -1, col: 4 },
157  { row: 'above', col: 6 },
158]
159
160export type Flight = { row: number; col: number; glyph: string; isHit: boolean }
161
162/**
163 * 第 frame 格時蝴蝶的位置。row 已換算成老虎圖的文字列(-1 為頭頂那行),
164 * pawLine 是前掌所在的文字列。
165 */
166export const flight = (frame: number, pawLine: number): Flight => {
167  const at = FLIGHT[frame % FLIGHT.length] ?? { row: 'above', col: 5 }
168  const row = at.row === 'above' ? -1 : Math.max(-1, pawLine + at.row)
169  const isHit = at.hit === true
170  return { row, col: at.col, isHit, glyph: isHit ? '*' : frame % 2 === 0 ? 'ʚɞ' : '><' }
171}
172
173/** 抓蝴蝶時,蝴蝶需要的前方空間(欄)。 */
174export const FLIGHT_ROOM = 8
175
176/** 抓蝴蝶時讓出前方空間給蝴蝶。 */
177const placeForPlay = (o: FrameInput, width: number): number =>
178  o.facing === 1
179    ? Math.max(0, Math.min(o.x, o.columns - width - FLIGHT_ROOM))
180    : Math.min(Math.max(0, o.columns - width), Math.max(o.x, FLIGHT_ROOM))
181
182/** 睡覺時打呼:靠老虎右緣、在頭頂那一行;符號直接蓋在背景上。 */
183const snoreMarks = (o: FrameInput, at: Placed): Mark[] => {
184  const text = snore(o.frame)
185  const col = Math.min(Math.max(0, at.x + at.width - text.length), o.columns - text.length)
186  return [{ line: at.line - 1, col, text, color: 'cyan' }]
187}
188
189/** 抓蝴蝶時蝴蝶在頭頂或掌邊。 */
190const butterflyMarks = (o: FrameInput, at: Placed): Mark[] => {
191  const pawLine = Math.floor((at.top + Math.floor(PAW_ROW * o.factor)) / 2)
192  const fly = flight(o.frame, pawLine - at.line)
193  // 蝴蝶的欄位:老虎面向哪邊,就在那一側的身體前緣外
194  const col = o.facing === 1 ? at.x + at.width + fly.col : at.x - fly.glyph.length - fly.col
195  return [{ line: at.line + fly.row, col, text: fly.glyph, color: fly.isHit ? 'yellow' : 'magenta' }]
196}
197
198export const tiger: Theme = {
199  name: 'tiger',
200  acts: {
201    working: { pose: frame => WALK[frame % WALK.length] ?? SLEEP },
202    resting: { pose: frame => PLAY_STEPS[frame % PLAY_STEPS.length] ?? SLEEP, place: placeForPlay, marks: butterflyMarks },
203    idle: { pose: sleepPose, marks: snoreMarks },
204  },
205  // 老虎疊在樹林前、站在草地上;放不下含樹的場景時只畫草
206  compose: o => composeScene({ pose: o.pose, x: o.x, columns: o.columns, withTrees: fitsTrees(o.rows, 0, o.pose.length) }),
207  palette: PALETTE,
208  width: spriteWidth(SLEEP),
209  minFactor: MIN_FACTOR,
210}
211
hooks/dango.ts 17 lines
1/**
2 * 三色丸子(手畫):粉、白、綠三顆串在竹籤上,綠雲在 idle 時從最上面一顆顆吃掉。
3 * 顏色字元用 puma 調色盤(見 puma-sprites.ts):P 粉、I 白、C 綠、D 竹籤。
4 */
5type Sprite = readonly string[]
6
7const STICK = '.D.'
8
9/** 竹籤由上往下:粉、白、綠各兩列,最下面一列是手拿的竹籤尾端。 */
10const BALLS = ['PPP', 'PPP', 'III', 'III', 'CCC', 'CCC']
11
12/** 吃掉最上面 eaten 顆,被吃掉的位置露出竹籤。 */
13const bite = (eaten: number): Sprite => [...BALLS.map((row, y) => (y < eaten * 2 ? STICK : row)), STICK]
14
15/** 4 個階段:剩 3、2、1、0 顆。 */
16export const DANGO: readonly Sprite[] = [bite(0), bite(1), bite(2), bite(3)]
17
hooks/puma-sprites.ts 502 lines
1/**
2 * 綠雲與愛心(puma 主題)的像素圖:每個字元是一個像素,'.' 為透明。
3 * 此檔由 scripts/gen-puma.ts 從 scripts/assets/green-cloud-and-heart.png 產生,請勿手改;
4 * 要調整請改腳本後執行 node scripts/gen-puma.ts。
5 */
6type Sprite = readonly string[]
7
8/** puma 調色盤:C 綠雲、Y 手腳、I 眼白、X 眉毛與輪廓、R 愛心、P 腮紅、D 愛心的腳。 */
9export const PUMA_PALETTE: Record<string, string> = {
10  C: '#5bb987',
11  Y: '#fad776',
12  I: '#fafafa',
13  X: '#111111',
14  R: '#f2466a',
15  P: '#fb8ea6',
16  D: '#8f5232',
17}
18
19/** 走路 12 格(原圖第 1 列左到右,再第 2 列),角色朝左;第 12 格沒有愛心。 */
20export const PUMA_WALK: readonly Sprite[] = [
21  [
22    '.....................................................',
23    '.....................................................',
24    '.....................................................',
25    '.....................................................',
26    '...............XXXX..................................',
27    '..............XCCCCXX................................',
28    '............XXCCCCCCCX.XXX...........................',
29    '.........XXXXXCCCCCCCXXCCXX..........................',
30    '........XCCCXXCCCCCCCXXCCCCX.........................',
31    '.......XXCCCCXXCCCCCCXCCCCCX.........................',
32    '.......XCCCCCCCCCCCCCCCCCCCX.........................',
33    '......XCCCCCCCCCCCCCCCCCCCCXX........................',
34    '...XX.XCCCCCCCCCCCCCCCCCCCCCCX.......................',
35    '..XYYXXCCCCCCCCCCCXXCCCCCCCCCCX......................',
36    '.XYYYYXXCCXXXCCXXXCCCCCCCCCCCCX......................',
37    'XYYYYXXXCXIIIXCCCXXXXCCCCCCCCCX......................',
38    'XYYYXXCCXIIIIIXXXIIIIXCCCCCCCCX......................',
39    'XYYYXCCCXIIIXIXXIIIIIIXCCCCCCX.......................',
40    'XYYYXCCCXIIIIIXXIXIIIIXCCCCCCX.......................',
41    'XYYYYXCCXXIIIXXXIIIIIIXCCCCCXXXX.....................',
42    '.XYYYYXCCXXXXXCXIIIIIIXCCCXXYYYYX....XXXX............',
43    '..XYYXXCCCXXXCCCXIIIIXCCCCXYYYYYXX..XRRRRX...........',
44    '..XXXXCCCCCCCCCCCXXXXCCCCXXXYYYYYX.XRRRRRRXXXXX......',
45    '...XXCCCCCCCCCCCCCCCCCCCCCCXXXYYYXXRRRRRRRXXRRRX.....',
46    '....XCCCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRRRRRRRRX....',
47    '.....XXCCCCCCCXCCCCCCCCCCCCXYYYYYXRRRRRRRXXXRRRRRX...',
48    '.....XCCCXXXXXXCCCCCCCCCCCCXYYYYXRRRRRXRRXXXRRRRRX...',
49    '......XCXYYYYYXXXCCCCCCCCCCCXXXX.XRRRPRXRXRRRRRRRX...',
50    '......XXYYYYYYYXCCCCCCCCCCCCX....XRRRRRRXRRRRRRRRX...',
51    '...XX.XYYYYYYYYXCCCCCCCCXCCX....XXXRRRRRRRRRRRRRX....',
52    '..XYYXYYYYYXXXXXXXCCCCCCXXX....XXRXRRRRRRRRRRRRX.....',
53    '..XYYYYYYYXXXCCCCXXCCCXXYYXX...XXRXXRRRRRRRRXXRRX....',
54    '...XYYYYYX..XXXXX.DXXXXYYYYX.....XX.XRRRRRRRXXRX.....',
55    '...XYYYYYX.......XXYYYYYYYYX......XXDXRRRRRXDXX......',
56    '....XYYYX..........XYYYYXYYX.....XDDDDXXXXXDDDX......',
57    '.....XXX............XXXXXYYX.....XDDDDX...XXDDX......',
58    '.........................XX.......XDDX....XDDX.......',
59    '...................................XX......XX........',
60  ],
61  [
62    '.....................................................',
63    '.....................................................',
64    '.....................................................',
65    '.....................................................',
66    '.....................................................',
67    '..................XXX................................',
68    '.................XCCCXX..............................',
69    '............XXXXXCCCCCCXXXX..........................',
70    '...........XCCCCXCCCCCXXCCCX.........................',
71    '..........XCCCCCXXCCCCXCCCCCX........................',
72    '.........XCCCCCCCCCCCCCCCCCCXX.......................',
73    '.........XCCCCCCCCCCCCCCCCCCCCX......................',
74    '.....XXXXCCCCCCCXCCCCCCCCCCCCCCX.....................',
75    '....XYYYXCCCXXXCXXXCCCCCCCCCCCCX.....................',
76    '...XYYYYXXXXIIIXCCXXXCCCCCCCCCCX.....................',
77    '...XYYYYXCXIIIIIXXIIIXCCCCCCXXCX.....................',
78    '...XYYYXCCXIIIXIXIIIIIXCCCCCCCX......................',
79    '...XYYYXCCXIIIIIXIXIIIXCCCCCCCX......................',
80    '...XYYYYXCXIIIIIXIXIIIXCCCCCCXXX.....................',
81    '...XYYYYYXCXIIIXXIIIIIXCCCCXXYYYX....................',
82    '....XYYYYXCCXXXCCXIIIXCCCCCXYYYYYX....XXX............',
83    '.....XYYXCCCCCCCCCXXXCCCCXXXXYYYYXX..XXXXXX..........',
84    '......XXCCCCCCCCCCCCCCCCCCCCCXXYYYXXXXRRRXX.XXX......',
85    '.......XCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRXXRRRX.....',
86    '.......XCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRRRRRRRX....',
87    '.......XCCCCCCCCCCCCCCCCCCCCXYYYYYXRRRRRRXXXRRRRRX...',
88    '........XCCXXXXXXCCCCCCCCCCCXYYYYXRRRRRXRXXXXRRRRX...',
89    '........XCXYYYYXXCCCCCCCCCCCCXXXX.XRRRRXXXXXRRRRRX...',
90    '.....XXX.XYYYYYYXCCCCCCCCCCCCX....XRRRRRXXRRRRRRRX...',
91    '....XYYYXYYYYYYYXCCCCCCCXCCCX....XXXRRRRRRRRRRRRX....',
92    '....XYYYYYYYYYXXCCCCCCCCXXXX....XRRXRRRRRRRRRRRX.....',
93    '....XYYYYYYYYXXCCCXCCCCXYYYYX...XXRXXRRRRRRRRXXRX....',
94    '.....XXYYYYYX..XXX.XXXXYYYYYX....XXX.XRRRRRRRXXXX....',
95    '.....XDXYYYYX......XYYYYYYYYX......XXDXRRRRRXDXX.....',
96    '.......XYYYX........XYYYYYYYX.....XDDDDXXXXXDDDX.....',
97    '........XXX..........XXXXXYYX.....XDDDDX...XXDDX.....',
98    '..........................XX.......XDDX....XDDX......',
99    '....................................XX......XX.......',
100  ],
101  [
102    '.................XXXX................................',
103    '................XCCCCX...............................',
104    '...............XXCCCCCX.XX...........................',
105    '............XXXXCCCCCCXXCCX..........................',
106    '..........XXCCCXCCCCCXXXCCXX.........................',
107    '.........XXCCCCCCCCCCCXCCCCX.........................',
108    '........XXXCCCCCCCCCCCCCCCCXXX.......................',
109    '....XXX.XCCCCCCCCCCCCCCCCCCCCCX......................',
110    '...XXYYXXCCCCCCCXCCXCCCCCCCCCCCX.....................',
111    '...XYYYXCCCCXXXCXCXXCCCCCCCCCCCX.....................',
112    '..XYYYYYCXCCXXXCXXXCCCCCCCCCCCCX.....................',
113    '..XYYYYYXXCXIIIXCCXXXCCCCCCCCCCX.....................',
114    '..XYYYXXCCXIIIIIXXIIIXCCCCCCCCCX.....................',
115    '..XYYYXCCCXIIIXIXIIIIIXCCCCCCXX......................',
116    '..XYYYXXCCXIIIIIXIXIIIXCCCCCCXX......................',
117    '..XYYYYXCCCXIIIXXIIIIIXCCCCCXXX......................',
118    '...XYYYYXCCXXXXXXIIIIIXCCCXXYYYX.....................',
119    '...XXYYXXCCCXXXCCXIIIXCCCCXXYYYXX....................',
120    '....XXXXCCCCCCCCCCXXXCCCCXXYYYYYX...XXXX.............',
121    '.....XXCCCCCCCCCCCCCCCCCCCCXXXYYYX.XRRRRX............',
122    '......XCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRX.XXX.......',
123    '......XCCCCCCCCCCCCCCCCCCCCCYYYYYXXRRRRRRXXRRRX......',
124    '......XCCCCCCCCCCCCCCCCCCCCXYYYYYXRRRRRRRRRRRRRX.....',
125    '.......XCCCXXXXXXCCCCCCCCCCXYYYYXRRRRRRRXXRRRRRRX....',
126    '.......XCCXYYYYXXCCCCCCCCCCCXYYYXRRRRXRRXXXXRRRRX....',
127    '........XXXYYYYXCCCCCCCCCCCXXXXXRRRRRXRPRXXRRRRRX....',
128    '........XXYYYYYXCCCCCCCCCCCXX...XRRRPRXRXXRRRRRRX....',
129    '........XYYYYYYXCCCCCCCXCCX.....XRRRRRXXXRRRRRRRX....',
130    '.....XX.YYYYYYXXXXCCCCCXCCX....XXRRRRRRRRRRRRRRX.....',
131    '....XYYXYYYYYXXCCXCCCXXYXX....XXRXRRRRRRRRRRRRXX.....',
132    '....XYYYYYYYYXXCCXXXXYYYYXX...XXRXXRRRRRRRRRRRX......',
133    '....XYYYYYYYXX.XXXXYYYYYYYX...XXRXXRRRRRRRRRXRXX.....',
134    '.....XYYYYYXX.....XYYYYYYYX....XX..XXRRRRRRXXXX......',
135    '......XYYYXX.......XXXXXYYX......XXDXXXXXXDXDDX......',
136    '.......XXXX.............XX......XDDDDXXXXXXDDDX......',
137    '.......XXX..............XX......XDDDDX...XXXDDX......',
138    '.................................XDDX....XXDDX.......',
139    '..................................XX.......XX........',
140  ],
141  [
142    '.....................................................',
143    '.....................................................',
144    '.....................................................',
145    '.....................................................',
146    '..................XXX................................',
147    '.................XCCCX...............................',
148    '............XXX.XCCCCCXXXXX..........................',
149    '...........XCCCXXCCCCCXXCCXX.........................',
150    '..........XCCCCCXXCCCCXCCCCXX........................',
151    '..........XCCCCCCCCCCCCCCCCXX........................',
152    '.........XCCCCCCCCCCCCCCCCCXXX.......................',
153    '.....XX..XCCCCCCCCCCCCCCCCCCCXX......................',
154    '....XYYXXXCCCCCCCCCCCCCCCCCCCCXX.....................',
155    '...XYYYXXCCCXXXCCXXCCCCCCCCCCCCX.....................',
156    '..XYYYYYXXCXIIIXCCXXXCCCCCCCCCCX.....................',
157    '..XYYYXXCCXIIIIIXXIIIXCCCCCCXCCX.....................',
158    '..XYYYXCCCXIIIXIXIIIIIXCCCCCXXX......................',
159    '..XYYYXXCCXIIIIIXIXIIIXCCCCCCXX......................',
160    '..XYYYYXCCCXIIIXXIIIIIXCCCCCXXX......................',
161    '...XYYYYXXCCXXXCXXIIIXXCCCXXYYYX.....................',
162    '....XYYXCCCCCCCCCXXXXXCCXXXYYYYYXX...XXXX............',
163    '.....XXCCCCCCCCCCCCCCCCXXCXXXXYYYXX.XRRRRX..XXX......',
164    '......XCCCCCCCCCCCCCCCCCCCCCXYYYYXXXRRRRRRXXRRRX.....',
165    '......XCCCCCCCCCCCCCCCCCCCCXYYYYYXXRRRRRRRXRRRRRX....',
166    '.......XCCCCCCCXXCCCCCCCCCCXYYYYYXRRRRRRRRRRRRRRXX...',
167    '........XCCXXXXXCCCCCCCCCCCXYYYYXXRRRRRRRXXXXRRRRX...',
168    '........XCXYYYYXXXCCCCCCCCCCXXXX.XRRRRXRRRXXXRRRRX...',
169    '.........XYYYYYYXCCCCCCCCCCCX....XRRRPRXRXXRRRRRRX...',
170    '.........XYYYYYYXCCCCCCXCCCXX....XRRRRRRXXRRRRRRRX...',
171    '.....XXXXYYYYYXXXXCCCCCXCCX....XXRXRRRRRRRRRRRRRX....',
172    '....XYYYXYYYYXXCCXXCCCXYXX.....XRRXXRRRRRRRRRRRX.....',
173    '....XYYYYYYYYXXXXXXXXXYYYXX....XRRRXRRRRRRRRRXRDX....',
174    '....XYYYYYYYYX.XXXXYYYYYYYX.....XXX.XRRRRRRRRXXRX....',
175    '.....XYYYYYYX.....XYYYYYYYX.........XXRRRRRRXDXX.....',
176    '......XXYYYX.......XXXXYYYX.......XXDDXXXXXXDDDX.....',
177    '........XXX............XYYX......XDDDDX....XXDDX.....',
178    '........................XX........XDDX....XXDDX......',
179    '...................................XX.......XX.......',
180  ],
181  [
182    '.....................................................',
183    '.....................................................',
184    '.................XXXX................................',
185    '................XXCCCX...............................',
186    '............XXXXXCCCCCXXXX...........................',
187    '...........XCCCXXCCCCXXXCCX..........................',
188    '..........XCCCCCXCCCCXXCCCCX.........................',
189    '.........XCCCCCCCCCCCCCCCCCXX........................',
190    '........XCCCCCCCCCCCCCCCCCCXXX.......................',
191    '........XCCCCCCCCCCCCCCCCCCCCCXX.....................',
192    '.......XCCCCCCCCCCCCCCCCCCCCCCCXX....................',
193    '....XXXXCCCCXXXCCXXCCCCCCCCCCCCX.....................',
194    '...XYYYXXXCXIIIXCCXXXCCCCCCCCCCX.....................',
195    '..XYYYXXCCXIIIIIXXIIIXCCCCCCCCCX.....................',
196    '.XYYYXXXCCXIIIXIXIIIIIXCCCCCCXX......................',
197    '.XYYYXXCCCXIIIIIXIXIIIXCCCCCCCX......................',
198    '.XYYYXXCCCCXIIIXXIIIIIXCCCCCXXXXX....................',
199    '.XYYYYXXCCCXXXXXXIIIIIXCCCXXYYYYYX...................',
200    '.XYYYYYYXCCCXXXCCXIIIXCCCXXYYYYYYXXX..XXXX...........',
201    '..XYYYYXXCCCCCCCCCXXXCCCCXXXYYYYYYXX.XRRRRX..........',
202    '...XYYXCCCCCCCCCCCCCCCCCCCCCXXYYYYXXXRRRRRRX.XXXX....',
203    '....XXCCCCCCCCCCCCCCCCCCCCCCXYYYYYXXXRRRRRRXXXRRXX...',
204    '.....XCCCCCCCCCCCCCCCCCCCCCXYYYYYYXRRRRRRRRRRRRRRRX..',
205    '.....XCCCCCCCCXCCCCCCCCCCCCXYYYYYYXRRRRRRRXXRRRRRRX..',
206    '......XCCCXXXXXXCCCCCCCCCCCXYYYYYXRRRRRXRRXXRXRRRRX..',
207    '.......XCXYYYYXXCCCCCCCCCCCCXXXXX.XRRRRXRRXXXXRRRRR..',
208    '........XYYYYYXCCCCCCCCCCCCCX.....XRRRPRXXXXRRRRRRR..',
209    '.......XYYYYYYXCCXXCCCCXCCXX.....XXRRRRRRXXRRRRRRRR..',
210    '.......XYYYYYXXXXCCCCCCXXXX.....XXRXRRRRRRRRRRRRRRX..',
211    '......XYYYYYXXCCCXCCCCXYYYX....XXRRXXRRRRRRRRRRRXX...',
212    '......XYYYYYX.XXXXXXXXYYYYYX....XRRXXRRRRRRRRRXRRXX..',
213    '....XXXYYYYYX.....XYYYYYYYYX.....XX.XXRRRRRRRRXXRXX..',
214    '...XXYYYYYYX.......XYYYXXYYX......XXXDXRRRRRRXDXXX...',
215    '....XYYYYYXX........XXXXXYYX.....XXDDDDXXXXXXDDDXX...',
216    '....XXYYYYX..............XX......XXDDDDX....XXDDXX...',
217    '......XXXX........................XXDDX....XXDDXX....',
218    '....................................XX......XDDX.....',
219    '....................................XX.......XX......',
220  ],
221  [
222    '.................XXXX................................',
223    '................XCCCCX...............................',
224    '............XXXXCCCCCCXXXX...........................',
225    '............XXXXCCCCCCXXCCX..........................',
226    '..........XXCCCXCCCCCXXCCCXX.........................',
227    '.........XXCCCCCCCCCCXXCCCCX.........................',
228    '.........XXCCCCCCCCCCCCCCCCXX........................',
229    '........XCCCCCCCCCCCCCCCCCCXCX.......................',
230    '.....XX.XCCCCCCCCCCCCCCCCCCCCCXX.....................',
231    '....XYYXCCCCCCCCXCCCCCCCCCCCCCXX.....................',
232    '...XXYYXCXCCXXXCXXXCCCCCCCCCCCXX.....................',
233    '...XYYYYXXCXIIIXCCCCCCCCCCCCCCXX.....................',
234    '.XXYYYYXXXXXIIIXXCXXXCCCCCCCCCXX.....................',
235    '.XXYYYYXCCXIIIIIXXIIIXCCCCCCCCXX.....................',
236    '.XXYYYXCCCXIIIXIXIIIIIXCCCCCCX.......................',
237    '.XXYYYXCCCXIIIIIXIXIIIXCCCCCCXXX.....................',
238    '.XXYYYYXCCCXIIIXXIIIIIXCCCCCXXXX.....................',
239    '.XXYYYYXXCCXXXXXXIIIIIXCCCXXYYYYX....................',
240    '...XYYYYXCCCXXXCCXIIIXCCCCXYYYYYYXX.XXXX.............',
241    '....XYXXCCCCCCCCCCXXXCCCCXXXYYYYYXXXRRRRX............',
242    '.....XCCCCCCCCCCCCCCCCCCCCCXXXYYYXXRRRRRRXXXX........',
243    '.....XCCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRXXRRX.......',
244    '.....XCCCCCCCCCCCCCCCCCCCCCXYYYYXRRRRRRRRRRRRRX......',
245    '......XCXCCCCCCXCCCCCCCCCCCXYYYYYRRRRRRRXRRRRRRX.....',
246    '.......XXCCCCCCXCCCCCCCCCCCXYYYYYRRRRRRRXXXRRRRX.....',
247    '.......XCCCXXXXXXCCCCCCCCCCXXYYYXRRRRXRRXXXRRRRX.....',
248    '.......XXCXXYYYXCCCCCCCCCCCXXXXXDRRRPRXRXRRRRRRX.....',
249    '........XXYYYYYYCCCCCCCXXCCCX...XRRRRRRXXRRRRRRX.....',
250    '.........XYYYYYXXXCCCCCXXCX....XRXRRRRRRRRRRRRX......',
251    '......XXXYYYYYXCCXXCCCXYYX....XRRXXRRRRRRRRRRX.......',
252    '.....XYYYYYYYYXXXXXXXXYYYYX...XXRXXRRRRRRRRXRRX......',
253    '.....XYYYYYYYYXXXXXYYYYYYYX....XX..XRRRRRRRXXXX......',
254    '.....XXYYYYYXX....XXYYYYYYX.....XXXDXRRRRRXDXX.......',
255    '......XYYYYYXX.....XYYYXYYX....XXDDDDXXXXXDDDX.......',
256    '.......XYYYXX.......XXXXYYX....XXDDDDX...XXDDX.......',
257    '........XXXX............XX......XXDDX....XDDX........',
258    '.........XX......................XXX.....XDDX........',
259    '..................................XX......XX.........',
260  ],
261  [
262    '.....................................................',
263    '.....................................................',
264    '...................XXX...............................',
265    '.................XXCCCX..............................',
266    '................XXCCCCCX.XXX.........................',
267    '............XXXXXXCCCCCXXCCCX........................',
268    '..........XXCCCCXXCCCCCXXCCCCX.......................',
269    '.........XXCCCCCXXXCCCCXCCCCCX.......................',
270    '.........XCCCCCCCCCCCCCCCCCCCXXX.....................',
271    '........XCCCCCCCCCCCCCCCCCCCCCCCX....................',
272    '....XX..XCCCCCCCCCCCCCCCCCCCCCCCCX...................',
273    '....XX..XCCCCCCCCCCCCCCCCCCCCCCCCX...................',
274    '...XYYXXCCCXXXXCCXXXCCCCCCCCCCCCCX...................',
275    '..XYYYYXXCXIIIIXCCCXXXCCCCCCCCCCCX...................',
276    '.XYYYYXCCXIIIIIIXCXIIIXCCCCCCCCCXX...................',
277    '.XYYYXCCCXIIIIXIXXIIIIIXCCCCCCCXX....................',
278    '.XYYYXCCCXIIIIIIXXIXIIIXCCCCCCCXX....................',
279    '.XYYYYXCCCXIIIIXXXIIIIIXCCCCCCXXXX...................',
280    '.XYYYYYXCCCXXXXCCXIIIIIXCCCCXXYYYX.....XXXX..........',
281    '..XYYYYXCCCCCCCCCCXIIIXCCCCCXYYYYYX...XXXXXX.........',
282    '...XYYXCCCCCCCCCCCCXXXCCCCXXYYYYYYX...XRRRRX.........',
283    '....XXCCCCCCCCCCCCCCCCCCCXXCCXXXYYYX.XRRRRRRX.XXXX...',
284    '....XXCCCCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRRXXRRRXX..',
285    '.....XCCCCCCCCCCCCCCCCCCCCCCCXYYYYYXRRRRRRRRRRRRRRRX.',
286    '.....XCCCCCCCCCCCCCCCCCCCCCCCXYYYYYXRRRRRRRXRRRRRRRXX',
287    '......XCCCCCCCCXXCCCCCCCCCCCCXYYYYXRRRRRRRRXXRRRRRRRX',
288    '......XCCCXXXXXXXXCCCCCCCCCCCCXXXXXXRRRRXRRXXXXRRRRRX',
289    '.......XCXYYYYYXXXCCCCCCCCCCCCXXXX.XRRRPRXRXXRRRRRRRX',
290    '.......XXYYYYYYYXCCCCCCCCCCCCCX....XRRRRRXXXRRRRRRRRX',
291    '.......XYYYYYYYYXCCCCCCCXXCCXX....XXRRRRRRRRRRRRRRRXX',
292    '......XYYYYYYYXXXXXCCCCCXXXXX.....XRXRRRRRRRRRRRRRXX.',
293    '..XX.XYYYYYYYXXCCCXXCCCXYYYYYX....XRRXRRRRRRRRRRRXX..',
294    '.XYYXYYYYYYXX.XXXX..XXXYYYYYYYX...XXX.XRRRRRRRRXXRRX.',
295    '.XYYYYYYYYX..........XYYYYYYYYX....XXXDXRRRRRXXDXXX..',
296    '..XYYYYYYX............XXXXXYYYX....XXXDDXXXXXDDDDX...',
297    '..XYYYYYX.................XXYYX...XDDDDDX...XXDDDX...',
298    '...XYYYX...................XXX.....XXDDX....XDDDX....',
299    '....XXX.............................XXX......XXX.....',
300  ],
301  [
302    '.....................................................',
303    '.....................................................',
304    '.................XXXX................................',
305    '................XCCCCXX..............................',
306    '...............XCCCCCCXXXX...........................',
307    '...........XXXXXCCCCCCXXCCX..........................',
308    '..........XCCCCXCCCCCCXXCCXX.........................',
309    '..........XCCCCXXCCCCCXCCCCX.........................',
310    '.........XCCCCCCCCCCCCCCCCCXXX.......................',
311    '........XCCCCCCCCCCCCCCCCCCXXXX......................',
312    '........XCCCCCCCCCCCCCCCCCCCCCX......................',
313    '.......XCCCCCCCCCCCCCCCCCCCCCCCX.....................',
314    '.....XXXCCCCXXXCCXXCCCCCCCCCCCCX.....................',
315    '....XYYXXCCXIIIXCCCCCCCCCCCCCCCX.....................',
316    '...XYYYYXCXIIIIIXCXXXXCCCCCCCCCX.....................',
317    '..XXYYYXCCXIIIIIXXIIIIXCCCCCCCXX.....................',
318    '..XYYYXXCCXIIIXIXIIIIIIXCCCCCXX......................',
319    '..XYYYXCCCXIIIIIXIXIIIIXCCCCXXXX.....................',
320    '..XYYYYXCCCXIIIXXIIIIIIXCCCXYXXXX....................',
321    '..XYYYYXXCCCXXXCXXIIIIXXCCXXYYYYX....XXXX............',
322    '...XYYYYXCCCCCCCCXXXXXXCCXXYYYYYYX..XRXXRX...........',
323    '....XYYXCCCCCCCCCCCCCCCCCCCXXXYYYX.XRRRRRXXXXXX......',
324    '.....XXCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRRXXRRRXX....',
325    '......XCCCCCCCCCCCCCCCCCCCCXYYYYYXDRRRRRRRRRRRRRX....',
326    '......XCCCCCCCCCXCCCCCCCCCCXYYYYYXRRRRRRRXXRRRRRR....',
327    '.......XXCCCXXXXXCCCCCCCCCCCXYYYXRRRRRXRRXXXRRRRR....',
328    '.......XCCCXYYYYXXCCCCCCCCCCXXXX.XRRRRXXRXXRRRRRR....',
329    '........XCXYYYYYYCCCCCCCCCCCX....XRRRPRXXXRRRRRRR....',
330    '.........XYYYYYYYCCCCCCCXCCX....XXRRRRRRRRRRRRRRX....',
331    '.....XXX.XYYYYYYXXXCCCCCXXX....XXRXRRRRRRRRRRRRRX....',
332    '....XXXXXYYYYYYXCCXXCCCXYYYX...XXRXRRRRRRRRRRRXX.....',
333    '....XYYYYYYYYYXXCXXXXXXYYYYYX...XRXXRRRRRRRRXXRRX....',
334    '....XYYYYYYYYXXXXX....XYYYYYX....X..XRRRRRRRXXXXX....',
335    '.....XYYYYYYX..........XXYYYX.....XXDXRRRRRXDDX......',
336    '......XXYYYXX...........XYYYX...XXDDDDXXXXXXDDX......',
337    '.......XYYYX.............XXX.....XDDDXX...XXDDX......',
338    '........XXX.......................XDDX....XDDX.......',
339    '...................................XX......XX........',
340  ],
341  [
342    '.....................................................',
343    '.....................................................',
344    '.....................................................',
345    '................XXXX.................................',
346    '...............XCCCCXX...............................',
347    '..............XCCCCCCXXXX............................',
348    '...........XXXXCCCCCCXXCCX...........................',
349    '.........XXCCCXCCCCCCXXCCXX..........................',
350    '.........XXCCCXCCCCCCXCCCCX..........................',
351    '........XXCCCCCCCCCCCCCCCCXX.........................',
352    '.......XCCCCCCCCCCCCCCCCCCCCX........................',
353    '.......XCCCCCCCCCCCCCCCCCCCCCX.......................',
354    '....XXXXCCCCCCCXCCCCCCCCCCCCCX.......................',
355    '...XYYXCCCCXXXCXXXCCCCCCCCCCCX.......................',
356    '..XYYYYXXCXIIIXCCXXXXCCCCCCCCX.......................',
357    '.XYYYYXCCXIIIIIXXIIIIXCCCCCXX........................',
358    '.XYYYXCCCXIIIXIXIIIIIIXCCCCCX........................',
359    '.XYYYXCCCXIIIIIXIXIIIIXCCCCCX........................',
360    '.XYYYYXCCCXIIIXXIIIIIIXCCCCCXX.......................',
361    '.XYYYYYXCCCXXXCXIIIIIIXCCCXXYYX......................',
362    '..XYYYYXXCCCCCCCXIIIIXCCCCXXYYX.....XXXX.............',
363    '...XYYXCCCCCCCCCCXXXXCCCXXYYYYYX...XXXXXX............',
364    '....XXCCCCCCCCCCCCCCCCCCXCXXXYYYXXXXRRRRXXXXXX.......',
365    '.....XCCCCCCCCCCCCCCCCCCCCCCXYYYXXXRRRRRRXXRRXX......',
366    '.....XCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRRRRRRRRX.....',
367    '......XXCCCCCCXCCCCCCCCCCCCXYYYYXRRRRRRRXRRRRRRX.....',
368    '......XCCCCCXXXCCCCCCCCCCCCXYYYXRRRRRXRRXXXRRRRR.....',
369    '.......XCCXXYYXXCCCCCCCCCCCCXXX.XRRRRXRRXXXRRRRR.....',
370    '.......XCXYYYYDCCCCCCCCCCCCCX...XRRRPRXXXRRRRRRR.....',
371    '........XXYYYYYCCXXCCCCCXCCX...XXXRRRRRXRRRRRRRX.....',
372    '........XYYYYYYXXCCCCCCCXXX...XXRXRRRRRRRRRRRRXX.....',
373    '.....XXXXYYYYYXCCXCCCCCXYYX...XXRXXRRRRRRRRXRRXX.....',
374    '.....XYYYYYYYXXXX.XXXXXYYYYX...XXX.XRRRRRRRXXRXX.....',
375    '......XYYYYYYX...XXYYYYYYYYX.....XXDXRRRRRXDDXX......',
376    '......XYYYYYXX....XXYYYYYYYX....XDDDDXXXXXDDDX.......',
377    '.......XYYYYX.......XXXXXYYX....XDDDDX...XXDDX.......',
378    '........XXXX............XYYX.....XDDX....XDDX........',
379    '.........................XX.......XX......XX.........',
380  ],
381  [
382    '.................XXX.................................',
383    '................XCCCXX...............................',
384    '...............XCCCCCXXXX............................',
385    '............XXXXCCCCCXXCCX...........................',
386    '...........XCCCXCCCCCXXCCCX..........................',
387    '..........XCCCCXXCCCCXCCCCX..........................',
388    '.........XXCCCCCCCCCCCCCCCXX.........................',
389    '.........XCCCCCCCCCCCCCCCCXCX........................',
390    '.....XX..XCCCCCCCCCCCCCCCCCCCX.......................',
391    '....XYYXXXCCCCCCXCXCCCCCCCCCCX.......................',
392    '...XYYYXXCCCXXXCXXXCCCCCCCCCCX.......................',
393    '..XYYYYXXXCXIIIXCCXXXCCCCCCCCX.......................',
394    '..XYYYXXCCXIIIIIXXIIIXCCCCCXX........................',
395    '..XYYYXCCCXIIIXIXIIIIIXCCCCCX........................',
396    '..XYYYXCCCXIIIIIXIIIIIXCCCCCX........................',
397    '..XYYYYXCCCXIIIXXIIIIIXCCCCCXXX......................',
398    '...XYYYYXCCCXXXCXIIIIIXCCCXXYYYX.....................',
399    '....XYYXCCCCCCCCCXXXXXCCXXYYYYYYX....XXXX............',
400    '.....XXCCCCCCCCCCCXXXCCCXCXXXYYYX...XRRRRXX.XXXX.....',
401    '......XCCCCCCCCCCCCCCCCCXCCCXYYYX..XRRRRRRXXXXXX.....',
402    '......XCCCCCCCCCCCCCCCCCCCCCXYYYX.XXRRRRRRXXXRRXX....',
403    '......XXCCCCCCCCCCCCCCCCCCCXYYYYX.XRRRRRRRRRRRRRRX...',
404    '.......XXCCCCCCCCCCCCCCCCCCXYYYYXRRRRRRRRXXXRRRRRX...',
405    '.......XXCCCXXXXXCCCCCCCCCCCXYYX.XRRRRXRRRXXXRRRRX...',
406    '........XCCXYYYYXCCCCCCCCCCCXXX..XRRRRXXRXXXRRRRRX...',
407    '........XCXYYYYYXCCCCCCCCCCCX....XRRRPRXXXXRRRRRRX...',
408    '.........XXYYYYYXCXCCCCCXCCX....XXRRRRRRXXRRRRRRRR...',
409    '..........XYYYYYXXXCCCCCXCCX...XXRXRRRRRRRRRRRRRRX...',
410    '..........XYYYYYXXCCCCCCXXXX...XRRXRRRRRRRRRRRRXXX...',
411    '.......XXXYYYYYXCCXCCCCXYYYXX..XRRRXRRRRRRRRRRRXX....',
412    '.......XXXYYYYYXCCXCXXXXYYYYX...XRXXRRRRRRRRRXXRRX...',
413    '......XYYXYYYYXDXX.XXXXYYYYYX...XXX.XRRRRRRRRXXRRX...',
414    '......XYYYYYYYX....XXYYYYXYYX.....XXDXRRRRRRXDXXX....',
415    '......XYYYYYYX......XXXXXXYYX...XXDDDDXXXXXXDDDX.....',
416    '.......XYYYYY.............XX....XXDDDDX....XXDDX.....',
417    '........XYYXX.....................XDDX.....XDDXX.....',
418    '........XXXX.......................XX......XDDX......',
419    '.........XX.................................XX.......',
420  ],
421  [
422    '.....................................................',
423    '.....................................................',
424    '................XXXX.................................',
425    '...............XCCCCXX...............................',
426    '..............XCCCCCCCXXX............................',
427    '...........XXXXCCCCCCXXCCX...........................',
428    '..........XCCCXXCCCCCXCCCCX..........................',
429    '.........XCCCCXXCCCCCXCCCCXX.........................',
430    '........XCCCCCCCCCCCCCCCCCXXX........................',
431    '.......XCCCCCCCCCCCCCCCCCCCCX........................',
432    '.......XCCCCCCCCCCCCCCCCCCCCCX.......................',
433    '.....XXXCCCCCCCCCCCCCCCCCCCCCX.......................',
434    '....XYXCCCCXXXCCXXCCCCCCCCCCCX.......................',
435    '...XYYYXXCXIIIXCCXXXXCCCCCCCCX.......................',
436    '..XYYYYXCXIIIIIXXIIIIXCCCCCXX........................',
437    '..XYYYXCCXIIIXIXIIIIIIXCCCCCCX.......................',
438    '..XYYYXCCXIIIIIXIXIIIIXCCCCCXXXX.....................',
439    '..XYYYYXCCXIIIXXIIIIIIXCCCXXXXXXX....................',
440    '..XYYYYXXCCXXXCCXIIIIXCCCCXXYYYYX....................',
441    '...XYYXCCCCCCCCCXXXXXXCCCXYYYYYYYX..XXXX.............',
442    '....XYXCCCCCCCCCCXXXXCCCXXXXXYYYYX.XRRRRX..XX........',
443    '....XXCCCCCCCCCCCCCCCCCCXCCXXYYYYXXXRRRRXXXXXX.......',
444    '.....XCCCCCCCCCCCCCCCCCCCCCCXYYYYXXRRRRRRXXRRXX......',
445    '.....XCCCCCCCCCCCCCCCCCCCCCXYYYYYXRRRRRRRRRRRRRX.....',
446    '......XXCCCCCCXXCCCCCCCCCCCXYYYYXRRRRRRRXXRRRRRRX....',
447    '......XXCCCXXXXXCCCCCCCCCCCCXYYXDRRRRXRRXXXRRRRRX....',
448    '.......XXCXYYYXXCCCCCCCCCCCCXXX.XRRRRXXRRXRRRRRRX....',
449    '.......XXXYYYYYXCCCCCCCCCCCCX...XRRRPRXXXRRRRRRRX....',
450    '........XXYYYYYXCXXCCCCCXCCX...XXRRRRRRXXRRRRRRXX....',
451    '........XYYYYYYXXXCCCCCCXXX...XXRXRRRRRRRRRRRRXX.....',
452    '........XYYYYXXCCXXCCCCXYYYX..XXRRXRRRRRRRRRRXX......',
453    '........XYYYYYYXX.XXXXXYYYYX...XXXXXRRRRRRRXXRRX.....',
454    '.........XYYYYYYX..XXYYYYYYX.......XRRRRRRRXXDX......',
455    '.........XXYYYYYX...XXXXXYYX.....XXDXRRRRRXDDXX......',
456    '..........XXYYYYX....XXXXYYX....XDDDDXXXXXDDDX.......',
457    '..........XYYYYYX........XX.....XDDDDX...XXDDX.......',
458    '..........XYYYYX.................XDDX....XDDX........',
459    '...........XXXX...................XX......XX.........',
460  ],
461  [
462    '.....................................................',
463    '..................XXX................................',
464    '.................XCCCX...............................',
465    '.............XXXXCCCCCXXXXX..........................',
466    '............XXXXXCCCCCXXCCCX.........................',
467    '..........XXCCCXXCCCCCXXCCCCX........................',
468    '..........XXCCCCXXCCCCXCCCCCX........................',
469    '.........XCCCCCCCCCCCCCCCCCCXXX......................',
470    '........XCCCCCCCCCCCCCCCCCCCCCCX.....................',
471    '.....XX.XCCCCCCCCXCCCCCCCCCCCCCCX....................',
472    '....XYYXCCCCXXXXCXCCCCCCCCCCCCCCX....................',
473    '...XYYYXXCCXIIIIXXCCCCCCCCCCCCCCX....................',
474    '..XYYYYYXXXIIIIIIXXXXXCCCCCCCCCCX....................',
475    '..XYYYYXCCXIIIIIIXXIIIXCCCCCCCCCX....................',
476    '..XYYYXCCCXIIIIXIXIIIIIXCCCCCCXX.....................',
477    '..XYYYXCCCXIIIIIIXIXIIIXCCCCCCCX.....................',
478    '..XYYYYXCCCXIIIIXXIIIIIXCCCCCCXXXX...................',
479    '..XXYYYYXCCCXXXXCXIIIIIXCCCCXXYYYYX..................',
480    '...XYYYYXCCCCCCCCCXIIIXCCCXCXYYYYYYX.................',
481    '....XYYXCCCCCCCCCCCXXXCCCCXXXXYYYYYX.................',
482    '.....XXCCCCCCCCCCCCCCCCCCXXCXXXXYYYYX................',
483    '......XCCCCCCCCCCCCCCCCCCCCCCCXXYYYYX................',
484    '......XCCCCCCCCCCCCCCCCCCCCCCCXYYYYYX................',
485    '.......XXCCCCCCCCCCCCCCCCCCCCXYYYYYYX................',
486    '.......XCCCCCCCCCCCCCCCCCCCCCXYYYYYX.................',
487    '.......XCCCCXXXXXCCCCCCCCCCCCCXYYYYX.................',
488    '........XCXXYYYYYXCCCCCCCCCCCCXXXXX..................',
489    '.........XXYYYYYYXCCCCCCCXCCXX.......................',
490    '.........XYYYYYYYDCCCCCCXXCCX........................',
491    '........XYYYYYYYYXCCCCCCXXXXX........................',
492    '.....XXXYYYYYYYXXXCCCCCXXYYYYX.......................',
493    '....XXXXYYYYYYXXCCXCCCXYYYYYYXX......................',
494    '....XYYYYYYYYX..XX.XXXYYYYYYYYX......................',
495    '....XYYYYYYYX.........XXXXXYYYX......................',
496    '.....XYYYYYXX..............XYYX......................',
497    '.....XXYYYXXX...............XX.......................',
498    '......XYYYX.................X........................',
499    '.......XXX...........................................',
500  ],
501]
502