Exported Lich, an Engram for Eidola, made from the bundled Lich.

An Engram for Eidola, made from the bundled Lich: an Engram plugin (contract 1). Eidola keeps the frame, the HUD, the corner and the picker; this plugin draws the creature.
engram.json: the Engram (id exported-lich, vessel.scene "self", remixOf "lich": while the drawing can't draw, Eidola draws the Lich in its place).hooks/register.tsx: announces the Engram to Eidola and hands each drawing Eidola asks for to hooks/draw.tsx.hooks/draw.tsx and the files beside it: the Lich's drawing, copied whole. Change the creature here.vessel/: the picker's stills.Before you share it, from the Eidola mod's folder:
node scripts/check-engram.mjs <this folder>
claude plugin validate <this folder>
To try it by hand, add the folder holding this one as a local marketplace, install it beside Eidola, and /awaken exported-lich.
hooks/register.tsx 150 lines1// Exported Lich: an Engram plugin for Eidola, made from the bundled Lich by
2// Eidola's scripts/export-engram.mjs (contract 1, docs/spec/engram-plugin.md
3// in the Eidola mod). This module announces the Engram, and hands each
4// drawing Eidola asks for to hooks/draw.tsx, the Lich's own drawing
5// module, copied here with everything it draws with: change the creature
6// there and in the files beside it. Run Eidola's checker on the folder
7// (node scripts/check-engram.mjs <folder>) before you share it.
8//
9// It writes none of Eidola's state and adds nothing to any prompt.
10
11import type { Elements, EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
12
13import { draw } from './draw'
14import type { DrawEls, DrawKit } from './drawing'
15import type { EidolaHandoff, EidolaWhere, Mood } from './eidola-types'
16import { FACES } from './sprite'
17
18/** This Engram's id: engram.json's. */
19const ID = 'exported-lich'
20/** Its name, for the drawing's alt text: engram.json's. */
21const NAME = "Exported Lich"
22/** The Box its drawing comes back in, and the slot Eidola leaves for it. */
23const SCENE = `eidola:${ID}`
24const SLOT = `eidola-slot:${ID}`
25/** Where a failure is reported from. */
26const FILE = 'hooks/register.tsx'
27/** How long the face shows the error effect after the ink rises, where no Client can run: in ms. */
28const ERROR_MS = 2000
29
30type Els = DrawEls & Pick<Elements['desktop'], 'Text'>
31
32// The backdrop last built for the pane and for the band. Its loops are
33// locked to the clock when it is built, so it is built again only when its
34// size changes: a redraw for anything else keeps the running drawing.
35const built = new Map<EidolaWhere, { key: string; svg: string }>()
36
37function memoFor(where: EidolaWhere): DrawKit['memo'] {
38 return (key, build) => {
39 const last = built.get(where)
40 if (last?.key === key) {
41 return last.svg
42 }
43 const svg = build()
44 built.set(where, { key, svg })
45 return svg
46 }
47}
48
49/** The creature, in one Box keyed eidola:<id>: live where a Client can run (the desktop), its face elsewhere (its error face while the ink is fresh). */
50function creature(els: Els, surface: RenderSurface, handoff: EidolaHandoff, now: number): RenderElement {
51 if (surface === 'desktop') {
52 const tree = draw(els, handoff, { name: NAME, now, memo: memoFor(handoff.where) })
53 return { ...(tree as object), props: { ...(tree as { props?: object }).props, key: SCENE } } as RenderElement
54 }
55 const { Box, Text } = els
56 return (
57 <Box key={SCENE} width={handoff.columns} height={handoff.rows}>
58 <Text>{FACES[handoff.ink > 0 && now - handoff.inkAt < ERROR_MS ? 'error' : handoff.mood]}</Text>
59 </Box>
60 )
61}
62
63/** Eidola's tree with this Engram's slot, wherever it is, filled; anything else as it came. */
64function fill(node: unknown, make: (columns: number, rows: number) => RenderElement): unknown {
65 if (node === null || typeof node !== 'object') {
66 return node
67 }
68 const element = node as { props?: { key?: unknown; width?: unknown; height?: unknown }; children?: unknown }
69 if (element.props?.key === SLOT) {
70 return make(Number(element.props.width) || 20, Number(element.props.height) || 5)
71 }
72 if (Array.isArray(element.children)) {
73 return { ...element, children: element.children.map(child => fill(child, make)) }
74 }
75 return node
76}
77
78/** True when the tree holds this Engram's slot. */
79function hasSlot(node: unknown): boolean {
80 if (node === null || typeof node !== 'object') {
81 return false
82 }
83 const element = node as { props?: { key?: unknown }; children?: unknown }
84 return element.props?.key === SLOT || (Array.isArray(element.children) && element.children.some(hasSlot))
85}
86
87/** Tells Eidola the drawing failed, with the file and the stack's line: it draws the fallback and marks the Engram not loading. */
88async function fault($: EngineInterface, error: unknown): Promise<void> {
89 try {
90 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
91 } catch {
92 // Eidola isn't there to tell.
93 }
94}
95
96/**
97 * One drawing, the pane's or the band's. Beneath Eidola (this plugin loaded
98 * after it), Eidola asks with the hand-off on e.props.eidola. Above it (this
99 * plugin loaded first), Eidola's tree comes back from next with a slot to
100 * fill, and the state is read from Eidola's.
101 */
102async function answer($: EngineInterface, where: EidolaWhere, surface: RenderSurface, els: Els, props: unknown, below: () => Promise<RenderElement>): Promise<RenderElement> {
103 const handoff = (props as { eidola?: EidolaHandoff }).eidola
104 if (handoff) {
105 if (handoff.engram !== ID) {
106 return below()
107 }
108 try {
109 return creature(els, surface, handoff, await $.clock.now())
110 } catch (error) {
111 await fault($, error)
112 return below()
113 }
114 }
115 const tree = await below()
116 if (!hasSlot(tree)) {
117 return tree
118 }
119 const { value: mood = 'idle' as Mood } = await $.state.get({ plugin: 'eidola', key: 'mood' })
120 const { value: moodBefore = 'idle' as Mood } = await $.state.get({ plugin: 'eidola', key: 'moodBefore' })
121 const { value: moodAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'moodAt' })
122 const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
123 const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
124 const { value: epoch = 0 } = await $.state.get({ plugin: 'eidola', key: 'epoch' })
125 const now = await $.clock.now()
126 try {
127 return fill(tree, (columns, rows) => creature(els, surface, { contract: 1, engram: ID, where, columns, rows, mood, moodBefore, moodAt, ink, inkAt, epoch }, now)) as RenderElement
128 } catch (error) {
129 await fault($, error)
130 return tree
131 }
132}
133
134export const register: Register = on => {
135 // Announce: Eidola reads engram.json from this folder. Without Eidola there
136 // is nobody to announce to, and nothing else changes.
137 on('session.start', async ($, e, next) => {
138 try {
139 await $.eidola.announce({ root: $.plugin.root })
140 } catch {
141 // Eidola isn't installed: this plugin draws nothing.
142 }
143 return next(e)
144 })
145
146 // The pane and the band, one hook each, the same way.
147 on('ui.render', { component: 'Pane', requestId: 'eidola' }, ($, e, next) => answer($, 'pane', e.surface, $.ui.resolve(e) as unknown as Els, e.props, () => next(e)))
148 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => answer($, 'band', e.surface, $.ui.resolve(e) as unknown as Els, e.props, () => next(e)))
149}
150hooks/draw.tsx 58 lines1// The Lich's drawing module (hooks/drawing.ts): the dark it hangs in drawn
2// once, and it drawn over that in glyphs by lich-live.tsx. In the pane, four
3// slices, top to bottom, that keep in step by the hand-off's epoch; in the
4// band, a strip across its grimoire.
5//
6// Eidola draws the bundled Lich through this, and an Engram plugin exported
7// from it (scripts/export-engram.mjs) draws with this same file.
8
9import { overdrawn } from './drawing'
10import type { Draw } from './drawing'
11import { renderScene } from './field'
12import { voidScene } from './lich'
13import { PX_PER_COLUMN, PX_PER_ROW, sceneSize } from './stage'
14
15/** The Lich's pane is drawn by four drawings, one above the next, each within its own bound. */
16export function lichSlices(rows: number): Array<[number, number]> {
17 const cuts = [0, 1, 2, 3, 4].map(i => Math.round((rows * i) / 4))
18 return [0, 1, 2, 3].map(i => [cuts[i]!, cuts[i + 1]! - cuts[i]!] as [number, number])
19}
20
21export const draw: Draw = (els, handoff, kit) => {
22 const { Box, Svg, Client } = els
23 const { columns: cols, rows } = handoff
24 // What the live drawings share in their props: the mood, the ink, and the clock that keeps split drawings in step.
25 const live = { mood: handoff.mood, moodAt: handoff.moodAt, moodBefore: handoff.moodBefore, ink: handoff.ink, inkAt: handoff.inkAt, epoch: handoff.epoch }
26 if (handoff.where === 'band') {
27 const width = Math.max(96, cols * PX_PER_COLUMN - 4)
28 const height = rows * PX_PER_ROW - 2
29 const backdrop = kit.memo(`lich-void:${width}x${height}`, () => renderScene(voidScene(width, height, 'band'), kit.now))
30 return (
31 <Box width={cols} height={rows}>
32 <Svg key="scene:band-void" source={backdrop} alt={`${kit.name} in the dark`} width={width} height={height} isInteractive />
33 <Box position="absolute" top={0} left={0}>
34 <Client key="lich-band" module="./lich-live.tsx" props={{ ...live, cols, rows, view: 'band' }} width={cols} height={rows} />
35 </Box>
36 </Box>
37 )
38 }
39 // The dark the same height as the drawing over it: any taller and the pane scrolls.
40 const { width } = sceneSize(cols, rows, 'dock')
41 const height = overdrawn(rows)
42 const backdrop = kit.memo(`lich-void:${width}x${height}`, () => renderScene(voidScene(width, height, 'pane'), kit.now))
43 const shared = { ...live, cols, rows }
44 const [a, b, c, d] = lichSlices(rows)
45 return (
46 <Box width={cols} height={rows} overflow="hidden" alignItems="flex-start">
47 {/* Nothing here may change with the mood, alt included: a changed prop reloads the drawing. */}
48 <Svg key="scene:void" source={backdrop} alt={`${kit.name} in the dark`} width={width} height={height} isInteractive />
49 <Box position="absolute" top={0} left={0} flexDirection="column">
50 <Client key="lich-0" module="./lich-live.tsx" props={{ ...shared, slice: a! }} width={cols} height={a![1]} />
51 <Client key="lich-1" module="./lich-live.tsx" props={{ ...shared, slice: b! }} width={cols} height={b![1]} />
52 <Client key="lich-2" module="./lich-live.tsx" props={{ ...shared, slice: c! }} width={cols} height={c![1]} />
53 <Client key="lich-3" module="./lich-live.tsx" props={{ ...shared, slice: d! }} width={cols} height={d![1]} />
54 </Box>
55 </Box>
56 )
57}
58hooks/drawing.ts 46 lines1// The drawing interface (docs/spec/engram-plugin.md, "The hand-off"): what
2// draws a creature from the hand-off. Each bundled Engram's drawing module,
3// engrams/<id>/hooks/draw.tsx, is one of these, and Eidola draws the bundled
4// three through it as it asks an Engram plugin. scripts/export-engram.mjs
5// copies a bundled Engram's drawing module, and this file with it, into an
6// Engram plugin of its own, whose hooks call the very same function with the
7// hand-off Eidola gives them.
8
9import type { Elements, RenderElement } from 'claude-code'
10
11import type { EidolaHandoff } from './eidola-types'
12import { PX_PER_ROW } from './stage'
13
14/** The elements a drawing draws with: a desktop's, where a Client can run. */
15export type DrawEls = Pick<Elements['desktop'], 'Box' | 'Svg' | 'Client'>
16
17/** What the hook drawing the creature gives it besides the hand-off. */
18export type DrawKit = {
19 /** The Engram's name, as its engram.json gives it: for the drawing's alt text. */
20 name: string
21 /** The clock now (ms): a still backdrop's loops are locked to it when built. */
22 now: number
23 /**
24 * A still backdrop's SVG by key, built once and kept while the key holds:
25 * a rebuilt one (a new clock) would reload the drawing on the surface.
26 */
27 memo: (key: string, build: () => string) => string
28}
29
30/**
31 * A creature's drawing, `columns` × `rows` cells of the hand-off: one Box,
32 * as wide and tall, holding the creature. The Engram plugin's hooks key it
33 * `eidola:<id>` before they answer it; Eidola places a bundled one as it is.
34 */
35export type Draw = (els: DrawEls, handoff: EidolaHandoff, kit: DrawKit) => RenderElement
36
37/**
38 * A pane backdrop's height in px: a row taller than its rows at 19 px each,
39 * clipped to them. A row is drawn a fraction of a pixel taller than 19, so a
40 * backdrop of exactly rows × 19 stops a few pixels short of the HUD's bottom
41 * brackets, which sit on the rows; drawn taller and clipped it meets them.
42 */
43export function overdrawn(rows: number): number {
44 return (rows + 1) * PX_PER_ROW
45}
46hooks/eidola-types.ts 128 lines1// Eidola's public types (contract 1), copied from the Eidola mod's
2// types/index.d.ts by scripts/export-engram.mjs: what the drawing reads off
3// the hand-off and Eidola's state. Types alone; $.eidola and Eidola's state
4// are typed by the contract the engine lays into .claude-plugin/types/eidola/
5// once this plugin loads beside Eidola.
6
7export type VesselState =
8 | 'idle'
9 | 'thinking'
10 | 'working'
11 | 'attention'
12 | 'success'
13 | 'responding'
14 | 'error'
15 | 'waiting'
16 | 'sleeping'
17
18/**
19 * What the drawings follow: three moods, set by the turn rather than by each
20 * tool, so a turn changes the drawing two or three times, not dozens.
21 */
22export type Mood = 'idle' | 'working' | 'attention'
23
24/** What the status line shows: the state and a short line of detail under it. */
25export type VesselView = { state: VesselState; detail: string }
26
27/**
28 * An Engram page's HUD preview: the mood its chips set, or the error its Error
29 * button shows, drawn in the preview alone and never in the session.
30 */
31export type Preview = {
32 mood: Mood
33 /** The Error button was pressed since the last mood chip. */
34 isError: boolean
35 /** How many times the Error button was pressed: the preview's own ink. */
36 ink: number
37 /** When it was last pressed, and when the preview's mood last changed (ms). */
38 inkAt: number
39 moodAt: number
40 /** The preview's mood before the last change. */
41 moodBefore: Mood
42}
43
44/** The version of the public contract this Eidola keeps. */
45export type EidolaContract = 1
46
47/** What an Engram plugin passes to `$.eidola.announce`. */
48export type EidolaAnnounceInput = {
49 /** The plugin's own folder, the one holding engram.json: `$.plugin.root`. */
50 root: string
51}
52
53/** One reason Eidola could not take an Engram plugin's engram.json. */
54export type EidolaIssue = { file: string; problem: string; fix: string }
55
56/** What `$.eidola.announce` answers. */
57export type EidolaAnnounced = {
58 /** The contract this Eidola keeps. */
59 contract: EidolaContract
60 /** The Engram's id when it is in the registry; null when it isn't. */
61 engram: string | null
62 /** Why it isn't: each file, problem and fix. Empty when it is. */
63 issues: EidolaIssue[]
64}
65
66/** What an Engram plugin passes to `$.eidola.fault` when its own drawing fails. */
67export type EidolaFaultInput = {
68 /** The Engram's id. */
69 engram: string
70 /** What went wrong, in a line. */
71 message: string
72 /** The file that failed, relative to the plugin's folder (`hooks/creature.tsx`). */
73 file?: string
74 /** The line in that file, when known. */
75 line?: number
76 /** The error's stack, when there is one: Eidola reads the line off it when `line` is left out. */
77 stack?: string
78}
79
80/** What `$.eidola.fault` answers: whether Eidola marked the Engram not loading. */
81export type EidolaFaulted = { isMarked: boolean }
82
83/** Where Eidola is handing the drawing off: its pane, or the band above the prompt. */
84export type EidolaWhere = 'pane' | 'band'
85
86/**
87 * The hand-off, on `e.props.eidola` of the `ui.render` Eidola passes to
88 * `next`: present only when Eidola asks the awake Engram's plugin for its
89 * drawing. Draw `columns` × `rows` cells, in one Box keyed `eidola:<engram>`.
90 */
91export type EidolaHandoff = {
92 contract: EidolaContract
93 /** The awake Engram's id: draw only when it is yours. */
94 engram: string
95 where: EidolaWhere
96 /** The drawing area inside Eidola's frame, in cells. */
97 columns: number
98 rows: number
99 mood: Mood
100 moodBefore: Mood
101 moodAt: number
102 ink: number
103 inkAt: number
104 epoch: number
105}
106
107/** The `eidola` noun Eidola adds to `$`: what an Engram plugin calls. */
108export type Eidola = {
109 /**
110 * Tells Eidola an Engram plugin is here. Call it from `session.start`:
111 * Eidola reads `<root>/engram.json`, checks it, and adds the Engram to its
112 * registry. Works whichever plugin loaded first.
113 *
114 * @example
115 * on('session.start', async ($, e, next) => {
116 * await $.eidola.announce({ root: $.plugin.root })
117 * return next(e)
118 * })
119 */
120 announce: (input: EidolaAnnounceInput) => Promise<EidolaAnnounced>
121 /**
122 * Tells Eidola the plugin's own drawing failed: Eidola draws the Engram it
123 * was remixed from (or the resting sprite) instead, and marks it not
124 * loading with the file and line, until the plugin announces again.
125 */
126 fault: (input: EidolaFaultInput) => Promise<EidolaFaulted>
127}
128hooks/sprite.ts 296 lines1// Built-in sprite: 20x20 pixel frames composed from layers, emitted as one
2// SVG whose frames cycle with CSS steps(). No redraw per frame: the pane
3// redraws only when the state changes. Bundled Engrams now ship baked SVGs
4// from the Vessel Forge; this is the fallback and the media test's control.
5
6import type { VesselState } from './eidola-types'
7
8export const GRID = 20
9
10/** Character -> palette slot. '.' is transparent. */
11export type Palette = {
12 o: string // outline
13 b: string // body
14 h: string // highlight
15 e: string // eye
16 w: string // eye shine
17 c: string // cheek
18 m: string // mouth
19 a: string // accent (thought dots, z's)
20 r: string // alert
21 y: string // sparkle
22 d: string // sweat drop
23}
24
25/**
26 * The built-in sprite's colors. It draws the media test's control and
27 * stands in when a baked Vessel's file can't be read.
28 */
29export const SPRITE_PALETTE: Palette = {
30 o: '#3b2f8f',
31 b: '#cfc6ff',
32 h: '#f4f1ff',
33 e: '#1a1c2b',
34 w: '#ffffff',
35 c: '#ff9ec4',
36 m: '#3b2f8f',
37 a: '#9d93ff',
38 r: '#ff5c5c',
39 y: '#ffd25e',
40 d: '#6fc3ff',
41}
42
43type Grid = string[][]
44type Dot = [x: number, y: number, ch: keyof Palette]
45
46// The body: a domed spirit. Rows 0-4 stay free for accessories.
47const BODY = [
48 '....................',
49 '....................',
50 '....................',
51 '....................',
52 '....................',
53 '.......oooooo.......',
54 '.....oohhbbbboo.....',
55 '....ohhbbbbbbbbo....',
56 '...ohbbbbbbbbbbbo...',
57 '...obbbbbbbbbbbbo...',
58 '...obbbbbbbbbbbbo...',
59 '...obbbbbbbbbbbbo...',
60 '...obbbbbbbbbbbbo...',
61 '...obbbbbbbbbbbbo...',
62 '...obbbbbbbbbbbbo...',
63 '...obbbbbbbbbbbbo...',
64 '...obbbbbbbbbbbbo...',
65 '...obbbbbbbbbbbbo...',
66]
67
68// Two tail poses; frames alternate them so the spirit always drifts.
69const TAILS: [string[], string[]] = [
70 ['...obbbobbbbobbbo...', '....ooo.oooo.ooo....'],
71 ['...obbbbobbbbobbo...', '....oooo.oooo.oo....'],
72]
73
74// Faces: dots drawn over the body.
75const EYES = {
76 open: [
77 [6, 10, 'w'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
78 [12, 10, 'w'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
79 ],
80 closed: [[6, 11, 'e'], [7, 11, 'e'], [12, 11, 'e'], [13, 11, 'e']],
81 happy: [[6, 11, 'e'], [7, 10, 'e'], [8, 11, 'e'], [11, 11, 'e'], [12, 10, 'e'], [13, 11, 'e']],
82 up: [
83 [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'],
84 [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'],
85 ],
86 focus: [[6, 11, 'e'], [7, 11, 'e'], [6, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'], [13, 10, 'e']],
87 wide: [
88 [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
89 [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
90 ],
91 left: [
92 [5, 10, 'w'], [6, 10, 'e'], [5, 11, 'e'], [6, 11, 'e'],
93 [11, 10, 'w'], [12, 10, 'e'], [11, 11, 'e'], [12, 11, 'e'],
94 ],
95 right: [
96 [7, 10, 'w'], [8, 10, 'e'], [7, 11, 'e'], [8, 11, 'e'],
97 [13, 10, 'w'], [14, 10, 'e'], [13, 11, 'e'], [14, 11, 'e'],
98 ],
99 x: [
100 [5, 9, 'e'], [7, 9, 'e'], [6, 10, 'e'], [5, 11, 'e'], [7, 11, 'e'],
101 [12, 9, 'e'], [14, 9, 'e'], [13, 10, 'e'], [12, 11, 'e'], [14, 11, 'e'],
102 ],
103} satisfies Record<string, Dot[]>
104
105const CHEEKS: Dot[] = [[5, 12, 'c'], [14, 12, 'c']]
106
107const MOUTH = {
108 smile: [[8, 13, 'm'], [9, 14, 'm'], [10, 14, 'm'], [11, 13, 'm']],
109 small: [[9, 14, 'm'], [10, 14, 'm']],
110 open: [[9, 13, 'm'], [10, 13, 'm'], [9, 14, 'm'], [10, 14, 'm']],
111 frown: [[8, 14, 'm'], [9, 13, 'm'], [10, 13, 'm'], [11, 14, 'm']],
112 o: [[9, 14, 'm']],
113} satisfies Record<string, Dot[]>
114
115function plus(x: number, y: number, ch: keyof Palette): Dot[] {
116 return [[x, y - 1, ch], [x - 1, y, ch], [x, y, ch], [x + 1, y, ch], [x, y + 1, ch]]
117}
118
119const BANG: Dot[] = [[9, 0, 'r'], [10, 0, 'r'], [9, 1, 'r'], [10, 1, 'r'], [9, 2, 'r'], [10, 2, 'r'], [9, 4, 'r'], [10, 4, 'r']]
120const DROP: Dot[] = [[16, 6, 'd'], [16, 7, 'd'], [17, 7, 'd'], [16, 8, 'd']]
121const Z_SMALL: Dot[] = [[16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'], [17, 4, 'a'], [16, 5, 'a'], [17, 5, 'a'], [18, 5, 'a']]
122const Z_BIG: Dot[] = [
123 [15, 0, 'a'], [16, 0, 'a'], [17, 0, 'a'], [18, 0, 'a'], [17, 1, 'a'], [16, 2, 'a'],
124 [15, 3, 'a'], [16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'],
125]
126
127/** One frame: which tail, and which dots on top. */
128type Frame = { tail: 0 | 1; dots: Dot[] }
129
130export type Animation = {
131 frames: Frame[]
132 /** Seconds each frame shows. */
133 frameS: number
134 /** Seconds per bob up and down; 0 holds still. */
135 bobS: number
136}
137
138const face = (...parts: Dot[][]): Dot[] => parts.flat()
139
140export const ANIMATIONS: Record<VesselState, Animation> = {
141 idle: {
142 frameS: 0.5,
143 bobS: 2,
144 frames: [0, 1, 0, 1, 0, 1].map((tail, i) => ({
145 tail: tail as 0 | 1,
146 dots: face(i === 5 ? EYES.closed : EYES.open, CHEEKS, MOUTH.small),
147 })),
148 },
149 thinking: {
150 frameS: 0.45,
151 bobS: 1.6,
152 frames: [
153 { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a']]) },
154 { tail: 1, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a']]) },
155 { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a'], [18, 1, 'a']]) },
156 ],
157 },
158 working: {
159 frameS: 0.18,
160 bobS: 0.5,
161 frames: [
162 { tail: 0, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(18, 7, 'y')) },
163 { tail: 1, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(1, 9, 'y')) },
164 ],
165 },
166 attention: {
167 frameS: 0.4,
168 bobS: 0.8,
169 frames: [
170 { tail: 0, dots: face(EYES.wide, CHEEKS, MOUTH.o, BANG) },
171 { tail: 1, dots: face(EYES.wide, CHEEKS, MOUTH.o) },
172 ],
173 },
174 success: {
175 frameS: 0.3,
176 bobS: 0.6,
177 frames: [
178 { tail: 0, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(2, 4, 'y'), [[17, 2, 'y']]) },
179 { tail: 1, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(17, 3, 'y'), [[2, 6, 'y']]) },
180 ],
181 },
182 responding: {
183 frameS: 0.2,
184 bobS: 1.2,
185 frames: [
186 { tail: 0, dots: face(EYES.open, CHEEKS, MOUTH.open) },
187 { tail: 1, dots: face(EYES.open, CHEEKS, MOUTH.small) },
188 ],
189 },
190 error: {
191 frameS: 0.6,
192 bobS: 0,
193 frames: [
194 { tail: 0, dots: face(EYES.x, MOUTH.frown, DROP) },
195 { tail: 1, dots: face(EYES.x, MOUTH.frown) },
196 ],
197 },
198 waiting: {
199 frameS: 0.45,
200 bobS: 1.6,
201 frames: [
202 { tail: 0, dots: face(EYES.left, CHEEKS, MOUTH.small) },
203 { tail: 1, dots: face(EYES.right, CHEEKS, MOUTH.small) },
204 ],
205 },
206 sleeping: {
207 frameS: 0.9,
208 bobS: 3.6,
209 frames: [
210 { tail: 0, dots: face(EYES.closed, MOUTH.o, Z_SMALL) },
211 { tail: 1, dots: face(EYES.closed, MOUTH.o, Z_BIG) },
212 ],
213 },
214}
215
216export function compose(frame: Frame): Grid {
217 const rows = [...BODY, ...TAILS[frame.tail]]
218 const grid = rows.map(row => row.split(''))
219 for (const [x, y, ch] of frame.dots) {
220 const row = grid[y]
221 if (row !== undefined && x >= 0 && x < GRID) {
222 row[x] = ch
223 }
224 }
225 return grid
226}
227
228/** Horizontal runs of one color become one rect, to keep the SVG small. */
229export function rects(grid: Grid, palette: Palette): string {
230 const out: string[] = []
231 grid.forEach((row, y) => {
232 let x = 0
233 while (x < row.length) {
234 const ch = row[x]
235 if (ch === '.') {
236 x += 1
237 continue
238 }
239 let end = x + 1
240 while (end < row.length && row[end] === ch) end += 1
241 const fill = palette[ch as keyof Palette] ?? '#ff00ff'
242 out.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${fill}"/>`)
243 x = end
244 }
245 })
246 return out.join('')
247}
248
249const r = (n: number) => Math.round(n * 1000) / 1000
250
251/** The whole animated Vessel for one state, as SVG markup. */
252export function vesselSvg(state: VesselState, palette: Palette, size: number): string {
253 const anim = ANIMATIONS[state]
254 const n = anim.frames.length
255 const cycle = r(anim.frameS * n)
256 const slice = r(100 / n)
257
258 const delays = anim.frames
259 .map((_, i) => `.d${i}{animation-delay:-${r(((n - i) % n) * anim.frameS)}s}`)
260 .join('')
261 const bob =
262 anim.bobS > 0
263 ? `.bob{animation:bob ${anim.bobS}s steps(1,end) infinite}` +
264 '@keyframes bob{0%{transform:translateY(0)}50%{transform:translateY(-1px)}100%{transform:translateY(0)}}'
265 : ''
266 const css =
267 `.f{opacity:0;animation:fr ${cycle}s steps(1,end) infinite}.f0{opacity:1}` +
268 `@keyframes fr{0%{opacity:1}${slice}%{opacity:0}100%{opacity:0}}` +
269 delays +
270 bob +
271 '@media (prefers-reduced-motion:reduce){.f,.bob{animation:none}}'
272
273 const frames = anim.frames
274 .map((frame, i) => `<g class="f f${i === 0 ? '0' : 'n'} d${i}">${rects(compose(frame), palette)}</g>`)
275 .join('')
276
277 return (
278 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 ${GRID} ${GRID + 1}" ` +
279 `width="${size}" height="${Math.round((size * (GRID + 1)) / GRID)}" shape-rendering="crispEdges">` +
280 `<style>${css}</style><g class="bob">${frames}</g></svg>`
281 )
282}
283
284/** Terminal fallback: one line per state. */
285export const FACES: Record<VesselState, string> = {
286 idle: '( o_o )',
287 thinking: '( o_o ) . o O',
288 working: '( >_< ) *',
289 attention: '( O_O ) !',
290 success: '( ^_^ ) +',
291 responding: '( o0o )',
292 error: '( x_x )',
293 waiting: '( o_o)>',
294 sleeping: '( -_- ) z',
295}
296hooks/field.ts 383 lines1// The field engine. A scene is a sampler: for a point and a frame it gives
2// a layer and a brightness. The engine samples it onto a grid sized to the
3// slot, once per frame, and an ink turns the grid into SVG:
4//
5// glyph a character per cell, from a ramp of dim to dense
6// pixel a block per cell, dithered between a layer's shades
7// particle a dot per lit cell, sized by brightness
8//
9// Each layer names its ink, so one scene can mix them: a pixel creature in
10// particle water. Frames cycle by CSS, locked to the wall clock, so a
11// redraw for a new state carries the motion on unbroken.
12
13export type Ink = 'glyph' | 'pixel' | 'particle'
14export const INKS: readonly Ink[] = ['glyph', 'pixel', 'particle']
15
16/** How one layer of a scene looks in each ink. */
17export type LayerStyle = {
18 /** glyph: characters from dim to dense; a space draws nothing. */
19 chars: string
20 /** pixel: shades from dim to bright. */
21 shades: string[]
22 /** glyph and particle: the fill, a colour or a paint server. */
23 fill: string
24 opacity?: number
25 /** Drawn with a soft bloom. */
26 isGlowing?: boolean
27 /** The ink this layer draws in; the scene's when absent. */
28 ink?: Ink
29}
30
31/** [layer, brightness]: layer 0 draws nothing; brightness runs 0 to 1. */
32export type Cell = readonly [number, number]
33export const EMPTY: Cell = [0, 0]
34
35export type FieldScene = {
36 width: number
37 height: number
38 /** Index 0 is the empty layer and is never drawn. */
39 layers: LayerStyle[]
40 /** The ink of any layer that names none. */
41 ink: Ink
42 /**
43 * A point in pixels, a frame of `frames`: what moves there, or EMPTY.
44 * `want` is the layers this pass draws; a scene may skip work for others.
45 */
46 sample: (x: number, y: number, frame: number, frames: number, want: ReadonlySet<number>) => Cell
47 /**
48 * What lies still behind it: sampled once and drawn outside the frame
49 * cycle, so a backdrop costs one frame however many the scene has. A
50 * cell the moving layers reach in any frame is left out of it.
51 */
52 still?: (x: number, y: number, want: ReadonlySet<number>) => Cell
53 /**
54 * How the still backdrop moves: a small drift side to side (the default),
55 * or a sway from the top edge, as light shafts hang from the surface.
56 */
57 stillMotion?: 'drift' | 'sway'
58 /** Seconds for one pass through the frames. */
59 period: number
60 /** Markup drawn under the field and over it; defs and CSS the scene needs. */
61 under: string
62 over: string
63 defs: string[]
64 css: string[]
65}
66
67/** Cell size in pixels and frame count per ink: what the budget allows. */
68export const GRID: Record<Ink, { cw: number; ch: number; frames: number }> = {
69 glyph: { cw: 8, ch: 14, frames: 8 },
70 pixel: { cw: 8, ch: 8, frames: 3 },
71 particle: { cw: 9, ch: 9, frames: 3 },
72}
73
74const r1 = (n: number) => Math.round(n * 10) / 10
75
76/** A stable value in [0, 1) for a cell: the same every frame and every draw. */
77export function hash(x: number, y: number): number {
78 const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
79 return s - Math.floor(s)
80}
81
82/** Smooth value noise in [0, 1). */
83export function noise(x: number, y: number): number {
84 const xi = Math.floor(x)
85 const yi = Math.floor(y)
86 const xf = x - xi
87 const yf = y - yi
88 const u = xf * xf * (3 - 2 * xf)
89 const v = yf * yf * (3 - 2 * yf)
90 const a = hash(xi, yi)
91 const b = hash(xi + 1, yi)
92 const c = hash(xi, yi + 1)
93 const d = hash(xi + 1, yi + 1)
94 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
95}
96
97export const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n)
98export const smoothstep = (a: number, b: number, x: number) => {
99 const t = clamp01((x - a) / (b - a))
100 return t * t * (3 - 2 * t)
101}
102
103/** 4×4 ordered dither thresholds. */
104const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(n => (n + 0.5) / 16)
105
106type Sampled = { cols: number; rows: number; layer: Uint8Array[]; value: Float32Array[]; still?: { layer: Uint8Array; value: Float32Array } }
107
108/** The scene sampled on one ink's grid, keeping only the layers drawn in that ink. */
109function sampleAll(scene: FieldScene, cw: number, ch: number, frames: number, keep: Set<number>): Sampled {
110 const cols = Math.floor(scene.width / cw)
111 const rows = Math.floor(scene.height / ch)
112 const layer: Uint8Array[] = []
113 const value: Float32Array[] = []
114 for (let f = 0; f < frames; f += 1) {
115 const L = new Uint8Array(cols * rows)
116 const V = new Float32Array(cols * rows)
117 for (let y = 0; y < rows; y += 1) {
118 for (let x = 0; x < cols; x += 1) {
119 const [l, v] = scene.sample((x + 0.5) * cw, (y + 0.5) * ch, f, frames, keep)
120 if (l > 0 && v > 0 && keep.has(l)) {
121 L[y * cols + x] = l
122 V[y * cols + x] = clamp01(v)
123 }
124 }
125 }
126 layer.push(L)
127 value.push(V)
128 }
129 let still: Sampled['still']
130 if (scene.still) {
131 const L = new Uint8Array(cols * rows)
132 const V = new Float32Array(cols * rows)
133 for (let y = 0; y < rows; y += 1) {
134 for (let x = 0; x < cols; x += 1) {
135 const i = y * cols + x
136 if (layer.some(f => f[i] !== 0)) {
137 continue
138 }
139 const [l, v] = scene.still((x + 0.5) * cw, (y + 0.5) * ch, keep)
140 if (l > 0 && v > 0 && keep.has(l)) {
141 L[i] = l
142 V[i] = clamp01(v)
143 }
144 }
145 }
146 still = { layer: L, value: V }
147 }
148 return { cols, rows, layer, value, still }
149}
150
151function glyphFrame(scene: FieldScene, s: Grid, cw: number, ch: number): string {
152 const out: string[] = []
153 scene.layers.forEach((style, l) => {
154 if (l === 0) {
155 return
156 }
157 const texts: string[] = []
158 for (let y = 0; y < s.rows; y += 1) {
159 let line = ''
160 let first = -1
161 let last = -1
162 for (let x = 0; x < s.cols; x += 1) {
163 const i = y * s.cols + x
164 let c = ' '
165 if (s.layer[i] === l) {
166 // Lifted and jittered a little, so mid-tones don't band into rows of one character.
167 const v = clamp01(Math.pow(s.value[i]!, 0.75) + (hash(x, y) - 0.5) * 0.14)
168 c = style.chars[Math.min(style.chars.length - 1, Math.floor(v * style.chars.length))]!
169 }
170 if (c !== ' ') {
171 if (first < 0) {
172 first = x
173 }
174 last = x
175 }
176 line += c
177 }
178 if (first >= 0) {
179 texts.push(`<text x="${first * cw}" y="${r1(y * ch + ch * 0.78)}" textLength="${(last - first + 1) * cw}">${line.slice(first, last + 1)}</text>`)
180 }
181 }
182 if (texts.length) {
183 out.push(layerGroup(style, texts.join(''), style.fill))
184 }
185 })
186 return out.join('')
187}
188
189function pixelFrame(scene: FieldScene, s: Grid): string {
190 const out: string[] = []
191 scene.layers.forEach((style, l) => {
192 if (l === 0) {
193 return
194 }
195 const n = style.shades.length
196 const runs: string[][] = style.shades.map(() => [])
197 for (let y = 0; y < s.rows; y += 1) {
198 let runLevel = -1
199 let runStart = 0
200 const flush = (end: number) => {
201 if (runLevel >= 0) {
202 const len = end - runStart
203 runs[runLevel]!.push(`M${runStart} ${y}h${len}v1h-${len}z`)
204 }
205 }
206 for (let x = 0; x < s.cols; x += 1) {
207 const i = y * s.cols + x
208 let level = -1
209 if (s.layer[i] === l) {
210 const at = s.value[i]! * (n - 1)
211 const base = Math.floor(at)
212 level = Math.min(n - 1, at - base > BAYER[(y % 4) * 4 + (x % 4)]! ? base + 1 : base)
213 }
214 if (level !== runLevel) {
215 flush(x)
216 runLevel = level
217 runStart = x
218 }
219 }
220 flush(s.cols)
221 }
222 const paths = runs.map((d, k) => (d.length ? `<path fill="${style.shades[k]}" d="${d.join('')}"/>` : '')).join('')
223 if (paths) {
224 out.push(layerGroup(style, paths))
225 }
226 })
227 return out.join('')
228}
229
230/** One grid's worth: a frame, or the still backdrop. */
231type Grid = { cols: number; rows: number; layer: Uint8Array; value: Float32Array }
232
233const DOT_SIZES = [0.32, 0.55, 0.85]
234
235function particleFrame(scene: FieldScene, s: Grid, cw: number, ch: number): string {
236 const out: string[] = []
237 scene.layers.forEach((style, l) => {
238 if (l === 0) {
239 return
240 }
241 const buckets: string[][] = DOT_SIZES.map(() => [])
242 for (let y = 0; y < s.rows; y += 1) {
243 for (let x = 0; x < s.cols; x += 1) {
244 const i = y * s.cols + x
245 if (s.layer[i] !== l) {
246 continue
247 }
248 const v = s.value[i]!
249 if (v < 0.08) {
250 continue
251 }
252 const k = v < 0.4 ? 0 : v < 0.72 ? 1 : 2
253 const jx = (hash(x, y) - 0.5) * cw * 0.6
254 const jy = (hash(y, x) - 0.5) * ch * 0.6
255 buckets[k]!.push(`M${Math.round((x + 0.5) * cw + jx)} ${Math.round((y + 0.5) * ch + jy)}h0`)
256 }
257 }
258 const paths = buckets
259 .map((d, k) => (d.length ? `<path stroke="${style.fill}" stroke-width="${r1(DOT_SIZES[k]! * cw)}" d="${d.join('')}"/>` : ''))
260 .join('')
261 if (paths) {
262 out.push(layerGroup(style, paths))
263 }
264 })
265 return out.join('')
266}
267
268function layerGroup(style: LayerStyle, body: string, fill?: string): string {
269 const attrs =
270 (fill ? ` fill="${fill}"` : '') +
271 (style.opacity !== undefined ? ` opacity="${style.opacity}"` : '') +
272 (style.isGlowing ? ' filter="url(#bloom)"' : '')
273 return `<g${attrs}>${body}</g>`
274}
275
276const BLOOM =
277 '<filter id="bloom" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>'
278
279/** What a drawing may come to: under the Svg element's 131,072, with room to spare. */
280export const BUDGET = 120_000
281
282/** The slot area GRID's cell sizes were set for: a 412 × 1442 dock. */
283const REFERENCE_AREA = 412 * 1442
284
285/**
286 * The scene drawn, its frames cycling from where the clock says. Runs of
287 * layers that share an ink are one pass, each on its own grid, drawn in
288 * layer order. `only` draws every layer in one ink, to compare inks.
289 *
290 * Cells grow with the slot so a larger one costs no more: from a scale set
291 * by its area, and coarser again until the drawing fits the budget.
292 */
293export function renderScene(scene: FieldScene, nowMs: number, only?: Ink): string {
294 let scale = Math.max(1, Math.sqrt((scene.width * scene.height) / REFERENCE_AREA))
295 let svg = renderAt(scene, nowMs, only, scale)
296 for (let tries = 0; svg.length > BUDGET && tries < 6; tries += 1) {
297 scale *= 1.2
298 svg = renderAt(scene, nowMs, only, scale)
299 }
300 return svg
301}
302
303function renderAt(scene: FieldScene, nowMs: number, only: Ink | undefined, scale: number): string {
304 const W = Math.round(scene.width)
305 const H = Math.round(scene.height)
306 const P = scene.period
307 const clock = (nowMs / 1000) % P
308
309 const passes: Array<{ ink: Ink; layers: number[] }> = []
310 scene.layers.forEach((style, l) => {
311 if (l === 0) {
312 return
313 }
314 const ink = only ?? style.ink ?? scene.ink
315 const last = passes[passes.length - 1]
316 if (last?.ink === ink) {
317 last.layers.push(l)
318 } else {
319 passes.push({ ink, layers: [l] })
320 }
321 })
322
323 const css: string[] = []
324 const reduced: string[] = []
325 const body = passes
326 .map((pass, p) => {
327 const base = GRID[pass.ink]
328 const cw = Math.round(base.cw * scale)
329 const ch = Math.round(base.ch * scale)
330 const frames = base.frames
331 const s = sampleAll(scene, cw, ch, frames, new Set(pass.layers))
332 const draw = (g: Grid) =>
333 pass.ink === 'glyph' ? glyphFrame(scene, g, cw, ch) : pass.ink === 'pixel' ? pixelFrame(scene, g) : particleFrame(scene, g, cw, ch)
334 const slot = P / frames
335
336 let stillMarkup = s.still ? draw({ cols: s.cols, rows: s.rows, ...s.still }) : ''
337 if (stillMarkup) {
338 // The still backdrop drifts a little, so it never reads as frozen.
339 // Pixel ink draws in cell units, the others in pixels.
340 const drift = pass.ink === 'pixel' ? 0.5 : r1(cw * 0.5)
341 const delay = r1(-((nowMs / 1000) % 18))
342 css.push(
343 scene.stillMotion === 'sway'
344 ? `.s${p}{transform-box:view-box;transform-origin:${r1(W / 2)}px 0;animation:s${p} 7s ${delay}s ease-in-out infinite alternate}@keyframes s${p}{from{transform:skewX(-2.5deg)}to{transform:skewX(2.5deg)}}`
345 : `.s${p}{animation:s${p} 9s ${delay}s ease-in-out infinite alternate}@keyframes s${p}{from{transform:translate(-${drift}px,0)}to{transform:translate(${drift}px,0)}}`,
346 )
347 reduced.push(`.s${p}`)
348 stillMarkup = `<g class="s${p}">${stillMarkup}</g>`
349 }
350
351 const frameBodies = Array.from({ length: frames }, (_, f) => draw({ cols: s.cols, rows: s.rows, layer: s.layer[f]!, value: s.value[f]! }))
352 let frameMarkup = ''
353 if (frameBodies.some(b => b)) {
354 css.push(`.p${p}{opacity:0;animation:p${p} ${P}s steps(1,end) infinite}@keyframes p${p}{0%{opacity:1}${r1(100 / frames)}%{opacity:0}100%{opacity:0}}`)
355 reduced.push(`.p${p}`)
356 frameMarkup = frameBodies
357 .map((b, f) => {
358 const delay = r1(-(((P - f * slot) % P) + clock) % P)
359 return `<g class="p${p}${f === 0 ? ' f0' : ''}" style="animation-delay:${delay}s">${b}</g>`
360 })
361 .join('')
362 }
363
364 const attrs =
365 pass.ink === 'glyph'
366 ? ` font-family="ui-monospace,Menlo,Consolas,monospace" font-size="${r1(ch * 1.02)}" font-weight="700" style="white-space:pre"`
367 : pass.ink === 'pixel'
368 ? ` transform="scale(${cw} ${ch})" shape-rendering="crispEdges"`
369 : ' fill="none" stroke-linecap="round"'
370 return stillMarkup || frameMarkup ? `<g${attrs}>${stillMarkup}${frameMarkup}</g>` : ''
371 })
372 .join('')
373
374 if (reduced.length) {
375 css.push(`@media (prefers-reduced-motion:reduce){${reduced.join(',')}{animation:none}.f0{opacity:1}}`)
376 }
377 return (
378 `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
379 `<style>${css.join('')}${scene.css.join('')}</style><defs>${BLOOM}${scene.defs.join('')}</defs>` +
380 `${scene.under}${body}${scene.over}</svg>`
381 )
382}
383hooks/lich.ts 172 lines1// The Lich's look: its glyphs and colours, how the pane and the band frame
2// it, the dark it hangs in (a still backdrop), and its field scenes for
3// surfaces that cannot draw it live (one pose a state, from the same model).
4
5import type { VesselState } from './eidola-types'
6import { EMPTY, hash } from './field'
7import type { FieldScene, LayerStyle } from './field'
8import { ARMS_DOWN, BONE, CROWN, FIRE, FLAME, GEM, MANTLE, RUNE, buildModel, sampleModel } from './lich3d'
9import type { Camera, Pose } from './lich3d'
10
11export type View = 'pane' | 'band'
12
13/** Each layer's colours, dark to bright: ember gold, the alchemist's palette; purple eyes, green arcane flame. */
14export const COLOURS: string[][] = [
15 [],
16 ['#1c0e06', '#3a1e0c', '#5e3414', '#8a4e1e', '#b8702c', '#e0973e', '#f6c063', '#ffe6a8'],
17 ['#3a2606', '#6e4a10', '#a8781c', '#d8a830', '#ffd65a', '#fff2b0'],
18 ['#2a0d0a', '#43150f', '#5f1e15', '#80291b', '#a53622', '#c8502c'],
19 ['#2a0a3a', '#5a1a7a', '#9a3ad0', '#c97cff', '#f0d8ff'],
20 ['#5a3a10', '#c08a2c', '#ffd27a', '#fff2c8'],
21 ['#5a0a18', '#b0182c', '#ff4a5a', '#ffc0c8'],
22 ['#0a3a12', '#1a7a2a', '#3ad04a', '#8cff7a', '#e0ffd0'],
23]
24
25/** Arcane fire: what kindles across its signs while it works, and its gem burns. */
26export const ARCANE = ['#0a3a12', '#1a7a2a', '#3ad04a', '#8cff7a', '#e0ffd0']
27
28/** Its eyes when it needs you: witchlight, a pale violet gone almost white. */
29export const WITCHLIGHT = ['#3a1a5a', '#7a4ab0', '#c49aff', '#ead8ff', '#ffffff']
30
31/** Its robe's colour by mood: dark red at rest (the mantle's own), purple at work, emerald when it needs you. */
32export const ROBE_PURPLE = ['#1c0a2c', '#30124c', '#4a1e72', '#662c98', '#8240b8', '#9e5ad2']
33export const ROBE_EMERALD = ['#06261a', '#0c3e2a', '#145a3c', '#1e7a52', '#2a9a68', '#3cba82']
34
35/** Scorched: everything gone to red, for a moment, when something went wrong. */
36export const SCORCHED = ['#1a0404', '#3a0808', '#6a1010', '#a01c14', '#d0301c', '#ff5a3a', '#ff9a7a', '#ffd0c0']
37
38/**
39 * Each layer's glyphs, from dim to dense, a few to a step so the bone
40 * shimmers. Only characters that keep to one cell in common monospace fonts.
41 */
42export const GLYPHS: string[][][] = [
43 [],
44 [['∙', ':'], ['∴', '∵', '+'], ['+', '×', '☿'], ['×', '♂', '♀', '☿'], ['▲', '▼', '♁', '●']],
45 [['·'], [':'], ['▲', '▼'], ['▲', '♁'], ['▲', '●']],
46 [['·', '∙'], [':', '∙'], ['∴', '∵', ':'], ['+', '∴', '×'], ['×', '+']],
47 [['∙'], [':', '∴'], ['▲'], ['●'], ['●']],
48 [['☿'], ['♀'], ['♂'], ['♁'], ['☽']],
49 [['∙'], ['●'], ['●'], ['●'], ['●']],
50 [['∙'], [':', '∴'], ['▲'], ['▲', '●'], ['●']],
51]
52
53/** A glyph for a layer at a brightness, picked by place and time so the bone shimmers. */
54export function glyphFor(layer: number, v: number, x: number, y: number, t: number): string {
55 const steps = GLYPHS[layer] ?? GLYPHS[BONE]!
56 const set = steps[Math.min(steps.length - 1, Math.max(0, Math.floor(v * steps.length)))]!
57 if (set.length === 1) {
58 return set[0]!
59 }
60 const k = Math.floor(hash(x, y) * set.length * 7 + t * (0.5 + hash(y, x) * 1.5))
61 return set[k % set.length]!
62}
63
64/** Its layers for the field engine: the same glyphs and colours. */
65export function lichLayers(): LayerStyle[] {
66 return COLOURS.map((shades, i) => ({
67 chars: i === 0 ? ' ' : ' ' + (GLYPHS[i] ?? []).map(step => step[0]).join(''),
68 shades: shades.length ? shades : ['#000'],
69 fill: i === 0 ? 'none' : (shades[Math.floor(shades.length * 0.7)] ?? '#888'),
70 isGlowing: i === FIRE || i === GEM || i === RUNE || i === FLAME,
71 }))
72}
73
74/** The head's size in the band: as in a tall pane, so the band shows the pane's face. */
75const BAND_HEAD = 258
76
77/** How a view frames it: the camera, the head's size, and where the head sits. */
78export function framing(view: View, W: number, H: number): { cam: Camera; s: number; home: [number, number] } {
79 if (view === 'band') {
80 // A strip across its face at the pane's own scale, the same signs the same
81 // size, its eyes on the middle row.
82 const s = BAND_HEAD
83 return { cam: { cx: W / 2, cy: H / 2, f: 1850 }, s, home: [0.5, (H / 2 - 0.12 * s) / H] }
84 }
85 return { cam: { cx: W / 2, cy: H * 0.42, f: H * 1.3 }, s: Math.min(W * 0.62, H * 0.28), home: [0.5, 0.26] }
86}
87
88/** A still pose for a state: for the field scenes. */
89export function restingPose(state: VesselState, view: View, W: number, H: number, t = 0): Pose {
90 const { s, home } = framing(view, W, H)
91 const base: Pose = { pos: [W * home[0], H * home[1], 0], s, yaw: 0, pitch: 0.1, roll: 0, bodyYaw: 0, jaw: 0, eyes: 0.55, look: [0, 0], flame: 0.15, runes: 0, spin: t * 0.15, t, light: 0.75, arms: ARMS_DOWN }
92 switch (state) {
93 case 'thinking':
94 case 'working':
95 case 'responding':
96 return { ...base, pitch: -0.04, eyes: 1.1, flame: 1, light: 1, arms: [{ raise: 1, spread: 0 }, { raise: 1, spread: 0 }] }
97 case 'attention':
98 return { ...base, pos: [W * home[0], H * home[1], view === 'band' ? -90 : -380], eyes: 1.6, flame: 0.5, jaw: 0.2, light: 1, arms: [{ raise: 0, spread: 0 }, { raise: 0.85, spread: 0 }] }
99 case 'error':
100 return { ...base, eyes: 1.4, flame: 0.8, jaw: 0.5, roll: 0.12 }
101 case 'sleeping':
102 return { ...base, pitch: 0.3, eyes: 0.1, flame: 0, light: 0.5 }
103 default:
104 return base
105 }
106}
107
108/**
109 * The dark it hangs in, drawn once as a still SVG: near black, a faint
110 * ember glow behind the head, and embers rising. All its motion is CSS,
111 * so it never reloads.
112 */
113export function voidScene(W: number, H: number, view: View): FieldScene {
114 const r = (n: number) => Math.round(n * 10) / 10
115 const isBand = view === 'band'
116 const defs = [
117 `<radialGradient id="dark" cx=".5" cy="${isBand ? '.5' : '.27'}" r="${isBand ? '.7' : '.9'}"><stop offset="0" stop-color="#2a0f08"/><stop offset=".45" stop-color="#120606"/><stop offset="1" stop-color="#050203"/></radialGradient>`,
118 `<radialGradient id="vig" cx=".5" cy=".5" r=".75"><stop offset=".55" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".65"/></radialGradient>`,
119 `<radialGradient id="ember"><stop offset="0" stop-color="#ffd27a"/><stop offset=".4" stop-color="#ff7a2a" stop-opacity=".7"/><stop offset="1" stop-color="#ff7a2a" stop-opacity="0"/></radialGradient>`,
120 ]
121 const embers = Array.from({ length: isBand ? 16 : 22 }, (_, i) => {
122 const rise = r(9 + hash(i, 91) * 10)
123 return (
124 `<g style="animation:rise ${rise}s ${r(-hash(i, 92) * rise)}s linear infinite">` +
125 `<circle cx="${r(hash(i, 93) * W)}" cy="${r(H + 6)}" r="${r(1.5 + hash(i, 94) * 2.5)}" fill="url(#ember)" style="animation:flick ${r(0.8 + hash(i, 95))}s ${r(-hash(i, 96))}s ease-in-out infinite alternate"/></g>`
126 )
127 }).join('')
128 const css =
129 `@keyframes rise{0%{transform:translate(0,0);opacity:0}15%{opacity:.9}100%{transform:translate(${r(W * 0.06)}px,${r(-H - 20)}px);opacity:0}}` +
130 '@keyframes flick{from{opacity:.35}to{opacity:1}}' +
131 '@media (prefers-reduced-motion:reduce){g,circle{animation:none!important}}'
132 return {
133 width: W,
134 height: H,
135 layers: lichLayers(),
136 ink: 'glyph',
137 period: 6,
138 under: `<rect width="${W}" height="${H}" fill="url(#dark)"/><g>${embers}</g>`,
139 over: `<rect width="${W}" height="${H}" fill="url(#vig)"/><rect x="1" y="1" width="${W - 2}" height="${H - 2}" fill="none" stroke="#ffcf8a" stroke-opacity=".1" stroke-width="2"/>`,
140 defs,
141 css: [css],
142 sample() {
143 return EMPTY
144 },
145 }
146}
147
148/** The Lich as a field scene, in glyphs: one pose a state, a little motion through the frames. */
149function lichScene(view: View, state: VesselState, W: number, H: number): FieldScene {
150 const backdrop = voidScene(W, H, view)
151 const { cam } = framing(view, W, H)
152 const models = new Map<number, ReturnType<typeof buildModel>>()
153 const period = state === 'working' || state === 'thinking' ? 3 : 6
154 return {
155 ...backdrop,
156 period,
157 sample(x, y, frame, frames) {
158 let m = models.get(frame)
159 if (!m) {
160 m = buildModel(restingPose(state, view, W, H, (frame / frames) * period), cam)
161 models.set(frame, m)
162 }
163 return sampleModel(m, x, y)?.cell ?? EMPTY
164 },
165 }
166}
167
168export const lichPaneScene = (state: VesselState, W: number, H: number) => lichScene('pane', state, W, H)
169export const lichBandScene = (state: VesselState, W: number, H: number) => lichScene('band', state, W, H)
170
171export { BONE, CROWN, FIRE, FLAME, GEM, MANTLE, RUNE }
172hooks/stage.ts 199 lines1// The stage: the Vessel set in a scene the size of the pane body, so the
2// pane reads as a place rather than a small image in a large box. A glow
3// and a wash in the state's colour, a soft floor shadow, motes rising at
4// the state's tempo. Every layer fades to transparent before the edge, so
5// an estimate a few pixels off never shows a seam, in either theme.
6
7import type { VesselState } from './eidola-types'
8
9// Measured on Claude Code Desktop with /eidola measure (2026-10-16): a
10// 52 × 76 cell dock body drew 412 × 1443 px, a 95-column band 756 px.
11export const PX_PER_COLUMN = 8
12export const PX_PER_ROW = 19
13
14/** The dark behind a band view drawn across the middle of the pane. */
15export const BACKDROP = '#07090c'
16
17/** The veil drawn over the resting scene while nothing is awake. */
18export const VEIL = '#000'
19
20/** The Svg element's limit on `source`. */
21export const SVG_LIMIT = 131072
22
23/**
24 * The band's square stage, in CSS pixels. Desktop draws the band at least
25 * about 170px tall whatever its tree, so this fills it rather than float.
26 */
27export const BAND_STAGE_PX = 140
28
29/** Inline the pane fits its tree, so the stage asks for no more than this. */
30const INLINE_ROWS = 10
31
32export type StageSize = { width: number; height: number }
33
34export const ACCENTS: Record<VesselState, string> = {
35 idle: '#8fa3bf',
36 thinking: '#8c7bff',
37 working: '#ffb547',
38 attention: '#ffd84a',
39 success: '#5fd38d',
40 responding: '#4fd1c5',
41 error: '#ff5c5c',
42 waiting: '#b39ddb',
43 sleeping: '#4a5a80',
44}
45
46/** Seconds per glow breath. */
47const BREATH: Record<VesselState, number> = {
48 idle: 6,
49 thinking: 3,
50 working: 1.6,
51 attention: 1.2,
52 success: 2.4,
53 responding: 2.4,
54 error: 1,
55 waiting: 4,
56 sleeping: 9,
57}
58
59/** Seconds for a mote to rise; busier states rise faster. */
60const RISE: Record<VesselState, number> = {
61 idle: 14,
62 thinking: 9,
63 working: 5,
64 attention: 7,
65 success: 6,
66 responding: 8,
67 error: 10,
68 waiting: 12,
69 sleeping: 22,
70}
71
72const MOTES = 18
73
74/** The pane body in CSS pixels, a cell short each way so it never overflows. */
75export function stageSize(columns: number, rows: number, placement: 'dock' | 'inline'): StageSize {
76 const usable = placement === 'inline' ? Math.min(rows, INLINE_ROWS) : rows
77 return {
78 width: Math.max(96, (columns - 1) * PX_PER_COLUMN),
79 height: Math.max(96, (usable - 1) * PX_PER_ROW),
80 }
81}
82
83/** A scene band's height: it fills the band's own minimum height. */
84export const BAND_SCENE_PX = 140
85
86/**
87 * The whole pane body in CSS pixels, for a scene that fills it edge to edge.
88 * The measured dock body was 4px under columns × 8 and 2px under rows × 19.
89 */
90export function sceneSize(columns: number, rows: number, placement: 'dock' | 'inline'): StageSize {
91 const usable = placement === 'inline' ? Math.min(rows, INLINE_ROWS) : rows
92 return {
93 width: Math.max(96, columns * PX_PER_COLUMN - 4),
94 height: Math.max(96, usable * PX_PER_ROW - 2),
95 }
96}
97
98type Box = { x: number; y: number; w: number; h: number }
99
100function viewBoxOf(svg: string): Box | undefined {
101 const m = /viewBox="([-\d.]+)[ ,]+([-\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)"/.exec(svg)
102 if (!m) {
103 return undefined
104 }
105 const [x, y, w, h] = [m[1], m[2], m[3], m[4]].map(Number) as [number, number, number, number]
106 return w > 0 && h > 0 ? { x, y, w, h } : undefined
107}
108
109/** A fixed sequence in [0, 1): the motes sit in the same places every draw. */
110function seeded(seed: number): () => number {
111 let a = seed >>> 0
112 return () => {
113 a = (a + 0x6d2b79f5) >>> 0
114 let t = a
115 t = Math.imul(t ^ (t >>> 15), t | 1)
116 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
117 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
118 }
119}
120
121const r1 = (n: number) => Math.round(n * 10) / 10
122
123/**
124 * The Vessel's SVG set on a stage of `size`, or undefined when it can't be:
125 * no viewBox to place it by, or the result past the Svg limit. The caller
126 * then draws the Vessel alone.
127 */
128export function stageSvg(vessel: string, state: VesselState, size: StageSize): string | undefined {
129 const box = viewBoxOf(vessel)
130 if (!box) {
131 return undefined
132 }
133 const { width: W, height: H } = size
134 const accent = ACCENTS[state]
135
136 // A tall dock seats the Vessel above centre, leaving room for its shadow.
137 const isTall = H > W * 1.2
138 const scale = Math.min((W * 0.72) / box.w, (H * (isTall ? 0.42 : 0.78)) / box.h)
139 const vw = box.w * scale
140 const vh = box.h * scale
141 const cx = W / 2
142 const cy = isTall ? H * 0.42 : H / 2
143 const vx = cx - vw / 2
144 const vy = cy - vh / 2
145 const reach = Math.max(vw, vh)
146 // The wash is an ellipse that ends at the nearest edge on each axis, and
147 // the glow a circle inside it, so neither tints an edge into a seam.
148 const rx = Math.max(1, Math.min(cx, W - cx))
149 const ry = Math.max(1, Math.min(cy, H - cy))
150 const glowR = Math.min(reach * 0.95, rx, ry)
151
152 const random = seeded(7)
153 const rise = Math.round(Math.min(H * 0.55, reach * 2.2))
154 const motes: string[] = []
155 for (let i = 0; i < MOTES; i += 1) {
156 const x = r1(cx + (random() - 0.5) * Math.min(W * 0.85, reach * 2))
157 const y = r1(cy + vh * 0.2 + random() * vh * 0.5)
158 const r = r1(0.8 + random() * 1.6)
159 const dur = r1(RISE[state] * (0.7 + random() * 0.6))
160 const delay = r1(-random() * dur)
161 motes.push(`<circle class="eds-mote" cx="${x}" cy="${y}" r="${r}" style="animation-duration:${dur}s;animation-delay:${delay}s"/>`)
162 }
163
164 // The Vessel nested at its place: its own width/height dropped, the slot's set.
165 const nested = vessel.replace(/<svg\b([^>]*)>/, (_match, attrs: string) => {
166 const kept = attrs.replace(/\s(width|height|x|y|preserveAspectRatio)="[^"]*"/g, '')
167 return `<svg x="${r1(vx)}" y="${r1(vy)}" width="${r1(vw)}" height="${r1(vh)}" preserveAspectRatio="xMidYMid meet"${kept}>`
168 })
169
170 const css =
171 `.eds-glow{animation:eds-breath ${BREATH[state]}s ease-in-out infinite alternate}` +
172 '@keyframes eds-breath{from{opacity:.6}to{opacity:1}}' +
173 `.eds-mote{fill:${accent};opacity:0;animation-name:eds-rise;animation-timing-function:linear;animation-iteration-count:infinite}` +
174 `@keyframes eds-rise{0%{transform:translateY(0);opacity:0}15%{opacity:.75}80%{opacity:.4}100%{transform:translateY(-${rise}px);opacity:0}}` +
175 '@media (prefers-reduced-motion:reduce){.eds-glow,.eds-mote{animation:none}.eds-mote{opacity:.35}}'
176
177 const out =
178 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
179 `<style>${css}</style>` +
180 '<defs>' +
181 `<radialGradient id="eds-wash" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" ` +
182 `gradientTransform="translate(${r1(cx)} ${r1(cy)}) scale(${r1(rx)} ${r1(ry)})">` +
183 `<stop offset="0" stop-color="${accent}" stop-opacity=".14"/><stop offset=".45" stop-color="${accent}" stop-opacity=".07"/>` +
184 `<stop offset=".75" stop-color="${accent}" stop-opacity=".02"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
185 `<radialGradient id="eds-glow" gradientUnits="userSpaceOnUse" cx="${r1(cx)}" cy="${r1(cy)}" r="${r1(glowR)}">` +
186 `<stop offset="0" stop-color="${accent}" stop-opacity=".42"/><stop offset=".35" stop-color="${accent}" stop-opacity=".2"/>` +
187 `<stop offset=".65" stop-color="${accent}" stop-opacity=".06"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
188 '<radialGradient id="eds-floor"><stop offset="0" stop-color="#000" stop-opacity=".45"/><stop offset="1" stop-color="#000" stop-opacity="0"/></radialGradient>' +
189 '</defs>' +
190 `<rect width="${W}" height="${H}" fill="url(#eds-wash)"/>` +
191 `<rect class="eds-glow" width="${W}" height="${H}" fill="url(#eds-glow)"/>` +
192 `<ellipse cx="${r1(cx)}" cy="${r1(vy + vh * 1.04)}" rx="${r1(vw * 0.45)}" ry="${r1(Math.max(3, vh * 0.05))}" fill="url(#eds-floor)"/>` +
193 `<g>${motes.join('')}</g>` +
194 nested +
195 '</svg>'
196
197 return out.length <= SVG_LIMIT ? out : undefined
198}
199hooks/lich-live.tsx 843 lines1// The live Lich: a Client module that keeps it and draws it every tick, in
2// glyphs, over the dark drawn once beneath it. The mood comes in props; the
3// instance and its timer outlive every redraw, so a new mood is something
4// it turns to, never a cut.
5//
6// It hangs in the dark, drifting, its bone a slow shimmer of signs. Idle,
7// its fire is low and a few runes wheel lazily about its head. Working, the
8// fire streams up from its sockets, the runes race, the signs churn, and now
9// and then it chants. When it needs you it comes right up to the glass, its
10// eyes gone to witchlight. Something gone wrong scorches it red for a moment.
11
12import type { ClientModule } from 'claude-code'
13
14import type { Mood } from './eidola-types'
15import { hash } from './field'
16import { ARCANE, COLOURS, ROBE_EMERALD, ROBE_PURPLE, FIRE, SCORCHED, WITCHLIGHT, framing, glyphFor } from './lich'
17import type { View } from './lich'
18import { BONE, CROWN, GEM, MANTLE, buildModel, modelBounds, sampleModel } from './lich3d'
19import type { Pose } from './lich3d'
20import { grimoireGlyphs } from './grimoire3d'
21import { drawGlyphs } from './live-glyphs'
22import { mixRamp, pick, short } from './live-pixels'
23
24/**
25 * The grid comes in props, never from the region: a drawing sized by its
26 * region resizes it. `rows` is the whole picture's height; `slice` the rows
27 * this drawing draws, [first, count], when it is split across several.
28 *
29 * Split, each drawing keeps its own copy, and they stay as one: the
30 * simulation is deterministic, its ticks counted from `epoch` (ms), and a
31 * new mood or ink takes effect a few ticks after its time (`moodAt`,
32 * `inkAt`, ms), by when every drawing has heard of it.
33 */
34type Props = {
35 mood: Mood
36 cols: number
37 rows: number
38 view?: View
39 ink?: number
40 slice?: [number, number]
41 epoch?: number
42 moodAt?: number
43 inkAt?: number
44 /** The mood before this one: what a drawing started late lived through first. */
45 moodBefore?: Mood
46}
47
48/**
49 * What one copy carries across a resync, so every copy agrees: the mood and
50 * the ink, now and pending.
51 */
52type Carry = Pick<Live, 'mood' | 'moodNext' | 'moodTick' | 'inkSeen' | 'inkPending' | 'inkTick'>
53
54/**
55 * What it does now and then. `turn` looks aside; `chuckle` rattles its jaw;
56 * `drift` moves it across the glass; `gutter` lets its fire sink and flare;
57 * `loom` brings it nearer; `chant` is a spell, jaw working, fire high; `surge`
58 * sets the runes racing; `tilt` cocks its head; `speak` is a word to you.
59 * And it flies: a `swoop` dives out through one side and comes back from the
60 * other; a `dip` sinks out of the bottom and rises again; a `pass` sweeps
61 * past right against the glass. A `vanish` dissolves it into drifting motes,
62 * and it gathers itself again somewhere else. Its arms hang at its sides
63 * but for a `gesture`, one hand lifted, and a `beckon` to you.
64 */
65type Act = 'turn' | 'chuckle' | 'drift' | 'gutter' | 'loom' | 'chant' | 'surge' | 'tilt' | 'speak' | 'swoop' | 'dip' | 'pass' | 'vanish' | 'gesture' | 'beckon'
66
67/**
68 * A waypoint: across and down the glass (fractions) and depth (pixels). A
69 * `jump` one is reached at once, and only ever while the Lich is out of view.
70 */
71type Waypoint = { x: number; y: number; z: number; jump?: boolean }
72
73/** Acts that fly a route: out of frame and back. */
74const FLIGHTS: ReadonlySet<Act> = new Set(['swoop', 'dip', 'pass'])
75
76type Plan = {
77 /** Across the glass (a fraction), depth behind it (pixels), height on the glass (a fraction). */
78 home: [number, number, number]
79 spread: number
80 pitch: number
81 eyes: number
82 flame: number
83 runes: number
84 /** Radians a second the runes wheel. */
85 spin: number
86 /** How fast the bone's signs change. */
87 churn: number
88 light: number
89 acts: Array<[Act, number]>
90 every: [number, number]
91}
92
93const PLANS: Record<Mood, Plan> = {
94 // Hanging in the dark, fire low, a few runes drifting round.
95 idle: { home: [0.5, 0, 0.27], spread: 0.1, pitch: 0.1, eyes: 0.55, flame: 0.15, runes: 0.5, spin: 0.15, churn: 0.6, light: 0.75, acts: [['turn', 3], ['chuckle', 1.5], ['drift', 2], ['gutter', 1.5], ['loom', 1], ['swoop', 2], ['dip', 1.5], ['pass', 1], ['vanish', 0.5], ['gesture', 1.5]], every: [50, 140] },
96 // Burning: flame streaming from its sockets, every rune racing.
97 working: { home: [0.5, -80, 0.26], spread: 0.06, pitch: -0.04, eyes: 1.1, flame: 1, runes: 1, spin: 0.9, churn: 3, light: 1, acts: [['chant', 3], ['surge', 2], ['turn', 1], ['swoop', 0.8], ['dip', 0.6], ['vanish', 0.2]], every: [40, 100] },
98 // At the glass, eyes gone to witchlight, looking at you.
99 attention: { home: [0.5, -380, 0.3], spread: 0.03, pitch: -0.02, eyes: 1.6, flame: 0.5, runes: 0.6, spin: 0.3, churn: 1, light: 1, acts: [['tilt', 3], ['speak', 2], ['loom', 1], ['beckon', 2]], every: [20, 50] },
100}
101
102/**
103 * The band: a strip across its face at the pane's own scale, the same signs
104 * the same size, its eyes in the middle row: brow above, nose and teeth below.
105 */
106const BAND_HOMES: Record<Mood, [number, number, number]> = {
107 idle: [0.5, 0, 0.27],
108 working: [0.5, 0, 0.3],
109 attention: [0.5, -40, 0.3],
110}
111
112function planOf(view: View, mood: Mood): Plan {
113 return view === 'band' ? { ...PLANS[mood], home: BAND_HOMES[mood], spread: mood === 'attention' ? 0.03 : 0.12, runes: 0 } : PLANS[mood]
114}
115
116export type Live = {
117 mood: Mood
118 view: View
119 planned: Mood | ''
120 tick: number
121 t: number
122 seed: number
123 x: number
124 z: number
125 y: number
126 xTarget: number
127 pitch: number
128 yaw: number
129 roll: number
130 jaw: number
131 eyes: number
132 look: [number, number]
133 lookTarget: [number, number]
134 nextLook: number
135 flame: number
136 runes: number
137 spin: number
138 spinRate: number
139 churn: number
140 /** Time for the glyphs: runs faster as the signs churn. */
141 glyphT: number
142 light: number
143 /** 0 ember, 1 witchlight: the fire's colour. */
144 witch: number
145 /** How far arcane fire has kindled across its signs: 1 while it works. */
146 arcane: number
147 /** How far its robe has gone to purple (working), and to emerald (attention): 0 to 1 each. */
148 heat: number
149 /** The band's grimoire: a page turning (0 to 1, or -1), which pages are open, when the next turns. */
150 flip: number
151 pages: number
152 nextFlip: number
153 alarm: number
154 blinkUntil: number
155 nextBlink: number
156 act: Act | 'none'
157 actUntil: number
158 nextAct: number
159 side: number
160 /** Its drift across the glass, in pixels a tick. */
161 vx: number
162 vy: number
163 /** A flight's waypoints still ahead. */
164 route: Waypoint[]
165 /** How much of it is here: 1 whole, 0 dissolved to nothing. */
166 presence: number
167 /** Its arms, x < 0 then x > 0: how raised and how flung out, easing to where they are going. */
168 arms: [{ raise: number; spread: number }, { raise: number; spread: number }]
169 /** A gesture greeting a new mood, and until when. */
170 greet: 'conjure' | 'beckon' | 'none'
171 greetUntil: number
172 inkSeen: number
173 scorchUntil: number
174 scorch: number
175 moodNext: Mood
176 moodTick: number
177 inkPending: boolean
178 inkTick: number
179}
180/** A copy's Lich, and the last few seconds of it, one snapshot a tick, to replay from. */
181type State = { live: Live; past: Live[] }
182
183const TICK_MS = 100
184const DT = TICK_MS / 1000
185const CW = 8
186const CH = 19
187/** Ticks a split drawing may simulate at once, catching up. */
188const CATCH_UP = 50
189/** How far back a freshly mounted drawing starts: a whole number of these before now. */
190const ERA = 6000
191/** Ticks a vanishing takes, all told: going, gone, and gathered again. */
192const VANISH = 30
193/** Ticks before its end it starts to gather again; and the tick, a little before that, it moves while nothing of it shows. */
194const GATHER = 12
195/** Ticks of the past a copy keeps, to replay a change it heard of late. */
196const MEMORY = 80
197
198const EMBER = COLOURS.map(ramp => ramp.map(short))
199const COLD = WITCHLIGHT.map(short)
200const GREEN = ARCANE.map(short)
201const RED = SCORCHED.map(short)
202function random(live: Live): number {
203 live.seed = (live.seed + 0x6d2b79f5) >>> 0
204 let t = live.seed
205 t = Math.imul(t ^ (t >>> 15), t | 1)
206 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
207 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
208}
209
210const between = (live: Live, [a, b]: [number, number]) => Math.round(a + random(live) * (b - a))
211
212function pickAct(live: Live, plan: Plan): Act {
213 let roll = random(live) * plan.acts.reduce((sum, [, w]) => sum + w, 0)
214 for (const [act, weight] of plan.acts) {
215 roll -= weight
216 if (roll <= 0) {
217 return act
218 }
219 }
220 return plan.acts[0]![0]
221}
222
223function poseOf(live: Live, W: number, H: number): Pose {
224 const { s } = framing(live.view, W, H)
225 // It floats: a slow rise and fall, never still.
226 const hover = Math.sin(live.t * 0.7) * s * 0.03
227 return {
228 pos: [live.x * W, live.y * H + hover, live.z],
229 s,
230 yaw: live.yaw,
231 pitch: live.pitch,
232 roll: live.roll,
233 bodyYaw: live.yaw * 0.3,
234 jaw: live.jaw,
235 eyes: live.tick < live.blinkUntil ? live.eyes * 0.15 : live.eyes,
236 look: live.look,
237 flame: live.flame,
238 runes: live.runes,
239 spin: live.spin,
240 t: live.t,
241 light: live.light,
242 arms: [{ ...live.arms[0] }, { ...live.arms[1] }],
243 }
244}
245
246function step(live: Live, W: number, H: number): void {
247 live.tick += 1
248 live.t = live.tick * DT
249 if (live.tick % ERA === ERA - 18) {
250 live.act = 'vanish'
251 live.actUntil = live.tick + VANISH
252 live.route = []
253 }
254 if (live.tick % ERA === 0) {
255 resync(live)
256 }
257 if (live.mood !== live.moodNext && live.tick >= live.moodTick) {
258 live.mood = live.moodNext
259 }
260 if (live.inkPending && live.tick >= live.inkTick) {
261 live.inkPending = false
262 live.scorchUntil = live.tick + 30
263 }
264 const plan = planOf(live.view, live.mood)
265 const ease = (from: number, to: number, rate: number) => from + (to - from) * rate
266
267 const endAct = () => {
268 live.act = 'none'
269 live.route = []
270 live.nextAct = live.tick + between(live, plan.every)
271 }
272 if (live.planned !== live.mood) {
273 live.planned = live.mood
274 live.xTarget = plan.home[0]
275 endAct()
276 live.nextAct = live.tick + between(live, [10, 30])
277 // A new mood is greeted with its hands: raised to conjure as work begins, one lifted to you when it needs you.
278 live.greet = live.mood === 'working' ? 'conjure' : live.mood === 'attention' ? 'beckon' : 'none'
279 live.greetUntil = live.tick + 45
280 live.side = random(live) < 0.5 ? -1 : 1
281 }
282 if (live.act !== 'none' && live.tick >= live.actUntil) {
283 endAct()
284 }
285 const homeY = live.view === 'band' ? framing('band', W, H).home[1] : plan.home[2]
286 if (live.act === 'none' && live.tick >= live.nextAct) {
287 const act = pickAct(live, plan)
288 live.act = act
289 live.side = random(live) < 0.5 ? -1 : 1
290 live.actUntil = live.tick + between(live, act === 'chant' ? [30, 50] : act === 'turn' ? [30, 60] : [18, 35])
291 if (act === 'drift') {
292 live.xTarget = plan.home[0] + (random(live) * 2 - 1) * plan.spread
293 endAct()
294 } else if (FLIGHTS.has(act)) {
295 live.route = flight(live, act, plan, homeY, W, H)
296 live.actUntil = live.tick + 600
297 } else if (act === 'vanish') {
298 live.actUntil = live.tick + VANISH
299 }
300 }
301
302 let pitch = plan.pitch
303 let yaw = Math.sin(live.t * 0.23) * 0.12
304 let roll = Math.sin(live.t * 0.31) * 0.05
305 let jaw = 0
306 let eyes = plan.eyes
307 let flame = plan.flame
308 let runes = plan.runes
309 let spinRate = plan.spin
310 let z = plan.home[1]
311 let look: [number, number] = live.lookTarget
312 switch (live.act) {
313 case 'turn':
314 yaw = live.side * 0.45
315 look = [live.side * 0.6, 0]
316 break
317 case 'chuckle':
318 jaw = 0.25 * Math.abs(Math.sin(live.t * 11))
319 pitch -= 0.1
320 break
321 case 'gutter': {
322 const k = (live.actUntil - live.tick) / 30
323 eyes *= k > 0.5 ? 0.15 : 1.6
324 break
325 }
326 case 'loom':
327 z -= 200
328 eyes += 0.3
329 break
330 case 'chant':
331 jaw = 0.35 * Math.abs(Math.sin(live.t * 8))
332 pitch = -0.15
333 eyes = 1.6
334 flame = 1
335 spinRate *= 2.5
336 break
337 case 'surge':
338 eyes += 0.3
339 flame = 1
340 break
341 case 'tilt':
342 roll = live.side * 0.22
343 look = [live.side * 0.3, 0]
344 break
345 case 'speak':
346 jaw = 0.3 * Math.abs(Math.sin(live.t * 9)) * (Math.sin(live.t * 2.3) > -0.3 ? 1 : 0)
347 break
348 }
349 // Scorched: it flinches back, its fire flaring, its jaw agape.
350 const scorched = live.tick < live.scorchUntil
351 if (scorched) {
352 const k = (live.scorchUntil - live.tick) / 30
353 roll += Math.sin(live.t * 18) * 0.15 * k
354 jaw = Math.max(jaw, 0.5 * k)
355 eyes = Math.max(eyes, 1.5)
356 z += 120 * k
357 }
358
359 // Its arms: down at its sides unless it is greeting a mood or doing something with them.
360 const arms = [
361 { raise: 0, spread: 0 },
362 { raise: 0, spread: 0 },
363 ]
364 const one = live.side < 0 ? 0 : 1
365 if (live.tick < live.greetUntil) {
366 if (live.greet === 'conjure') {
367 arms[0]!.raise = arms[1]!.raise = 1
368 } else if (live.greet === 'beckon') {
369 arms[one]!.raise = 0.85
370 }
371 }
372 switch (live.act) {
373 case 'chant':
374 arms[0]!.raise = arms[1]!.raise = 1
375 break
376 case 'surge':
377 arms[0]!.spread = arms[1]!.spread = 1
378 arms[0]!.raise = arms[1]!.raise = 0.5
379 break
380 case 'gesture':
381 arms[one]!.raise = 0.75
382 break
383 case 'beckon':
384 arms[one]!.raise = 0.8 + 0.12 * Math.sin(live.t * 4)
385 break
386 case 'speak':
387 arms[one]!.raise = 0.45 + 0.2 * Math.sin(live.t * 2.5)
388 break
389 }
390 if (scorched) {
391 const k = (live.scorchUntil - live.tick) / 30
392 arms[0]!.spread = arms[1]!.spread = 0.7 * k
393 }
394 for (const i of [0, 1] as const) {
395 live.arms[i].raise = ease(live.arms[i].raise, arms[i]!.raise, 0.1)
396 live.arms[i].spread = ease(live.arms[i].spread, arms[i]!.spread, 0.1)
397 }
398
399 // Dissolving and gathering again: gone for a moment in the middle, it
400 // moves while there is nothing of it to see.
401 let presence = 1
402 if (live.act === 'vanish') {
403 const left = live.actUntil - live.tick
404 presence = left > GATHER ? 0 : 1
405 if (left === GATHER + 1) {
406 live.xTarget = plan.home[0] + (random(live) * 2 - 1) * Math.max(plan.spread, 0.2)
407 live.x = live.xTarget
408 live.y = homeY + (random(live) - 0.5) * (live.view === 'band' ? 0 : 0.06)
409 live.vx = 0
410 live.vy = 0
411 }
412 }
413 // Quick each way, and whole (or gone) once nearly there: no motes lingering at the edge.
414 live.presence = ease(live.presence, presence, presence ? 0.2 : 0.22)
415 if (presence ? live.presence > 0.97 : live.presence < 0.02) {
416 live.presence = presence
417 }
418
419 // Where it is headed: home, or the next waypoint of a flight. A jump is
420 // taken only once nothing of it is on the glass.
421 let tx = live.xTarget
422 let ty = homeY
423 const wp = live.route[0]
424 if (wp?.jump) {
425 if (isOutOfView(live, W, H)) {
426 live.x = wp.x
427 live.y = wp.y
428 live.z = wp.z
429 live.vx = 0
430 live.vy = 0
431 live.route.shift()
432 } else {
433 // Not out yet: keep going the way it was.
434 tx = live.x + (live.vx / W) * 20
435 ty = live.y + (live.vy / H) * 20
436 }
437 } else if (wp) {
438 tx = wp.x
439 ty = wp.y
440 z = wp.z
441 if (Math.hypot((wp.x - live.x) * W, (wp.y - live.y) * H) < 40) {
442 live.route.shift()
443 if (!live.route.length) {
444 endAct()
445 }
446 }
447 }
448 // It flies on a spring: it gathers speed, carries through its turns, and settles.
449 live.vx = (live.vx + (tx - live.x) * W * 0.004) * 0.9
450 live.vy = (live.vy + (ty - live.y) * H * 0.004) * 0.9
451 live.x += live.vx / W
452 live.y += live.vy / H
453 live.z = ease(live.z, z, 0.06)
454 // Banking into its turns, nose down as it dives.
455 live.pitch = ease(live.pitch, pitch + Math.max(-0.3, Math.min(0.3, live.vy * 0.008)), 0.08)
456 live.yaw = ease(live.yaw, yaw + Math.max(-0.45, Math.min(0.45, live.vx * 0.012)), 0.07)
457 live.roll = ease(live.roll, roll - Math.max(-0.3, Math.min(0.3, live.vx * 0.006)), scorched ? 0.5 : 0.08)
458 live.jaw = ease(live.jaw, jaw, 0.4)
459 live.eyes = ease(live.eyes, eyes, 0.15)
460 live.look = [ease(live.look[0], look[0], 0.2), ease(live.look[1], look[1], 0.2)]
461 live.flame = ease(live.flame, flame, 0.08)
462 live.runes = ease(live.runes, runes, 0.04)
463 live.spinRate = ease(live.spinRate, spinRate, 0.05)
464 live.spin += live.spinRate * DT
465 live.churn = ease(live.churn, plan.churn, 0.05)
466 live.glyphT += live.churn * DT
467 live.light = ease(live.light, plan.light, 0.05)
468 live.witch = ease(live.witch, live.mood === 'attention' ? 1 : 0, 0.1)
469 live.arcane = ease(live.arcane, live.mood === 'working' ? (live.act === 'chant' || live.act === 'surge' ? 1.5 : 1) : 0, 0.05)
470 live.heat = ease(live.heat, live.mood === 'working' ? 1 : 0, 0.06)
471 // The grimoire turns its pages while it works, one after another; at rest it lies still.
472 if (live.flip >= 0) {
473 live.flip += 0.07
474 if (live.flip >= 1) {
475 live.flip = -1
476 live.pages += 1
477 live.nextFlip = live.tick + between(live, [6, 18])
478 }
479 } else if (live.mood === 'working' && live.tick >= live.nextFlip) {
480 live.flip = 0
481 }
482 live.alarm = ease(live.alarm, live.mood === 'attention' ? 1 : 0, 0.08)
483 live.scorch = ease(live.scorch, scorched ? 1 : 0, scorched ? 0.4 : 0.06)
484
485 if (live.tick >= live.nextLook && live.act === 'none') {
486 live.lookTarget = [(random(live) * 2 - 1) * 0.4, (random(live) * 2 - 1) * 0.2]
487 live.nextLook = live.tick + between(live, [25, 70])
488 }
489 if (live.tick >= live.nextBlink) {
490 live.blinkUntil = live.tick + 2
491 live.nextBlink = live.tick + between(live, [50, 140])
492 }
493}
494
495/** Whether nothing of the Lich is on the glass. */
496function isOutOfView(live: Live, W: number, H: number): boolean {
497 const [x0, y0, x1] = modelBounds(buildModel(poseOf(live, W, H), framing(live.view, W, H).cam))
498 return x1 < 0 || x0 > W || y0 > H
499}
500
501/** How far past an edge (as a fraction) its centre must be for all of it to be gone, at a depth. */
502function outside(view: View, W: number, H: number, z: number): { x: number; y: number } {
503 const { cam, s } = framing(view, W, H)
504 const k = cam.f / (cam.f + z)
505 return { x: (1.8 * s * k) / W + 0.05, y: (1.8 * s * k) / H + 0.05 }
506}
507
508/**
509 * A flight's waypoints. A swoop dives out one side and comes back in from
510 * the other; a dip sinks out the bottom and rises again elsewhere; a pass
511 * leaves, sweeps back across right against the glass, and comes home.
512 */
513function flight(live: Live, act: Act, plan: Plan, homeY: number, W: number, H: number): Waypoint[] {
514 const side = live.side
515 const z = plan.home[1]
516 const far = outside(live.view, W, H, z)
517 const isBand = live.view === 'band'
518 const home = { x: plan.home[0], y: homeY, z }
519 const edge = (dir: number, o: { x: number }) => (dir > 0 ? 1 + o.x : -o.x)
520 if (act === 'dip' && !isBand) {
521 return [
522 { x: live.x + side * 0.15, y: 1 + far.y, z },
523 { x: plan.home[0] - side * 0.2, y: 1 + far.y, z, jump: true },
524 home,
525 ]
526 }
527 if (act === 'pass') {
528 const near = -420
529 const close = outside(live.view, W, H, near)
530 return [
531 { x: edge(side, far), y: homeY + (isBand ? 0 : 0.06), z },
532 { x: edge(-side, close), y: homeY + (isBand ? 0 : 0.04), z: near, jump: true },
533 { x: edge(side, close), y: homeY + (isBand ? 0 : 0.1), z: near },
534 { x: edge(-side, far), y: homeY - (isBand ? 0 : 0.08), z, jump: true },
535 home,
536 ]
537 }
538 return [
539 { x: edge(side, far), y: homeY + (isBand ? 0 : 0.12), z: z - 80 },
540 { x: edge(-side, far), y: homeY - (isBand ? 0 : 0.1), z: z + 120, jump: true },
541 home,
542 ]
543}
544
545/**
546 * A Lich at a tick, the same for every copy that starts there. Past the
547 * first, a start is a resync: mid-vanish, nothing of it showing, gathering
548 * itself again.
549 */
550export function born(carry: Carry, view: View, tick: number): Live {
551 const plan = planOf(view, carry.mood)
552 const isResync = tick > 0
553 return {
554 ...carry,
555 view,
556 planned: carry.mood,
557 tick,
558 t: tick * DT,
559 seed: (1234567 + tick * 2654435761) >>> 0,
560 x: plan.home[0],
561 z: plan.home[1],
562 y: plan.home[2],
563 xTarget: plan.home[0],
564 pitch: plan.pitch,
565 yaw: 0,
566 roll: 0,
567 jaw: 0,
568 eyes: plan.eyes,
569 look: [0, 0],
570 lookTarget: [0, 0],
571 nextLook: tick + 30,
572 flame: plan.flame,
573 runes: plan.runes,
574 spin: 0,
575 spinRate: plan.spin,
576 churn: plan.churn,
577 glyphT: 0,
578 light: plan.light,
579 witch: carry.mood === 'attention' ? 1 : 0,
580 arcane: carry.mood === 'working' ? 1 : 0,
581 heat: carry.mood === 'working' ? 1 : 0,
582 flip: -1,
583 pages: 0,
584 nextFlip: tick + 10,
585 alarm: carry.mood === 'attention' ? 1 : 0,
586 blinkUntil: 0,
587 nextBlink: tick + 50,
588 act: isResync ? 'vanish' : 'none',
589 actUntil: isResync ? tick + GATHER + 2 : 0,
590 nextAct: tick + 40,
591 side: 1,
592 vx: 0,
593 vy: 0,
594 route: [],
595 presence: isResync ? 0 : 1,
596 arms: [
597 { raise: 0, spread: 0 },
598 { raise: 0, spread: 0 },
599 ],
600 greet: 'none',
601 greetUntil: 0,
602 scorchUntil: 0,
603 scorch: 0,
604 }
605}
606
607/**
608 * Every ERA ticks every copy vanishes, and while nothing of it shows, is
609 * born again from the same state at the same tick: copies that started at
610 * different times, or drifted, are one again. A copy that starts late starts
611 * from the last of these.
612 */
613function resync(live: Live): void {
614 const { mood, moodNext, moodTick, inkSeen, inkPending, inkTick, flip, pages, nextFlip } = live
615 Object.assign(live, born({ mood, moodNext, moodTick, inkSeen, inkPending, inkTick }, live.view, live.tick))
616 // The grimoire keeps its place: an open book does not jump back a chapter.
617 Object.assign(live, { flip, pages, nextFlip })
618}
619
620type Sampled = { layer: number; v: number; glyph?: string }
621
622const copy = (live: Live): Live => JSON.parse(JSON.stringify(live)) as Live
623
624/** One tick on, keeping what it was before it. */
625export function advance(live: Live, past: Live[], W: number, H: number): void {
626 past.push(copy(live))
627 if (past.length > MEMORY) {
628 past.shift()
629 }
630 step(live, W, H)
631}
632
633/**
634 * A change heard of after its tick (`at`) had already gone by: what the
635 * other copies did on time, this one does late, and from there their lots
636 * would part. So it goes back to before that tick and lives it again,
637 * knowing. Past what it remembers, it takes the change as it comes.
638 */
639export function replay(live: Live, past: Live[], at: number, W: number, H: number): void {
640 const from = past.findLastIndex(p => p.tick < at)
641 if (from < 0) {
642 return
643 }
644 const now = live.tick
645 const known = { moodNext: live.moodNext, moodTick: live.moodTick, inkSeen: live.inkSeen, inkTick: live.inkTick }
646 Object.assign(live, past[from]!)
647 past.length = from
648 live.moodNext = known.moodNext
649 live.moodTick = known.moodTick
650 if (known.inkSeen > live.inkSeen) {
651 live.inkSeen = known.inkSeen
652 live.inkPending = true
653 live.inkTick = known.inkTick
654 }
655 while (live.tick < now) {
656 advance(live, past, W, H)
657 }
658}
659
660const LichLive: ClientModule<Props, State> = (props, surface) => {
661 const { Box, Text } = surface.elements
662 const W = props.cols * CW
663 const H = props.rows * CH
664 const view = props.view ?? 'pane'
665 const [first, count] = props.slice ?? [0, props.rows]
666
667 const clock = (): number | undefined => {
668 if (props.epoch === undefined || typeof Date === 'undefined') {
669 return undefined
670 }
671 const tick = Math.floor((Date.now() - props.epoch) / TICK_MS)
672 return tick >= 0 && tick < 1e8 ? tick : undefined
673 }
674 const tickOf = (ms: number | undefined, fallback: number) => (ms === undefined || props.epoch === undefined ? fallback : Math.floor((ms - props.epoch) / TICK_MS) + 3)
675
676 let state = surface.state
677 if (!state) {
678 const now = clock()
679 const base = now === undefined ? 0 : Math.max(0, Math.floor((now - CATCH_UP) / ERA) * ERA)
680 // What it was living through at the base tick: a newer mood or ink is still to come.
681 const moodTick = tickOf(props.moodAt, 0)
682 const inkTick = tickOf(props.inkAt, 0)
683 const isNewMood = moodTick > base && props.moodBefore !== undefined
684 const isNewInk = inkTick > base && (props.ink ?? 0) > 0
685 const live = born(
686 {
687 mood: isNewMood ? props.moodBefore! : props.mood,
688 moodNext: props.mood,
689 moodTick,
690 inkSeen: (props.ink ?? 0) - (isNewInk ? 1 : 0),
691 inkPending: isNewInk,
692 inkTick,
693 },
694 view,
695 base,
696 )
697 const past: Live[] = []
698 while (now !== undefined && live.tick < now) {
699 if (now - live.tick > MEMORY) {
700 step(live, W, H)
701 } else {
702 advance(live, past, W, H)
703 }
704 }
705 state = { live, past }
706 // Tell the hooks which clock this copy keeps: shared, or its own (and so not in step).
707 surface.post({ clock: now === undefined ? `own (epoch ${props.epoch ?? 'none'})` : `shared, tick ${now}` })
708 surface.every(TICK_MS, () => {
709 const target = clock() ?? live.tick + 1
710 for (let i = 0; i < CATCH_UP && live.tick < target; i += 1) {
711 advance(live, past, W, H)
712 }
713 surface.setState({ live, past })
714 })
715 surface.setState(state)
716 }
717 const { live, past } = state
718 if (props.mood !== live.moodNext) {
719 live.moodNext = props.mood
720 live.moodTick = tickOf(props.moodAt, live.tick + 1)
721 if (live.moodTick <= live.tick) {
722 replay(live, past, live.moodTick, W, H)
723 }
724 }
725 if ((props.ink ?? 0) > live.inkSeen) {
726 live.inkSeen = props.ink ?? 0
727 live.inkPending = true
728 live.inkTick = tickOf(props.inkAt, live.tick + 1)
729 if (live.inkTick <= live.tick) {
730 replay(live, past, live.inkTick, W, H)
731 }
732 }
733
734 const scorch = live.scorch
735 const witch = live.witch
736 const arcane = live.arcane
737 // The robe's colour this frame: dark red, going to purple as it works or
738 // emerald when it needs you, the whole robe at once.
739 const robeRamp = mixRamp(mixRamp(COLOURS[MANTLE]!, ROBE_PURPLE, live.heat), ROBE_EMERALD, live.alarm).map(short)
740 // Which crown signs burn arcane changes a few times a second.
741 const kindle = Math.floor(live.t * 3)
742 const presence = live.presence
743 const paint = (cell: Sampled | null | undefined, x: number, y: number, levels: number) => {
744 if (!cell) {
745 return undefined
746 }
747 // Half here: each sign is in or out by its own lot, and those on the
748 // edge of going are motes, shifting cell to cell.
749 if (presence < 0.999) {
750 if (presence < 0.03) {
751 return undefined
752 }
753 const lot = hash(x * 5 + 1, y * 7 + 3)
754 // The band of motes at the edge of what is left narrows to nothing as it goes, and as it comes whole.
755 const band = 0.12 * Math.min(1, presence * 5, (1 - presence) * 6)
756 if (lot > presence + band) {
757 return undefined
758 }
759 if (lot > presence - band) {
760 if (hash(x + live.tick, y * 3 - live.tick) > 0.45) {
761 return undefined
762 }
763 // One colour for every mote, so neighbours share a run.
764 return [GREEN[2]!, hash(x, y + live.tick) < 0.5 ? '·' : '∴'] as const
765 }
766 }
767 // In patches, not cell by cell: a colour a cell would be a run a cell.
768 const isScorched = scorch > 0.02 && hash(Math.floor(x / 5), y) < scorch
769 // While it works its gem burns arcane green, as its flame does, and a few
770 // arcane sparks run over its crown; never over the skull.
771 const isArcane =
772 arcane > 0.02 && ((cell.layer === GEM && arcane > 0.5) || (cell.layer === CROWN && hash(x * 3 + kindle, y * 5 - kindle) < arcane * (0.02 + 0.1 * cell.v)))
773 // The robe wears the mood's colour.
774 const robe = cell.layer === MANTLE ? robeRamp : undefined
775 const v = cell.v
776 const ramp = isScorched ? RED : isArcane ? GREEN : robe ?? (cell.layer === FIRE && hash(y, Math.floor(x / 3)) < witch ? COLD : EMBER[cell.layer]!)
777 // Lifted: a sign in the dark wants to be seen.
778 const colour = pick(ramp, 0.2 + 0.85 * Math.pow(v, 0.7), levels)
779 // Scorched signs glitch: any glyph at all, for a moment.
780 const glyph = isScorched && cell.layer === BONE ? glyphFor(BONE, hash(x, live.tick), x, y, live.glyphT) : (cell.glyph ?? glyphFor(cell.layer, 0.15 + 0.9 * v, x, y, live.glyphT))
781 return [colour, glyph] as const
782 }
783 // In the band, the Lich's grimoire lies open.
784 if (view === 'band') {
785 const at = grimoireGlyphs({ flip: live.flip, pages: live.pages, heat: live.heat, alarm: live.alarm, t: live.t }, props.cols, props.rows)
786 return drawGlyphs({ Box, Text }, props.cols, count, (x, sy, levels) => at(x, sy + first, levels))
787 }
788
789 const model = buildModel(poseOf(live, W, H), framing(view, W, H).cam)
790 // Each cell sampled once a frame, at two heights: a glyph is tall.
791 const cells = new Map<number, Sampled | null>()
792 const cellAt = (x: number, y: number): Sampled | null => {
793 const key = y * props.cols + x
794 const known = cells.get(key)
795 if (known === undefined) {
796 let cell: Sampled | null = null
797 let sum = 0
798 let hits = 0
799 for (const f of [0.3, 0.7]) {
800 const hit = sampleModel(model, (x + 0.5) * CW, (y + f) * CH)
801 if (hit) {
802 hits += 1
803 sum += hit.cell[1]
804 const before = cell as Sampled | null
805 if (!before || hit.glyph) {
806 cell = { layer: hit.cell[0], v: 0, glyph: hit.glyph ?? before?.glyph }
807 }
808 }
809 }
810 if (cell) {
811 // Edges, half in and half out, draw lighter signs.
812 cell.v = (sum / Math.max(1, hits)) * (0.7 + 0.15 * hits)
813 }
814 cells.set(key, cell)
815 return cell
816 }
817 return known
818 }
819 // Runes and the crown's gem are a glyph a cell, where their centre falls;
820 // one behind the head is hidden by it.
821 const singles = new Map<number, { glyph: string; layer: number; v: number }>()
822 for (const sp of model.sparks) {
823 if (!sp.glyph) {
824 continue
825 }
826 const x = Math.floor(sp.x / CW)
827 const y = Math.floor(sp.y / CH)
828 if (x < 0 || x >= props.cols || y < first || y >= first + count) {
829 continue
830 }
831 if (sp.z > model.pose.pos[2] && cellAt(x, y)) {
832 continue
833 }
834 singles.set(y * props.cols + x, { glyph: sp.glyph, layer: sp.layer, v: sp.v })
835 }
836 return drawGlyphs({ Box, Text }, props.cols, count, (x, sy, levels) => {
837 const y = sy + first
838 return paint(singles.get(y * props.cols + x) ?? cellAt(x, y), x, y, levels)
839 })
840}
841
842export default LichLive
843hooks/lich3d.ts 571 lines1// The Lich in three dimensions, for the live drawing: a crowned skull with
2// soul-fire in its sockets, a high collar rising behind it, a mantle over
3// bony shoulders, robes whose ragged hem frays into ash. Seen close through the glass, in perspective;
4// each point takes whatever is nearest.
5//
6// World: x right, y down, z away from the glass, in pixels; the glass is
7// z 0. The skull and crown are one distance field, the body another, each
8// marched per point; the fire, its flames and the ash are points on the glass.
9
10import { clamp01, hash, noise } from './field'
11import type { Cell } from './field'
12
13export type V3 = [number, number, number]
14const TAU = Math.PI * 2
15
16// Layers.
17export const BONE = 1
18export const CROWN = 2
19export const MANTLE = 3
20export const FIRE = 4
21export const RUNE = 5
22export const GEM = 6
23/** The flame its eyes stream when it works: arcane fire, apart from the eyes themselves. */
24export const FLAME = 7
25
26/** Where perspective converges, and how strongly. */
27export type Camera = { cx: number; cy: number; f: number }
28
29/** Everything the live drawing animates about the Lich. */
30export type Pose = {
31 /** The skull's centre, in world pixels. */
32 pos: V3
33 /** The head's size: about half the skull's width, in world pixels. */
34 s: number
35 /** The head: turned (+ to our right), nodded (+ down), tilted (+ clockwise). */
36 yaw: number
37 pitch: number
38 roll: number
39 /** The shoulders' own turn. */
40 bodyYaw: number
41 /** 0 shut, 1 open. */
42 jaw: number
43 /** The soul-fire: 0 embers, 1 burning, more flares. */
44 eyes: number
45 /** Where the fire looks, within the sockets: -1 to 1 each way. */
46 look: [number, number]
47 /** Flame streaming up from the sockets, 0 to 1. */
48 flame: number
49 /** The runes about its head: how many show (0 to 1), and how far round they are (radians). */
50 runes: number
51 spin: number
52 /** Seconds. */
53 t: number
54 /** How much light reaches it, 0 to 1. */
55 light: number
56 /** Each arm, its left then its right (our right, then our left): how far raised and how far flung out. */
57 arms: [ArmPose, ArmPose]
58}
59
60/**
61 * An arm: 0 hanging at its side, hand down; `raise` 1 bent up before the
62 * chest, hand up; `spread` 1 flung out wide, palm up.
63 */
64export type ArmPose = { raise: number; spread: number }
65
66/** Arms at rest: down at its sides. */
67export const ARMS_DOWN: [ArmPose, ArmPose] = [
68 { raise: 0, spread: 0 },
69 { raise: 0, spread: 0 },
70]
71
72type Mat = [V3, V3, V3]
73
74const add = (a: V3, b: V3): V3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
75const sub = (a: V3, b: V3): V3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
76const mul = (a: V3, k: number): V3 => [a[0] * k, a[1] * k, a[2] * k]
77const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
78const len = (a: V3) => Math.hypot(a[0], a[1], a[2])
79const norm = (a: V3): V3 => mul(a, 1 / (len(a) || 1))
80
81/** A rotation: yaw about y, then pitch about x, then roll about z. */
82function rotation(yaw: number, pitch: number, roll: number): Mat {
83 const [cy, sy, cp, sp, cr, sr] = [Math.cos(yaw), Math.sin(yaw), Math.cos(pitch), Math.sin(pitch), Math.cos(roll), Math.sin(roll)]
84 const rz: Mat = [
85 [cr, -sr, 0],
86 [sr, cr, 0],
87 [0, 0, 1],
88 ]
89 const rx: Mat = [
90 [1, 0, 0],
91 [0, cp, -sp],
92 [0, sp, cp],
93 ]
94 const ry: Mat = [
95 [cy, 0, -sy],
96 [0, 1, 0],
97 [sy, 0, cy],
98 ]
99 return matMul(ry, matMul(rx, rz))
100}
101
102function matMul(a: Mat, b: Mat): Mat {
103 const row = (i: number): V3 => [0, 1, 2].map(j => a[i]![0] * b[0]![j]! + a[i]![1] * b[1]![j]! + a[i]![2] * b[2]![j]!) as V3
104 return [row(0), row(1), row(2)]
105}
106
107const apply = (m: Mat, v: V3): V3 => [dot(m[0], v), dot(m[1], v), dot(m[2], v)]
108const applyT = (m: Mat, v: V3): V3 => [m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2], m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2], m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2]]
109
110function project(cam: Camera, p: V3): { x: number; y: number; z: number; k: number } {
111 const k = cam.f / (cam.f + Math.max(-cam.f * 0.95, p[2]))
112 return { x: cam.cx + (p[0] - cam.cx) * k, y: cam.cy + (p[1] - cam.cy) * k, z: p[2], k }
113}
114
115// ── Distance fields, in the head's or the body's own units ────────────────
116
117/** An ellipsoid's distance, near enough to march by. */
118function ell(px: number, py: number, pz: number, cx: number, cy: number, cz: number, rx: number, ry: number, rz: number): number {
119 const x = (px - cx) / rx
120 const y = (py - cy) / ry
121 const z = (pz - cz) / rz
122 const k0 = Math.sqrt(x * x + y * y + z * z)
123 const k1 = Math.sqrt((x / rx) ** 2 + (y / ry) ** 2 + (z / rz) ** 2)
124 return k1 === 0 ? -Math.min(rx, ry, rz) : (k0 * (k0 - 1)) / k1
125}
126
127function smin(a: number, b: number, k: number): number {
128 const h = Math.max(k - Math.abs(a - b), 0) / k
129 return Math.min(a, b) - h * h * k * 0.25
130}
131
132const smax = (a: number, b: number, k: number) => -smin(-a, -b, k)
133
134function capsule(px: number, py: number, pz: number, a: V3, b: V3, r: number): number {
135 const ba = sub(b, a)
136 const pa: V3 = [px - a[0], py - a[1], pz - a[2]]
137 const h = clamp01(dot(pa, ba) / dot(ba, ba))
138 return len(sub(pa, mul(ba, h))) - r
139}
140
141/** The sockets: centre and radii, mirrored about x. */
142const SOCKET = { x: 0.19, y: 0.08, z: -0.44, rx: 0.15, ry: 0.13, rz: 0.18 }
143/** The soul-fire's place within each socket. */
144const EMBER = { x: 0.19, y: 0.1, z: -0.36 }
145
146/** The skull: about 1 wide and 1.3 tall; x right, y down, z back. */
147function skull(px: number, py: number, pz: number, jaw: number): number {
148 const ax = Math.abs(px)
149 let d = ell(px, py, pz, 0, -0.15, 0.08, 0.52, 0.5, 0.6)
150 d = smin(d, ell(px, py, pz, 0, 0.18, -0.12, 0.42, 0.42, 0.42), 0.12)
151 d = smin(d, ell(ax, py, pz, 0.31, 0.17, -0.22, 0.14, 0.12, 0.14), 0.08)
152 d = smin(d, ell(px, py, pz, 0, 0.43, -0.27, 0.24, 0.12, 0.2), 0.08)
153 const drop = jaw * 0.1
154 const mandible = smin(ell(px, py, pz, 0, 0.63 + drop, -0.17 + drop * 0.3, 0.27, 0.1, 0.23), ell(ax, py, pz, 0.27, 0.45 + drop * 0.5, -0.02, 0.07, 0.2, 0.09), 0.06)
155 d = jaw > 0.05 ? Math.min(d, mandible) : smin(d, mandible, 0.04)
156 d = smax(d, -ell(ax, py, pz, SOCKET.x, SOCKET.y, SOCKET.z, SOCKET.rx, SOCKET.ry, SOCKET.rz), 0.04)
157 d = smax(d, -ell(px, py, pz, 0, 0.3, -0.45, 0.06, 0.09, 0.13), 0.03)
158 d = smax(d, -ell(ax, py, pz, 0.57, 0.1, -0.06, 0.13, 0.16, 0.14), 0.06)
159 return d
160}
161
162/** Seven points, the front one tallest: how high the crown rises at an angle round it (0 at the front). */
163function crownTop(theta: number): number {
164 const k = (theta / TAU) * 7 + 0.5
165 const tooth = Math.pow(1 - Math.abs(k - Math.floor(k) - 0.5) * 2, 1.6)
166 const front = Math.cos(theta) > 0 ? 1 + 0.5 * Math.cos(theta) ** 8 : 1
167 return -0.43 - 0.3 * tooth * front
168}
169
170/** The crown: a thin band round the brow that flares as it rises into its points. */
171function crown(px: number, py: number, pz: number): number {
172 const x = px / 0.5
173 const z = (pz - 0.08) / 0.57
174 const theta = Math.atan2(x, -z)
175 const flare = 1 + 0.35 * Math.max(0, -0.3 - py)
176 const shell = Math.abs(Math.hypot(x, z) - flare) * 0.5 - 0.025
177 return Math.max(shell, crownTop(theta) - py, py + 0.28)
178}
179
180/** The head: skull and crown, and which of them is nearer. */
181function head(px: number, py: number, pz: number, jaw: number): { d: number; isCrown: boolean } {
182 const a = skull(px, py, pz, jaw)
183 const b = crown(px, py, pz)
184 return b < a ? { d: b, isCrown: true } : { d: a, isCrown: false }
185}
186
187/**
188 * The body from the base of the neck: a cultist's robe. Shoulders, a chest,
189 * the robe falling long below; a high collar behind the head; arms bent at
190 * the elbow in wide bell sleeves; bony hands raised from the cuffs; a cord
191 * knotted at the waist with two tassels hanging.
192 */
193function body(px: number, py: number, pz: number): number {
194 return Math.min(robe(px, py, pz), sleeves(px, py, pz), hands(px, py, pz), cord(px, py, pz))
195}
196
197/** Shoulders, chest and the long robe, and the collar rising behind the head. */
198function robe(px: number, py: number, pz: number): number {
199 const ax = Math.abs(px)
200 let d = capsule(ax, py, pz, [0, 0.05, 0.25], [0, 0.4, 0.3], 0.17)
201 d = smin(d, capsule(ax, py, pz, [0.05, 0.42, 0.32], [0.58, 0.58, 0.34], 0.2), 0.25)
202 d = smin(d, ell(ax, py, pz, 0.6, 0.6, 0.32, 0.24, 0.24, 0.28), 0.1)
203 d = smin(d, ell(px, py, pz, 0, 1.15, 0.42, 0.56, 0.72, 0.42), 0.3)
204 d = smin(d, ell(px, py, pz, 0, 2.4, 0.5, 0.66, 1.4, 0.5), 0.3)
205 // The collar: a thin shell behind the head, open at the front, its rim scalloped.
206 let collar = Math.abs(ell(px, py, pz, 0, -0.4, 0.5, 0.72, 0.95, 0.38)) - 0.03
207 collar = Math.max(collar, 0.2 - pz)
208 collar = Math.max(collar, py - 0.3)
209 collar = Math.max(collar, -1.15 - 0.12 * Math.abs(Math.sin(px * 7)) - py)
210 return Math.min(d, collar)
211}
212
213/** The distance to a box (centre, half sizes): never more than the distance to anything inside it. */
214function boxDistance(px: number, py: number, pz: number, c: V3, h: V3): number {
215 const qx = Math.max(Math.abs(px - c[0]) - h[0], 0)
216 const qy = Math.max(Math.abs(py - c[1]) - h[1], 0)
217 const qz = Math.max(Math.abs(pz - c[2]) - h[2], 0)
218 return Math.hypot(qx, qy, qz)
219}
220
221/** The shoulder, where each arm hangs from (on the side x > 0; the other mirrors it). */
222const SHOULDER: V3 = [0.6, 0.62, 0.3]
223
224/** An arm's joints for a pose: elbow, wrist, the way its fingers point, and its sleeve's bell. */
225type Joints = { elbow: V3; wrist: V3; up: V3; side: V3; bell: V3; palm: V3; box: [V3, V3] }
226
227/** Three poses an arm moves between: hanging, raised before the chest, flung out wide. */
228const POSES = {
229 down: { elbow: [0.74, 1.15, 0.32] as V3, wrist: [0.72, 1.62, 0.26] as V3, up: [0.02, 1, 0] as V3 },
230 raised: { elbow: [0.74, 1.12, 0.02] as V3, wrist: [0.46, 0.86, -0.36] as V3, up: [-0.12, -1, -0.12] as V3 },
231 spread: { elbow: [0.98, 0.94, 0.16] as V3, wrist: [1.18, 0.7, 0.0] as V3, up: [0.22, -1, -0.12] as V3 },
232}
233
234const lerp3 = (a: V3, b: V3, k: number): V3 => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
235
236/** Where an arm's joints are, for its pose. */
237function jointsFor(a: ArmPose): Joints {
238 const r = clamp01(a.raise)
239 const sp = clamp01(a.spread)
240 const pick = (k: 'elbow' | 'wrist' | 'up') => lerp3(lerp3(POSES.down[k], POSES.raised[k], r), POSES.spread[k], sp)
241 const elbow = pick('elbow')
242 const wrist = pick('wrist')
243 const up = norm(pick('up'))
244 // Across the hand: square to the way it points, in the plane of the glass.
245 const side = norm([up[1], -up[0], 0])
246 // The bell hangs below the forearm, nearer the cuff.
247 const mid = lerp3(elbow, wrist, 0.6)
248 const bell: V3 = [mid[0], mid[1] + 0.16, mid[2]]
249 const palm = add(wrist, mul(up, 0.15))
250 const reach = 0.42
251 return { elbow, wrist, up, side, bell, palm, box: [palm, [reach, reach, reach]] }
252}
253
254/** This frame's arms, [x < 0, x > 0]: set from the model before its body is marched. */
255let ARMS: [Joints, Joints] = [jointsFor(ARMS_DOWN[0]), jointsFor(ARMS_DOWN[1])]
256
257/** Arms bent at the elbow, in sleeves that hang in a wide bell below the forearm. */
258function sleeves(px: number, py: number, pz: number): number {
259 const ax = Math.abs(px)
260 const a = ARMS[px < 0 ? 0 : 1]
261 let d = capsule(ax, py, pz, SHOULDER, a.elbow, 0.15)
262 d = smin(d, capsule(ax, py, pz, a.elbow, a.wrist, 0.12), 0.08)
263 // The bell: cloth hanging from the forearm, widest and lowest at the cuff.
264 return smin(d, ell(ax, py, pz, a.bell[0], a.bell[1], a.bell[2], 0.26, 0.34, 0.2), 0.14)
265}
266
267/** Bony hands from the cuffs, fingers a little spread, thumbs out. */
268function hands(px: number, py: number, pz: number): number {
269 const ax = Math.abs(px)
270 const a = ARMS[px < 0 ? 0 : 1]
271 // Far from the hand, the distance to its box is near enough, and cheap.
272 const box = boxDistance(ax, py, pz, a.box[0], a.box[1])
273 if (box > 0.1) {
274 return box
275 }
276 let d = ell(ax, py, pz, a.palm[0], a.palm[1], a.palm[2], 0.09, 0.09, 0.06)
277 for (let f = 0; f < 4; f += 1) {
278 const across = (f - 1.5) * 0.05
279 const length = f === 1 || f === 2 ? 0.24 : 0.19
280 const root = add(add(a.wrist, mul(a.up, 0.2)), mul(a.side, across))
281 const tip = add(add(root, mul(a.up, length)), mul(a.side, across * 0.5))
282 d = Math.min(d, capsule(ax, py, pz, root, tip, 0.028))
283 }
284 const thumb = add(add(a.wrist, mul(a.up, 0.1)), mul(a.side, 0.09))
285 return Math.min(d, capsule(ax, py, pz, thumb, add(add(thumb, mul(a.up, 0.08)), mul(a.side, 0.1)), 0.03))
286}
287
288/** The cord round the waist, its knot at the front, two tassels hanging from it. */
289function cord(px: number, py: number, pz: number): number {
290 const box = boxDistance(px, py, pz, [0, 1.62, 0.2], [0.65, 0.4, 0.5])
291 if (box > 0.1) {
292 return box
293 }
294 const ring = (Math.hypot(px / 0.55, (pz - 0.42) / 0.43) - 1) * 0.5
295 let d = Math.hypot(ring, py - 1.32) - 0.035
296 d = Math.min(d, ell(px, py, pz, 0, 1.34, -0.03, 0.07, 0.06, 0.05))
297 const ax = Math.abs(px)
298 d = Math.min(d, capsule(ax, py, pz, [0.04, 1.36, -0.04], [0.08, 1.82, -0.07], 0.025))
299 return Math.min(d, ell(ax, py, pz, 0.085, 1.9, -0.07, 0.04, 0.09, 0.04))
300}
301
302/** The robe's ragged hem, by its x, shifting slowly. */
303const hemAt = (px: number, t: number) => 2.3 + 0.2 * noise(px * 5 + 3, t * 0.3) + 0.08 * Math.sin(px * 9)
304
305function normalOf(f: (x: number, y: number, z: number) => number, px: number, py: number, pz: number, e: number): V3 {
306 const a = f(px + e, py - e, pz - e)
307 const b = f(px - e, py - e, pz + e)
308 const c = f(px - e, py + e, pz - e)
309 const d = f(px + e, py + e, pz + e)
310 return norm([a - b - c + d, -a - b + c + d, -a + b - c + d])
311}
312
313// ── The model: everything placed for this frame ───────────────────────────
314
315/** A point of light on the glass: a socket's fire, a lick of its flame, a rune. */
316export type Spark = { x: number; y: number; z: number; r: number; v: number; layer: number; glyph?: string }
317
318export type Model = {
319 cam: Camera
320 pose: Pose
321 R: Mat
322 B: Mat
323 /** The base of the neck. */
324 base: V3
325 sparks: Spark[]
326 /** The vertebrae under the jaw: centres and radii on the glass. */
327 neck: Array<{ x: number; y: number; z: number; r: number }>
328 headBox: [number, number, number, number]
329 /** Its arms' joints this frame. */
330 arms: [Joints, Joints]
331}
332
333/** The runes, drawn as themselves: planets and metals of the old work. */
334export const RUNES = ['☿', '♀', '♂', '♁', '☽', '∴']
335
336export function buildModel(p: Pose, cam: Camera): Model {
337 const s = p.s
338 const R = rotation(p.yaw, p.pitch, p.roll)
339 const B = rotation(p.bodyYaw, 0.04, p.roll * 0.3)
340 const H = (x: number, y: number, z: number): V3 => add(p.pos, mul(apply(R, [x, y, z]), s))
341 const base = add(p.pos, [0, s * 0.75, s * 0.15])
342 const Bw = (x: number, y: number, z: number): V3 => add(base, mul(apply(B, [x, y, z]), s))
343 const sparks: Spark[] = []
344
345 // The fire in each socket, and the flame it streams upward when it burns.
346 for (const side of [-1, 1]) {
347 const at = H(side * EMBER.x + p.look[0] * 0.035, EMBER.y + p.look[1] * 0.03, EMBER.z)
348 const pr = project(cam, at)
349 sparks.push({ x: pr.x, y: pr.y, z: at[2], r: s * (0.05 + 0.025 * p.eyes) * pr.k, v: Math.min(1, 0.45 + 0.55 * p.eyes), layer: FIRE })
350 const licks = Math.round(9 * p.flame)
351 for (let k = 1; k <= licks; k += 1) {
352 const u = k / 10
353 const sway = Math.sin(p.t * 5 - k * 0.9 + side) * 0.05 * k
354 // Streaming up past the brow and back over the crown, in front of the bone.
355 const q = add(H(side * (EMBER.x + 0.03 * u) + sway, EMBER.y - 0.12 * k, EMBER.z - 0.12 + 0.05 * k * u), [0, 0, -s * 0.12])
356 const fr = project(cam, q)
357 sparks.push({ x: fr.x, y: fr.y, z: q[2], r: s * 0.06 * (1 - u * 0.8) * fr.k, v: (1 - u) * p.flame, layer: FLAME })
358 }
359 }
360
361 // The gem at the crown's front.
362 const gem = project(cam, H(0, -0.5, -0.56))
363 sparks.push({ x: gem.x, y: gem.y, z: gem.z - 2, r: s * 0.05 * gem.k, v: 0.6 + 0.4 * p.eyes * 0.6, layer: GEM, glyph: '●' })
364
365 // The robe frays at its hem into ash, drifting down and thinning to nothing.
366 for (let i = 0; i < 34; i += 1) {
367 const x0 = (hash(i, 101) * 2 - 1) * 0.62
368 const phase = (p.t * (0.05 + 0.06 * hash(i, 102)) + hash(i, 103)) % 1
369 const x = x0 + Math.sin(p.t * 0.7 + i) * 0.05 + phase * (hash(i, 104) - 0.5) * 0.3
370 const q = Bw(x, hemAt(x0, p.t) - 0.05 + phase * 0.8, 0.1)
371 const pr = project(cam, q)
372 sparks.push({ x: pr.x, y: pr.y, z: q[2], r: 1, v: (1 - phase) * 0.6 * p.light, layer: MANTLE, glyph: phase < 0.3 ? '∴' : phase < 0.6 ? ':' : phase < 0.85 ? '∙' : '·' })
373 }
374
375 const neck = [0, 1, 2].map(i => {
376 const pr = project(cam, Bw(0, -0.05 + i * 0.13, 0.05))
377 return { x: pr.x, y: pr.y, z: pr.z, r: s * 0.1 * pr.k }
378 })
379
380 const c = project(cam, p.pos)
381 const reach = s * 1.05 * c.k
382 return { cam, pose: p, R, B, base, sparks, neck, headBox: [c.x - reach, c.y - reach * 1.2, c.x + reach, c.y + reach * 1.1], arms: [jointsFor(p.arms[0]), jointsFor(p.arms[1])] }
383}
384
385/** Where on the glass it could draw anything, side to side and from the top. */
386export function modelBounds(m: Model): [number, number, number, number] {
387 const c = project(m.cam, m.pose.pos)
388 const reach = m.pose.s * 1.6 * c.k
389 return [c.x - reach, c.y - reach, c.x + reach, Infinity]
390}
391
392// ── What the viewer sees at a point ───────────────────────────────────────
393
394/** [layer, brightness], how near, and the glyph it insists on, if any. */
395type Hit = { z: number; cell: Cell; glyph?: string }
396
397/** From above and in front, a little to the left: the light the fire throws up is separate. */
398const KEY = norm([-0.35, -0.7, -0.6])
399
400function headHit(m: Model, X: number, Y: number): Hit | undefined {
401 const [x0, y0, x1, y1] = m.headBox
402 if (X < x0 || X > x1 || Y < y0 || Y > y1) {
403 return undefined
404 }
405 const { cam, pose, R } = m
406 const s = pose.s
407 const O: V3 = [cam.cx, cam.cy, -cam.f]
408 const D = norm([X - cam.cx, Y - cam.cy, cam.f])
409 const o = mul(applyT(R, sub(O, pose.pos)), 1 / s)
410 const d = applyT(R, D)
411 const oc = sub(o, [0, -0.05, 0.05])
412 const b = dot(oc, d)
413 const c = dot(oc, oc) - 1.05 * 1.05
414 const disc = b * b - c
415 if (disc < 0) {
416 return undefined
417 }
418 let t = Math.max(0, -b - Math.sqrt(disc))
419 const tEnd = -b + Math.sqrt(disc)
420 for (let i = 0; i < 56 && t < tEnd; i += 1) {
421 const px = o[0] + d[0] * t
422 const py = o[1] + d[1] * t
423 const pz = o[2] + d[2] * t
424 const h = head(px, py, pz, pose.jaw)
425 if (h.d < 0.004) {
426 const z = O[2] + D[2] * t * s
427 return { z, cell: h.isCrown ? shadeCrown(m, px, py, pz) : shadeSkull(m, px, py, pz) }
428 }
429 t += h.d * 0.75
430 }
431 return undefined
432}
433
434function shadeSkull(m: Model, px: number, py: number, pz: number): Cell {
435 const pose = m.pose
436 const field = (x: number, y: number, z: number) => skull(x, y, z, pose.jaw)
437 const n = normalOf(field, px, py, pz, 0.004)
438 const nw = apply(m.R, n)
439 const ax = Math.abs(px)
440 if (ell(ax, py, pz, SOCKET.x, SOCKET.y, SOCKET.z, SOCKET.rx * 1.05, SOCKET.ry * 1.05, SOCKET.rz * 1.05) < 0) {
441 return [BONE, clamp01(0.02 + 0.1 * pose.eyes * Math.max(0, -n[2]))]
442 }
443 const bite = 0.52 + pose.jaw * 0.05
444 if (pz < -0.2 && ax < 0.21 && py > 0.44 && py < 0.6 + pose.jaw * 0.1) {
445 if (Math.abs(py - bite) < 0.018 + pose.jaw * 0.05) {
446 return [BONE, 0.02]
447 }
448 const gap = (ax + 0.035) / 0.07
449 if (Math.abs(gap - Math.round(gap)) < 0.13) {
450 return [BONE, 0.1]
451 }
452 }
453 const lambert = Math.max(0, dot(nw, KEY))
454 const rim = Math.pow(1 - Math.abs(nw[2]), 3) * 0.3
455 const ao = clamp01(field(px + n[0] * 0.07, py + n[1] * 0.07, pz + n[2] * 0.07) / 0.07)
456 // The fire's own glow round the sockets and up the brow.
457 const glow = pose.eyes * 0.3 * Math.max(0, 1 - (Math.hypot(ax - SOCKET.x, py - SOCKET.y) - 0.12) / 0.16)
458 return [BONE, clamp01((0.05 + 0.8 * lambert * (0.35 + 0.65 * ao) + rim) * pose.light + glow)]
459}
460
461function shadeCrown(m: Model, px: number, py: number, pz: number): Cell {
462 const n = normalOf(crown, px, py, pz, 0.006)
463 const nw = apply(m.R, n)
464 const lambert = Math.abs(dot(nw, KEY))
465 const theta = Math.atan2(px / 0.5, -(pz - 0.08) / 0.57)
466 // Brighter toward the points; a dark band of filigree round the base.
467 const height = clamp01((-0.3 - py) / 0.4)
468 const band = py > -0.36 && py < -0.33 ? 0.4 : 1
469 return [CROWN, clamp01((0.2 + 0.6 * lambert + 0.25 * height) * band * m.pose.light * (0.75 + 0.25 * Math.cos(theta)))]
470}
471
472function bodyHit(m: Model, X: number, Y: number): Hit | undefined {
473 const { cam, pose, B, base } = m
474 const s = pose.s
475 const O: V3 = [cam.cx, cam.cy, -cam.f]
476 const D = norm([X - cam.cx, Y - cam.cy, cam.f])
477 const o = mul(applyT(B, sub(O, base)), 1 / s)
478 const d = applyT(B, D)
479 // Only the stretch of the line of sight inside the body's box is marched.
480 let t = 0
481 let tEnd = Infinity
482 for (const [lo, hi, k] of [
483 [-1.6, 1.6, 0],
484 [-1.7, 3.1, 1],
485 [-0.75, 1.05, 2],
486 ] as const) {
487 if (Math.abs(d[k]) < 1e-9) {
488 if (o[k] < lo || o[k] > hi) {
489 return undefined
490 }
491 continue
492 }
493 const a = (lo - o[k]) / d[k]
494 const b = (hi - o[k]) / d[k]
495 t = Math.max(t, Math.min(a, b))
496 tEnd = Math.min(tEnd, Math.max(a, b))
497 }
498 if (t >= tEnd) {
499 return undefined
500 }
501 for (let i = 0; i < 64 && t < tEnd; i += 1) {
502 const px = o[0] + d[0] * t
503 const py = o[1] + d[1] * t
504 const pz = o[2] + d[2] * t
505 const dist = body(px, py, pz)
506 if (dist < 0.006) {
507 return robeAt(m, px, py, pz, O[2] + D[2] * t * s)
508 }
509 t += dist * 0.85
510 }
511 return undefined
512}
513
514/**
515 * The robes: folds that run down them, lit from above and darker where the
516 * cloth folds in, thinning toward a ragged hem that shifts slowly and frays
517 * into the ash below it; the hands bone, the cord a lighter rope.
518 */
519function robeAt(m: Model, px: number, py: number, pz: number, z: number): Hit | undefined {
520 const { pose } = m
521 const n = normalOf(body, px, py, pz, 0.006)
522 const nw = apply(m.B, n)
523 const lambert = Math.max(0, dot(nw, KEY))
524 const rim = Math.pow(1 - Math.abs(nw[2]), 3) * 0.35
525 // The hands are bone, lit as the skull is.
526 if (hands(px, py, pz) < 0.012) {
527 return { z, cell: [BONE, clamp01((0.15 + 0.7 * lambert + rim) * pose.light)] }
528 }
529 // The cord is rope, a lighter red than the cloth it binds.
530 if (cord(px, py, pz) < 0.012) {
531 return { z, cell: [MANTLE, clamp01((0.45 + 0.4 * lambert) * pose.light)] }
532 }
533 // The cloth darkens where it folds in: under the arms, inside the bells.
534 const shade = 0.45 + 0.55 * clamp01(body(px + n[0] * 0.09, py + n[1] * 0.09, pz + n[2] * 0.09) / 0.09)
535 const fold = 0.85 + 0.15 * Math.sin(px * 14 + py * 1.5)
536 const fade = clamp01((hemAt(px, pose.t) - py) / 0.85)
537 const v = (0.12 + 0.6 * lambert * fold + rim) * shade * pose.light * fade
538 return v < 0.07 ? undefined : { z, cell: [MANTLE, clamp01(v)] }
539}
540
541/** What the viewer sees at a point on the glass: the nearest part of the Lich, if any, and a glyph if it insists on one. */
542export function sampleModel(m: Model, X: number, Y: number): { cell: Cell; glyph?: string } | undefined {
543 ARMS = m.arms
544 let best = headHit(m, X, Y)
545 let glyph: string | undefined
546 const consider = (hit: Hit | undefined) => {
547 if (hit && (!best || hit.z < best.z)) {
548 best = hit
549 glyph = hit.glyph
550 }
551 }
552 consider(bodyHit(m, X, Y))
553 for (const n of m.neck) {
554 const d = Math.hypot(X - n.x, Y - n.y)
555 if (d < n.r) {
556 consider({ z: n.z, cell: [BONE, 0.25 + 0.3 * (1 - d / n.r)] })
557 }
558 }
559 for (const sp of m.sparks) {
560 // A spark with a glyph of its own is one cell: the drawing places it.
561 if (sp.glyph) {
562 continue
563 }
564 const d = Math.hypot(X - sp.x, (Y - sp.y) * 0.8)
565 if (d < sp.r) {
566 consider({ z: sp.z, cell: [sp.layer, clamp01(sp.v * (0.6 + 0.4 * (1 - d / sp.r)))] })
567 }
568 }
569 return best ? { cell: best.cell, glyph } : undefined
570}
571hooks/grimoire3d.ts 478 lines1// The Lich's grimoire in three dimensions, for the band: an open book seen
2// from a little above, in perspective, lit as the Lich is and drawn in
3// glyphs. Two boards of red leather and a gilt spine between them; a thick
4// block of pages on each board, their tops curving down into the gutter,
5// their stacked edges toward the glass; faint signs written on the pages.
6// While the Lich works a page lifts from the right, curls over the spine and
7// settles on the left; when it needs you its ink glows.
8//
9// Book space: u across (the spine at 0, + to our right), h up, v away from
10// the glass. Every solid is a cross-section in (u, h) carried straight back
11// through v, so a line of sight meets it on its front face or on a side.
12
13import { clamp01, hash, smoothstep } from './field'
14import { COLOURS, CROWN, MANTLE } from './lich'
15import type { GlyphAt } from './live-glyphs'
16import { mixHex, pick, short } from './live-pixels'
17
18type V2 = readonly [number, number]
19type V3 = readonly [number, number, number]
20
21/** What the book shows: a page turning (0 to 1, or -1 for none), how many have turned, the ink's mood, and the time (s). */
22export type Book = { flip: number; pages: number; heat: number; alarm: number; t: number }
23
24// Materials.
25const LEATHER = 1
26const PARCH = 2
27const EDGE = 3
28const GILT = 4
29
30/** Half the pages' depth, front to back; the boards stand a little proud of them. */
31const PAGE_D = 0.48
32const BOARD_D = 0.53
33const BOARD_H = 0.045
34const GUTTER = 0.015
35/** A page's width, spine to fore-edge. */
36const SHEET = 0.97
37/** How much of its height a turning page keeps: it bows over, low. */
38const LOW = 0.45
39
40/** How high the pages stand at a distance from the spine (0 to 1): low in the gutter, swelling, rounding down to the fore-edge. */
41export function pageTop(a: number): number {
42 return BOARD_H + 0.035 + 0.15 * (1 - Math.exp(-a * 7)) * (1 - 0.6 * Math.pow(a, 1.5))
43}
44
45/** An edge of a cross-section: what it is made of, seen from that side. */
46type Side = 'top' | 'leather' | 'edge' | 'gilt' | 'hidden'
47type Solid = { poly: V2[]; sides: Side[]; v0: number; v1: number; cap: 'edge' | 'leather' | 'spine'; box: [number, number, number, number] }
48
49function solid(poly: V2[], sides: Side[], v0: number, v1: number, cap: Solid['cap']): Solid {
50 const us = poly.map(p => p[0])
51 const hs = poly.map(p => p[1])
52 return { poly, sides, v0, v1, cap, box: [Math.min(...us), Math.min(...hs), Math.max(...us), Math.max(...hs)] }
53}
54
55/** A cross-section mirrored to the other side of the spine, kept counter-clockwise. */
56function mirror(s: Solid): Solid {
57 const n = s.poly.length
58 const poly = s.poly.map(([u, h]) => [-u, h] as V2).reverse()
59 // Edge i ran from point i to i + 1; reversed, it runs from n - 2 - i to n - 1 - i.
60 const sides = s.poly.map((_, i) => s.sides[(n - 2 - i + n) % n]!)
61 return solid(poly, sides, s.v0, s.v1, s.cap)
62}
63
64const PROFILE = 36
65
66/** The pages on the right: along the board, up the fore-edge, back over the top to the gutter. */
67function pageBlock(): Solid {
68 const poly: V2[] = [
69 [GUTTER, BOARD_H],
70 [1, BOARD_H],
71 ]
72 const sides: Side[] = ['hidden', 'edge']
73 for (let i = PROFILE; i >= 0; i -= 1) {
74 const a = GUTTER + ((1 - GUTTER) * i) / PROFILE
75 poly.push([a, pageTop(a)])
76 sides.push(i ? 'top' : 'hidden')
77 }
78 return solid(poly, sides, -PAGE_D, PAGE_D, 'edge')
79}
80
81function board(): Solid {
82 return solid(
83 [
84 [0.05, 0],
85 [1.07, 0],
86 [1.07, BOARD_H],
87 [0.05, BOARD_H],
88 ],
89 ['hidden', 'leather', 'leather', 'hidden'],
90 -BOARD_D,
91 BOARD_D,
92 'leather',
93 )
94}
95
96/** The spine: rounded beneath the gutter, filling the gap between the boards. */
97function spine(): Solid {
98 const r = 0.05
99 const poly: V2[] = []
100 for (let i = 0; i <= 12; i += 1) {
101 const a = Math.PI + (Math.PI * i) / 12
102 poly.push([r * Math.cos(a), BOARD_H + r * Math.sin(a)])
103 }
104 poly.push([0.03, pageTop(0) - 0.01], [-0.03, pageTop(0) - 0.01])
105 // Seen only at its front, a small gilt headband; its top, down in the gutter, is hidden by the pages.
106 return solid(
107 poly,
108 poly.map((_, i) => (i < 12 ? 'leather' : 'hidden')),
109 -BOARD_D,
110 BOARD_D,
111 'spine',
112 )
113}
114
115const RIGHT = pageBlock()
116const SOLIDS: Solid[] = [RIGHT, mirror(RIGHT), board(), mirror(board()), spine()]
117
118/** Where a line of sight meets the book. */
119type Hit = { t: number; mat: number; n: V3; u: number; h: number; v: number; page?: number; a?: number; isShaded?: boolean; isLifted?: boolean }
120
121const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
122const norm = (a: V3): V3 => {
123 const l = Math.hypot(a[0], a[1], a[2]) || 1
124 return [a[0] / l, a[1] / l, a[2] / l]
125}
126
127function inside(poly: V2[], u: number, h: number): boolean {
128 let isIn = false
129 for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
130 const [ui, hi] = poly[i]!
131 const [uj, hj] = poly[j]!
132 if (hi > h !== hj > h && u < ((uj - ui) * (h - hi)) / (hj - hi) + ui) {
133 isIn = !isIn
134 }
135 }
136 return isIn
137}
138
139/** Where a line in (u, h) meets the segment a→b: its t along the line, or -1. */
140function cross2(o: V3, d: V3, a: V2, b: V2): number {
141 const eu = b[0] - a[0]
142 const eh = b[1] - a[1]
143 const den = d[0] * eh - d[1] * eu
144 if (Math.abs(den) < 1e-12) {
145 return -1
146 }
147 const au = a[0] - o[0]
148 const ah = a[1] - o[1]
149 const s = (au * d[1] - ah * d[0]) / den
150 return s < 0 || s > 1 ? -1 : (au * eh - ah * eu) / den
151}
152
153function hitSolid(s: Solid, o: V3, d: V3, best: Hit | undefined): Hit | undefined {
154 // Its front face, toward the glass.
155 if (d[2] > 1e-9) {
156 const t = (s.v0 - o[2]) / d[2]
157 if (t > 0 && (!best || t < best.t)) {
158 const u = o[0] + d[0] * t
159 const h = o[1] + d[1] * t
160 if (u >= s.box[0] && u <= s.box[2] && h >= s.box[1] && h <= s.box[3] && inside(s.poly, u, h)) {
161 const mat = s.cap === 'edge' ? EDGE : s.cap === 'leather' ? LEATHER : GILT
162 best = { t, mat, n: [0, 0, -1], u, h, v: s.v0 }
163 }
164 }
165 }
166 // Its sides, as far back as it goes.
167 for (let i = 0; i < s.poly.length; i += 1) {
168 const side = s.sides[i]!
169 if (side === 'hidden') {
170 continue
171 }
172 const a = s.poly[i]!
173 const b = s.poly[(i + 1) % s.poly.length]!
174 const t = cross2(o, d, a, b)
175 if (t <= 0 || (best && t >= best.t)) {
176 continue
177 }
178 const v = o[2] + d[2] * t
179 if (v < s.v0 || v > s.v1) {
180 continue
181 }
182 const n = norm([b[1] - a[1], -(b[0] - a[0]), 0])
183 if (dot(n, d) > 0) {
184 continue
185 }
186 const u = o[0] + d[0] * t
187 const h = o[1] + d[1] * t
188 const mat = side === 'top' ? PARCH : side === 'edge' ? EDGE : side === 'gilt' ? GILT : LEATHER
189 best = { t, mat, n, u, h, v, a: Math.abs(u) }
190 }
191 return best
192}
193
194/** The page turning: a bent sheet from the spine, its points and their distance along it. */
195type Sheet = { pts: V2[]; s: number[] }
196
197/**
198 * The sheet `flip` of the way over: its fore-edge lifts first and leads, so
199 * the page curls up from the right, rolls over the spine and comes down on
200 * the left edge first. Nowhere does it sink into the pages beneath it.
201 */
202export function sheetAt(flip: number): Sheet {
203 const N = 24
204 const theta = Math.PI * smoothstep(0, 1, flip)
205 const curl = 2.1 * Math.sin(Math.PI * flip)
206 const ds = SHEET / N
207 let u = 0
208 let h = pageTop(0)
209 const pts: V2[] = []
210 const s: number[] = []
211 // A heavy page bows low as it goes over, never standing up straight.
212 const rest = (p: number, q: number): V2 => {
213 const floor = pageTop(Math.min(1, Math.abs(p)))
214 return [p, Math.max(floor + (q - floor) * LOW, Math.abs(p) <= 1 ? floor + 0.006 : -1)]
215 }
216 pts.push(rest(u, h))
217 s.push(0)
218 for (let i = 0; i < N; i += 1) {
219 const k = (i + 0.5) / N
220 const phi = Math.min(Math.PI, theta + curl * Math.pow(k, 1.4))
221 u += Math.cos(phi) * ds
222 h += Math.sin(phi) * ds
223 pts.push(rest(u, h))
224 s.push((i + 1) * ds)
225 }
226 return { pts, s }
227}
228
229function hitSheet(sh: Sheet, o: V3, d: V3, best: Hit | undefined, front: number, back: number): Hit | undefined {
230 for (let i = 0; i + 1 < sh.pts.length; i += 1) {
231 const a = sh.pts[i]!
232 const b = sh.pts[i + 1]!
233 const t = cross2(o, d, a, b)
234 if (t <= 0 || (best && t >= best.t)) {
235 continue
236 }
237 const v = o[2] + d[2] * t
238 if (v < -PAGE_D || v > PAGE_D) {
239 continue
240 }
241 // Its upper face, as it lay on the right: which side the viewer sees.
242 let n = norm([-(b[1] - a[1]), b[0] - a[0], 0])
243 const isBack = dot(n, d) > 0
244 if (isBack) {
245 n = [-n[0], -n[1], 0]
246 }
247 const u = o[0] + d[0] * t
248 const ab = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1
249 const along = sh.s[i]! + ((sh.s[i + 1]! - sh.s[i]!) * Math.hypot(u - a[0], o[1] + d[1] * t - a[1])) / ab
250 best = { t, mat: PARCH, n, u, h: o[1] + d[1] * t, v, page: isBack ? back : front, a: along }
251 }
252 return best
253}
254
255// ── The camera ────────────────────────────────────────────────────────────
256
257/** From the glass, a little above: how steeply it looks down, and how near it stands. */
258const ELEVATION = 0.5
259const DISTANCE = 3.4
260const TARGET: V3 = [0, 0.16, 0.02]
261
262type Cam = { E: V3; F: V3; U: V3; f: number; cx: number; cy: number; box: [number, number, number, number] }
263
264const CW = 8
265const CH = 19
266const cams = new Map<string, Cam>()
267
268/** The camera for a strip of cells: the book as large as it fits, centred; a turning page may rise out of the top, as the Lich flies out of frame. */
269export function cameraFor(cols: number, rows: number): Cam {
270 const key = `${cols}x${rows}`
271 const known = cams.get(key)
272 if (known) {
273 return known
274 }
275 const W = cols * CW
276 const H = rows * CH
277 const E: V3 = [TARGET[0], TARGET[1] + DISTANCE * Math.sin(ELEVATION), TARGET[2] - DISTANCE * Math.cos(ELEVATION)]
278 const F = norm([TARGET[0] - E[0], TARGET[1] - E[1], TARGET[2] - E[2]])
279 const U: V3 = [0, F[2], -F[1]]
280 const corners: V3[] = []
281 for (const u of [-1.08, 1.08]) {
282 for (const h of [-0.02, 0.22]) {
283 for (const v of [-BOARD_D, BOARD_D]) {
284 corners.push([u, h, v])
285 }
286 }
287 }
288 const flat = corners.map(p => {
289 const q: V3 = [p[0] - E[0], p[1] - E[1], p[2] - E[2]]
290 const z = dot(q, F)
291 return [q[0] / z, -dot(q, U) / z] as const
292 })
293 const xs = flat.map(p => p[0])
294 const ys = flat.map(p => p[1])
295 const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]
296 const f = Math.min((W * 0.86) / (x1 - x0), (H * 0.92) / (y1 - y0))
297 const cx = W / 2 - f * ((x0 + x1) / 2)
298 const cy = H / 2 - f * ((y0 + y1) / 2)
299 const cam: Cam = { E, F, U, f, cx, cy, box: [cx + f * x0 - CW, -Infinity, cx + f * x1 + CW, cy + f * y1 + CH] }
300 cams.set(key, cam)
301 return cam
302}
303
304function rayAt(cam: Cam, X: number, Y: number): V3 {
305 const x = (X - cam.cx) / cam.f
306 const y = (Y - cam.cy) / cam.f
307 return norm([cam.F[0] + x, cam.F[1] - cam.U[1] * y, cam.F[2] - cam.U[2] * y])
308}
309
310// ── What is written ───────────────────────────────────────────────────────
311
312/** What the pages are written in: the Lich's own signs. */
313const SIGNS = ['∴', '∵', '+', '×', '☿', '♀', '♂', '♁', ':']
314
315/**
316 * The ink at a place on a page (a: from the spine, w: front to back, both 0
317 * to 1): how strong, and its sign. Every page is a block of lines of signs,
318 * square to its margins, so the writing reads as a page.
319 */
320function writing(page: number, a: number, w: number): { k: number; glyph: string } | undefined {
321 if (a < 0.13 || a > 0.9 || w < 0.1 || w > 0.9) {
322 return undefined
323 }
324 const line = Math.floor(w * 6)
325 if (w * 6 - line > 0.6) {
326 return undefined
327 }
328 // Words with gaps between them.
329 const col = Math.floor(a / 0.045)
330 if (hash(col + line * 53 + page * 7, 3.7) < 0.24) {
331 return undefined
332 }
333 return { k: 0.6 + 0.4 * hash(col, line + page), glyph: SIGNS[Math.floor(hash(col * 5 + page * 31, line * 11 - page) * SIGNS.length)]! }
334}
335
336// ── Colour and glyph ──────────────────────────────────────────────────────
337
338// What a cell shows besides the solids' own materials.
339const INK = 5
340
341/** The ink by mood: ember at rest, violet at work, emerald when it needs you. */
342const INKS = { idle: '#e0603e', working: '#a860f0', attention: '#3ccf8a' }
343/** Old parchment in the dark: one colour, a few close shades of it. */
344const PARCHMENT = ['#5e4a30', '#6c5638', '#7a6240', '#886e48']
345const EDGES = ['#1a160e', '#30291b', '#4a3f2a', '#665839', '#82704a', '#9e885c']
346
347const GLYPHS: Record<number, string[][]> = {
348 [LEATHER]: [['∙']],
349 [PARCH]: [[':']],
350 [EDGE]: [['=']],
351 [GILT]: [['∙'], [':'], ['+'], ['×'], ['●']],
352}
353
354function glyphOf(mat: number, v: number, x: number, y: number): string {
355 const steps = GLYPHS[mat]!
356 const set = steps[Math.min(steps.length - 1, Math.max(0, Math.floor(v * steps.length)))]!
357 return set.length === 1 ? set[0]! : set[Math.floor(hash(x, y) * set.length)]!
358}
359
360/** From above and in front, a little to the left. */
361const LIGHT = norm([-0.7, 0.6, -0.4])
362
363type Seen = { mat: number; v: number; t: number; glyph?: string }
364
365/** How a hit looks: its material lit, or the ink written on it. */
366function shade(hit: Hit, glow: number): Seen {
367 const lambert = Math.max(0, dot(hit.n, LIGHT))
368 let v = (0.1 + 0.85 * lambert) * (hit.isShaded ? 0.35 : 1)
369 if (hit.mat === PARCH) {
370 const a = hit.a ?? Math.abs(hit.u)
371 // Darker down in the gutter, where the light cannot reach.
372 v *= 0.3 + 0.7 * smoothstep(0.02, 0.25, a)
373 // The turning page catches the light.
374 if (hit.isLifted) {
375 v = 0.25 + 0.9 * v
376 }
377 const page = hit.page ?? -1
378 const ink = page >= 0 ? writing(page, a, (hit.v + PAGE_D) / (2 * PAGE_D)) : undefined
379 if (ink) {
380 return { mat: INK, v: clamp01(ink.k * (0.15 + 0.5 * v) + glow * 0.6), t: hit.t, glyph: ink.glyph }
381 }
382 return { mat: PARCH, v: clamp01(v), t: hit.t }
383 }
384 if (hit.mat === GILT || (hit.mat === LEATHER && hit.n[2] < -0.5 && Math.abs(hit.u) > 1.0)) {
385 return { mat: GILT, v: clamp01(v + 0.15), t: hit.t }
386 }
387 return { mat: hit.mat, v: clamp01(v), t: hit.t }
388}
389
390/** Which page lies open where: left, right (or the one beneath a turning page), and the turning page's two faces. */
391function pagesOf(book: Book): { left: number; right: number; front: number; back: number } {
392 const n = book.pages * 2
393 return { left: n, right: book.flip >= 0 ? n + 3 : n + 1, front: n + 1, back: n + 2 }
394}
395
396/** What the viewer sees at a point on the glass, nearest first; and whether the turning page shades it. */
397function sampleBook(cam: Cam, sheet: Sheet | undefined, pages: ReturnType<typeof pagesOf>, X: number, Y: number): Hit | undefined {
398 const o = cam.E
399 const d = rayAt(cam, X, Y)
400 let best: Hit | undefined
401 for (const s of SOLIDS) {
402 best = hitSolid(s, o, d, best)
403 }
404 if (best && best.mat === PARCH) {
405 best.page = best.u < 0 ? pages.left : pages.right
406 }
407 if (sheet) {
408 const under = best
409 best = hitSheet(sheet, o, d, best, pages.front, pages.back)
410 if (best === under && best) {
411 // In the turning page's shadow, or not.
412 const p: V3 = [best.u + LIGHT[0] * 0.01, best.h + LIGHT[1] * 0.01, best.v + LIGHT[2] * 0.01]
413 best.isShaded = !!hitSheet(sheet, p, LIGHT, undefined, 0, 0)
414 } else if (best) {
415 best.isLifted = true
416 }
417 }
418 return best
419}
420
421/** Priority when a cell's two samples disagree: the thin and bright win. */
422const RANK = [0, 1, 2, 4, 3, 5, 6]
423
424/**
425 * The book this frame as glyphs: colour and character at column x of row y
426 * (of `rows`), from at most `levels` shades. Each cell is looked at twice,
427 * high and low, since a glyph is tall.
428 */
429export function grimoireGlyphs(book: Book, cols: number, rows: number): GlyphAt {
430 const cam = cameraFor(cols, rows)
431 const sheet = book.flip >= 0 ? sheetAt(book.flip) : undefined
432 const pages = pagesOf(book)
433 const glow = book.alarm
434 const ink = mixHex(mixHex(INKS.idle, INKS.working, book.heat), INKS.attention, book.alarm)
435 const inkRamp = ['#000000', '#000000', '#000000', '#000000', ink, '#ffffff', '#ffffff'].map((c, i) => short(mixHex(ink, c, [0.75, 0.6, 0.42, 0.22, 0, 0.3 + 0.3 * glow, 0.6 + 0.2 * glow][i]!)))
436 // When it needs you, the glow of the ink lies on the pages.
437 const ramps: Record<number, string[]> = {
438 [LEATHER]: COLOURS[MANTLE]!.map(short),
439 [PARCH]: PARCHMENT.map(c => short(mixHex(c, ink, 0.22 * glow))),
440 [EDGE]: EDGES.map(short),
441 [GILT]: COLOURS[CROWN]!.map(short),
442 [INK]: inkRamp,
443 }
444
445 const cells = new Map<number, Seen | null>()
446 const seen = (x: number, y: number): Seen | null => {
447 const key = y * cols + x
448 const known = cells.get(key)
449 if (known !== undefined) {
450 return known
451 }
452 let cell: Seen | null = null
453 const X = (x + 0.5) * CW
454 if (X >= cam.box[0] && X <= cam.box[2] && (y + 1) * CH >= cam.box[1] && y * CH <= cam.box[3]) {
455 const looks = [0.3, 0.7].map(f => sampleBook(cam, sheet, pages, X, (y + f) * CH)).map(hit => (hit ? shade(hit, glow) : undefined))
456 const [hi, lo] = looks
457 const one = hi && lo ? (RANK[lo.mat]! > RANK[hi.mat]! ? lo : hi) : (hi ?? lo)
458 if (one) {
459 const same = looks.filter((l): l is Seen => !!l && l.mat === one.mat)
460 const v = same.reduce((sum, l) => sum + l.v, 0) / same.length
461 // Edges, half on the book and half off, are fainter.
462 cell = { ...one, v: v * (hi && lo ? 1 : 0.8) }
463 }
464 }
465 cells.set(key, cell)
466 return cell
467 }
468
469 return (x, y, levels) => {
470 const cell = seen(x, y)
471 if (!cell) {
472 return undefined
473 }
474 // Lifted: a sign in the dark wants to be seen.
475 return [pick(ramps[cell.mat]!, 0.1 + 0.9 * Math.pow(cell.v, 0.8), levels), cell.glyph ?? glyphOf(cell.mat, cell.v, x, y)]
476 }
477}
478hooks/live-glyphs.tsx 129 lines1// Glyphs for the live drawings: one character a cell (8 × 19 px on desktop),
2// each row a run of Boxes, one per stretch of one colour, its characters a
3// Text inside. A run's Box holds its width whatever the font makes of a
4// glyph, so a wide one can never push the rest of its row out of line.
5
6import type { ClientElements, RenderElement } from 'claude-code'
7
8/**
9 * Characters a frame may hold in its tree, as measured here: the page counts
10 * more than this measure does, so it stays far inside the 100,000 bound.
11 */
12export const GLYPH_BUDGET = 60_000
13
14/**
15 * The cell at column `x` of row `y`: its colour (from at most `levels`
16 * shades) and its character; undefined for nothing.
17 */
18export type GlyphAt = (x: number, y: number, levels: number) => readonly [string, string] | undefined
19
20type Elements = Pick<ClientElements, 'Box' | 'Text'>
21
22/**
23 * Set only by the thumbnail bake (scripts/bake-thumbs.mjs): handed every
24 * frame's glyphs before they are drawn, at full detail, budget or no.
25 */
26export const captureGlyphs: { to?: (cols: number, rows: number, glyphAt: GlyphAt) => void } = {}
27
28/**
29 * The glyphs as one column of rows, within the budget: fewer shades if need
30 * be, and past that, short runs merged into their neighbours' colour until
31 * it fits. Whatever a frame holds, it is never drawn past the bound.
32 */
33export function drawGlyphs({ Box, Text }: Elements, cols: number, rows: number, glyphAt: GlyphAt) {
34 captureGlyphs.to?.(cols, rows, glyphAt)
35 const draw = (levels: number, minRun: number) => {
36 const out: RenderElement[] = []
37 let size = 0
38 let empty = 0
39 for (let y = 0; y < rows; y += 1) {
40 const runs: RenderElement[] = []
41 let start = 0
42 let colour = ''
43 let text = ''
44 const flush = (end: number) => {
45 if (end > start) {
46 if (colour) {
47 runs.push(
48 <Box width={end - start}>
49 <Text color={colour} wrap="truncate">
50 {text}
51 </Text>
52 </Box>,
53 )
54 size += 115 + text.length
55 } else {
56 runs.push(<Box width={end - start} />)
57 size += 50
58 }
59 }
60 }
61 for (let x = 0; x < cols; x += 1) {
62 const cell = glyphAt(x, y, levels)
63 const c = cell ? cell[0] : ''
64 if (c !== colour) {
65 if (minRun > 1 && x - start < minRun) {
66 // Too short to stand alone: it joins the run, in the run's colour.
67 colour ||= c
68 } else {
69 flush(x)
70 start = x
71 colour = c
72 text = ''
73 }
74 }
75 // A space keeps the glyphs after an empty cell in their columns.
76 text += cell ? cell[1] : ' '
77 }
78 if (colour) {
79 flush(cols)
80 }
81 if (!runs.length) {
82 empty += 1
83 continue
84 }
85 if (empty) {
86 out.push(<Box height={empty} />)
87 empty = 0
88 }
89 out.push(
90 <Box flexDirection="row" height={1}>
91 {runs}
92 </Box>,
93 )
94 size += 70
95 }
96 return { out, size }
97 }
98 // Measured, not estimated: a frame past the bound is never drawn at all.
99 const measure = (d: { out: RenderElement[]; size: number }) => {
100 try {
101 return JSON.stringify(d.out).length
102 } catch {
103 return d.size
104 }
105 }
106 let drawn = draw(6, 1)
107 for (const [levels, minRun] of [
108 [4, 1],
109 [3, 1],
110 [2, 1],
111 [1, 1],
112 [1, 2],
113 [1, 3],
114 [1, 5],
115 [1, 8],
116 [1, 13],
117 ] as const) {
118 if (measure(drawn) <= GLYPH_BUDGET) {
119 break
120 }
121 drawn = draw(levels, minRun)
122 }
123 return (
124 <Box flexDirection="column" width={cols} height={rows}>
125 {drawn.out}
126 </Box>
127 )
128}
129