SLOPSHOPPER

exported-lich

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

paneband
A shopper browsing a rack in a slop shop
README

Exported 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.

Source 13 files
hooks/register.tsx 150 lines
1// 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}
150
hooks/draw.tsx 58 lines
1// 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}
58
hooks/drawing.ts 46 lines
1// 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}
46
hooks/eidola-types.ts 128 lines
1// 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}
128
hooks/sprite.ts 296 lines
1// 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}
296
hooks/field.ts 383 lines
1// 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}
383
hooks/lich.ts 172 lines
1// 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 }
172
hooks/stage.ts 199 lines
1// 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}
199
hooks/lich-live.tsx 843 lines
1// 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
843
hooks/lich3d.ts 571 lines
1// 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}
571
hooks/grimoire3d.ts 478 lines
1// 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}
478
hooks/live-glyphs.tsx 129 lines
1// 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