Publishes a pixel Clawd acting out what Claude is doing right now, for the desktop bar to paint.

NixOS + Home Manager configuration for victus, structured as a dendritic flake-parts tree: every .nix file under modules/ is a self-contained, self-registering module, auto-discovered by import-tree - there's no manual imports = [ ... ] list anywhere in modules/. A feature that spans both layers (e.g. Hyprland: compositor enable at the NixOS level, config + theme registration at the Home Manager level) declares both in one file instead of being split across a system/user directory boundary.
Hyprland under UWSM, SDDM wearing the same face as the lock screen, and a Quickshell shell that owns the desktop's notifications, lock screen, polkit prompts, credential prompts, screen-share picker and tray. It is deliberately plain: one status line along the bottom, a themed wmenu for every choice, and flat cards only where something needs an answer. The Omen key switches the whole desktop between a flat look (square, translucent, blurred, still layers) and a soft one (rounded, opaque, animated layers). Colours are runtime, not build-time: theme-set <name> repaints almost everything without a rebuild.
flake.nix # entry point: inputs + flake-parts + import-tree ./modules
hosts/victus/ # host identity + hardware - not a pooled aspect
default.nix # hostName, networking, users, GPU workarounds
hardware.nix # nixos-generate-config output
modules/ # one directory per aspect: <name>/<name>.nix
# plus whatever files it reads or symlinks
flake/ # the flake's own assembly, not aspects
system.nix # nixosConfigurations.victus assembly
checks.nix # theme-render check (see below)
formatter.nix # treefmt (nixfmt, deadnix, stylua, shfmt, keep-sorted)
apps.nix, lib.nix # nix run .#<app> standalone previews
boot/, locales/, nix/, coredump/, secrets/ # nixos-only aspects
desktop/ # portals, power, bluetooth
networking/ # NetworkManager, BBR, wireshark
virtualisation/ # libvirt + virt-manager (the Proxmox lab VM)
sddm/ # SDDM (Xorg-hosted greeter) wearing a theme
# built from the lock screen's own QML
auth/ # bare-askpass: ssh, git and sudo prompts
# routed into the shell (nixos + homeManager)
screencast/ # bare-share-picker: the screen-share
# picker xdg-desktop-portal-hyprland raises
hyprland/ # compositor: nixos enable + homeManager
# config (Lua, live-edited via symlink)
theme-engine/ # runtime theming system - theme-set CLI,
# theme_engine.py, themes/*.toml
quickshell/ # the shell (QML) - see below, plus its
# lock screen's PAM service and hypridle
bare/ # the menus: bare-menu (themed wmenu,
# patched for image previews), bare-pick
# (the menus), bare-look (flat / soft)
gaming/ # RTX 3050 Ti under PRIME offload, Steam
# and gamescope, the gamerun wrapper
calendar/ # Google Calendar sync (gcal CLI + timer)
# feeding the agenda, and course deadlines
# pushed from Markdown files on save
audio/, files/, localsend/ # remaining service aspects
cli/, packages/, appearance/, cursor/,
icons/, scripts/ # shell/fonts/cursor/icons/ad-hoc packages,
# writeShellApplication wrappers
neovim/ # nixvim, and the $EDITOR/$VISUAL contract
music/ # mpd + rmpc, local files, MPRIS into the shell
kitty/, browser/, gtk/, qt/, starship/,
vesktop/ # remaining homeManager-only aspects
utils/ # exposes `utils` module arg
# (dotfiles path + symlink helper)
modules/quickshell/ is a Quickshell/QML shell with one status line along the bottom: workspaces, the focused app (or qutebrowser's and yazi's own status line while they have focus), and on the right whatever is going on - a recording, a transfer, a download, the next event, the timer, muted, do not disturb, night light, the mic in use - then weather, battery, network and the clock. Every segment is clickable. It owns the desktop's notification server, lock screen, polkit agent, screen-share picker and system tray, and every part of it is backed by a real service - PipeWire, MPRIS, UPower, NetworkManager, bluez, cliphist, StatusNotifier, open-meteo, PAM, polkit, xdg-desktop-portal, Hyprland's IPC - with nothing mocked.
bare-pick <source> opens a themed wmenu for each of them: apps (or a web search), windows, clipboard, wallpaper, theme, media controls, power and profiles, Wi-Fi, Bluetooth, audio, displays, calendar, course deadlines, notes, the timer, the files shelf, LocalSend, the tray (an app, then its own right-click menu), and a calculator REPL. SUPER+B lists them all.SUPER+? opens a cheatsheet card.shell.qml # ShellRoot: the bar per screen, the OSD and cards, the session lock, every IPC target
singletons/ # state, theme tokens, and the service wrappers
bare/ # the surfaces: bar, OSD, popup, prompt cards, cheatsheet
lock/ # the session lock surface and its face (also the SDDM greeter's)
components/ # the icon glyph the Moodle app shares
Driven by keybinds or over IPC:
| Keys | Action |
|---|---|
SUPER+B | every menu |
SUPER+space | launcher, or ?query to search |
Calculator | calculator |
SUPER+V / SUPER+SHIFT+V | clipboard / files shelf |
SUPER+A | wallpaper |
SUPER+SHIFT+T | timer |
SUPER+M / SUPER+O | media controls / windows |
SUPER+Escape | power and profiles |
SUPER+T | tray |
SUPER+? | keybinding cheatsheet |
SUPER+CTRL+L | lock |
Omen | the flat look and the soft one |
SUPER+D | Discord and music pad |
bare-pick # list every menu
bare-pick wifi # open one from a terminal
quickshell ipc call bare osd vol
quickshell ipc call look toggle
quickshell ipc call timer start 300
QML is symlinked from the repo, so edits apply on save with no rebuild.
The theme engine (theme-set <name>) renders each app's config from a template and reloads it in place. Hyprland, kitty, Neovim, vesktop, rmpc, GTK, Qt, SDDM and the shell all follow it without a rebuild.
A theme is a palette and a font, nothing else - no wallpaper, no radius, no light/dark flag. The wallpaper is your own pick (theme-set wallpaper <path>, or the wallpaper menu) and persists across theme switches, re-tinted to each theme's accent unless you pass --no-recolor. The wallpaper menu also lists Gustave Doré's engravings, which are always dithered to two tones of the current theme.
Adding a theme means dropping a .toml into modules/theme-engine/themes/; just check renders every theme against every template and fails on a missing key or a leftover {{token}}.
The flake describes one machine (victus) and one user (neru). To install it somewhere else, rename those and delete the parts that only make sense here.
/mnt.~/flake, because the shell, Hyprland and the other live-edited configs are symlinks into that checkout: ``sh git clone https://github.com/isneru/flake /mnt/home/<user>/flake cd /mnt/home/<user>/flake ``sh git rm -r modules/{calendar,isep-site,phone,moodle,java,mic,wireguard} modules/packages/personal.nix modules/packages/*-monitor.sh ` and, unless you set them up, modules/secureboot (needs sbctl create-keys and the keys enrolled in firmware) and modules/gaming (needs NVIDIA hybrid graphics). Without calendar and isep-site nothing reads secrets/, so no sops key is needed. modules/whatsapp (a WhatsApp client you link to your own account) and modules/claude (Claude Code skills) are optional - delete them if you do not use them. modules/packages/optional.nix is tools nothing else in the flake calls - keep, trim or empty it to taste; core.nix` beside it is what the shell and scripts need.sh git mv hosts/victus hosts/<host> sed -i 's/\bneru\b/<user>/g' hosts/<host>/default.nix modules/flake/system.nix \ modules/sddm/sddm.nix modules/virtualisation/virtualisation.nix sed -i 's/\bvictus\b/<host>/g' hosts/<host>/default.nix modules/flake/system.nix \ modules/neovim/neovim.nix ` If you kept calendar, isep-site or phone, rename in modules/calendar/calendar.nix, modules/isep-site/isep-site.nix and modules/phone/phone.nix too. Other matches for neru (lua/neru/ in Neovim, --neru-surface` in the Vesktop theme) are internal names - leave them.hosts/<host>/default.nix: timezone, keyboard layout, and drop what belongs to this laptop (the hardware.nvidia.prime bus IDs, i915 and its enable_fbc=0, the nouveau blacklist): ``sh nixos-generate-config --root /mnt --show-hardware-config > hosts/<host>/hardware.nix ``git add -A (the flake cannot see untracked files), then install and give the user a password (SDDM logs in automatically, but the lock screen and sudo ask for it): ``sh nixos-install --flake .#<host> nixos-enter --root /mnt -c 'passwd <user>' nixos-enter --root /mnt -c 'chown -R <user>:users /home/<user>/flake' ``just switch # nixos-rebuild switch --flake . (needs a password - run it yourself)
just build # nixos-rebuild build (no activation) - safe to verify a change
just check # nix flake check
just fmt # format all files (treefmt)
just update # update flake inputs, commit the lockfile
just clean # gc generations older than 7 days, optimise the store
just repair # verify and repair the Nix store (alias: just fix)
just deploy <host> # switch a remote host over SSH
just secrets # decrypt/edit secrets/secrets.yaml with sops
just rotate # rotate SOPS secrets (secrets/*.yaml)
Run just with no arguments to list all recipes.
mkOutOfStoreSymlink (utils.create_symlink), so structural edits take effect immediately; Nix-generated and templated files still need a rebuild or theme-set reapply.nix run github:isneru/flake#kitty (also #quickshell, #btop, #shell) starts either app with this config and a theme of your choice, with no checkout and nothing written to your $HOME.gamerun (or gamerun %command% in Steam). Nothing infers "this is a game", and the launcher deliberately wraps nothing.xdg-desktop-portal-hyprland is pointed at a picker that draws in the shell. It also absorbs the burst - a Chromium app opens two or three portal sessions for one share, so picking a source used to mean answering the picker two or three times over.vesktop-plugins flake input (isneru/vesktop-custom-plugins).modules/hyprland/frame-plugin/ with its licence and built against the system's own Hyprland, so it never needs rebuilding by hand; the plates (public domain, sources in plates.toml) are in modules/theme-engine/plates/.secrets/. Rotate with just rotate.hooks/register.tsx 222 lines1import type { EngineInterface, Register, TurnStepChunk } from 'claude-code'
2
3import { drawMiniScene, recolor, type Scene, type Tint } from './scene'
4
5type Family = 'read' | 'edit' | 'bash' | 'web' | 'agent' | 'ask' | 'other'
6type Kind = 'idle' | 'waiting' | 'thinking' | 'writing' | 'drafting' | 'tool' | 'compacting' | 'done' | 'error'
7type Activity = { kind: Kind; family?: Family }
8
9const TICK_MS = 150
10const SETTLE_MS = 4000
11const SLEEP_AFTER_MS = 10 * 60_000
12const HEARTBEAT_MS = 1000
13
14const IDLE: Activity = { kind: 'idle' }
15const WAITING: Activity = { kind: 'waiting' }
16const THINKING: Activity = { kind: 'thinking' }
17const WRITING: Activity = { kind: 'writing' }
18const COMPACTING: Activity = { kind: 'compacting' }
19const DONE: Activity = { kind: 'done' }
20
21const FAMILIES: Record<string, Family> = {
22 Read: 'read',
23 Grep: 'read',
24 Glob: 'read',
25 LS: 'read',
26 LSP: 'read',
27 ToolSearch: 'read',
28 Edit: 'edit',
29 MultiEdit: 'edit',
30 Write: 'edit',
31 NotebookEdit: 'edit',
32 Bash: 'bash',
33 BashOutput: 'bash',
34 KillShell: 'bash',
35 PowerShell: 'bash',
36 Monitor: 'bash',
37 TaskStop: 'bash',
38 WebFetch: 'web',
39 WebSearch: 'web',
40 Agent: 'agent',
41 Task: 'agent',
42 SendMessage: 'agent',
43 AskUserQuestion: 'ask',
44 ExitPlanMode: 'ask',
45}
46
47let activity = IDLE
48let activitySince = 0
49let isActivityStamped = false
50let isTurnActive = false
51let frame = 0
52let callCount = 0
53
54const running = new Map<string, Activity>()
55const bar = { path: undefined as string | undefined, key: '', writtenAt: 0, id: '', tintsPath: '', tintReadAt: 0 }
56let kitty: { pid: number; window: number } | undefined
57let tint: Tint | undefined
58
59const familyOf = (tool: string): Family =>
60 FAMILIES[tool] ?? (/browser|chrome|fetch/i.test(tool) ? 'web' : 'other')
61
62function become(next: Activity): void {
63 if (next.kind !== activity.kind || next.family !== activity.family) isActivityStamped = false
64 activity = next
65}
66
67function stamp(now: number): void {
68 if (!isActivityStamped) {
69 activitySince = now
70 isActivityStamped = true
71 }
72 const isSettling = activity.kind === 'done' || activity.kind === 'error'
73 if (isSettling && now - activitySince > SETTLE_MS) {
74 activity = IDLE
75 activitySince = now
76 }
77}
78
79function sceneAt(now: number): Scene {
80 switch (activity.kind) {
81 case 'idle':
82 return now - activitySince > SLEEP_AFTER_MS ? 'sleep' : 'idle'
83 case 'drafting':
84 case 'tool':
85 return activity.family ?? 'other'
86 case 'done':
87 return 'hop'
88 case 'error':
89 return 'sweat'
90 default:
91 return activity.kind
92 }
93}
94
95function watch(chunk: TurnStepChunk): void {
96 switch (chunk.kind) {
97 case 'engine':
98 if (activity.kind === 'waiting') become(THINKING)
99 break
100 case 'thinking':
101 become(THINKING)
102 break
103 case 'text':
104 become(WRITING)
105 break
106 case 'tool':
107 become({ kind: 'drafting', family: familyOf(chunk.name) })
108 break
109 }
110}
111
112async function publish($: EngineInterface, path: string, now: number, scene: Scene): Promise<void> {
113 const { width, height, pixels } = recolor(drawMiniScene(scene, frame), tint)
114 const fields = { width, height, pixels: Array.from(pixels) }
115 const key = JSON.stringify(fields)
116 if (key === bar.key && now - bar.writtenAt < HEARTBEAT_MS) return
117 bar.key = key
118 bar.writtenAt = now
119 await $.fs.write(path, JSON.stringify({ updatedAt: now, kitty, ...fields }))
120}
121
122async function readTint($: EngineInterface): Promise<Tint | undefined> {
123 try {
124 const entry = JSON.parse(await $.fs.read(bar.tintsPath))[bar.id]
125 return entry && { body: parseInt(entry.body.slice(1), 16), shade: parseInt(entry.shade.slice(1), 16) }
126 } catch {
127 return tint
128 }
129}
130
131async function tick($: EngineInterface, path: string): Promise<void> {
132 const now = await $.clock.now()
133 stamp(now)
134 frame += 1
135 if (now - bar.tintReadAt >= HEARTBEAT_MS) {
136 bar.tintReadAt = now
137 tint = await readTint($)
138 }
139 await publish($, path, now, sceneAt(now))
140}
141
142export const register: Register = on => {
143 on('session.start', async ($, e, next) => {
144 const runtimeDir = await $.env.get('XDG_RUNTIME_DIR')
145 if (runtimeDir) {
146 bar.id = await $.session.id()
147 bar.path = `${runtimeDir}/claude-mascot/${bar.id}.json`
148 bar.tintsPath = `${runtimeDir}/claude-mascot/.tints`
149 const [pid, window] = [await $.env.get('KITTY_PID'), await $.env.get('KITTY_WINDOW_ID')]
150 kitty = pid && window ? { pid: Number(pid), window: Number(window) } : undefined
151 let isTicking = false
152 $.clock.every(TICK_MS, () => {
153 if (isTicking || bar.path === undefined) return
154 isTicking = true
155 void tick($, bar.path).finally(() => {
156 isTicking = false
157 })
158 })
159 }
160 return next(e)
161 })
162
163 on('session.end', async ($, e, next) => {
164 if (bar.path !== undefined && e.reason !== 'clear' && e.reason !== 'resume') {
165 const path = bar.path
166 bar.path = undefined
167 await $.fs.write(path, JSON.stringify({ updatedAt: await $.clock.now(), isEnded: true }))
168 }
169 return next(e)
170 })
171
172 on('turn.start', async ($, e, next) => {
173 isTurnActive = true
174 running.clear()
175 become(WAITING)
176 return next(e)
177 })
178
179 on('turn.step', async function* ($, e, next) {
180 const isMain = e.agentId === undefined
181 if (isMain) become(WAITING)
182 for await (const chunk of next(e)) {
183 if (isMain) watch(chunk)
184 yield chunk
185 }
186 })
187
188 on('tool.call', async ($, e, next) => {
189 if (e.agentId !== undefined) return next(e)
190 const call: Activity = { kind: 'tool', family: familyOf(e.tool) }
191 const id = e.tool_use_id ?? `call-${++callCount}`
192 running.set(id, call)
193 become(call)
194 try {
195 return await next(e)
196 } finally {
197 running.delete(id)
198 become([...running.values()].at(-1) ?? WAITING)
199 }
200 })
201
202 on('session.compact', async ($, e, next) => {
203 if (e.agentId !== undefined) return next(e)
204 const before = activity
205 become(COMPACTING)
206 try {
207 return await next(e)
208 } finally {
209 become(isTurnActive ? before : DONE)
210 }
211 })
212
213 on('turn.complete', async ($, e, next) => {
214 if (e.agentId === undefined) {
215 isTurnActive = false
216 running.clear()
217 become(e.reason === 'answer' ? DONE : { kind: 'error' })
218 }
219 return next(e)
220 })
221}
222hooks/scene.ts 276 lines1export type Scene =
2 | 'idle'
3 | 'sleep'
4 | 'waiting'
5 | 'thinking'
6 | 'writing'
7 | 'read'
8 | 'edit'
9 | 'bash'
10 | 'web'
11 | 'agent'
12 | 'ask'
13 | 'other'
14 | 'compacting'
15 | 'hop'
16 | 'sweat'
17
18type Canvas = { width: number; height: number; pixels: Int32Array }
19type Palette = Record<string, number>
20
21const MINI_WIDTH = 20
22const MINI_HEIGHT = 8
23const CLEAR = -1
24
25const ORANGE = 0xd97757
26const ORANGE_DARK = 0x9e4a33
27
28export type Tint = { body: number; shade: number }
29const EYE = 0x1a1a1a
30const PAPER = 0xede8dc
31const INK = 0x8a8378
32const BULB_ON = 0xffd24a
33const BULB_OFF = 0x8a7f5a
34const METAL = 0x9a9a9a
35const METAL_DARK = 0x4a4a4a
36const SCREEN = 0x111418
37const CODE = 0x4ee07a
38const CODE_DIM = 0x1f7a3c
39const OCEAN = 0x3b82c4
40const LAND_GREEN = 0x6bbf59
41const WOOD = 0x8b5e3c
42const GLASS = 0xcfe8f7
43const ERASER = 0xf28ca6
44const PENCIL_BODY = 0xf2c14e
45const LEAD = 0x3a2e2a
46const MUG = 0xe9e1d3
47const TEA = 0x8b5a2b
48const STEAM = 0xbfc5cc
49const SNOOZE = 0x9cc9f5
50const DROP = 0x7cc4f5
51const DOT_ON = 0xe6e6e6
52const DOT_OFF = 0x5a5a5a
53const DUST = 0xa89f91
54
55const MINI_BODY = ['.OOOOOO.', '.OOOOOO.', 'OOOOOOOO', '..O..O..']
56const CROWN = ['Y.YY.Y', 'YYYYYY']
57const MINI_Z = ['ZZZ', '..Z', '.Z.', 'ZZZ']
58const SPARKLE = ['.Y.', 'YYY', '.Y.']
59const MINI_CUP = ['MTM.', 'MMMM', 'MMM.']
60const MINI_BULB = ['YYY', 'YYY', '.G.']
61const MINI_LENS = ['GGG.', 'GlG.', 'GGG.', '...H']
62const MINI_PENCIL = ['..P', '.U.', 'k..']
63const MINI_QUESTION = ['YY.', '..Y', '.Y.', '...', '.Y.']
64const MINI_MONITOR = ['FFFFFFF', 'FSSSSSF', 'FSSSSSF', 'FSSSSSF', 'FFFFFFF', '..FFF..']
65const MINI_GLOBE = ['.XXX.', 'XXXXX', 'XXXXX', 'XXXXX', '.XXX.']
66const LAND = [
67 '..NN......N...',
68 '.NNNN....NNN..',
69 'NNNNN...NNNNN.',
70 '.NNN.....NNNN.',
71 '..NN......NN..',
72 '...N.......N..',
73 '..............',
74]
75
76function put(c: Canvas, x: number, y: number, color: number): void {
77 if (x >= 0 && x < c.width && y >= 0 && y < c.height) c.pixels[y * c.width + x] = color
78}
79
80function block(c: Canvas, x: number, y: number, w: number, h: number, color: number): void {
81 for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) put(c, x + dx, y + dy, color)
82}
83
84function sprite(c: Canvas, rows: readonly string[], x: number, y: number, palette: Palette): void {
85 rows.forEach((row, dy) => {
86 for (let dx = 0; dx < row.length; dx++) {
87 const color = palette[row[dx]!]
88 if (color !== undefined) put(c, x + dx, y + dy, color)
89 }
90 })
91}
92
93type Pose = {
94 y?: number
95 eyes?: 'open' | 'closed' | 'left' | 'right'
96 wave?: 'left' | 'right' | 'both'
97 step?: boolean
98 isCrowned?: boolean
99}
100
101function miniClawd(c: Canvas, pose: Pose = {}, x = 0): void {
102 const y = pose.y ?? 4
103 sprite(c, MINI_BODY, x, y, { O: ORANGE })
104 const shift = pose.eyes === 'left' ? -1 : pose.eyes === 'right' ? 1 : 0
105 const eye = pose.eyes === 'closed' ? ORANGE_DARK : EYE
106 put(c, x + 2 + shift, y + 1, eye)
107 put(c, x + 5 + shift, y + 1, eye)
108 if (pose.wave === 'left' || pose.wave === 'both') {
109 put(c, x, y + 2, CLEAR)
110 put(c, x, y + 1, ORANGE)
111 }
112 if (pose.wave === 'right' || pose.wave === 'both') {
113 put(c, x + 7, y + 2, CLEAR)
114 put(c, x + 7, y + 1, ORANGE)
115 }
116 if (pose.step) {
117 put(c, x + 2, y + 3, CLEAR)
118 put(c, x + 5, y + 3, CLEAR)
119 put(c, x + 1, y + 3, ORANGE)
120 put(c, x + 6, y + 3, ORANGE)
121 }
122 if (pose.isCrowned) sprite(c, CROWN, x + 1, y - 2, { Y: BULB_ON })
123}
124
125function miniPaper(c: Canvas, lineLengths: readonly number[]): void {
126 block(c, 10, 2, 5, 6, PAPER)
127 lineLengths.forEach((length, i) => block(c, 11, 3 + i * 2, length, 1, INK))
128}
129
130const clamp = (n: number, low: number, high: number) => Math.min(high, Math.max(low, n))
131const every = (f: number, ticks: number) => Math.floor(f / ticks)
132
133const MINI_SCENES: Record<Scene, (c: Canvas, f: number) => void> = {
134 idle(c, f) {
135 const look = (['open', 'open', 'left', 'open', 'right', 'open'] as const)[every(f, 30) % 6]
136 miniClawd(c, { eyes: f % 40 === 0 ? 'closed' : look })
137 sprite(c, MINI_CUP, 11, 5, { M: MUG, T: TEA })
138 const rise = every(f, 3) % 3
139 put(c, 12 + (rise % 2), 4 - rise, STEAM)
140 },
141 sleep(c, f) {
142 miniClawd(c, { eyes: 'closed' })
143 const rise = every(f, 3) % 8
144 if (rise < 6) {
145 sprite(c, MINI_Z, 8 + Math.floor(rise / 2), 3 - Math.floor((rise + 1) / 2), { Z: SNOOZE })
146 }
147 },
148 waiting(c, f) {
149 miniClawd(c, { eyes: 'right', step: every(f, 2) % 2 === 1 })
150 const lit = every(f, 3) % 4
151 for (let i = 0; i < 3; i++) put(c, 10 + i * 2, 6, i < lit ? DOT_ON : DOT_OFF)
152 },
153 thinking(c, f) {
154 const beat = every(f, 2) % 12
155 miniClawd(c, { eyes: beat < 6 ? 'right' : 'open' })
156 if (beat >= 1) put(c, 8, 3, DOT_ON)
157 if (beat >= 2) put(c, 10, 1, DOT_ON)
158 if (beat >= 3) {
159 const isLit = beat >= 5 && beat <= 9
160 sprite(c, MINI_BULB, 12, 0, { Y: isLit ? BULB_ON : BULB_OFF, G: METAL })
161 if (isLit && f % 2 === 0) {
162 for (const [x, y] of [[16, 0], [16, 2]] as const) put(c, x, y, BULB_ON)
163 }
164 }
165 },
166 writing(c, f) {
167 const progress = f % 8
168 miniClawd(c, { eyes: 'right', wave: f % 2 === 0 ? 'right' : undefined })
169 miniPaper(c, [0, 1].map(i => clamp(progress - i * 4, 0, 3)))
170 },
171 read(c, f) {
172 miniClawd(c, { eyes: 'right' })
173 miniPaper(c, [3, 2])
174 const sweep = every(f, 2) % 4
175 sprite(c, MINI_LENS, 9 + (sweep < 2 ? sweep : 3 - sweep) * 2, every(f, 8) % 2 === 0 ? 2 : 4, {
176 G: METAL_DARK,
177 l: GLASS,
178 H: WOOD,
179 })
180 },
181 edit(c, f) {
182 const progress = f % 8
183 const line = Math.floor(progress / 4)
184 const length = progress % 4
185 miniClawd(c, { eyes: 'right' })
186 miniPaper(c, [0, 1].map(i => (i < line ? 3 : i === line ? length : 0)))
187 sprite(c, MINI_PENCIL, 11 + length, 1 + line * 2, { P: ERASER, U: PENCIL_BODY, k: LEAD })
188 },
189 bash(c, f) {
190 miniClawd(c, { eyes: 'right', wave: f % 2 === 0 ? 'right' : 'left' })
191 sprite(c, MINI_MONITOR, 10, 2, { F: METAL_DARK, S: SCREEN })
192 for (let i = 0; i < 5; i += 2) {
193 const head = (f + i * 2) % 5
194 if (head < 3) put(c, 11 + i, 3 + head, CODE)
195 if (head >= 1 && head <= 3) put(c, 11 + i, 2 + head, CODE_DIM)
196 }
197 },
198 web(c, f) {
199 miniClawd(c, { eyes: 'right' })
200 const spin = every(f, 2)
201 MINI_GLOBE.forEach((row, y) => {
202 for (let x = 0; x < row.length; x++) {
203 if (row[x] === 'X') put(c, 11 + x, 2 + y, LAND[y + 1]![(x + spin) % 14] === 'N' ? LAND_GREEN : OCEAN)
204 }
205 })
206 block(c, 12, 7, 3, 1, WOOD)
207 },
208 agent(c, f) {
209 miniClawd(c, { eyes: 'right', wave: every(f, 2) % 2 === 0 ? 'right' : undefined, isCrowned: true })
210 const x = 9 + (f % 14)
211 miniClawd(c, { step: f % 2 === 1 }, x)
212 if (f % 2 === 1) put(c, x - 1, 7, DUST)
213 },
214 ask(c, f) {
215 miniClawd(c, { eyes: 'right', wave: every(f, 3) % 2 === 0 ? 'right' : undefined })
216 if (every(f, 4) % 4 !== 3) sprite(c, MINI_QUESTION, 11, 1, { Y: BULB_ON })
217 },
218 other(c, f) {
219 const phase = (['up', 'up', 'mid', 'down', 'down', 'mid'] as const)[f % 6]
220 miniClawd(c, { eyes: 'right', wave: phase === 'down' ? 'right' : undefined })
221 block(c, 9, 7, 6, 1, WOOD)
222 put(c, 11, 6, METAL)
223 if (phase === 'up') {
224 block(c, 15, 0, 3, 2, METAL_DARK)
225 block(c, 16, 2, 1, 3, WOOD)
226 } else if (phase === 'mid') {
227 block(c, 12, 0, 2, 2, METAL_DARK)
228 for (const [x, y] of [[14, 2], [15, 3], [16, 4]] as const) put(c, x, y, WOOD)
229 } else {
230 block(c, 11, 3, 2, 3, METAL_DARK)
231 block(c, 13, 4, 4, 1, WOOD)
232 if (f % 6 === 3) for (const [x, y] of [[10, 5], [13, 6]] as const) put(c, x, y, BULB_ON)
233 }
234 },
235 compacting(c, f) {
236 const height = 3 - (every(f, 2) % 3)
237 miniClawd(c, { eyes: 'right', wave: 'both' })
238 block(c, 10, 2, 6, 1, METAL_DARK)
239 block(c, 10, 3, 1, 5, METAL_DARK)
240 block(c, 15, 3, 1, 5, METAL_DARK)
241 for (let i = 0; i < height; i++) block(c, 11, 7 - i, 4, 1, i % 2 === 0 ? PAPER : INK)
242 block(c, 11, 7 - height, 4, 1, METAL)
243 block(c, 12, 3, 2, 4 - height, METAL)
244 },
245 hop(c, f) {
246 const y = [4, 3, 2, 2, 3, 4][f % 6]!
247 miniClawd(c, { y, wave: y < 4 ? 'both' : undefined })
248 if (f % 2 === 0) {
249 sprite(c, SPARKLE, 10, 1, { Y: BULB_ON })
250 sprite(c, SPARKLE, 15, 3, { Y: BULB_ON })
251 } else {
252 sprite(c, SPARKLE, 12, 2, { Y: BULB_ON })
253 }
254 },
255 sweat(c, f) {
256 miniClawd(c, { eyes: f % 20 === 0 ? 'closed' : 'open' })
257 block(c, 8, 2 + (every(f, 3) % 3), 1, 2, DROP)
258 },
259}
260
261export function recolor(c: Canvas, tint: Tint | undefined): Canvas {
262 if (tint !== undefined) {
263 c.pixels.forEach((color, i) => {
264 if (color === ORANGE) c.pixels[i] = tint.body
265 else if (color === ORANGE_DARK) c.pixels[i] = tint.shade
266 })
267 }
268 return c
269}
270
271export function drawMiniScene(scene: Scene, frame: number): Canvas {
272 const canvas = { width: MINI_WIDTH, height: MINI_HEIGHT, pixels: new Int32Array(MINI_WIDTH * MINI_HEIGHT).fill(CLEAR) }
273 MINI_SCENES[scene](canvas, frame)
274 return canvas
275}
276