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

An Engram for Eidola, made from the bundled Kraken: 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-kraken, vessel.scene "self", remixOf "kraken": while the drawing can't draw, Eidola draws the Kraken 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 Kraken'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-kraken.
hooks/register.tsx 150 lines1// Exported Kraken: an Engram plugin for Eidola, made from the bundled Kraken 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 Kraken'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-kraken'
20/** Its name, for the drawing's alt text: engram.json's. */
21const NAME = "Exported Kraken"
22/** The Box its drawing comes back in, and the slot Eidola leaves for it. */
23const SCENE = `eidola:${ID}`
24const SLOT = `eidola-slot:${ID}`
25/** Where a failure is reported from. */
26const FILE = 'hooks/register.tsx'
27/** How long the face shows the error effect after the ink rises, where no Client can run: in ms. */
28const ERROR_MS = 2000
29
30type Els = DrawEls & Pick<Elements['desktop'], 'Text'>
31
32// The backdrop last built for the pane and for the band. Its loops are
33// locked to the clock when it is built, so it is built again only when its
34// size changes: a redraw for anything else keeps the running drawing.
35const built = new Map<EidolaWhere, { key: string; svg: string }>()
36
37function memoFor(where: EidolaWhere): DrawKit['memo'] {
38 return (key, build) => {
39 const last = built.get(where)
40 if (last?.key === key) {
41 return last.svg
42 }
43 const svg = build()
44 built.set(where, { key, svg })
45 return svg
46 }
47}
48
49/** The creature, in one Box keyed eidola:<id>: live where a Client can run (the desktop), its face elsewhere (its error face while the ink is fresh). */
50function creature(els: Els, surface: RenderSurface, handoff: EidolaHandoff, now: number): RenderElement {
51 if (surface === 'desktop') {
52 const tree = draw(els, handoff, { name: NAME, now, memo: memoFor(handoff.where) })
53 return { ...(tree as object), props: { ...(tree as { props?: object }).props, key: SCENE } } as RenderElement
54 }
55 const { Box, Text } = els
56 return (
57 <Box key={SCENE} width={handoff.columns} height={handoff.rows}>
58 <Text>{FACES[handoff.ink > 0 && now - handoff.inkAt < ERROR_MS ? 'error' : handoff.mood]}</Text>
59 </Box>
60 )
61}
62
63/** Eidola's tree with this Engram's slot, wherever it is, filled; anything else as it came. */
64function fill(node: unknown, make: (columns: number, rows: number) => RenderElement): unknown {
65 if (node === null || typeof node !== 'object') {
66 return node
67 }
68 const element = node as { props?: { key?: unknown; width?: unknown; height?: unknown }; children?: unknown }
69 if (element.props?.key === SLOT) {
70 return make(Number(element.props.width) || 20, Number(element.props.height) || 5)
71 }
72 if (Array.isArray(element.children)) {
73 return { ...element, children: element.children.map(child => fill(child, make)) }
74 }
75 return node
76}
77
78/** True when the tree holds this Engram's slot. */
79function hasSlot(node: unknown): boolean {
80 if (node === null || typeof node !== 'object') {
81 return false
82 }
83 const element = node as { props?: { key?: unknown }; children?: unknown }
84 return element.props?.key === SLOT || (Array.isArray(element.children) && element.children.some(hasSlot))
85}
86
87/** Tells Eidola the drawing failed, with the file and the stack's line: it draws the fallback and marks the Engram not loading. */
88async function fault($: EngineInterface, error: unknown): Promise<void> {
89 try {
90 await $.eidola.fault({ engram: ID, message: error instanceof Error ? error.message : String(error), file: FILE, stack: error instanceof Error ? error.stack : undefined })
91 } catch {
92 // Eidola isn't there to tell.
93 }
94}
95
96/**
97 * One drawing, the pane's or the band's. Beneath Eidola (this plugin loaded
98 * after it), Eidola asks with the hand-off on e.props.eidola. Above it (this
99 * plugin loaded first), Eidola's tree comes back from next with a slot to
100 * fill, and the state is read from Eidola's.
101 */
102async function answer($: EngineInterface, where: EidolaWhere, surface: RenderSurface, els: Els, props: unknown, below: () => Promise<RenderElement>): Promise<RenderElement> {
103 const handoff = (props as { eidola?: EidolaHandoff }).eidola
104 if (handoff) {
105 if (handoff.engram !== ID) {
106 return below()
107 }
108 try {
109 return creature(els, surface, handoff, await $.clock.now())
110 } catch (error) {
111 await fault($, error)
112 return below()
113 }
114 }
115 const tree = await below()
116 if (!hasSlot(tree)) {
117 return tree
118 }
119 const { value: mood = 'idle' as Mood } = await $.state.get({ plugin: 'eidola', key: 'mood' })
120 const { value: moodBefore = 'idle' as Mood } = await $.state.get({ plugin: 'eidola', key: 'moodBefore' })
121 const { value: moodAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'moodAt' })
122 const { value: ink = 0 } = await $.state.get({ plugin: 'eidola', key: 'ink' })
123 const { value: inkAt = 0 } = await $.state.get({ plugin: 'eidola', key: 'inkAt' })
124 const { value: epoch = 0 } = await $.state.get({ plugin: 'eidola', key: 'epoch' })
125 const now = await $.clock.now()
126 try {
127 return fill(tree, (columns, rows) => creature(els, surface, { contract: 1, engram: ID, where, columns, rows, mood, moodBefore, moodAt, ink, inkAt, epoch }, now)) as RenderElement
128 } catch (error) {
129 await fault($, error)
130 return tree
131 }
132}
133
134export const register: Register = on => {
135 // Announce: Eidola reads engram.json from this folder. Without Eidola there
136 // is nobody to announce to, and nothing else changes.
137 on('session.start', async ($, e, next) => {
138 try {
139 await $.eidola.announce({ root: $.plugin.root })
140 } catch {
141 // Eidola isn't installed: this plugin draws nothing.
142 }
143 return next(e)
144 })
145
146 // The pane and the band, one hook each, the same way.
147 on('ui.render', { component: 'Pane', requestId: 'eidola' }, ($, e, next) => answer($, 'pane', e.surface, $.ui.resolve(e) as unknown as Els, e.props, () => next(e)))
148 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => answer($, 'band', e.surface, $.ui.resolve(e) as unknown as Els, e.props, () => next(e)))
149}
150hooks/draw.tsx 45 lines1// The Kraken's drawing module (hooks/drawing.ts): still water drawn once as
2// an SVG whose source never changes, the creature drawn live over it by
3// kraken-live.tsx. A new mood reaches the running Client as props: no
4// reload, no cut. In the pane, deep water; in the band, a wide strip of it.
5//
6// Eidola draws the bundled Kraken through this, and an Engram plugin
7// exported 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 { krakenWaterScene } from './kraken'
13import { PX_PER_COLUMN, PX_PER_ROW, sceneSize } from './stage'
14
15export const draw: Draw = (els, handoff, kit) => {
16 const { Box, Svg, Client } = els
17 const { columns: cols, rows, mood, ink } = handoff
18 if (handoff.where === 'band') {
19 const width = Math.max(96, cols * PX_PER_COLUMN - 4)
20 const height = rows * PX_PER_ROW - 2
21 const water = kit.memo(`kraken-water:${width}x${height}`, () => renderScene(krakenWaterScene(width, height), kit.now))
22 return (
23 <Box width={cols} height={rows}>
24 <Svg key="scene:band-water" source={water} alt={`${kit.name} in deep water`} width={width} height={height} isInteractive />
25 <Box position="absolute" top={0} left={0}>
26 <Client key="kraken-band" module="./kraken-live.tsx" props={{ mood, cols, rows, view: 'band', ink }} width={cols} height={rows} />
27 </Box>
28 </Box>
29 )
30 }
31 // The water the same height as the drawing over it: any taller and the pane scrolls.
32 const { width } = sceneSize(cols, rows, 'dock')
33 const height = overdrawn(rows)
34 const water = kit.memo(`kraken-water:${width}x${height}`, () => renderScene(krakenWaterScene(width, height), kit.now))
35 return (
36 <Box width={cols} height={rows} overflow="hidden" alignItems="flex-start">
37 {/* Nothing here may change with the mood, alt included: a changed prop reloads the drawing. */}
38 <Svg key="scene:water" source={water} alt={`${kit.name} in deep water`} width={width} height={height} isInteractive />
39 <Box position="absolute" top={0} left={0}>
40 <Client key="kraken-live" module="./kraken-live.tsx" props={{ mood, cols, rows, ink }} width={cols} height={rows} />
41 </Box>
42 </Box>
43 )
44}
45hooks/drawing.ts 46 lines1// The drawing interface (docs/spec/engram-plugin.md, "The hand-off"): what
2// draws a creature from the hand-off. Each bundled Engram's drawing module,
3// engrams/<id>/hooks/draw.tsx, is one of these, and Eidola draws the bundled
4// three through it as it asks an Engram plugin. scripts/export-engram.mjs
5// copies a bundled Engram's drawing module, and this file with it, into an
6// Engram plugin of its own, whose hooks call the very same function with the
7// hand-off Eidola gives them.
8
9import type { Elements, RenderElement } from 'claude-code'
10
11import type { EidolaHandoff } from './eidola-types'
12import { PX_PER_ROW } from './stage'
13
14/** The elements a drawing draws with: a desktop's, where a Client can run. */
15export type DrawEls = Pick<Elements['desktop'], 'Box' | 'Svg' | 'Client'>
16
17/** What the hook drawing the creature gives it besides the hand-off. */
18export type DrawKit = {
19 /** The Engram's name, as its engram.json gives it: for the drawing's alt text. */
20 name: string
21 /** The clock now (ms): a still backdrop's loops are locked to it when built. */
22 now: number
23 /**
24 * A still backdrop's SVG by key, built once and kept while the key holds:
25 * a rebuilt one (a new clock) would reload the drawing on the surface.
26 */
27 memo: (key: string, build: () => string) => string
28}
29
30/**
31 * A creature's drawing, `columns` × `rows` cells of the hand-off: one Box,
32 * as wide and tall, holding the creature. The Engram plugin's hooks key it
33 * `eidola:<id>` before they answer it; Eidola places a bundled one as it is.
34 */
35export type Draw = (els: DrawEls, handoff: EidolaHandoff, kit: DrawKit) => RenderElement
36
37/**
38 * A pane backdrop's height in px: a row taller than its rows at 19 px each,
39 * clipped to them. A row is drawn a fraction of a pixel taller than 19, so a
40 * backdrop of exactly rows × 19 stops a few pixels short of the HUD's bottom
41 * brackets, which sit on the rows; drawn taller and clipped it meets them.
42 */
43export function overdrawn(rows: number): number {
44 return (rows + 1) * PX_PER_ROW
45}
46hooks/eidola-types.ts 128 lines1// Eidola's public types (contract 1), copied from the Eidola mod's
2// types/index.d.ts by scripts/export-engram.mjs: what the drawing reads off
3// the hand-off and Eidola's state. Types alone; $.eidola and Eidola's state
4// are typed by the contract the engine lays into .claude-plugin/types/eidola/
5// once this plugin loads beside Eidola.
6
7export type VesselState =
8 | 'idle'
9 | 'thinking'
10 | 'working'
11 | 'attention'
12 | 'success'
13 | 'responding'
14 | 'error'
15 | 'waiting'
16 | 'sleeping'
17
18/**
19 * What the drawings follow: three moods, set by the turn rather than by each
20 * tool, so a turn changes the drawing two or three times, not dozens.
21 */
22export type Mood = 'idle' | 'working' | 'attention'
23
24/** What the status line shows: the state and a short line of detail under it. */
25export type VesselView = { state: VesselState; detail: string }
26
27/**
28 * An Engram page's HUD preview: the mood its chips set, or the error its Error
29 * button shows, drawn in the preview alone and never in the session.
30 */
31export type Preview = {
32 mood: Mood
33 /** The Error button was pressed since the last mood chip. */
34 isError: boolean
35 /** How many times the Error button was pressed: the preview's own ink. */
36 ink: number
37 /** When it was last pressed, and when the preview's mood last changed (ms). */
38 inkAt: number
39 moodAt: number
40 /** The preview's mood before the last change. */
41 moodBefore: Mood
42}
43
44/** The version of the public contract this Eidola keeps. */
45export type EidolaContract = 1
46
47/** What an Engram plugin passes to `$.eidola.announce`. */
48export type EidolaAnnounceInput = {
49 /** The plugin's own folder, the one holding engram.json: `$.plugin.root`. */
50 root: string
51}
52
53/** One reason Eidola could not take an Engram plugin's engram.json. */
54export type EidolaIssue = { file: string; problem: string; fix: string }
55
56/** What `$.eidola.announce` answers. */
57export type EidolaAnnounced = {
58 /** The contract this Eidola keeps. */
59 contract: EidolaContract
60 /** The Engram's id when it is in the registry; null when it isn't. */
61 engram: string | null
62 /** Why it isn't: each file, problem and fix. Empty when it is. */
63 issues: EidolaIssue[]
64}
65
66/** What an Engram plugin passes to `$.eidola.fault` when its own drawing fails. */
67export type EidolaFaultInput = {
68 /** The Engram's id. */
69 engram: string
70 /** What went wrong, in a line. */
71 message: string
72 /** The file that failed, relative to the plugin's folder (`hooks/creature.tsx`). */
73 file?: string
74 /** The line in that file, when known. */
75 line?: number
76 /** The error's stack, when there is one: Eidola reads the line off it when `line` is left out. */
77 stack?: string
78}
79
80/** What `$.eidola.fault` answers: whether Eidola marked the Engram not loading. */
81export type EidolaFaulted = { isMarked: boolean }
82
83/** Where Eidola is handing the drawing off: its pane, or the band above the prompt. */
84export type EidolaWhere = 'pane' | 'band'
85
86/**
87 * The hand-off, on `e.props.eidola` of the `ui.render` Eidola passes to
88 * `next`: present only when Eidola asks the awake Engram's plugin for its
89 * drawing. Draw `columns` × `rows` cells, in one Box keyed `eidola:<engram>`.
90 */
91export type EidolaHandoff = {
92 contract: EidolaContract
93 /** The awake Engram's id: draw only when it is yours. */
94 engram: string
95 where: EidolaWhere
96 /** The drawing area inside Eidola's frame, in cells. */
97 columns: number
98 rows: number
99 mood: Mood
100 moodBefore: Mood
101 moodAt: number
102 ink: number
103 inkAt: number
104 epoch: number
105}
106
107/** The `eidola` noun Eidola adds to `$`: what an Engram plugin calls. */
108export type Eidola = {
109 /**
110 * Tells Eidola an Engram plugin is here. Call it from `session.start`:
111 * Eidola reads `<root>/engram.json`, checks it, and adds the Engram to its
112 * registry. Works whichever plugin loaded first.
113 *
114 * @example
115 * on('session.start', async ($, e, next) => {
116 * await $.eidola.announce({ root: $.plugin.root })
117 * return next(e)
118 * })
119 */
120 announce: (input: EidolaAnnounceInput) => Promise<EidolaAnnounced>
121 /**
122 * Tells Eidola the plugin's own drawing failed: Eidola draws the Engram it
123 * was remixed from (or the resting sprite) instead, and marks it not
124 * loading with the file and line, until the plugin announces again.
125 */
126 fault: (input: EidolaFaultInput) => Promise<EidolaFaulted>
127}
128hooks/sprite.ts 296 lines1// Built-in sprite: 20x20 pixel frames composed from layers, emitted as one
2// SVG whose frames cycle with CSS steps(). No redraw per frame: the pane
3// redraws only when the state changes. Bundled Engrams now ship baked SVGs
4// from the Vessel Forge; this is the fallback and the media test's control.
5
6import type { VesselState } from './eidola-types'
7
8export const GRID = 20
9
10/** Character -> palette slot. '.' is transparent. */
11export type Palette = {
12 o: string // outline
13 b: string // body
14 h: string // highlight
15 e: string // eye
16 w: string // eye shine
17 c: string // cheek
18 m: string // mouth
19 a: string // accent (thought dots, z's)
20 r: string // alert
21 y: string // sparkle
22 d: string // sweat drop
23}
24
25/**
26 * The built-in sprite's colors. It draws the media test's control and
27 * stands in when a baked Vessel's file can't be read.
28 */
29export const SPRITE_PALETTE: Palette = {
30 o: '#3b2f8f',
31 b: '#cfc6ff',
32 h: '#f4f1ff',
33 e: '#1a1c2b',
34 w: '#ffffff',
35 c: '#ff9ec4',
36 m: '#3b2f8f',
37 a: '#9d93ff',
38 r: '#ff5c5c',
39 y: '#ffd25e',
40 d: '#6fc3ff',
41}
42
43type Grid = string[][]
44type Dot = [x: number, y: number, ch: keyof Palette]
45
46// The body: a domed spirit. Rows 0-4 stay free for accessories.
47const BODY = [
48 '....................',
49 '....................',
50 '....................',
51 '....................',
52 '....................',
53 '.......oooooo.......',
54 '.....oohhbbbboo.....',
55 '....ohhbbbbbbbbo....',
56 '...ohbbbbbbbbbbbo...',
57 '...obbbbbbbbbbbbo...',
58 '...obbbbbbbbbbbbo...',
59 '...obbbbbbbbbbbbo...',
60 '...obbbbbbbbbbbbo...',
61 '...obbbbbbbbbbbbo...',
62 '...obbbbbbbbbbbbo...',
63 '...obbbbbbbbbbbbo...',
64 '...obbbbbbbbbbbbo...',
65 '...obbbbbbbbbbbbo...',
66]
67
68// Two tail poses; frames alternate them so the spirit always drifts.
69const TAILS: [string[], string[]] = [
70 ['...obbbobbbbobbbo...', '....ooo.oooo.ooo....'],
71 ['...obbbbobbbbobbo...', '....oooo.oooo.oo....'],
72]
73
74// Faces: dots drawn over the body.
75const EYES = {
76 open: [
77 [6, 10, 'w'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
78 [12, 10, 'w'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
79 ],
80 closed: [[6, 11, 'e'], [7, 11, 'e'], [12, 11, 'e'], [13, 11, 'e']],
81 happy: [[6, 11, 'e'], [7, 10, 'e'], [8, 11, 'e'], [11, 11, 'e'], [12, 10, 'e'], [13, 11, 'e']],
82 up: [
83 [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'],
84 [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'],
85 ],
86 focus: [[6, 11, 'e'], [7, 11, 'e'], [6, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'], [13, 10, 'e']],
87 wide: [
88 [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
89 [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
90 ],
91 left: [
92 [5, 10, 'w'], [6, 10, 'e'], [5, 11, 'e'], [6, 11, 'e'],
93 [11, 10, 'w'], [12, 10, 'e'], [11, 11, 'e'], [12, 11, 'e'],
94 ],
95 right: [
96 [7, 10, 'w'], [8, 10, 'e'], [7, 11, 'e'], [8, 11, 'e'],
97 [13, 10, 'w'], [14, 10, 'e'], [13, 11, 'e'], [14, 11, 'e'],
98 ],
99 x: [
100 [5, 9, 'e'], [7, 9, 'e'], [6, 10, 'e'], [5, 11, 'e'], [7, 11, 'e'],
101 [12, 9, 'e'], [14, 9, 'e'], [13, 10, 'e'], [12, 11, 'e'], [14, 11, 'e'],
102 ],
103} satisfies Record<string, Dot[]>
104
105const CHEEKS: Dot[] = [[5, 12, 'c'], [14, 12, 'c']]
106
107const MOUTH = {
108 smile: [[8, 13, 'm'], [9, 14, 'm'], [10, 14, 'm'], [11, 13, 'm']],
109 small: [[9, 14, 'm'], [10, 14, 'm']],
110 open: [[9, 13, 'm'], [10, 13, 'm'], [9, 14, 'm'], [10, 14, 'm']],
111 frown: [[8, 14, 'm'], [9, 13, 'm'], [10, 13, 'm'], [11, 14, 'm']],
112 o: [[9, 14, 'm']],
113} satisfies Record<string, Dot[]>
114
115function plus(x: number, y: number, ch: keyof Palette): Dot[] {
116 return [[x, y - 1, ch], [x - 1, y, ch], [x, y, ch], [x + 1, y, ch], [x, y + 1, ch]]
117}
118
119const BANG: Dot[] = [[9, 0, 'r'], [10, 0, 'r'], [9, 1, 'r'], [10, 1, 'r'], [9, 2, 'r'], [10, 2, 'r'], [9, 4, 'r'], [10, 4, 'r']]
120const DROP: Dot[] = [[16, 6, 'd'], [16, 7, 'd'], [17, 7, 'd'], [16, 8, 'd']]
121const Z_SMALL: Dot[] = [[16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'], [17, 4, 'a'], [16, 5, 'a'], [17, 5, 'a'], [18, 5, 'a']]
122const Z_BIG: Dot[] = [
123 [15, 0, 'a'], [16, 0, 'a'], [17, 0, 'a'], [18, 0, 'a'], [17, 1, 'a'], [16, 2, 'a'],
124 [15, 3, 'a'], [16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'],
125]
126
127/** One frame: which tail, and which dots on top. */
128type Frame = { tail: 0 | 1; dots: Dot[] }
129
130export type Animation = {
131 frames: Frame[]
132 /** Seconds each frame shows. */
133 frameS: number
134 /** Seconds per bob up and down; 0 holds still. */
135 bobS: number
136}
137
138const face = (...parts: Dot[][]): Dot[] => parts.flat()
139
140export const ANIMATIONS: Record<VesselState, Animation> = {
141 idle: {
142 frameS: 0.5,
143 bobS: 2,
144 frames: [0, 1, 0, 1, 0, 1].map((tail, i) => ({
145 tail: tail as 0 | 1,
146 dots: face(i === 5 ? EYES.closed : EYES.open, CHEEKS, MOUTH.small),
147 })),
148 },
149 thinking: {
150 frameS: 0.45,
151 bobS: 1.6,
152 frames: [
153 { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a']]) },
154 { tail: 1, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a']]) },
155 { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a'], [18, 1, 'a']]) },
156 ],
157 },
158 working: {
159 frameS: 0.18,
160 bobS: 0.5,
161 frames: [
162 { tail: 0, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(18, 7, 'y')) },
163 { tail: 1, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(1, 9, 'y')) },
164 ],
165 },
166 attention: {
167 frameS: 0.4,
168 bobS: 0.8,
169 frames: [
170 { tail: 0, dots: face(EYES.wide, CHEEKS, MOUTH.o, BANG) },
171 { tail: 1, dots: face(EYES.wide, CHEEKS, MOUTH.o) },
172 ],
173 },
174 success: {
175 frameS: 0.3,
176 bobS: 0.6,
177 frames: [
178 { tail: 0, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(2, 4, 'y'), [[17, 2, 'y']]) },
179 { tail: 1, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(17, 3, 'y'), [[2, 6, 'y']]) },
180 ],
181 },
182 responding: {
183 frameS: 0.2,
184 bobS: 1.2,
185 frames: [
186 { tail: 0, dots: face(EYES.open, CHEEKS, MOUTH.open) },
187 { tail: 1, dots: face(EYES.open, CHEEKS, MOUTH.small) },
188 ],
189 },
190 error: {
191 frameS: 0.6,
192 bobS: 0,
193 frames: [
194 { tail: 0, dots: face(EYES.x, MOUTH.frown, DROP) },
195 { tail: 1, dots: face(EYES.x, MOUTH.frown) },
196 ],
197 },
198 waiting: {
199 frameS: 0.45,
200 bobS: 1.6,
201 frames: [
202 { tail: 0, dots: face(EYES.left, CHEEKS, MOUTH.small) },
203 { tail: 1, dots: face(EYES.right, CHEEKS, MOUTH.small) },
204 ],
205 },
206 sleeping: {
207 frameS: 0.9,
208 bobS: 3.6,
209 frames: [
210 { tail: 0, dots: face(EYES.closed, MOUTH.o, Z_SMALL) },
211 { tail: 1, dots: face(EYES.closed, MOUTH.o, Z_BIG) },
212 ],
213 },
214}
215
216export function compose(frame: Frame): Grid {
217 const rows = [...BODY, ...TAILS[frame.tail]]
218 const grid = rows.map(row => row.split(''))
219 for (const [x, y, ch] of frame.dots) {
220 const row = grid[y]
221 if (row !== undefined && x >= 0 && x < GRID) {
222 row[x] = ch
223 }
224 }
225 return grid
226}
227
228/** Horizontal runs of one color become one rect, to keep the SVG small. */
229export function rects(grid: Grid, palette: Palette): string {
230 const out: string[] = []
231 grid.forEach((row, y) => {
232 let x = 0
233 while (x < row.length) {
234 const ch = row[x]
235 if (ch === '.') {
236 x += 1
237 continue
238 }
239 let end = x + 1
240 while (end < row.length && row[end] === ch) end += 1
241 const fill = palette[ch as keyof Palette] ?? '#ff00ff'
242 out.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${fill}"/>`)
243 x = end
244 }
245 })
246 return out.join('')
247}
248
249const r = (n: number) => Math.round(n * 1000) / 1000
250
251/** The whole animated Vessel for one state, as SVG markup. */
252export function vesselSvg(state: VesselState, palette: Palette, size: number): string {
253 const anim = ANIMATIONS[state]
254 const n = anim.frames.length
255 const cycle = r(anim.frameS * n)
256 const slice = r(100 / n)
257
258 const delays = anim.frames
259 .map((_, i) => `.d${i}{animation-delay:-${r(((n - i) % n) * anim.frameS)}s}`)
260 .join('')
261 const bob =
262 anim.bobS > 0
263 ? `.bob{animation:bob ${anim.bobS}s steps(1,end) infinite}` +
264 '@keyframes bob{0%{transform:translateY(0)}50%{transform:translateY(-1px)}100%{transform:translateY(0)}}'
265 : ''
266 const css =
267 `.f{opacity:0;animation:fr ${cycle}s steps(1,end) infinite}.f0{opacity:1}` +
268 `@keyframes fr{0%{opacity:1}${slice}%{opacity:0}100%{opacity:0}}` +
269 delays +
270 bob +
271 '@media (prefers-reduced-motion:reduce){.f,.bob{animation:none}}'
272
273 const frames = anim.frames
274 .map((frame, i) => `<g class="f f${i === 0 ? '0' : 'n'} d${i}">${rects(compose(frame), palette)}</g>`)
275 .join('')
276
277 return (
278 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 ${GRID} ${GRID + 1}" ` +
279 `width="${size}" height="${Math.round((size * (GRID + 1)) / GRID)}" shape-rendering="crispEdges">` +
280 `<style>${css}</style><g class="bob">${frames}</g></svg>`
281 )
282}
283
284/** Terminal fallback: one line per state. */
285export const FACES: Record<VesselState, string> = {
286 idle: '( o_o )',
287 thinking: '( o_o ) . o O',
288 working: '( >_< ) *',
289 attention: '( O_O ) !',
290 success: '( ^_^ ) +',
291 responding: '( o0o )',
292 error: '( x_x )',
293 waiting: '( o_o)>',
294 sleeping: '( -_- ) z',
295}
296hooks/field.ts 383 lines1// The field engine. A scene is a sampler: for a point and a frame it gives
2// a layer and a brightness. The engine samples it onto a grid sized to the
3// slot, once per frame, and an ink turns the grid into SVG:
4//
5// glyph a character per cell, from a ramp of dim to dense
6// pixel a block per cell, dithered between a layer's shades
7// particle a dot per lit cell, sized by brightness
8//
9// Each layer names its ink, so one scene can mix them: a pixel creature in
10// particle water. Frames cycle by CSS, locked to the wall clock, so a
11// redraw for a new state carries the motion on unbroken.
12
13export type Ink = 'glyph' | 'pixel' | 'particle'
14export const INKS: readonly Ink[] = ['glyph', 'pixel', 'particle']
15
16/** How one layer of a scene looks in each ink. */
17export type LayerStyle = {
18 /** glyph: characters from dim to dense; a space draws nothing. */
19 chars: string
20 /** pixel: shades from dim to bright. */
21 shades: string[]
22 /** glyph and particle: the fill, a colour or a paint server. */
23 fill: string
24 opacity?: number
25 /** Drawn with a soft bloom. */
26 isGlowing?: boolean
27 /** The ink this layer draws in; the scene's when absent. */
28 ink?: Ink
29}
30
31/** [layer, brightness]: layer 0 draws nothing; brightness runs 0 to 1. */
32export type Cell = readonly [number, number]
33export const EMPTY: Cell = [0, 0]
34
35export type FieldScene = {
36 width: number
37 height: number
38 /** Index 0 is the empty layer and is never drawn. */
39 layers: LayerStyle[]
40 /** The ink of any layer that names none. */
41 ink: Ink
42 /**
43 * A point in pixels, a frame of `frames`: what moves there, or EMPTY.
44 * `want` is the layers this pass draws; a scene may skip work for others.
45 */
46 sample: (x: number, y: number, frame: number, frames: number, want: ReadonlySet<number>) => Cell
47 /**
48 * What lies still behind it: sampled once and drawn outside the frame
49 * cycle, so a backdrop costs one frame however many the scene has. A
50 * cell the moving layers reach in any frame is left out of it.
51 */
52 still?: (x: number, y: number, want: ReadonlySet<number>) => Cell
53 /**
54 * How the still backdrop moves: a small drift side to side (the default),
55 * or a sway from the top edge, as light shafts hang from the surface.
56 */
57 stillMotion?: 'drift' | 'sway'
58 /** Seconds for one pass through the frames. */
59 period: number
60 /** Markup drawn under the field and over it; defs and CSS the scene needs. */
61 under: string
62 over: string
63 defs: string[]
64 css: string[]
65}
66
67/** Cell size in pixels and frame count per ink: what the budget allows. */
68export const GRID: Record<Ink, { cw: number; ch: number; frames: number }> = {
69 glyph: { cw: 8, ch: 14, frames: 8 },
70 pixel: { cw: 8, ch: 8, frames: 3 },
71 particle: { cw: 9, ch: 9, frames: 3 },
72}
73
74const r1 = (n: number) => Math.round(n * 10) / 10
75
76/** A stable value in [0, 1) for a cell: the same every frame and every draw. */
77export function hash(x: number, y: number): number {
78 const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
79 return s - Math.floor(s)
80}
81
82/** Smooth value noise in [0, 1). */
83export function noise(x: number, y: number): number {
84 const xi = Math.floor(x)
85 const yi = Math.floor(y)
86 const xf = x - xi
87 const yf = y - yi
88 const u = xf * xf * (3 - 2 * xf)
89 const v = yf * yf * (3 - 2 * yf)
90 const a = hash(xi, yi)
91 const b = hash(xi + 1, yi)
92 const c = hash(xi, yi + 1)
93 const d = hash(xi + 1, yi + 1)
94 return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
95}
96
97export const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n)
98export const smoothstep = (a: number, b: number, x: number) => {
99 const t = clamp01((x - a) / (b - a))
100 return t * t * (3 - 2 * t)
101}
102
103/** 4×4 ordered dither thresholds. */
104const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(n => (n + 0.5) / 16)
105
106type Sampled = { cols: number; rows: number; layer: Uint8Array[]; value: Float32Array[]; still?: { layer: Uint8Array; value: Float32Array } }
107
108/** The scene sampled on one ink's grid, keeping only the layers drawn in that ink. */
109function sampleAll(scene: FieldScene, cw: number, ch: number, frames: number, keep: Set<number>): Sampled {
110 const cols = Math.floor(scene.width / cw)
111 const rows = Math.floor(scene.height / ch)
112 const layer: Uint8Array[] = []
113 const value: Float32Array[] = []
114 for (let f = 0; f < frames; f += 1) {
115 const L = new Uint8Array(cols * rows)
116 const V = new Float32Array(cols * rows)
117 for (let y = 0; y < rows; y += 1) {
118 for (let x = 0; x < cols; x += 1) {
119 const [l, v] = scene.sample((x + 0.5) * cw, (y + 0.5) * ch, f, frames, keep)
120 if (l > 0 && v > 0 && keep.has(l)) {
121 L[y * cols + x] = l
122 V[y * cols + x] = clamp01(v)
123 }
124 }
125 }
126 layer.push(L)
127 value.push(V)
128 }
129 let still: Sampled['still']
130 if (scene.still) {
131 const L = new Uint8Array(cols * rows)
132 const V = new Float32Array(cols * rows)
133 for (let y = 0; y < rows; y += 1) {
134 for (let x = 0; x < cols; x += 1) {
135 const i = y * cols + x
136 if (layer.some(f => f[i] !== 0)) {
137 continue
138 }
139 const [l, v] = scene.still((x + 0.5) * cw, (y + 0.5) * ch, keep)
140 if (l > 0 && v > 0 && keep.has(l)) {
141 L[i] = l
142 V[i] = clamp01(v)
143 }
144 }
145 }
146 still = { layer: L, value: V }
147 }
148 return { cols, rows, layer, value, still }
149}
150
151function glyphFrame(scene: FieldScene, s: Grid, cw: number, ch: number): string {
152 const out: string[] = []
153 scene.layers.forEach((style, l) => {
154 if (l === 0) {
155 return
156 }
157 const texts: string[] = []
158 for (let y = 0; y < s.rows; y += 1) {
159 let line = ''
160 let first = -1
161 let last = -1
162 for (let x = 0; x < s.cols; x += 1) {
163 const i = y * s.cols + x
164 let c = ' '
165 if (s.layer[i] === l) {
166 // Lifted and jittered a little, so mid-tones don't band into rows of one character.
167 const v = clamp01(Math.pow(s.value[i]!, 0.75) + (hash(x, y) - 0.5) * 0.14)
168 c = style.chars[Math.min(style.chars.length - 1, Math.floor(v * style.chars.length))]!
169 }
170 if (c !== ' ') {
171 if (first < 0) {
172 first = x
173 }
174 last = x
175 }
176 line += c
177 }
178 if (first >= 0) {
179 texts.push(`<text x="${first * cw}" y="${r1(y * ch + ch * 0.78)}" textLength="${(last - first + 1) * cw}">${line.slice(first, last + 1)}</text>`)
180 }
181 }
182 if (texts.length) {
183 out.push(layerGroup(style, texts.join(''), style.fill))
184 }
185 })
186 return out.join('')
187}
188
189function pixelFrame(scene: FieldScene, s: Grid): string {
190 const out: string[] = []
191 scene.layers.forEach((style, l) => {
192 if (l === 0) {
193 return
194 }
195 const n = style.shades.length
196 const runs: string[][] = style.shades.map(() => [])
197 for (let y = 0; y < s.rows; y += 1) {
198 let runLevel = -1
199 let runStart = 0
200 const flush = (end: number) => {
201 if (runLevel >= 0) {
202 const len = end - runStart
203 runs[runLevel]!.push(`M${runStart} ${y}h${len}v1h-${len}z`)
204 }
205 }
206 for (let x = 0; x < s.cols; x += 1) {
207 const i = y * s.cols + x
208 let level = -1
209 if (s.layer[i] === l) {
210 const at = s.value[i]! * (n - 1)
211 const base = Math.floor(at)
212 level = Math.min(n - 1, at - base > BAYER[(y % 4) * 4 + (x % 4)]! ? base + 1 : base)
213 }
214 if (level !== runLevel) {
215 flush(x)
216 runLevel = level
217 runStart = x
218 }
219 }
220 flush(s.cols)
221 }
222 const paths = runs.map((d, k) => (d.length ? `<path fill="${style.shades[k]}" d="${d.join('')}"/>` : '')).join('')
223 if (paths) {
224 out.push(layerGroup(style, paths))
225 }
226 })
227 return out.join('')
228}
229
230/** One grid's worth: a frame, or the still backdrop. */
231type Grid = { cols: number; rows: number; layer: Uint8Array; value: Float32Array }
232
233const DOT_SIZES = [0.32, 0.55, 0.85]
234
235function particleFrame(scene: FieldScene, s: Grid, cw: number, ch: number): string {
236 const out: string[] = []
237 scene.layers.forEach((style, l) => {
238 if (l === 0) {
239 return
240 }
241 const buckets: string[][] = DOT_SIZES.map(() => [])
242 for (let y = 0; y < s.rows; y += 1) {
243 for (let x = 0; x < s.cols; x += 1) {
244 const i = y * s.cols + x
245 if (s.layer[i] !== l) {
246 continue
247 }
248 const v = s.value[i]!
249 if (v < 0.08) {
250 continue
251 }
252 const k = v < 0.4 ? 0 : v < 0.72 ? 1 : 2
253 const jx = (hash(x, y) - 0.5) * cw * 0.6
254 const jy = (hash(y, x) - 0.5) * ch * 0.6
255 buckets[k]!.push(`M${Math.round((x + 0.5) * cw + jx)} ${Math.round((y + 0.5) * ch + jy)}h0`)
256 }
257 }
258 const paths = buckets
259 .map((d, k) => (d.length ? `<path stroke="${style.fill}" stroke-width="${r1(DOT_SIZES[k]! * cw)}" d="${d.join('')}"/>` : ''))
260 .join('')
261 if (paths) {
262 out.push(layerGroup(style, paths))
263 }
264 })
265 return out.join('')
266}
267
268function layerGroup(style: LayerStyle, body: string, fill?: string): string {
269 const attrs =
270 (fill ? ` fill="${fill}"` : '') +
271 (style.opacity !== undefined ? ` opacity="${style.opacity}"` : '') +
272 (style.isGlowing ? ' filter="url(#bloom)"' : '')
273 return `<g${attrs}>${body}</g>`
274}
275
276const BLOOM =
277 '<filter id="bloom" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>'
278
279/** What a drawing may come to: under the Svg element's 131,072, with room to spare. */
280export const BUDGET = 120_000
281
282/** The slot area GRID's cell sizes were set for: a 412 × 1442 dock. */
283const REFERENCE_AREA = 412 * 1442
284
285/**
286 * The scene drawn, its frames cycling from where the clock says. Runs of
287 * layers that share an ink are one pass, each on its own grid, drawn in
288 * layer order. `only` draws every layer in one ink, to compare inks.
289 *
290 * Cells grow with the slot so a larger one costs no more: from a scale set
291 * by its area, and coarser again until the drawing fits the budget.
292 */
293export function renderScene(scene: FieldScene, nowMs: number, only?: Ink): string {
294 let scale = Math.max(1, Math.sqrt((scene.width * scene.height) / REFERENCE_AREA))
295 let svg = renderAt(scene, nowMs, only, scale)
296 for (let tries = 0; svg.length > BUDGET && tries < 6; tries += 1) {
297 scale *= 1.2
298 svg = renderAt(scene, nowMs, only, scale)
299 }
300 return svg
301}
302
303function renderAt(scene: FieldScene, nowMs: number, only: Ink | undefined, scale: number): string {
304 const W = Math.round(scene.width)
305 const H = Math.round(scene.height)
306 const P = scene.period
307 const clock = (nowMs / 1000) % P
308
309 const passes: Array<{ ink: Ink; layers: number[] }> = []
310 scene.layers.forEach((style, l) => {
311 if (l === 0) {
312 return
313 }
314 const ink = only ?? style.ink ?? scene.ink
315 const last = passes[passes.length - 1]
316 if (last?.ink === ink) {
317 last.layers.push(l)
318 } else {
319 passes.push({ ink, layers: [l] })
320 }
321 })
322
323 const css: string[] = []
324 const reduced: string[] = []
325 const body = passes
326 .map((pass, p) => {
327 const base = GRID[pass.ink]
328 const cw = Math.round(base.cw * scale)
329 const ch = Math.round(base.ch * scale)
330 const frames = base.frames
331 const s = sampleAll(scene, cw, ch, frames, new Set(pass.layers))
332 const draw = (g: Grid) =>
333 pass.ink === 'glyph' ? glyphFrame(scene, g, cw, ch) : pass.ink === 'pixel' ? pixelFrame(scene, g) : particleFrame(scene, g, cw, ch)
334 const slot = P / frames
335
336 let stillMarkup = s.still ? draw({ cols: s.cols, rows: s.rows, ...s.still }) : ''
337 if (stillMarkup) {
338 // The still backdrop drifts a little, so it never reads as frozen.
339 // Pixel ink draws in cell units, the others in pixels.
340 const drift = pass.ink === 'pixel' ? 0.5 : r1(cw * 0.5)
341 const delay = r1(-((nowMs / 1000) % 18))
342 css.push(
343 scene.stillMotion === 'sway'
344 ? `.s${p}{transform-box:view-box;transform-origin:${r1(W / 2)}px 0;animation:s${p} 7s ${delay}s ease-in-out infinite alternate}@keyframes s${p}{from{transform:skewX(-2.5deg)}to{transform:skewX(2.5deg)}}`
345 : `.s${p}{animation:s${p} 9s ${delay}s ease-in-out infinite alternate}@keyframes s${p}{from{transform:translate(-${drift}px,0)}to{transform:translate(${drift}px,0)}}`,
346 )
347 reduced.push(`.s${p}`)
348 stillMarkup = `<g class="s${p}">${stillMarkup}</g>`
349 }
350
351 const frameBodies = Array.from({ length: frames }, (_, f) => draw({ cols: s.cols, rows: s.rows, layer: s.layer[f]!, value: s.value[f]! }))
352 let frameMarkup = ''
353 if (frameBodies.some(b => b)) {
354 css.push(`.p${p}{opacity:0;animation:p${p} ${P}s steps(1,end) infinite}@keyframes p${p}{0%{opacity:1}${r1(100 / frames)}%{opacity:0}100%{opacity:0}}`)
355 reduced.push(`.p${p}`)
356 frameMarkup = frameBodies
357 .map((b, f) => {
358 const delay = r1(-(((P - f * slot) % P) + clock) % P)
359 return `<g class="p${p}${f === 0 ? ' f0' : ''}" style="animation-delay:${delay}s">${b}</g>`
360 })
361 .join('')
362 }
363
364 const attrs =
365 pass.ink === 'glyph'
366 ? ` font-family="ui-monospace,Menlo,Consolas,monospace" font-size="${r1(ch * 1.02)}" font-weight="700" style="white-space:pre"`
367 : pass.ink === 'pixel'
368 ? ` transform="scale(${cw} ${ch})" shape-rendering="crispEdges"`
369 : ' fill="none" stroke-linecap="round"'
370 return stillMarkup || frameMarkup ? `<g${attrs}>${stillMarkup}${frameMarkup}</g>` : ''
371 })
372 .join('')
373
374 if (reduced.length) {
375 css.push(`@media (prefers-reduced-motion:reduce){${reduced.join(',')}{animation:none}.f0{opacity:1}}`)
376 }
377 return (
378 `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
379 `<style>${css.join('')}${scene.css.join('')}</style><defs>${BLOOM}${scene.defs.join('')}</defs>` +
380 `${scene.under}${body}${scene.over}</svg>`
381 )
382}
383hooks/kraken.ts 525 lines1// The Kraken as a field scene, for any ink. The pane is deep water with the
2// Kraken spread across it; the band is the sea just under the surface, a
3// hull above and arms rising out of the dark. Light falls in soft shafts
4// that sway; runes and eyes glow; the arms sway through the frames.
5
6import type { VesselState } from './eidola-types'
7import { EMPTY, clamp01, hash, noise, smoothstep } from './field'
8import type { Cell, FieldScene, LayerStyle } from './field'
9
10type Pt = [number, number]
11const TAU = Math.PI * 2
12
13// Layers, in drawing order.
14const WATER = 1
15export const BODY = 2
16export const DETAIL = 3
17export const GLOW = 4
18const BUBBLE = 5
19/** The iris up close: a palette of its own, from a deep teal rim to a pale centre. */
20export const IRIS = 6
21
22/**
23 * The live Kraken's eyes and runes by mood: cyan at rest (the layers' own),
24 * crimson at work, gold when it needs you. Its glow first, then its iris.
25 */
26export const CRIMSON = { glow: ['#5a0a10', '#b81a24', '#ff3a40', '#ffc0c0'], iris: ['#2a0608', '#480a0e', '#6a1016', '#8e1a20', '#b4282c', '#d64a48', '#f08a80', '#ffd6d0'] }
27export const GOLD = { glow: ['#5a3a06', '#c08a10', '#ffcf3a', '#fff2b0'], iris: ['#2a1a04', '#483008', '#6a480c', '#8e6612', '#b4881c', '#d6aa36', '#f0d070', '#fff4c8'] }
28
29export function layers(isAngry: boolean): LayerStyle[] {
30 const glow = isAngry ? '#ff5a4a' : '#5ff6ff'
31 return [
32 { chars: ' ', shades: [], fill: 'none' },
33 // The water's motes and the bubbles are particles; the Kraken is pixels.
34 { chars: " .'`:-~", shades: ['#0d3442', '#16495a', '#246475', '#3b8594'], fill: '#8fe3ec', opacity: 0.5, ink: 'particle' },
35 {
36 chars: ' .:;+x*%#@',
37 shades: ['#0c161b', '#15242b', '#20353e', '#2f4a54', '#43636d', '#5f8189', '#87a7ae', '#b4cdd2'],
38 fill: 'url(#skin)',
39 },
40 { chars: 'oO@', shades: ['#3c5860', '#9db7bb', '#d6e6e8'], fill: '#d2e4e7' },
41 {
42 chars: '+*#',
43 shades: isAngry ? ['#5a1008', '#b8281a', '#ff5a4a', '#ffd2c8'] : ['#0b5c66', '#1fb2c0', '#5ff6ff', '#dcffff'],
44 fill: glow,
45 isGlowing: true,
46 },
47 { chars: '°oO', shades: ['#3b8594', '#8fd6df', '#d8f6fa'], fill: '#c9f3ff', opacity: 0.8, ink: 'particle' },
48 { chars: '.:+*#', shades: ['#062a30', '#0a434c', '#0f5e68', '#178089', '#24a3ab', '#4cc6c9', '#93e4e0', '#d9fbf6'], fill: '#4cc6c9', isGlowing: true },
49 ]
50}
51
52// ── Arms ───────────────────────────────────────────────────────────────
53
54export type Arm = { pts: Pt[]; w: number[]; len: number; box: [number, number, number, number] }
55
56type ArmSpec = { x: number; y: number; angle: number; len: number; curl: number; amp: number; k: number; w0: number }
57
58function buildArm(a: ArmSpec, phase: number, n = 14): Arm {
59 const pts: Pt[] = [[a.x, a.y]]
60 let x = a.x
61 let y = a.y
62 const step = a.len / n
63 for (let i = 1; i <= n; i += 1) {
64 const s = i / n
65 const theta = a.angle + a.curl * s * s * s + a.amp * Math.sin(TAU * a.k * s - phase) * s
66 x += Math.cos(theta) * step
67 y += Math.sin(theta) * step
68 pts.push([x, y])
69 }
70 const w = pts.map((_, i) => Math.max(1.5, a.w0 * Math.pow(1 - i / n, 0.8)))
71 const pad = a.w0
72 const xs = pts.map(p => p[0])
73 const ys = pts.map(p => p[1])
74 return { pts, w, len: a.len, box: [Math.min(...xs) - pad, Math.min(...ys) - pad, Math.max(...xs) + pad, Math.max(...ys) + pad] }
75}
76
77/** Where a point falls on an arm: t along it, side across it (-1 to 1, signed), or undefined if off it. */
78function onArm(arm: Arm, x: number, y: number): { t: number; side: number } | undefined {
79 const [x0, y0, x1, y1] = arm.box
80 if (x < x0 || x > x1 || y < y0 || y > y1) {
81 return undefined
82 }
83 let best: { t: number; side: number } | undefined
84 let bestD = Infinity
85 const n = arm.pts.length - 1
86 for (let i = 0; i < n; i += 1) {
87 const [ax, ay] = arm.pts[i]!
88 const [bx, by] = arm.pts[i + 1]!
89 const dx = bx - ax
90 const dy = by - ay
91 const l2 = dx * dx + dy * dy || 1
92 const u = clamp01(((x - ax) * dx + (y - ay) * dy) / l2)
93 const px = ax + dx * u
94 const py = ay + dy * u
95 const d = Math.hypot(x - px, y - py)
96 const half = (arm.w[i]! + (arm.w[i + 1]! - arm.w[i]!) * u) / 2
97 if (d <= half && d < bestD) {
98 bestD = d
99 const cross = dx * (y - ay) - dy * (x - ax)
100 best = { t: (i + u) / n, side: (cross < 0 ? -1 : 1) * (d / half) }
101 }
102 }
103 return best
104}
105
106// ── The Kraken ─────────────────────────────────────────────────────────
107
108export type Runes = 'faint' | 'calm' | 'pulse' | 'bright'
109const RUNE_LEVEL: Record<Runes, number> = { faint: 0.25, calm: 0.55, pulse: 0.8, bright: 1 }
110
111export type Pose = {
112 cx: number
113 cy: number
114 /** Mantle width. */
115 s: number
116 /** 1 lit by the surface, toward 0 lost in the dark. */
117 depth: number
118 runes: Runes
119 /** The runes' glow, 0 to 1, in place of `runes`' level: what a live drawing eases. */
120 runeLevel?: number
121 isAngry?: boolean
122 /** 0 open, 1 shut. */
123 lid?: number
124 /** Pupils: -1 left, 1 right. */
125 look?: number
126 /** Whether it has arms of its own. */
127 hasArms: boolean
128 reach?: number
129 sway: number
130}
131
132const ARM_ANGLES = [-28, 14, 48, 76, 104, 132, 166, 208]
133
134export function krakenArms(p: Pose, phase: number): Arm[] {
135 if (!p.hasArms) {
136 return []
137 }
138 return ARM_ANGLES.map((deg, i) => {
139 const a = (deg * Math.PI) / 180
140 const h = hash(i, 7)
141 return buildArm(
142 {
143 x: p.cx + Math.cos(a) * p.s * 0.34,
144 y: p.cy + p.s * 0.3 + Math.sin(a) * p.s * 0.1,
145 angle: a + (h - 0.5) * 0.3,
146 len: p.s * (p.reach ?? 2.8) * (0.8 + hash(i, 3) * 0.4),
147 curl: (i % 2 ? 1 : -1) * (1.8 + h * 1.6),
148 amp: p.sway * (0.8 + hash(i, 5) * 0.4),
149 k: 0.6 + hash(i, 9) * 0.3,
150 w0: p.s * 0.4,
151 },
152 phase + i * 0.9,
153 )
154 })
155}
156
157/** The Kraken at a point: its body, suckers, runes and eyes. */
158export function krakenAt(p: Pose, arms: Arm[], x: number, y: number, frame: number): Cell | undefined {
159 const { cx, cy, s } = p
160 const lvl = p.runeLevel ?? RUNE_LEVEL[p.runes]
161
162 // Eyes.
163 for (const side of [-1, 1]) {
164 const ex = cx + side * s * 0.44
165 const ey = cy + s * 0.1
166 const er = s * 0.1
167 const d = Math.hypot(x - ex, (y - ey) * 1.2)
168 if (d < er) {
169 const lid = p.lid ?? 0
170 if (y - ey < -er + 2 * er * lid) {
171 return [BODY, 0.45 * p.depth]
172 }
173 const px = ex + (p.look ?? 0) * er * 0.3
174 if (Math.abs(y - ey) < er * 0.2 && Math.abs(x - px) < er * 0.62) {
175 return [BODY, 0.02]
176 }
177 return [GLOW, 0.75 + 0.25 * (1 - d / er)]
178 }
179 if (d < er * 1.7) {
180 return [BODY, (0.7 + 0.2 * (1 - d / (er * 1.7))) * p.depth]
181 }
182 }
183
184 // Mantle: a sack swept up from a wide rim.
185 const u = (x - cx) / s
186 const v = (y - cy) / s
187 const sack = (u / 0.62) ** 2 + ((v + 0.42) / 0.78) ** 2
188 const rim = (u / 0.6) ** 2 + ((v - 0.12) / 0.3) ** 2
189 if (sack < 1 || rim < 1) {
190 const top = smoothstep(0.2, -0.9, v) // lighter toward the crown
191 const edge = Math.min(1, Math.max(sack, rim < 1 ? 0 : 1))
192 const mottle = 0.82 + 0.25 * noise(x / (s * 0.12), y / (s * 0.12))
193 const rimLight = smoothstep(0.75, 1, sack) * smoothstep(0, -0.7, v) * 0.35
194 return [BODY, clamp01((0.28 + 0.5 * top + rimLight) * mottle * (1 - 0.15 * edge) * p.depth)]
195 }
196
197 // Arms.
198 for (const arm of arms) {
199 const hit = onArm(arm, x, y)
200 if (!hit) {
201 continue
202 }
203 const { t, side } = hit
204 const along = t * arm.len
205 // Ringed suckers down the underside.
206 if (side < -0.38 && side > -0.92 && t < 0.88) {
207 const spacing = Math.max(6, arm.w[Math.floor(t * (arm.w.length - 1))]! * 0.45)
208 const ph = (along / spacing) % 1
209 if (ph < 0.5) {
210 return [DETAIL, (0.55 + 0.4 * (1 - Math.abs(ph - 0.25) * 4)) * p.depth]
211 }
212 }
213 // Runes down the spine.
214 if (Math.abs(side) < 0.16 && t > 0.05 && t < 0.8) {
215 const mark = Math.floor(along / 9) % 6
216 if (mark === 0 || mark === 2 || mark === 3) {
217 return [GLOW, lvl * 0.9]
218 }
219 }
220 // Lit along the top, shadowed underneath, dark at the edges.
221 const lit = 0.3 + 0.45 * clamp01(side * 0.6 + 0.5)
222 const edgeDark = Math.abs(side) > 0.88 ? 0.55 : 1
223 const mottle = 0.85 + 0.2 * noise(x / 14, y / 14)
224 return [BODY, clamp01(lit * edgeDark * mottle * (1 - 0.35 * t) * p.depth)]
225 }
226 return undefined
227}
228
229// ── Water ──────────────────────────────────────────────────────────────
230
231type Shaft = { x: number; w: number; slope: number; sway: number; strength: number }
232
233function shaftsFor(W: number, count: number, seed: number): Shaft[] {
234 return Array.from({ length: count }, (_, i) => ({
235 x: W * ((i + 0.5) / count) + (hash(i, seed) - 0.5) * W * 0.12,
236 w: W * (0.025 + hash(i, seed + 1) * 0.04),
237 slope: 0.12 + hash(i, seed + 2) * 0.12,
238 sway: W * 0.02,
239 strength: 0.5 + hash(i, seed + 3) * 0.5,
240 }))
241}
242
243/** Soft light shafts, caustic shimmer near the surface, specks and bubbles. */
244function waterAt(W: number, H: number, shafts: Shaft[], x: number, y: number, phase: number, dim: number, surfaceY: number): Cell {
245 let light = 0
246 for (const [i, sh] of shafts.entries()) {
247 const centre = sh.x + sh.slope * (y - surfaceY) + Math.sin(phase + i * 1.7) * sh.sway
248 const across = (x - centre) / sh.w
249 light += Math.exp(-across * across) * sh.strength
250 }
251 const fall = Math.exp(-(y - surfaceY) / (H * 0.45))
252 let v = light * fall * (0.55 + 0.45 * noise(x / 18 + phase * 0.3, y / 26))
253 // Caustics: a band of shimmer just below the surface.
254 const band = Math.exp(-(((y - surfaceY) / (H * 0.035)) ** 2))
255 v += band * smoothstep(0.45, 0.8, noise(x / 11 + phase * 0.6, y / 7)) * 0.9
256 // Specks.
257 if (hash(Math.floor(x / 5), Math.floor(y / 5)) > 0.985) {
258 v = Math.max(v, 0.35)
259 }
260 v *= dim
261 return v > 0.14 ? [WATER, v] : EMPTY
262}
263
264/** Bubbles rising through the frames: a column of small rings. */
265function bubbleAt(x: number, y: number, cx: number, spread: number, bottom: number, height: number, frame: number, frames: number): Cell | undefined {
266 for (let i = 0; i < 14; i += 1) {
267 const bx = cx + (hash(i, 31) - 0.5) * spread + Math.sin(frame * 1.3 + i) * 4
268 const travel = ((hash(i, 37) + frame / frames) % 1) * height
269 const by = bottom - travel
270 const br = 3 + hash(i, 41) * 4
271 if (Math.hypot(x - bx, y - by) < br) {
272 return [BUBBLE, 0.35 + hash(i, 43) * 0.65]
273 }
274 }
275 return undefined
276}
277
278function backdrop(W: number, H: number, top: string, mid: string, defs: string[]): string {
279 defs.push(
280 `<linearGradient id="deep" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${top}"/><stop offset=".35" stop-color="${mid}"/><stop offset="1" stop-color="#02070b"/></linearGradient>`,
281 `<linearGradient id="skin" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="${H}"><stop offset="0" stop-color="#d4e8eb"/><stop offset="1" stop-color="#7c9ba2"/></linearGradient>`,
282 `<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=".6"/></radialGradient>`,
283 )
284 return `<rect width="${W}" height="${H}" fill="url(#deep)"/>`
285}
286
287const OVER = (W: number, H: number) =>
288 `<rect width="${W}" height="${H}" fill="url(#vig)"/><rect x="1" y="1" width="${W - 2}" height="${H - 2}" fill="none" stroke="#bfefff" stroke-opacity=".12" stroke-width="2"/>`
289
290// ── Scenes ─────────────────────────────────────────────────────────────
291
292function deepPose(state: VesselState, W: number, H: number): Pose {
293 const s = Math.min(W * 0.44, H * 0.26)
294 const base = { cx: W * 0.5, cy: H * 0.42, s, depth: 1, hasArms: true, sway: 0.3 }
295 switch (state) {
296 case 'idle':
297 return { ...base, cx: W * 0.55, cy: H * 0.74, depth: 0.45, runes: 'faint', sway: 0.2 }
298 case 'sleeping':
299 return { ...base, cy: H * 0.82, depth: 0.3, runes: 'faint', lid: 1, sway: 0.12 }
300 case 'thinking':
301 return { ...base, runes: 'pulse', look: 0.4 }
302 case 'working':
303 return { ...base, cx: W * 0.45, cy: H * 0.38, depth: 0.9, runes: 'bright', look: 0.5, sway: 0.45 }
304 case 'responding':
305 return { ...base, runes: 'calm', look: -0.3, sway: 0.35 }
306 case 'waiting':
307 return { ...base, cy: H * 0.56, depth: 0.75, runes: 'faint', lid: 0.5, look: -0.5, sway: 0.2 }
308 case 'attention':
309 return { ...base, cx: W - s * 0.12, cy: H * 0.4, s: s * 1.35, runes: 'bright', look: -1, hasArms: false }
310 case 'success':
311 return { ...base, runes: 'bright', sway: 0.4 }
312 case 'error':
313 return { ...base, cy: H * 0.45, runes: 'bright', isAngry: true, sway: 0.6 }
314 }
315}
316
317/** Arms that belong to the scene rather than the body: tips round the edge while it peeks. */
318function sceneArms(state: VesselState, pose: Pose, W: number, H: number, phase: number): Arm[] {
319 if (state === 'attention') {
320 // Tips curling round the right edge, below the peering head.
321 return [0, 1, 2].map(i =>
322 buildArm(
323 { x: W + 10, y: H * (0.58 + i * 0.12), angle: Math.PI + (i - 1) * 0.25, len: pose.s * 1.1, curl: (i % 2 ? 1 : -1) * 2.6, amp: 0.3, k: 0.8, w0: pose.s * 0.22 },
324 phase + i,
325 ),
326 )
327 }
328 return []
329}
330
331/** The pane: deep water, the Kraken spread across it. */
332export function krakenPaneScene(state: VesselState, W: number, H: number): FieldScene {
333 const pose = deepPose(state, W, H)
334 const shafts = shaftsFor(W, 5, 11)
335 const armsByFrame = new Map<number, Arm[]>()
336 const defs: string[] = []
337 const under = backdrop(W, H, '#1d6170', '#0a2e3b', defs)
338 const busy = state === 'working' || state === 'error'
339 return {
340 width: W,
341 height: H,
342 layers: layers(!!pose.isAngry),
343 ink: 'pixel',
344 period: busy ? 3.2 : state === 'sleeping' ? 10 : 6,
345 under,
346 over: OVER(W, H),
347 defs,
348 css: [],
349 sample(x, y, frame, frames, want) {
350 if (want.has(BODY)) {
351 let arms = armsByFrame.get(frame)
352 if (!arms) {
353 arms = [...sceneArms(state, pose, W, H, (frame / frames) * TAU), ...krakenArms(pose, (frame / frames) * TAU)]
354 armsByFrame.set(frame, arms)
355 }
356 return krakenAt(pose, arms, x, y, frame) ?? EMPTY
357 }
358 return bubbleAt(x, y, pose.cx, W * 0.7, Math.min(H, pose.cy + pose.s * 0.4), H * 0.6, frame, frames) ?? EMPTY
359 },
360 still(x, y, want) {
361 if (want.has(BODY)) {
362 return EMPTY
363 }
364 return waterAt(W, H, shafts, x, y, 0, state === 'sleeping' ? 0.4 : 1, 0)
365 },
366 }
367}
368
369/** The band: just under the surface, a hull above, arms rising from below. */
370export function krakenBandScene(state: VesselState, W: number, H: number): FieldScene {
371 const surfaceY = H * 0.14
372 const shafts = shaftsFor(W, 8, 23)
373 const hx = W * 0.68
374 const hl = Math.min(W * 0.09, H * 0.6)
375 const isAngry = state === 'error'
376 const depth = state === 'idle' ? 0.4 : state === 'sleeping' ? 0.25 : 1
377 const runes: Runes = state === 'working' || state === 'error' ? 'bright' : state === 'thinking' ? 'pulse' : 'faint'
378 const rising: Array<[number, number, number, number]> =
379 state === 'working'
380 ? [
381 [0.42, 0.35, 1.25, 2.6],
382 [0.53, 0.15, 1.35, -2.2],
383 [0.6, 0.05, 1.2, 2.4],
384 [0.82, -0.35, 1.2, -2.6],
385 ]
386 : state === 'thinking' || state === 'responding' || state === 'success' || state === 'error'
387 ? [
388 [0.26, 0.25, 1.05, 2.4],
389 [0.4, -0.2, 0.95, -2.2],
390 ]
391 : state === 'idle'
392 ? [[0.12, 0.3, 0.8, 2.2]]
393 : []
394 const pose: Pose = { cx: W * 0.2, cy: H * 0.7, s: H * 0.55, depth: 1, runes: 'bright', look: -1, hasArms: false, sway: 0, lid: state === 'waiting' ? 0.5 : 0 }
395 const showHead = state === 'attention' || state === 'waiting'
396 if (state === 'waiting') {
397 pose.cy = H * 0.86
398 }
399 const armsByFrame = new Map<number, Arm[]>()
400 const defs: string[] = []
401 const under =
402 backdrop(W, H, '#3f9aa8', '#0f4757', defs) +
403 `<path d="M0 0H${W}V${Math.round(surfaceY)}H0Z" fill="#a8e8f0" fill-opacity=".35"/>` +
404 `<path d="M${Math.round(hx - hl)} ${Math.round(surfaceY - 2)}Q${Math.round(hx)} ${Math.round(surfaceY + H * 0.22)} ${Math.round(hx + hl)} ${Math.round(surfaceY - 2)}Z" fill="#051a22"/>`
405 return {
406 width: W,
407 height: H,
408 layers: layers(isAngry),
409 ink: 'pixel',
410 period: state === 'working' || state === 'error' ? 3.2 : 6,
411 under,
412 over: OVER(W, H),
413 defs,
414 css: [],
415 sample(x, y, frame, frames, want) {
416 if (!want.has(BODY)) {
417 return bubbleAt(x, y, W * 0.45, W * 0.8, H + 4, H * 0.9, frame, frames) ?? EMPTY
418 }
419 const phase = (frame / frames) * TAU
420 let arms = armsByFrame.get(frame)
421 if (!arms) {
422 arms = rising.map(([px, lean, reach, curl], i) =>
423 buildArm({ x: W * px, y: H + 14, angle: -Math.PI / 2 + lean, len: H * reach, curl, amp: 0.3, k: 0.7, w0: H * 0.3 }, phase + i * 1.3, 12),
424 )
425 armsByFrame.set(frame, arms)
426 }
427 const armPose: Pose = { ...pose, depth, runes, isAngry, hasArms: true }
428 const k = krakenAt(showHead ? { ...pose, isAngry } : { ...armPose, cx: -1e4, cy: -1e4 }, arms, x, y, frame)
429 return k ?? EMPTY
430 },
431 still(x, y, want) {
432 if (want.has(BODY)) {
433 return EMPTY
434 }
435 // Nothing drawn over the hull.
436 const hy = surfaceY + H * 0.22 * (1 - ((x - hx) / hl) ** 2) * 0.5
437 if (Math.abs(x - hx) < hl && y < hy) {
438 return EMPTY
439 }
440 // The surface overhead: bright, its edge rippling.
441 const edge = surfaceY + Math.sin(x / 26) * 2.5 + Math.sin(x / 9) * 1.2
442 if (y < edge) {
443 return [WATER, 0.75 + 0.25 * noise(x / 8, y / 4)]
444 }
445 return waterAt(W, H, shafts, x, y, 0, state === 'sleeping' ? 0.4 : 1, surfaceY)
446 },
447 }
448}
449
450// ── Live ───────────────────────────────────────────────────────────────
451
452/**
453 * The deep water alone, the same whatever the mood: drawn once as an SVG
454 * whose source never changes, under the live Kraken. Since it never
455 * reloads, its motion is all CSS and runs on unbroken: soft light sways
456 * down from the surface, bubbles rise and wobble, specks of snow drift.
457 */
458export function krakenWaterScene(W: number, H: number): FieldScene {
459 const defs: string[] = []
460 const r = (n: number) => Math.round(n * 10) / 10
461
462 // Open water: nothing blocks the light, so no shafts; it only fades with
463 // depth, a little brighter toward the surface.
464 defs.push(
465 `<radialGradient id="glow" cx=".5" cy="0" r=".9"><stop offset="0" stop-color="#bff4ff" stop-opacity=".18"/><stop offset="1" stop-color="#bff4ff" stop-opacity="0"/></radialGradient>`,
466 )
467 const under = backdrop(W, H, '#1d6170', '#0a2e3b', defs) + `<rect width="${W}" height="${r(H * 0.55)}" fill="url(#glow)"/>`
468
469 // Bubbles: some in streams from below, some loose, each rising the whole height.
470 const vents = [W * 0.18, W * 0.62, W * 0.84]
471 const bubbles = Array.from({ length: 30 }, (_, i) => {
472 const inStream = i < 18
473 const x = inStream ? vents[i % vents.length]! + (hash(i, 51) - 0.5) * 14 : hash(i, 52) * W
474 const size = 1.5 + hash(i, 53) * 4
475 const rise = r(7 + hash(i, 54) * 9)
476 const wobble = r(1.2 + hash(i, 55) * 1.6)
477 return (
478 `<g style="animation:rise ${rise}s ${r(-hash(i, 56) * rise)}s linear infinite">` +
479 `<circle cx="${r(x)}" cy="${r(H + 8)}" r="${r(size)}" style="animation:wobble ${wobble}s ${r(-hash(i, 57) * wobble)}s ease-in-out infinite alternate"/></g>`
480 )
481 }).join('')
482
483 // Marine snow at three depths: far specks tiny, dim and slow, near ones
484 // larger, brighter and quicker. The parallax gives the water its depth.
485 const depths = [
486 { count: 55, size: 0.5, spread: 0.5, fall: [34, 52], opacity: 0.35, seed: 61 },
487 { count: 30, size: 0.9, spread: 0.7, fall: [20, 32], opacity: 0.55, seed: 81 },
488 { count: 12, size: 1.6, spread: 1, fall: [11, 18], opacity: 0.75, seed: 91 },
489 ] as const
490 const snow = depths
491 .map(d => {
492 const specks = Array.from({ length: d.count }, (_, i) => {
493 const fall = r(d.fall[0] + hash(i, d.seed) * (d.fall[1] - d.fall[0]))
494 return `<circle cx="${r(hash(i, d.seed + 1) * W)}" cy="${r(hash(i, d.seed + 2) * H * 0.9)}" r="${r(d.size + hash(i, d.seed + 3) * d.spread)}" style="animation:snow ${fall}s ${r(-hash(i, d.seed + 4) * fall)}s linear infinite"/>`
495 }).join('')
496 return `<g fill-opacity="${d.opacity}">${specks}</g>`
497 })
498 .join('')
499
500 const css =
501 `@keyframes rise{from{transform:translateY(0)}to{transform:translateY(${r(-H - 20)}px)}}` +
502 '@keyframes wobble{from{transform:translateX(-5px)}to{transform:translateX(5px)}}' +
503 `@keyframes snow{0%{transform:translate(0,0);opacity:0}12%{opacity:.7}88%{opacity:.5}100%{transform:translate(14px,${r(H * 0.4)}px);opacity:0}}` +
504
505 '@media (prefers-reduced-motion:reduce){g,circle,path{animation:none!important}}'
506
507 return {
508 width: W,
509 height: H,
510 layers: layers(false),
511 ink: 'particle',
512 period: 6,
513 under,
514 over:
515 `<g fill="#d9f2ff">${snow}</g>` +
516 `<g fill="none" stroke="#c9f3ff" stroke-opacity=".65" stroke-width="1">${bubbles}</g>` +
517 OVER(W, H),
518 defs,
519 css: [css],
520 sample() {
521 return EMPTY
522 },
523 }
524}
525hooks/stage.ts 199 lines1// The stage: the Vessel set in a scene the size of the pane body, so the
2// pane reads as a place rather than a small image in a large box. A glow
3// and a wash in the state's colour, a soft floor shadow, motes rising at
4// the state's tempo. Every layer fades to transparent before the edge, so
5// an estimate a few pixels off never shows a seam, in either theme.
6
7import type { VesselState } from './eidola-types'
8
9// Measured on Claude Code Desktop with /eidola measure (2026-10-16): a
10// 52 × 76 cell dock body drew 412 × 1443 px, a 95-column band 756 px.
11export const PX_PER_COLUMN = 8
12export const PX_PER_ROW = 19
13
14/** The dark behind a band view drawn across the middle of the pane. */
15export const BACKDROP = '#07090c'
16
17/** The veil drawn over the resting scene while nothing is awake. */
18export const VEIL = '#000'
19
20/** The Svg element's limit on `source`. */
21export const SVG_LIMIT = 131072
22
23/**
24 * The band's square stage, in CSS pixels. Desktop draws the band at least
25 * about 170px tall whatever its tree, so this fills it rather than float.
26 */
27export const BAND_STAGE_PX = 140
28
29/** Inline the pane fits its tree, so the stage asks for no more than this. */
30const INLINE_ROWS = 10
31
32export type StageSize = { width: number; height: number }
33
34export const ACCENTS: Record<VesselState, string> = {
35 idle: '#8fa3bf',
36 thinking: '#8c7bff',
37 working: '#ffb547',
38 attention: '#ffd84a',
39 success: '#5fd38d',
40 responding: '#4fd1c5',
41 error: '#ff5c5c',
42 waiting: '#b39ddb',
43 sleeping: '#4a5a80',
44}
45
46/** Seconds per glow breath. */
47const BREATH: Record<VesselState, number> = {
48 idle: 6,
49 thinking: 3,
50 working: 1.6,
51 attention: 1.2,
52 success: 2.4,
53 responding: 2.4,
54 error: 1,
55 waiting: 4,
56 sleeping: 9,
57}
58
59/** Seconds for a mote to rise; busier states rise faster. */
60const RISE: Record<VesselState, number> = {
61 idle: 14,
62 thinking: 9,
63 working: 5,
64 attention: 7,
65 success: 6,
66 responding: 8,
67 error: 10,
68 waiting: 12,
69 sleeping: 22,
70}
71
72const MOTES = 18
73
74/** The pane body in CSS pixels, a cell short each way so it never overflows. */
75export function stageSize(columns: number, rows: number, placement: 'dock' | 'inline'): StageSize {
76 const usable = placement === 'inline' ? Math.min(rows, INLINE_ROWS) : rows
77 return {
78 width: Math.max(96, (columns - 1) * PX_PER_COLUMN),
79 height: Math.max(96, (usable - 1) * PX_PER_ROW),
80 }
81}
82
83/** A scene band's height: it fills the band's own minimum height. */
84export const BAND_SCENE_PX = 140
85
86/**
87 * The whole pane body in CSS pixels, for a scene that fills it edge to edge.
88 * The measured dock body was 4px under columns × 8 and 2px under rows × 19.
89 */
90export function sceneSize(columns: number, rows: number, placement: 'dock' | 'inline'): StageSize {
91 const usable = placement === 'inline' ? Math.min(rows, INLINE_ROWS) : rows
92 return {
93 width: Math.max(96, columns * PX_PER_COLUMN - 4),
94 height: Math.max(96, usable * PX_PER_ROW - 2),
95 }
96}
97
98type Box = { x: number; y: number; w: number; h: number }
99
100function viewBoxOf(svg: string): Box | undefined {
101 const m = /viewBox="([-\d.]+)[ ,]+([-\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)"/.exec(svg)
102 if (!m) {
103 return undefined
104 }
105 const [x, y, w, h] = [m[1], m[2], m[3], m[4]].map(Number) as [number, number, number, number]
106 return w > 0 && h > 0 ? { x, y, w, h } : undefined
107}
108
109/** A fixed sequence in [0, 1): the motes sit in the same places every draw. */
110function seeded(seed: number): () => number {
111 let a = seed >>> 0
112 return () => {
113 a = (a + 0x6d2b79f5) >>> 0
114 let t = a
115 t = Math.imul(t ^ (t >>> 15), t | 1)
116 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
117 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
118 }
119}
120
121const r1 = (n: number) => Math.round(n * 10) / 10
122
123/**
124 * The Vessel's SVG set on a stage of `size`, or undefined when it can't be:
125 * no viewBox to place it by, or the result past the Svg limit. The caller
126 * then draws the Vessel alone.
127 */
128export function stageSvg(vessel: string, state: VesselState, size: StageSize): string | undefined {
129 const box = viewBoxOf(vessel)
130 if (!box) {
131 return undefined
132 }
133 const { width: W, height: H } = size
134 const accent = ACCENTS[state]
135
136 // A tall dock seats the Vessel above centre, leaving room for its shadow.
137 const isTall = H > W * 1.2
138 const scale = Math.min((W * 0.72) / box.w, (H * (isTall ? 0.42 : 0.78)) / box.h)
139 const vw = box.w * scale
140 const vh = box.h * scale
141 const cx = W / 2
142 const cy = isTall ? H * 0.42 : H / 2
143 const vx = cx - vw / 2
144 const vy = cy - vh / 2
145 const reach = Math.max(vw, vh)
146 // The wash is an ellipse that ends at the nearest edge on each axis, and
147 // the glow a circle inside it, so neither tints an edge into a seam.
148 const rx = Math.max(1, Math.min(cx, W - cx))
149 const ry = Math.max(1, Math.min(cy, H - cy))
150 const glowR = Math.min(reach * 0.95, rx, ry)
151
152 const random = seeded(7)
153 const rise = Math.round(Math.min(H * 0.55, reach * 2.2))
154 const motes: string[] = []
155 for (let i = 0; i < MOTES; i += 1) {
156 const x = r1(cx + (random() - 0.5) * Math.min(W * 0.85, reach * 2))
157 const y = r1(cy + vh * 0.2 + random() * vh * 0.5)
158 const r = r1(0.8 + random() * 1.6)
159 const dur = r1(RISE[state] * (0.7 + random() * 0.6))
160 const delay = r1(-random() * dur)
161 motes.push(`<circle class="eds-mote" cx="${x}" cy="${y}" r="${r}" style="animation-duration:${dur}s;animation-delay:${delay}s"/>`)
162 }
163
164 // The Vessel nested at its place: its own width/height dropped, the slot's set.
165 const nested = vessel.replace(/<svg\b([^>]*)>/, (_match, attrs: string) => {
166 const kept = attrs.replace(/\s(width|height|x|y|preserveAspectRatio)="[^"]*"/g, '')
167 return `<svg x="${r1(vx)}" y="${r1(vy)}" width="${r1(vw)}" height="${r1(vh)}" preserveAspectRatio="xMidYMid meet"${kept}>`
168 })
169
170 const css =
171 `.eds-glow{animation:eds-breath ${BREATH[state]}s ease-in-out infinite alternate}` +
172 '@keyframes eds-breath{from{opacity:.6}to{opacity:1}}' +
173 `.eds-mote{fill:${accent};opacity:0;animation-name:eds-rise;animation-timing-function:linear;animation-iteration-count:infinite}` +
174 `@keyframes eds-rise{0%{transform:translateY(0);opacity:0}15%{opacity:.75}80%{opacity:.4}100%{transform:translateY(-${rise}px);opacity:0}}` +
175 '@media (prefers-reduced-motion:reduce){.eds-glow,.eds-mote{animation:none}.eds-mote{opacity:.35}}'
176
177 const out =
178 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
179 `<style>${css}</style>` +
180 '<defs>' +
181 `<radialGradient id="eds-wash" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" ` +
182 `gradientTransform="translate(${r1(cx)} ${r1(cy)}) scale(${r1(rx)} ${r1(ry)})">` +
183 `<stop offset="0" stop-color="${accent}" stop-opacity=".14"/><stop offset=".45" stop-color="${accent}" stop-opacity=".07"/>` +
184 `<stop offset=".75" stop-color="${accent}" stop-opacity=".02"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
185 `<radialGradient id="eds-glow" gradientUnits="userSpaceOnUse" cx="${r1(cx)}" cy="${r1(cy)}" r="${r1(glowR)}">` +
186 `<stop offset="0" stop-color="${accent}" stop-opacity=".42"/><stop offset=".35" stop-color="${accent}" stop-opacity=".2"/>` +
187 `<stop offset=".65" stop-color="${accent}" stop-opacity=".06"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
188 '<radialGradient id="eds-floor"><stop offset="0" stop-color="#000" stop-opacity=".45"/><stop offset="1" stop-color="#000" stop-opacity="0"/></radialGradient>' +
189 '</defs>' +
190 `<rect width="${W}" height="${H}" fill="url(#eds-wash)"/>` +
191 `<rect class="eds-glow" width="${W}" height="${H}" fill="url(#eds-glow)"/>` +
192 `<ellipse cx="${r1(cx)}" cy="${r1(vy + vh * 1.04)}" rx="${r1(vw * 0.45)}" ry="${r1(Math.max(3, vh * 0.05))}" fill="url(#eds-floor)"/>` +
193 `<g>${motes.join('')}</g>` +
194 nested +
195 '</svg>'
196
197 return out.length <= SVG_LIMIT ? out : undefined
198}
199hooks/kraken-live.tsx 839 lines1// The live Kraken: a Client module that simulates the creature and draws it
2// every tick, over water drawn once beneath it. The mood comes in props; the
3// instance and its timer outlive every redraw, so a new mood is a new place
4// to swim to, never a cut.
5//
6// It swims as an octopus does: jet strokes, mantle first, arms drawn into a
7// trailing bundle, then a glide as they open again. It lives in depth: far
8// back in the murk when idle, up at the glass when it needs you. Between
9// moves it is never still: it breathes, turns, looks about and blinks.
10//
11// Pixels are rows of Boxes, two to a cell row (8 × 9.5 px on desktop): a
12// coloured Box per run of one shade, a bare Box for a gap.
13
14import type { ClientModule } from 'claude-code'
15
16import { noise } from './field'
17
18import type { Mood } from './eidola-types'
19import { BODY, CRIMSON, DETAIL, GLOW, GOLD, IRIS, layers } from './kraken'
20import { buildModel, eyeAt, len3, modelBounds, norm3, sampleModel } from './kraken3d'
21import type { ArmPose, Body, Camera, V3 } from './kraken3d'
22import { PX_H, PX_W, drawPixels, mixRamp, pick, short } from './live-pixels'
23import type { Steady } from './live-pixels'
24
25/** The grid comes in props, never from the region: a drawing sized by its region resizes it. */
26type Props = { mood: Mood; cols: number; rows: number; view?: View; ink?: number }
27
28/** The pane, tall beside the transcript; or the band, a wide strip above the prompt. */
29type View = 'pane' | 'band'
30
31/** How a mood behaves: where it lives, how it moves, how it glows. */
32type Plan = {
33 /** Home on the glass (fractions) and depth behind it (pixels). */
34 home: V3
35 /** How far it roams from home, the same way. */
36 spread: V3
37 light: number
38 rune: number
39 /** Seconds per rune pulse. */
40 runePulse: number
41 sway: number
42 /** How fast its arms move. */
43 pace: number
44 lid: number
45 /** Ticks between picking a new spot. */
46 wander: [number, number]
47 /** Seconds a jet stroke takes. */
48 stroke: number
49 /** How far it turns about, and which way it favours. */
50 turn: number
51 turnBias: number
52 /** Eyes dart, rather than drift. */
53 darts: boolean
54 /** Arms flare wide now and then. */
55 flexes: boolean
56 /** How drawn-in its arms rest. */
57 rest: number
58 /**
59 * What it does now and then, by weight: `peer` brings one eye to the glass
60 * until it fills the pane; `pass` sweeps it past right against the glass;
61 * `cruise` swims it out through one edge and back in through another.
62 */
63 acts?: Array<{ kind: Act; weight: number }>
64 /** Ticks between acts. */
65 every?: [number, number]
66 /** Ticks a peer holds. */
67 hold?: [number, number]
68 /** It leaves and returns through the sides only. */
69 sideways?: boolean
70}
71
72type Act = 'peer' | 'pass' | 'cruise' | 'ink'
73
74/** A cloud of ink on the glass: where, how it drifts and spreads, how dense it still is. */
75type Puff = { x: number; y: number; vx: number; vy: number; r: number; grow: number; d: number; seed: number }
76/** Where the Kraken has just swum through ink, clearing it: a circle on the glass, ageing. */
77type Wake = { x: number; y: number; r: number; age: number }
78
79const PLANS: Record<Mood, Plan> = {
80 // Back in the murk: drifting, half asleep, turning lazily so its arms wheel about.
81 idle: { home: [0.5, 0.58, 120], spread: [0.55, 0.3, 80], light: 0.6, rune: 0.25, runePulse: 4, sway: 0.28, pace: 0.35, lid: 0.25, wander: [90, 200], stroke: 2.6, turn: 1.3, turnBias: 0, darts: false, flexes: false, rest: 0.1, acts: [{ kind: 'cruise', weight: 3 }, { kind: 'pass', weight: 1 }, { kind: 'ink', weight: 0.25 }], every: [250, 500] },
82 // Up in the light, restless: shifting spot, eyes darting, arms busy, runes racing.
83 working: { home: [0.5, 0.45, -380], spread: [0.55, 0.3, 60], light: 0.6, rune: 1, runePulse: 1.4, sway: 0.5, pace: 0.9, lid: 0, wander: [50, 110], stroke: 1.6, turn: 0.7, turnBias: 0, darts: true, flexes: true, rest: 0, acts: [{ kind: 'cruise', weight: 3 }, { kind: 'pass', weight: 3 }, { kind: 'ink', weight: 0.4 }], every: [80, 160], sideways: true },
84 // At the glass, facing the conversation, leaning in now and then.
85 attention: { home: [0.5, 0.4, -480], spread: [0.08, 0.06, 40], light: 0.6, rune: 0.9, runePulse: 2, sway: 0.25, pace: 0.5, lid: 0, wander: [70, 140], stroke: 1.8, turn: 0.15, turnBias: -0.35, darts: false, flexes: false, rest: 0, acts: [{ kind: 'peer', weight: 1 }], every: [15, 45], hold: [50, 80] },
86}
87
88/**
89 * One arm's own life: its clock, the intent it is easing toward, and when it
90 * picks its next. Eight of these, each on its own schedule.
91 */
92type ArmLife = ArmPose & {
93 /** Its own pace, against the body's. */
94 speed: number
95 /** How quickly it follows the body's gather: the stroke ripples through the arms. */
96 lag: number
97 /** How quickly it moves toward its intent. */
98 ease: number
99 intent: { curl: number; sweep: number; reach: number; splay: number; wave: number; toward: number; tuck: number }
100 next: number
101 /** How far this arm alone is drawn in under the head, 0 to 1, whatever the others do. */
102 tuck: number
103}
104
105/** -1 or 1, evenly. */
106const either = (rnd: () => number) => (rnd() < 0.5 ? -1 : 1)
107
108/**
109 * What an arm may set out to do, and how. Curls go both ways: out and up,
110 * or inward, back toward the body; and to either side. Each draws from
111 * `rnd`, the Kraken's own fixed sequence, so a run from one seed repeats.
112 */
113const INTENTS = {
114 drift: (rnd: () => number) => ({ curl: -0.6 + rnd() * 2, sweep: (rnd() - 0.5) * 1.2, reach: 0.9 + rnd() * 0.15, splay: (rnd() - 0.5) * 0.5, wave: 0.6 + rnd() * 0.7, toward: 0, tuck: 0 }),
115 coil: (rnd: () => number) => ({ curl: either(rnd) * (2 + rnd() * 1), sweep: (rnd() - 0.5) * 1.6, reach: 0.75, splay: -0.1, wave: 0.3, toward: 0, tuck: 0 }),
116 hook: (rnd: () => number) => ({ curl: -(1.8 + rnd() * 1), sweep: (rnd() - 0.5) * 0.8, reach: 0.95, splay: 0.15, wave: 0.35, toward: 0, tuck: 0 }),
117 reach: (rnd: () => number) => ({ curl: (rnd() - 0.5) * 0.6, sweep: (rnd() - 0.5) * 0.6, reach: 1.25 + rnd() * 0.15, splay: -0.35, wave: 0.4, toward: rnd() < 0.5 ? 0.55 : 0, tuck: 0 }),
118 creep: (rnd: () => number) => ({ curl: (rnd() - 0.5) * 1.2, sweep: (rnd() - 0.5) * 1, reach: 1.1, splay: -0.2, wave: 0.25, toward: 0.6 + rnd() * 0.2, tuck: 0 }),
119 // Drawn in under the head, hanging, the tip curling a little as it trails.
120 tuck: (rnd: () => number) => ({ curl: -0.5 + rnd() * 0.9, sweep: (rnd() - 0.5) * 0.6, reach: 0.85 + rnd() * 0.1, splay: 0.25, wave: 0.5, toward: 0, tuck: 0.7 + rnd() * 0.25 }),
121} as const
122
123type Intent = keyof typeof INTENTS | 'twitch'
124
125/** Each mood's mix of what its arms get up to. */
126const ARM_MIX: Record<Mood, Array<[Intent, number]>> = {
127 idle: [
128 ['tuck', 5],
129 ['drift', 7],
130 ['coil', 1.5],
131 ['hook', 1],
132 ['reach', 1],
133 ['twitch', 0.5],
134 ],
135 working: [
136 ['tuck', 2.5],
137 ['drift', 3],
138 ['coil', 2],
139 ['hook', 1.5],
140 ['reach', 2],
141 ['twitch', 2],
142 ],
143 attention: [
144 ['tuck', 3],
145 ['creep', 4],
146 ['drift', 2],
147 ['hook', 1],
148 ['coil', 1],
149 ['twitch', 1],
150 ],
151}
152
153type Live = {
154 mood: Mood
155 view: View
156 arms: ArmLife[]
157 /** The mood the current target was picked for. */
158 planned: Mood | ''
159 tick: number
160 /** Animation time: runs faster while working and while stroking. */
161 t: number
162 seed: number
163 pos: V3
164 vel: V3
165 axis: V3
166 target: V3
167 nextPick: number
168 /** Progress through a jet stroke, 0 to 1; -1 between strokes. */
169 stroke: number
170 gather: number
171 squash: number
172 yaw: number
173 yawTarget: number
174 look: number
175 lookTarget: number
176 nextLook: number
177 blinkUntil: number
178 nextBlink: number
179 flexUntil: number
180 nextFlex: number
181 light: number
182 rune: number
183 sway: number
184 pace: number
185 lid: number
186 /** How fine it drew last, so it holds a resolution rather than flickering between two. */
187 steady: Steady
188 /** How far its eyes and runes have gone to crimson (working), and to gold (attention): 0 to 1 each. */
189 flush: number
190 glow: number
191 /** What it is doing: roaming its home, or a close encounter in stages. */
192 act: 'roam' | Act
193 actStage: number
194 actUntil: number
195 nextAct: number
196 /**
197 * A pass or a cruise: the waypoints still ahead. A `jump` waypoint is
198 * reached at once: it is only ever taken while the Kraken is out of view.
199 */
200 route: Array<{ p: V3; jump?: boolean }>
201 /** The ink in the water, and the wake that clears it. */
202 puffs: Puff[]
203 wake: Wake[]
204 /** The ink count last seen in props; a rise means something went wrong. */
205 inkSeen: number
206 inkPending: boolean
207 lastInk: number
208 /** Until when it is squirting, and until when the blackout holds before it returns. */
209 squirtUntil: number
210 /** For a peer: which eye it brings to the glass. */
211 peerSide: number
212}
213type State = { live: Live }
214
215const TICK_MS = 100
216const DT = TICK_MS / 1000
217const RAW = layers(false)
218const STYLES = RAW.map(style => ({ ...style, shades: style.shades.map(short) }))
219
220/** A layer's shade for a brightness, from at most `levels` of its shades. */
221function shade(layer: number, v: number, levels: number): string {
222 return pick(STYLES[layer]!.shades, v, levels)
223}
224
225/** A fixed sequence in [0, 1), carried in the state. */
226function random(live: Live): number {
227 live.seed = (live.seed + 0x6d2b79f5) >>> 0
228 let t = live.seed
229 t = Math.imul(t ^ (t >>> 15), t | 1)
230 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
231 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
232}
233
234const between = (live: Live, [a, b]: [number, number]) => Math.round(a + random(live) * (b - a))
235
236/**
237 * The band is a strip: the same creature, framed wide, and far too big for
238 * it: a slice of mantle and arms slides past; it roams side to side and
239 * leaves through the sides; its eye at the glass fills the strip's height.
240 */
241const BAND_PLACES: Record<Mood, Pick<Plan, 'home' | 'spread'>> = {
242 idle: { home: [0.5, 0.55, 200], spread: [0.55, 0.25, 60] },
243 working: { home: [0.5, 0.5, -150], spread: [0.55, 0.3, 50] },
244 attention: { home: [0.5, 0.5, -250], spread: [0.06, 0.05, 30] },
245}
246
247function planOf(view: View, mood: Mood): Plan {
248 return view === 'band' ? { ...PLANS[mood], ...BAND_PLACES[mood] } : PLANS[mood]
249}
250
251function camera(W: number, H: number, view: View): Camera {
252 return view === 'band' ? { cx: W / 2, cy: H / 2, f: W } : { cx: W / 2, cy: H * 0.45, f: H * 1.3 }
253}
254
255/** Mantle width for a view: a share of the pane's width, or of the band's height. */
256function sizeOf(W: number, H: number, view: View): number {
257 // In the band it is the creature that does not fit: its mantle alone the strip's height.
258 return view === 'band' ? H : Math.min(W * 0.42, H * 0.25)
259}
260
261/** A point on the glass at a depth, as a world point. */
262function onGlass(cam: Camera, sx: number, sy: number, z: number): V3 {
263 const k = cam.f / (cam.f + z)
264 return [cam.cx + (sx - cam.cx) / k, cam.cy + (sy - cam.cy) / k, z]
265}
266
267/** Somewhere near home, partly out of frame as often as not: the frame is a window, not a wall. */
268function pickSpot(live: Live, plan: Plan, cam: Camera, W: number, H: number): V3 {
269 const jitter = () => random(live) * 2 - 1
270 // Mostly to the other side of where it is now: it crosses back and forth.
271 const k = cam.f / (cam.f + Math.max(-cam.f * 0.95, live.pos[2]))
272 const nowX = (cam.cx + (live.pos[0] - cam.cx) * k) / W
273 const across = (nowX < plan.home[0] ? 1 : -1) * (random(live) < 0.7 ? 1 : -1)
274 const sx = Math.min(1.15, Math.max(-0.15, plan.home[0] + across * (0.35 + random(live) * 0.65) * plan.spread[0])) * W
275 const sy = Math.min(1.1, Math.max(-0.1, plan.home[1] + jitter() * plan.spread[1])) * H
276 const z = Math.max(-cam.f * 0.6, plan.home[2] + jitter() * plan.spread[2])
277 return onGlass(cam, sx, sy, z)
278}
279
280/**
281 * A point just out of view past an edge (dx, dy each -1, 0 or 1: a side or
282 * a corner), at a depth: far enough out that the whole Kraken is hidden.
283 */
284function offscreen(live: Live, cam: Camera, W: number, H: number, s: number, dx: number, dy: number, z: number): V3 {
285 const k = cam.f / (cam.f + z)
286 const margin = 3 * s * k + 10
287 const sx = dx < 0 ? -margin : dx > 0 ? W + margin : (0.2 + random(live) * 0.6) * W
288 const sy = dy < 0 ? -margin : dy > 0 ? H + margin : (0.2 + random(live) * 0.6) * H
289 return onGlass(cam, sx, sy, z)
290}
291
292const EDGES: Array<[number, number]> = [
293 [-1, 0],
294 [1, 0],
295 [0, -1],
296 [0, 1],
297 [-1, -1],
298 [1, -1],
299 [-1, 1],
300 [1, 1],
301]
302
303function pickAct(live: Live, plan: Plan): Act | undefined {
304 const acts = plan.acts ?? []
305 let roll = random(live) * acts.reduce((sum, a) => sum + a.weight, 0)
306 for (const a of acts) {
307 roll -= a.weight
308 if (roll <= 0) {
309 return a.kind
310 }
311 }
312 return acts[0]?.kind
313}
314
315/** Whether nothing of the Kraken is on the glass: mantle and every arm. */
316function isOutOfView(live: Live, cam: Camera, W: number, H: number, s: number): boolean {
317 const [x0, y0, x1, y1] = modelBounds(
318 buildModel(
319 {
320 pos: live.pos,
321 axis: live.axis,
322 facing: [Math.sin(live.yaw), 0, -Math.cos(live.yaw)],
323 s,
324 squash: live.squash,
325 gather: live.gather,
326 phase: live.t * 1.3,
327 sway: live.sway,
328 look: 0,
329 lid: 0,
330 rune: 0,
331 light: 1,
332 arms: live.arms,
333 },
334 cam,
335 ),
336 )
337 return x1 < 0 || x0 > W || y1 < 0 || y0 > H
338}
339
340/** A world point's place on the glass, and its scale there. */
341function project3(cam: Camera, p: V3): { x: number; y: number; k: number } {
342 const k = cam.f / (cam.f + Math.max(-cam.f * 0.95, p[2]))
343 return { x: cam.cx + (p[0] - cam.cx) * k, y: cam.cy + (p[1] - cam.cy) * k, k }
344}
345
346/** One tick of the Kraken's life. */
347function step(live: Live, W: number, H: number, s: number): void {
348 const plan = planOf(live.view, live.mood)
349 const cam = camera(W, H, live.view)
350 const ease = (from: number, to: number, rate: number) => from + (to - from) * rate
351 live.tick += 1
352
353 // The mood's glow and pace come on gradually.
354 live.light = ease(live.light, plan.light, 0.05)
355 live.rune = ease(live.rune, plan.rune, 0.06)
356 live.sway = ease(live.sway, plan.sway, 0.05)
357 live.pace = ease(live.pace, plan.pace, 0.05)
358 live.lid = ease(live.lid, plan.lid, 0.08)
359 live.flush = ease(live.flush, live.mood === 'working' ? 1 : 0, 0.05)
360 live.glow = ease(live.glow, live.mood === 'attention' ? 1 : 0, 0.06)
361
362 // A new mood ends any encounter and sets when the next one comes.
363 const nextActAt = () => live.tick + (plan.every ? between(live, plan.every) : Infinity)
364 if (live.planned !== live.mood) {
365 live.act = 'roam'
366 live.route = []
367 live.nextAct = nextActAt()
368 }
369 const near = (d: number) => len3([live.target[0] - live.pos[0], live.target[1] - live.pos[1], live.target[2] - live.pos[2]]) < d
370 const endAct = () => {
371 live.act = 'roam'
372 live.route = []
373 live.nextPick = live.tick
374 live.nextAct = nextActAt()
375 }
376 // Inking: it squirts and jets away, out past an edge and deeper; holds there
377 // while the ink spreads to black; then comes back close to the glass from
378 // another side and swims straight through it, carving it away.
379 const startInk = () => {
380 live.act = 'ink'
381 live.actStage = 0
382 live.lastInk = live.tick
383 live.squirtUntil = live.tick + 8
384 live.actUntil = live.tick + 8 + between(live, [20, 32])
385 const at = project3(cam, live.pos)
386 const dx = at.x < W / 2 ? 1 : -1
387 const dy = live.view === 'band' ? 0 : random(live) < 0.5 ? -1 : 1
388 const close = live.view === 'band' ? -cam.f * 0.25 : -cam.f * 0.35
389 live.route = [
390 { p: offscreen(live, cam, W, H, s, dx, dy, live.pos[2] + 400) },
391 { p: offscreen(live, cam, W, H, s, -dx, 0, close), jump: true },
392 { p: offscreen(live, cam, W, H, s, dx, 0, close) },
393 ]
394 live.target = live.route.shift()!.p
395 live.stroke = 0
396 }
397 if (live.inkPending && live.act !== 'ink' && live.tick - live.lastInk > 150) {
398 live.inkPending = false
399 startInk()
400 }
401 live.inkPending = false
402 if (live.act === 'roam' && live.tick >= live.nextAct && live.planned === live.mood) {
403 const kind = pickAct(live, plan)
404 if (kind === 'ink') {
405 startInk()
406 } else if (kind) {
407 live.act = kind
408 live.actStage = 0
409 if (kind === 'peer') {
410 // One eye to the centre of the glass, so close it fills the pane.
411 live.peerSide = random(live) < 0.5 ? -1 : 1
412 live.yawTarget = 0
413 } else if (kind === 'pass') {
414 // In close from one side, across, and out the other.
415 const from = random(live) < 0.5 ? -1 : 1
416 const y = (0.25 + random(live) * 0.5) * H
417 const z = -cam.f * 0.62
418 live.route = [
419 { p: onGlass(cam, from < 0 ? -0.35 * W : 1.35 * W, y, z) },
420 { p: onGlass(cam, from < 0 ? 1.4 * W : -0.4 * W, y + (random(live) - 0.5) * H * 0.4, z) },
421 ]
422 } else {
423 // Out through one edge, and back in through another, at another depth.
424 // The band is a strip, and at work it crosses side to side: through the sides.
425 const edges = live.view === 'band' || plan.sideways ? EDGES.filter(([dx]) => dx !== 0) : EDGES
426 const out = edges[Math.floor(random(live) * edges.length)]!
427 let back = edges[Math.floor(random(live) * edges.length)]!
428 if (back[0] === out[0] && back[1] === out[1]) {
429 back = [-out[0], -out[1]]
430 }
431 const depth = () => plan.home[2] + (random(live) * 2 - 1) * plan.spread[2]
432 live.route = [
433 { p: offscreen(live, cam, W, H, s, out[0], out[1], depth()) },
434 { p: offscreen(live, cam, W, H, s, back[0], back[1], depth()), jump: true },
435 { p: pickSpot(live, plan, cam, W, H) },
436 ]
437 }
438 const first = live.route.shift()
439 if (first) {
440 live.target = first.p
441 }
442 }
443 }
444 if (live.act === 'peer') {
445 // Steer by where the eye is now, not where it would be once upright:
446 // the body is still turning as it arrives.
447 const facing: V3 = [Math.sin(live.yaw), 0, -Math.cos(live.yaw)]
448 const eye = eyeAt(live.pos, live.axis, facing, s, live.peerSide)
449 const k = (Math.min(W, H) * 0.42) / (0.1 * s * 0.84)
450 const want: V3 = [cam.cx, cam.cy, cam.f / k - cam.f]
451 live.target = [live.pos[0] + want[0] - eye[0], live.pos[1] + want[1] - eye[1], live.pos[2] + want[2] - eye[2]]
452 if (live.actStage === 0 && near(s * 0.12) && len3(live.vel) < s * 0.3) {
453 live.actStage = 1
454 live.actUntil = live.tick + between(live, plan.hold ?? [50, 80])
455 } else if (live.actStage === 1 && live.tick >= live.actUntil) {
456 endAct()
457 }
458 } else if (live.act === 'pass' || live.act === 'cruise' || live.act === 'ink') {
459 // Heading out past an edge, it is gone once nothing of it is in view:
460 // mantle and every arm. Only then may it come round unseen; after inking,
461 // only once the blackout has held.
462 const leaving = live.route[0]?.jump === true && (live.act !== 'ink' || live.tick >= live.actUntil)
463 if (leaving && near(s * 0.6) && !isOutOfView(live, cam, W, H, s)) {
464 // There, but an arm still shows: the way out goes a little further.
465 const out = norm3([live.target[0] - cam.cx, live.target[1] - cam.cy, 0])
466 live.target = [live.target[0] + out[0] * s * 1.5, live.target[1] + out[1] * s * 1.5, live.target[2]]
467 }
468 if (leaving ? isOutOfView(live, cam, W, H, s) : near(s * 0.8)) {
469 let next = live.route.shift()
470 if (next?.jump) {
471 // Out of view: come round unseen to where it re-enters, heading in.
472 live.pos = next.p
473 live.vel = [0, 0, 0]
474 next = live.route.shift()
475 if (next) {
476 live.axis = norm3([next.p[0] - live.pos[0], next.p[1] - live.pos[1], next.p[2] - live.pos[2]])
477 }
478 }
479 if (next) {
480 live.target = next.p
481 live.actStage += 1
482 } else {
483 endAct()
484 }
485 }
486 }
487
488 // Somewhere new to be, while roaming: on a new mood at once, else now and then.
489 if (live.act === 'roam' && (live.planned !== live.mood || live.tick >= live.nextPick)) {
490 live.target = pickSpot(live, plan, cam, W, H)
491 live.nextPick = live.tick + between(live, plan.wander)
492 live.yawTarget = plan.turnBias + (random(live) * 2 - 1) * plan.turn
493 }
494 live.planned = live.mood
495
496 // Swim there in jet strokes: squeeze and draw the arms in, then glide.
497 const toward: V3 = [live.target[0] - live.pos[0], live.target[1] - live.pos[1], live.target[2] - live.pos[2]]
498 const distance = len3(toward)
499 if (live.stroke < 0 && distance > s * 0.5) {
500 live.stroke = 0
501 }
502 if (live.stroke >= 0) {
503 // A jet stroke in three: the arms flare open and the mantle fills; then
504 // they snap shut into a trailing bundle as the mantle squeezes, which is
505 // the thrust; then it glides, the bundle loosening slowly.
506 live.stroke += DT / plan.stroke
507 if (live.stroke < 0.15) {
508 live.gather = ease(live.gather, -0.5, 0.4)
509 live.squash = ease(live.squash, -0.06, 0.4)
510 } else if (live.stroke < 0.4) {
511 // Into the glass at full speed; the sweep across it slower and statelier.
512 const cap = live.act === 'pass' && live.actStage >= 1 ? s * 1.6 : s * 2.4
513 const push = Math.min(distance * 0.75, cap) / (0.83 * Math.max(1, (0.25 * plan.stroke) / DT))
514 const dir = norm3(toward)
515 live.vel = [live.vel[0] + dir[0] * push, live.vel[1] + dir[1] * push, live.vel[2] + dir[2] * push]
516 live.gather = ease(live.gather, 1.05, 0.5)
517 live.squash = ease(live.squash, 0.16, 0.45)
518 } else {
519 live.gather = ease(live.gather, 0.5, 0.06)
520 live.squash = ease(live.squash, 0, 0.15)
521 }
522 if (live.stroke >= 1) {
523 live.stroke = -1
524 }
525 } else {
526 // Between strokes: breathing, arms at rest, or flared wide in a flex.
527 // Peering at the glass, it keeps its arms down and back, clear of its face.
528 const flexing = live.tick < live.flexUntil
529 const rest = live.act === 'peer' ? 0.6 : flexing ? -0.45 : plan.rest
530 live.gather = ease(live.gather, rest, flexing ? 0.25 : 0.08)
531 live.squash = ease(live.squash, Math.sin(live.t * 0.9) * 0.04, 0.2)
532 }
533 // A long, slow coast after each stroke.
534 live.vel = [live.vel[0] * 0.93, live.vel[1] * 0.93, live.vel[2] * 0.93]
535 live.pos = [live.pos[0] + live.vel[0] * DT, live.pos[1] + live.vel[1] * DT, live.pos[2] + live.vel[2] * DT]
536 // Close to its mark, it settles the last of the way with its fins rather than a stroke:
537 // up at the glass a small miss would be magnified across the pane.
538 if (live.stroke < 0 && distance < s * 0.5) {
539 live.pos = [
540 live.pos[0] + (live.target[0] - live.pos[0]) * 0.04,
541 live.pos[1] + (live.target[1] - live.pos[1]) * 0.04,
542 live.pos[2] + (live.target[2] - live.pos[2]) * 0.04,
543 ]
544 }
545
546 // Mantle first while it swims; upright, swaying a little, while it hovers.
547 const speed = len3(live.vel)
548 const moving = Math.hypot(live.vel[0], live.vel[1]) > s * 0.5
549 const want: V3 = moving ? norm3([live.vel[0], live.vel[1], live.vel[2] * 0.2]) : norm3([Math.sin(live.t * 0.31) * 0.18, -1, Math.sin(live.t * 0.23) * 0.18])
550 const turnRate = moving ? 0.15 : 0.04
551 live.axis = norm3([
552 live.axis[0] * (1 - turnRate) + want[0] * turnRate,
553 live.axis[1] * (1 - turnRate) + want[1] * turnRate,
554 live.axis[2] * (1 - turnRate) + want[2] * turnRate,
555 ])
556 live.yaw = ease(live.yaw, live.yawTarget, 0.03)
557
558 // Eyes: darting while working, drifting otherwise, on the conversation when it needs you.
559 if (live.act === 'peer') {
560 live.lookTarget = live.actStage === 1 ? (random(live) - 0.5) * 0.3 : 0
561 } else if (live.tick >= live.nextLook) {
562 live.lookTarget = live.mood === 'attention' ? -1 + random(live) * 0.3 : random(live) * 2 - 1
563 live.nextLook = live.tick + (plan.darts ? between(live, [12, 30]) : between(live, [40, 90]))
564 }
565 live.look = ease(live.look, live.lookTarget, plan.darts ? 0.45 : 0.08)
566 if (live.tick >= live.nextBlink) {
567 live.blinkUntil = live.tick + 2
568 live.nextBlink = live.tick + between(live, [40, 95])
569 }
570 if (plan.flexes && live.tick >= live.nextFlex) {
571 live.flexUntil = live.tick + 8
572 live.nextFlex = live.tick + between(live, [70, 130])
573 }
574
575 live.t += DT * live.pace * (live.stroke >= 0 ? 1.8 : 1)
576
577 // The ink: squirted from under the mantle, spreading and drifting, thinning
578 // slowly while it holds and fast once the Kraken is back through it.
579 const scale = cam.f / (cam.f + Math.max(-cam.f * 0.95, live.pos[2]))
580 if (live.act === 'ink' && live.tick < live.squirtUntil) {
581 const siphon = project3(cam, [live.pos[0] - live.axis[0] * s * 0.2, live.pos[1] - live.axis[1] * s * 0.2, live.pos[2]])
582 for (let i = 0; i < 5 && live.puffs.length < 60; i += 1) {
583 const angle = random(live) * Math.PI * 2
584 const speed = 8 + random(live) * 22
585 live.puffs.push({ x: siphon.x, y: siphon.y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, r: s * scale * 0.35, grow: (W + H) * (0.009 + random(live) * 0.006), d: 1, seed: random(live) * 100 })
586 }
587 }
588 const clearing = live.act !== 'ink' || live.actStage >= 1
589 for (const puff of live.puffs) {
590 puff.x += puff.vx
591 puff.y += puff.vy
592 puff.vx *= 0.92
593 puff.vy *= 0.92
594 puff.r += puff.grow
595 puff.grow *= 0.95
596 puff.d *= clearing ? 0.975 : 0.999
597 }
598 live.puffs = live.puffs.filter(puff => puff.d > 0.04)
599 // Where it swims through the ink, it clears a path, and pushes the clouds aside.
600 if (live.puffs.length && clearing) {
601 const at = project3(cam, live.pos)
602 const reach = s * at.k * 1.4
603 live.wake.push({ x: at.x, y: at.y, r: reach, age: 0 })
604 for (const puff of live.puffs) {
605 const dx = puff.x - at.x
606 const dy = puff.y - at.y
607 const d = Math.hypot(dx, dy) || 1
608 if (d < puff.r + reach) {
609 puff.d *= 0.92
610 puff.vx += (dx / d) * 6
611 puff.vy += (dy / d) * 6
612 }
613 }
614 }
615 for (const w of live.wake) {
616 w.age += 1
617 }
618 live.wake = live.wake.filter(w => w.age < 40)
619
620 // Each arm on its own: its own clock, its own lag on the stroke, its own
621 // intent, picked on its own schedule from the mood's mix.
622 const mix = ARM_MIX[live.mood]
623 const total = mix.reduce((sum, [, w]) => sum + w, 0)
624 for (const arm of live.arms) {
625 arm.phase += DT * live.pace * arm.speed * (live.stroke >= 0 ? 1.8 : 1)
626 // An arm follows the body's gather, lagging; one tucked in stays drawn in whatever the body does.
627 arm.tuck = ease(arm.tuck, arm.intent.tuck, arm.ease)
628 arm.gather += (Math.max(live.gather, arm.tuck) - arm.gather) * arm.lag
629 if (live.tick >= arm.next) {
630 let roll = random(live) * total
631 let pick: Intent = 'drift'
632 for (const [kind, weight] of mix) {
633 roll -= weight
634 if (roll <= 0) {
635 pick = kind
636 break
637 }
638 }
639 if (pick === 'twitch') {
640 arm.twitch = 0.5 + random(live) * 0.4
641 } else {
642 arm.intent = INTENTS[pick](() => random(live))
643 }
644 arm.ease = 0.015 + random(live) * 0.045
645 arm.next = live.tick + between(live, pick === 'twitch' ? [15, 40] : [50, 160])
646 }
647 arm.curl = ease(arm.curl, arm.intent.curl, arm.ease)
648 arm.sweep = ease(arm.sweep, arm.intent.sweep, arm.ease)
649 arm.reach = ease(arm.reach, arm.intent.reach, arm.ease)
650 arm.splay = ease(arm.splay, arm.intent.splay, arm.ease)
651 arm.wave = ease(arm.wave, arm.intent.wave, arm.ease)
652 arm.toward = ease(arm.toward, arm.intent.toward, arm.ease * 0.7)
653 arm.twitch *= 0.82
654 }
655}
656
657const KrakenLive: ClientModule<Props, State> = (props, surface) => {
658 const { Box } = surface.elements
659 const W = props.cols * PX_W
660 const H = props.rows * PX_H * 2
661 const view = props.view ?? 'pane'
662 const s = sizeOf(W, H, view)
663 const cam = camera(W, H, view)
664
665 let state = surface.state
666 if (!state) {
667 const plan = planOf(view, props.mood)
668 const live: Live = {
669 mood: props.mood,
670 view,
671 arms: Array.from({ length: 8 }, (_, i) => ({
672 phase: i * 1.7,
673 gather: plan.rest,
674 curl: 1,
675 sweep: 0,
676 reach: 1,
677 splay: 0,
678 wave: 1,
679 toward: 0,
680 twitch: 0,
681 speed: 0.6 + ((i * 0.37) % 1),
682 lag: 0.35 + ((i * 0.53) % 0.4),
683 ease: 0.05,
684 intent: { curl: 1, sweep: 0, reach: 1, splay: 0, wave: 1, toward: 0, tuck: 0 },
685 next: i * 7,
686 tuck: 0,
687 })),
688 planned: '',
689 tick: 0,
690 t: 0,
691 seed: 1234567,
692 pos: onGlass(cam, plan.home[0] * W, plan.home[1] * H, plan.home[2]),
693 vel: [0, 0, 0],
694 axis: [0, -1, 0],
695 target: [0, 0, 0],
696 nextPick: 0,
697 stroke: -1,
698 gather: plan.rest,
699 squash: 0,
700 yaw: 0,
701 yawTarget: 0,
702 look: 0,
703 lookTarget: 0,
704 nextLook: 0,
705 blinkUntil: 0,
706 nextBlink: 50,
707 flexUntil: 0,
708 nextFlex: 90,
709 light: plan.light,
710 rune: plan.rune,
711 sway: plan.sway,
712 pace: plan.pace,
713 lid: plan.lid,
714 steady: { step: 0, calm: 0 },
715 flush: props.mood === 'working' ? 1 : 0,
716 glow: props.mood === 'attention' ? 1 : 0,
717 act: 'roam',
718 actStage: 0,
719 actUntil: 0,
720 nextAct: plan.acts ? 60 : Infinity,
721 route: [],
722 puffs: [],
723 wake: [],
724 inkSeen: props.ink ?? 0,
725 inkPending: false,
726 lastInk: -1000,
727 squirtUntil: 0,
728 peerSide: 1,
729 }
730 state = { live }
731 surface.every(TICK_MS, () => {
732 step(live, W, H, s)
733 surface.setState({ live })
734 })
735 surface.setState(state)
736 }
737 const live = state.live
738 live.mood = props.mood
739 if ((props.ink ?? 0) > live.inkSeen) {
740 live.inkSeen = props.ink ?? 0
741 live.inkPending = true
742 }
743 const plan = planOf(live.view, live.mood)
744
745 // Hovering bobs a little; the attention lean comes and goes.
746 const hovering = live.stroke < 0
747 const scale = cam.f / (cam.f + Math.max(-cam.f * 0.95, live.pos[2]))
748 const bob = hovering ? (Math.sin(live.t * 1.1) * s * 0.05) / Math.max(1, scale * scale) : 0
749 const lean = live.mood === 'attention' && live.act === 'roam' ? Math.max(0, Math.sin(live.t * 0.8)) * -40 : 0
750 const body: Body = {
751 pos: [live.pos[0], live.pos[1] + bob, live.pos[2] + lean],
752 axis: live.axis,
753 facing: [Math.sin(live.yaw), 0, -Math.cos(live.yaw)],
754 s,
755 squash: live.squash,
756 gather: live.gather,
757 phase: live.t * 1.3,
758 sway: live.sway,
759 look: live.look,
760 lid: live.tick < live.blinkUntil ? 1 : live.lid,
761 rune: live.rune * (0.7 + 0.3 * Math.sin((live.t / plan.runePulse) * Math.PI * 2)),
762 light: live.light,
763 arms: live.arms,
764 }
765 const model = buildModel(body, cam)
766
767 // The Kraken's cells, sampled once a frame; shading is chosen below.
768 const cells: Array<Array<readonly [number, number] | undefined>> = []
769 for (let py = 0; py < props.rows * 2; py += 1) {
770 const row: Array<readonly [number, number] | undefined> = []
771 for (let x = 0; x < props.cols; x += 1) {
772 const cell = sampleModel(model, (x + 0.5) * PX_W, (py + 0.5) * PX_H)
773 row.push(cell && (cell[0] === BODY || cell[0] === DETAIL || cell[0] === GLOW || cell[0] === IRIS) ? cell : undefined)
774 }
775 cells.push(row)
776 }
777
778 // The ink, per pixel: the clouds' density, less what the wake has cleared.
779 // It covers the Kraken, except once it is back and swimming through it.
780 const inkBehind = live.act === 'ink' && live.actStage >= 1
781 const inkGrid = new Map<number, string>()
782 const inkAt = (x: number, py: number): string => {
783 if (!live.puffs.length) {
784 return ''
785 }
786 const key = py * props.cols + x
787 const known = inkGrid.get(key)
788 if (known !== undefined) {
789 return known
790 }
791 const X = (x + 0.5) * PX_W
792 const Y = (py + 0.5) * PX_H
793 let density = 0
794 for (const puff of live.puffs) {
795 const dx = X - puff.x
796 const dy = Y - puff.y
797 const dist = Math.hypot(dx, dy)
798 if (dist > puff.r * 1.15) {
799 continue
800 }
801 // A ragged edge that shifts as it billows, not a disc.
802 const edge = 0.78 + 0.37 * noise(Math.atan2(dy, dx) * 2.5 + puff.seed, live.t * 0.6 + puff.seed)
803 const q = dist / (puff.r * edge)
804 if (q < 1) {
805 density += puff.d * (1 - q * q) * 1.4
806 }
807 }
808 let cleared = 0
809 for (const w of live.wake) {
810 const q = Math.hypot(X - w.x, Y - w.y) / w.r
811 if (q < 1) {
812 cleared = Math.max(cleared, (1 - q) * (1 - w.age / 60))
813 }
814 }
815 density = Math.min(1, density) * (1 - 0.95 * cleared)
816 const colour = density > 0.75 ? '#020204' : density > 0.5 ? '#06080d' : density > 0.28 ? '#0a141c' : ''
817 inkGrid.set(key, colour)
818 return colour
819 }
820
821 // Ink covers the Kraken, except once it is back and swimming through it.
822 // Its eyes and runes in the mood's colour, all of them at once.
823 const runes = mixRamp(mixRamp(RAW[GLOW]!.shades, CRIMSON.glow, live.flush), GOLD.glow, live.glow).map(short)
824 const iris = mixRamp(mixRamp(RAW[IRIS]!.shades, CRIMSON.iris, live.flush), GOLD.iris, live.glow).map(short)
825 return drawPixels(Box, props.cols, props.rows, (x, py, levels) => {
826 const cell = cells[py]![x]
827 const ink = inkAt(x, py)
828 if (ink && !(cell && inkBehind)) {
829 return ink
830 }
831 if (!cell) {
832 return ink
833 }
834 return cell[0] === GLOW ? pick(runes, cell[1], levels) : cell[0] === IRIS ? pick(iris, cell[1], levels) : shade(cell[0], cell[1], levels)
835 }, undefined, live.steady)
836}
837
838export default KrakenLive
839hooks/kraken3d.ts 464 lines1// The Kraken in three dimensions, for the live drawing. A mantle that is an
2// ellipsoid, eight arms that are tapering tubes bent and waved in 3D, eyes
3// on the mantle's sides; all seen in perspective through the glass, lit from
4// the surface above and fogged with distance. Each pixel takes whatever is
5// nearest the viewer, so arms pass behind the mantle and in front of it.
6//
7// World: x right, y down, z away from the glass, in pixels; the glass is
8// z 0. The Kraken's own frame is its axis (from the arm roots up through
9// the mantle), the way it faces, and the side between them.
10
11import { clamp01, hash, noise } from './field'
12import type { Cell } from './field'
13import { BODY, DETAIL, GLOW, IRIS } from './kraken'
14
15export type V3 = [number, number, number]
16const TAU = Math.PI * 2
17
18const add = (a: V3, b: V3): V3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
19const sub = (a: V3, b: V3): V3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
20const mul = (a: V3, k: number): V3 => [a[0] * k, a[1] * k, a[2] * k]
21const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
22const cross = (a: V3, b: V3): V3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
23export const len3 = (a: V3) => Math.hypot(a[0], a[1], a[2])
24export const norm3 = (a: V3): V3 => {
25 const l = len3(a) || 1
26 return [a[0] / l, a[1] / l, a[2] / l]
27}
28
29/** `v` turned `angle` about the unit `axis` (Rodrigues). */
30function rotate(v: V3, axis: V3, angle: number): V3 {
31 const c = Math.cos(angle)
32 const s = Math.sin(angle)
33 return add(add(mul(v, c), mul(cross(axis, v), s)), mul(axis, dot(axis, v) * (1 - c)))
34}
35
36/** Where perspective converges, and how strongly: a longer `f` flattens it. */
37export type Camera = { cx: number; cy: number; f: number }
38
39/** A point's place on the glass and its scale there. */
40function project(cam: Camera, p: V3): { x: number; y: number; k: number } {
41 const k = cam.f / (cam.f + Math.max(-cam.f * 0.95, p[2]))
42 return { x: cam.cx + (p[0] - cam.cx) * k, y: cam.cy + (p[1] - cam.cy) * k, k }
43}
44
45/** Everything the live drawing animates about the Kraken. */
46export type Body = {
47 /** The ring where the arms join, in world pixels. */
48 pos: V3
49 /** Unit: from the arm roots up through the mantle. */
50 axis: V3
51 /** The way the eyes look; kept square to the axis. */
52 facing: V3
53 /** Mantle width in world pixels. */
54 s: number
55 /** Stretch along the axis, the squeeze of a jet stroke. */
56 squash: number
57 /** 0 arms spread wide, 1 drawn together, trailing. */
58 gather: number
59 /** The arms' wave, in radians of its cycle. */
60 phase: number
61 sway: number
62 /** Pupils: -1 to 1, across. */
63 look: number
64 /** 0 open, 1 shut. */
65 lid: number
66 /** The runes' glow, 0 to 1. */
67 rune: number
68 /** How much light reaches it, 0 to 1. */
69 light: number
70 /** Each arm's own state, when it has one: arms that move on their own. */
71 arms?: ArmPose[]
72}
73
74/**
75 * One arm's own pose: what makes the eight move independently rather than
76 * as one. Each is a multiplier or an offset on the body's own values.
77 */
78export type ArmPose = {
79 /** Its own wave, in radians of its own cycle. */
80 phase: number
81 /** How drawn-in it is: its own lagging share of the body's gather. */
82 gather: number
83 /**
84 * How its tip curls, against the arm's own built curl: 1 as built (out
85 * and up), more coils it, 0 straightens it, negative curls it inward,
86 * back toward the body and under it.
87 */
88 curl: number
89 /** A curl to one side or the other, about the body's axis: tips sweeping sideways. */
90 sweep: number
91 /** Its length: 1 as built. */
92 reach: number
93 /** Radians further out (negative) or down (positive) than the rest. */
94 splay: number
95 /** Its wave's strength: 1 as the body's. */
96 wave: number
97 /** How far it bends toward the glass, 0 to 1: a tip coming at you. */
98 toward: number
99 /** A fast shiver along it, 0 to 1. */
100 twitch: number
101}
102
103type Vert = { x: number; y: number; z: number; w: number }
104type Arm = { v: Vert[]; len: number; box: [number, number, number, number] }
105type Eye = { x: number; y: number; z: number; r: number; facing: number }
106/** An ellipsoid of the head: its centre and its radii along the axis, side and facing. */
107type Blob = { C: V3; ru: number; rr: number; rf: number; k0: number }
108/** The membrane between two neighbouring arms near their roots, as quads on the glass. */
109type Web = { quads: Array<[Vert, Vert, Vert, Vert]>; box: [number, number, number, number] }
110
111export type Model = {
112 cam: Camera
113 body: Body
114 /** The mantle sack's centre: what the eyes face out from. */
115 centre: V3
116 u: V3
117 r: V3
118 f: V3
119 /** The head: a great round mantle sack, and the face below it where the eyes are. */
120 blobs: Blob[]
121 arms: Arm[]
122 webs: Web[]
123 eyes: Eye[]
124}
125
126/** From the surface, a little toward the glass. */
127const LIGHT = norm3([0.25, -1, -0.45])
128
129/** Fog: the further back, the less of it reaches the eye. */
130function fog(z: number): number {
131 return Math.max(0.28, Math.min(1, 1.08 - z / 1500))
132}
133
134/** The Kraken's own frame: its axis, the way it faces, and the side between. */
135function frame(axis: V3, facing: V3): { u: V3; f: V3; r: V3 } {
136 const u = norm3(axis)
137 let f = sub(facing, mul(u, dot(facing, u)))
138 if (len3(f) < 0.05) {
139 f = Math.abs(u[2]) < 0.9 ? sub([0, 0, -1], mul(u, -u[2])) : [1, 0, 0]
140 }
141 f = norm3(f)
142 return { u, f, r: cross(u, f) }
143}
144
145/** The face, from the arm ring: its centre's offset, and its radii. */
146const FACE = { up: 0.18, forward: 0.12, ru: 0.42, rr: 0.58, rf: 0.5 }
147/** Which way an eye looks out from the face's centre: up a little, forward, and to one side. */
148const EYE = { up: 0.06, forward: 0.36, side: 0.3 }
149
150/** Where an eye is (side -1 or 1): on the face's own surface, just proud of it. */
151function eyeOnFace(pos: V3, u: V3, f: V3, r: V3, s: number, side: number): V3 {
152 const [a, b, c] = [EYE.up, side * EYE.side, EYE.forward]
153 const reach = 1.04 / Math.hypot(a / FACE.ru, b / FACE.rr, c / FACE.rf)
154 const centre = add(add(pos, mul(u, s * FACE.up)), mul(f, s * FACE.forward))
155 return add(centre, mul(add(add(mul(u, a), mul(r, b)), mul(f, c)), s * reach))
156}
157
158/** Where an eye is (side -1 or 1), from the arm ring, axis, facing and size. */
159export function eyeAt(pos: V3, axis: V3, facing: V3, s: number, side: number): V3 {
160 const { u, f, r } = frame(axis, facing)
161 return eyeOnFace(pos, u, f, r, s, side)
162}
163
164export function buildModel(b: Body, cam: Camera): Model {
165 const { u, f, r } = frame(b.axis, b.facing)
166 const s = b.s
167 // A bulbous head: the mantle sack, great and round, swept up and back;
168 // the face below it, where the eyes sit and the arms pour out.
169 const centre = add(add(b.pos, mul(u, s * 0.78)), mul(f, -s * 0.18))
170 const faceCentre = add(add(b.pos, mul(u, s * FACE.up)), mul(f, s * FACE.forward))
171 const blobs: Blob[] = [
172 { C: centre, ru: s * 0.86 * (1 + b.squash), rr: s * 0.78 * (1 - b.squash * 0.5), rf: s * 0.8 * (1 - b.squash * 0.5), k0: project(cam, centre).k },
173 { C: faceCentre, ru: s * FACE.ru, rr: s * FACE.rr, rf: s * FACE.rf, k0: project(cam, faceCentre).k },
174 ]
175
176 const arms: Arm[] = []
177 for (let i = 0; i < 8; i += 1) {
178 const theta = ((i + 0.5) / 8) * TAU + 0.2
179 const radial = add(mul(r, Math.cos(theta)), mul(f, Math.sin(theta)))
180 const a = b.arms?.[i] ?? { phase: b.phase - i * 0.9, gather: b.gather, curl: 1, sweep: 0, reach: 1, splay: 0, wave: 1, toward: 0, twitch: 0 }
181 // Roots in a wide ring round the face, thick, so the arms seem to pour out of it.
182 // Drawn in, the roots close up and every curve straightens: the arms
183 // trail behind the mantle in one tight bundle, as an octopus jets.
184 const drawn = Math.max(0, a.gather)
185 const root = add(add(b.pos, mul(radial, s * (0.38 - 0.2 * drawn))), mul(u, s * 0.04))
186 // Spread out below the rim, or drawn down into a trailing bundle.
187 const splay = Math.min(1.55, 0.35 + 1.25 * a.gather + a.splay * (1 - drawn))
188 let dir = norm3(add(mul(radial, Math.cos(splay)), mul(u, -Math.sin(splay))))
189 // Some arms bend round toward the glass, tips coming at the viewer.
190 if (a.toward > 0) {
191 dir = norm3(add(mul(dir, 1 - a.toward), mul([0, 0, -1], a.toward)))
192 }
193 const bend = norm3(cross(radial, u))
194 const n = 20
195 const length = s * 2.7 * (0.85 + hash(i, 3) * 0.3) * (1 + 0.18 * drawn) * a.reach
196 const step = length / n
197 // How it bends along its length: S-curves that turn one way then the
198 // other and ripple outward, and a spiral at the tip, its way the arm's own.
199 const coil = (2.2 + hash(i, 7) * 1.6) * (1 - 0.9 * drawn) * a.curl
200 const sCurve = (1.4 + hash(i, 11) * 0.8) * (0.4 + 0.6 * a.wave) * (0.5 + b.sway) * (1 - 0.85 * drawn)
201 const turns = 0.7 + hash(i, 13) * 0.6
202 // A gentle rise along the whole arm, so arms arch up and over as in life.
203 const arc = 0.9 * (1 - 0.9 * drawn)
204 const verts: Vert[] = []
205 let p = root
206 const w0 = s * 0.46
207 for (let k = 0; k <= n; k += 1) {
208 const t = k / n
209 const pr = project(cam, p)
210 verts.push({ x: pr.x, y: pr.y, z: p[2], w: Math.max(1.5, w0 * Math.pow(1 - t, 1.1)) * pr.k })
211 if (k === n) {
212 break
213 }
214 const s1 = (k + 1) / n
215 // Curvature at this point: the S-curve wave, stronger toward the tip and
216 // travelling along the arm; the tip's spiral; a shiver when it twitches.
217 const kappa =
218 arc +
219 sCurve * Math.sin(TAU * turns * s1 - a.phase) * (0.4 + s1) +
220 coil * Math.pow(Math.max(0, (s1 - 0.6) / 0.4), 2) * 9 +
221 a.twitch * Math.sin(TAU * 3 * s1 + a.phase * 7) * 2
222 const twist = (b.sway * a.wave * 0.7 * Math.sin(TAU * 0.5 * s1 - a.phase * 0.7 + i * 1.7) * s1 + a.sweep * s1 * s1 * 2) * (1 - 0.8 * drawn)
223 dir = norm3(rotate(rotate(dir, bend, (kappa / n) * 2), u, (twist / n) * 2))
224 p = add(p, mul(dir, step))
225 }
226 const pad = w0
227 const xs = verts.map(v => v.x)
228 const ys = verts.map(v => v.y)
229 arms.push({ v: verts, len: length, box: [Math.min(...xs) - pad, Math.min(...ys) - pad, Math.max(...xs) + pad, Math.max(...ys) + pad] })
230 }
231
232 // Webbing between each pair of neighbouring arms, a third of the way down.
233 const webs: Web[] = arms.map((armA, i) => {
234 const armB = arms[(i + 1) % arms.length]!
235 const quads: Array<[Vert, Vert, Vert, Vert]> = []
236 for (let k = 0; k < 4; k += 1) {
237 quads.push([armA.v[k]!, armA.v[k + 1]!, armB.v[k + 1]!, armB.v[k]!])
238 }
239 const xs = quads.flat().map(v => v.x)
240 const ys = quads.flat().map(v => v.y)
241 return { quads, box: [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] }
242 })
243
244 const eyes: Eye[] = []
245 for (const side of [-1, 1]) {
246 const at = eyeOnFace(b.pos, u, f, r, s, side)
247 const outward = norm3(sub(at, faceCentre))
248 const facing = -outward[2]
249 const pr = project(cam, at)
250 eyes.push({ x: pr.x, y: pr.y, z: at[2] - s * 0.05, r: s * 0.1 * pr.k * Math.sqrt(Math.max(0, facing)), facing })
251 }
252
253 return { cam, body: b, centre, u, r, f, blobs, arms, webs, eyes }
254}
255
256/** Where on the glass the Kraken could draw anything: mantle and every arm. */
257export function modelBounds(m: Model): [number, number, number, number] {
258 let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]
259 for (const blob of m.blobs) {
260 const c = project(m.cam, blob.C)
261 const rad = Math.max(blob.ru, blob.rr, blob.rf) * c.k
262 x0 = Math.min(x0, c.x - rad)
263 y0 = Math.min(y0, c.y - rad)
264 x1 = Math.max(x1, c.x + rad)
265 y1 = Math.max(y1, c.y + rad)
266 }
267 for (const arm of m.arms) {
268 x0 = Math.min(x0, arm.box[0])
269 y0 = Math.min(y0, arm.box[1])
270 x1 = Math.max(x1, arm.box[2])
271 y1 = Math.max(y1, arm.box[3])
272 }
273 return [x0, y0, x1, y1]
274}
275
276type Hit = { z: number; cell: Cell }
277
278/** One ellipsoid of the head, met by the viewer's line of sight; lit from above, darkly. */
279function blobHit(m: Model, blob: Blob, X: number, Y: number): Hit | undefined {
280 const { cam, u, r, f } = m
281 const { C, ru, rr, rf, k0 } = blob
282 const wx = cam.cx + (X - cam.cx) / k0
283 const wy = cam.cy + (Y - cam.cy) / k0
284 const q0: V3 = [wx - C[0], wy - C[1], -C[2]]
285 const a0 = dot(q0, u) / ru
286 const b0 = dot(q0, r) / rr
287 const c0 = dot(q0, f) / rf
288 const au = u[2] / ru
289 const br = r[2] / rr
290 const cf = f[2] / rf
291 const A = au * au + br * br + cf * cf
292 const B = 2 * (a0 * au + b0 * br + c0 * cf)
293 const C0 = a0 * a0 + b0 * b0 + c0 * c0 - 1
294 const disc = B * B - 4 * A * C0
295 if (disc < 0) {
296 return undefined
297 }
298 const t = (-B - Math.sqrt(disc)) / (2 * A)
299 const a = a0 + t * au
300 const b = b0 + t * br
301 const c = c0 + t * cf
302 const n = norm3(add(add(mul(u, a / ru), mul(r, b / rr)), mul(f, c / rf)))
303 const lambert = Math.max(0, dot(n, LIGHT))
304 const rim = Math.pow(1 - Math.abs(n[2]), 3) * 0.28
305 const v = (0.07 + 0.55 * lambert + rim) * m.body.light * fog(t)
306 return { z: t, cell: [BODY, clamp01(v)] }
307}
308
309function mantleHit(m: Model, X: number, Y: number): Hit | undefined {
310 let best: Hit | undefined
311 for (const blob of m.blobs) {
312 const hit = blobHit(m, blob, X, Y)
313 if (hit && (!best || hit.z < best.z)) {
314 best = hit
315 }
316 }
317 return best
318}
319
320/** Whether a point lies in a triangle on the glass. */
321function inTriangle(X: number, Y: number, a: Vert, b: Vert, c: Vert): boolean {
322 const d1 = (X - b.x) * (a.y - b.y) - (a.x - b.x) * (Y - b.y)
323 const d2 = (X - c.x) * (b.y - c.y) - (b.x - c.x) * (Y - c.y)
324 const d3 = (X - a.x) * (c.y - a.y) - (c.x - a.x) * (Y - a.y)
325 const hasNeg = d1 < 0 || d2 < 0 || d3 < 0
326 const hasPos = d1 > 0 || d2 > 0 || d3 > 0
327 return !(hasNeg && hasPos)
328}
329
330/** The webbing between two arms: a dark membrane, just behind the arms it joins. */
331function webHit(m: Model, web: Web, X: number, Y: number): Hit | undefined {
332 const [x0, y0, x1, y1] = web.box
333 if (X < x0 || X > x1 || Y < y0 || Y > y1) {
334 return undefined
335 }
336 for (const [k, q] of web.quads.entries()) {
337 if (inTriangle(X, Y, q[0], q[1], q[2]) || inTriangle(X, Y, q[0], q[2], q[3])) {
338 const z = (q[0].z + q[1].z + q[2].z + q[3].z) / 4 + 2
339 return { z, cell: [BODY, clamp01((0.2 - k * 0.025) * m.body.light * fog(z))] }
340 }
341 }
342 return undefined
343}
344
345function armHit(m: Model, arm: Arm, X: number, Y: number): Hit | undefined {
346 const [x0, y0, x1, y1] = arm.box
347 if (X < x0 || X > x1 || Y < y0 || Y > y1) {
348 return undefined
349 }
350 let best: Hit | undefined
351 const n = arm.v.length - 1
352 for (let i = 0; i < n; i += 1) {
353 const A = arm.v[i]!
354 const B = arm.v[i + 1]!
355 const dx = B.x - A.x
356 const dy = B.y - A.y
357 const l2 = dx * dx + dy * dy || 1
358 const k = clamp01(((X - A.x) * dx + (Y - A.y) * dy) / l2)
359 const px = A.x + dx * k
360 const py = A.y + dy * k
361 const d = Math.hypot(X - px, Y - py)
362 const half = (A.w + (B.w - A.w) * k) / 2
363 if (d > half) {
364 continue
365 }
366 const z = A.z + (B.z - A.z) * k
367 if (best && best.z <= z) {
368 continue
369 }
370 const t = (i + k) / n
371 const cross2 = dx * (Y - A.y) - dy * (X - A.x)
372 const side = (cross2 < 0 ? -1 : 1) * (d / half)
373 // The lit side is whichever side faces up on the glass.
374 const up = dx >= 0 ? -1 : 1
375 const toward = side * up
376 const along = t * arm.len
377 let cell: Cell
378 if (toward < -0.38 && toward > -0.92 && t < 0.88 && (along / Math.max(6, half * 0.9)) % 1 < 0.5) {
379 cell = [DETAIL, (0.35 + 0.2 * (1 - t)) * m.body.light * fog(z)]
380 } else if (Math.abs(side) < 0.16 && t > 0.05 && t < 0.8 && [0, 2, 3].includes(Math.floor(along / 9) % 6)) {
381 cell = [GLOW, m.body.rune * 0.9]
382 } else {
383 const lit = 0.1 + 0.3 * clamp01(toward * 0.6 + 0.5)
384 const edge = Math.abs(side) > 0.88 ? 0.55 : 1
385 cell = [BODY, clamp01(lit * edge * (1 - 0.3 * t) * m.body.light * fog(z))]
386 }
387 best = { z, cell }
388 }
389 return best
390}
391
392function eyeHit(m: Model, eye: Eye, X: number, Y: number): Hit | undefined {
393 if (eye.facing < 0.08 || eye.r < 1) {
394 return undefined
395 }
396 const r = eye.r
397 const d = Math.hypot(X - eye.x, (Y - eye.y) * 1.15)
398 if (d > r * 1.55) {
399 return undefined
400 }
401 const light = m.body.light
402 if (d > r) {
403 // The skin round the eye: dark, with a lit fold of upper lid.
404 const isFold = Y < eye.y - r * 0.3 && d < r * 1.3
405 return { z: eye.z, cell: [BODY, (isFold ? 0.42 : 0.16 + 0.12 * (1 - (d - r) / (r * 0.55))) * light] }
406 }
407 if (Y - eye.y < -r + 2 * r * m.body.lid) {
408 return { z: eye.z, cell: [BODY, 0.3 * light] }
409 }
410 // The pupil: a horizontal slit with rounded ends, as an octopus has.
411 const px = eye.x + m.body.look * r * 0.25
412 const half = r * (r < 10 ? 0.62 : 0.5)
413 const thick = r * (r < 10 ? 0.2 : 0.17)
414 const along = Math.max(-half, Math.min(half, X - px))
415 const fromSlit = Math.hypot(X - px - along, (Y - eye.y) * 1.15)
416 if (fromSlit < thick) {
417 return { z: eye.z, cell: [BODY, 0.02] }
418 }
419 if (r < 10) {
420 // Small and far: a glow, no more.
421 return { z: eye.z, cell: [GLOW, 0.75 + 0.25 * (1 - d / r)] }
422 }
423 // Up close, an iris of its own: a dark limbal ring, fine fibres, darker
424 // round the pupil, brighter toward the middle, and a glint of the surface.
425 const rho = d / r
426 if (rho > 0.86) {
427 return { z: eye.z, cell: [IRIS, 0.06] }
428 }
429 if (Math.hypot(X - (eye.x - r * 0.36), Y - (eye.y - r * 0.38)) < r * 0.12) {
430 return { z: eye.z, cell: [IRIS, 1] }
431 }
432 const angle = Math.atan2(Y - eye.y, X - eye.x)
433 const fibres = noise(angle * 9 + 10, rho * 3)
434 let v = 0.3 + 0.5 * Math.pow(1 - rho, 0.8) + (fibres - 0.5) * 0.18
435 if (fromSlit < thick * 1.7) {
436 v *= 0.72
437 }
438 return { z: eye.z, cell: [IRIS, clamp01(v)] }
439}
440
441/** What the viewer sees at a point on the glass: the nearest part of the Kraken, if any. */
442export function sampleModel(m: Model, X: number, Y: number): Cell | undefined {
443 let best = mantleHit(m, X, Y)
444 for (const eye of m.eyes) {
445 const hit = eyeHit(m, eye, X, Y)
446 if (hit && (!best || hit.z < best.z)) {
447 best = hit
448 }
449 }
450 for (const arm of m.arms) {
451 const hit = armHit(m, arm, X, Y)
452 if (hit && (!best || hit.z < best.z)) {
453 best = hit
454 }
455 }
456 for (const web of m.webs) {
457 const hit = webHit(m, web, X, Y)
458 if (hit && (!best || hit.z < best.z)) {
459 best = hit
460 }
461 }
462 return best?.cell
463}
464hooks/live-pixels.tsx 255 lines1// Pixels for the live drawings, shared by every live Engram: rows of Boxes,
2// two to a cell row (8 × 9.5 px on desktop), a coloured Box per run of one
3// colour and a bare Box for a gap. A frame that would hold too many Boxes is
4// drawn again with fewer shades, then with pixels twice the size each way.
5
6import type { ClientElements } from 'claude-code'
7
8export const PX_W = 8
9export const PX_H = 9.5
10/** Boxes a frame may hold: about 60,000 characters, well inside the 100,000 bound. */
11export const BOX_BUDGET = 700
12
13/** #rrggbb as #rgb, nearest: a few characters less on every Box. */
14export function short(hex: string): string {
15 const n = parseInt(hex.slice(1), 16)
16 return '#' + [16, 8, 0].map(k => Math.round(((n >> k) & 255) / 17).toString(16)).join('')
17}
18
19/** One colour part way to another, as #rrggbb. */
20export function mixHex(a: string, b: string, k: number): string {
21 const ca = parseInt(a.slice(1), 16)
22 const cb = parseInt(b.slice(1), 16)
23 const channel = (shift: number) => Math.round(((ca >> shift) & 255) * (1 - k) + ((cb >> shift) & 255) * k)
24 return '#' + ((channel(16) << 16) | (channel(8) << 8) | channel(0)).toString(16).padStart(6, '0')
25}
26
27/** A ramp part way to another, shade by shade; the shorter one stretched to the longer. */
28export function mixRamp(a: readonly string[], b: readonly string[], k: number): string[] {
29 const n = Math.max(a.length, b.length)
30 const at = (r: readonly string[], i: number) => r[Math.round((i * (r.length - 1)) / (n - 1))]!
31 return Array.from({ length: n }, (_, i) => mixHex(at(a, i), at(b, i), k))
32}
33
34/** A shade for a brightness, from at most `levels` of a ramp. */
35export function pick(ramp: readonly string[], v: number, levels: number): string {
36 const n = Math.min(levels, ramp.length)
37 const k = Math.min(n - 1, Math.max(0, Math.floor(v * n)))
38 return ramp[n === 1 ? ramp.length - 1 : Math.round((k * (ramp.length - 1)) / (n - 1))]!
39}
40
41/**
42 * The colour at pixel column `x` of pixel row `py` (two to a cell row), from
43 * at most `levels` shades; '' for nothing. A coarse pass asks only for even
44 * columns and rows.
45 */
46export type Colour = (x: number, py: number, levels: number) => string
47
48type Box = ClientElements['Box']
49
50/**
51 * Set only by the thumbnail bake (scripts/bake-thumbs.mjs): handed every
52 * frame's pixels before they are drawn, at full detail, budget or no.
53 */
54export const capturePixels: { to?: (cols: number, rows: number, colour: Colour) => void } = {}
55
56/**
57 * The pixels as one column of rows, within the Box budget. With a `focus`
58 * (a cell row), detail is spent where it matters: full-size pixels from that
59 * row outward for as far as the budget goes, and twice the size beyond it.
60 */
61export function drawPixels(Box: Box, cols: number, rows: number, colour: Colour, focus?: number, steady?: Steady) {
62 capturePixels.to?.(cols, rows, colour)
63 /** One pixel row as Boxes: a bare Box per gap, a coloured one per run of one shade. */
64 const pixelRow = (py: number, levels: number, step: number) => {
65 const boxes = []
66 let start = 0
67 const at = (x: number) => colour(x - (x % step), py, levels)
68 let c = at(0)
69 for (let x = 1; x <= cols; x += 1) {
70 const n = x < cols ? at(x) : '\u0000'
71 if (n !== c) {
72 if (c) {
73 boxes.push(<Box width={x - start} backgroundColor={c} />)
74 } else if (n !== '\u0000') {
75 boxes.push(<Box width={x - start} />)
76 }
77 start = x
78 c = n
79 }
80 }
81 return boxes
82 }
83
84 // Full detail if it fits: 8 × 9.5 px pixels, two rows to a cell row. Else
85 // fewer shades; and last, pixels twice as big each way (16 × 19 px, a row
86 // to a cell row), so something up at the glass reads as pixel art zoomed in.
87 const draw = (levels: number, isCoarse: boolean) => {
88 const out = []
89 let boxes = 0
90 let empty = 0
91 for (let y = 0; y < rows; y += 1) {
92 const halves = isCoarse ? [pixelRow(y * 2, levels, 2)] : [pixelRow(y * 2, levels, 1), pixelRow(y * 2 + 1, levels, 1)]
93 if (halves.every(h => !h.length)) {
94 empty += 1
95 continue
96 }
97 if (empty) {
98 out.push(<Box height={empty} />)
99 empty = 0
100 }
101 boxes += halves.reduce((sum, h) => sum + h.length, 0)
102 out.push(
103 isCoarse ? (
104 <Box flexDirection="row" height={1} alignItems="stretch">
105 {halves[0]}
106 </Box>
107 ) : (
108 <Box flexDirection="column" height={1}>
109 <Box flexDirection="row" height="50%" alignItems="stretch">
110 {halves[0]}
111 </Box>
112 <Box flexDirection="row" height="50%" alignItems="stretch">
113 {halves[1]}
114 </Box>
115 </Box>
116 ),
117 )
118 }
119 return { out, boxes }
120 }
121 if (focus !== undefined) {
122 return focused(Box, cols, rows, pixelRow, focus)
123 }
124 if (steady) {
125 return held(Box, cols, rows, draw, steady)
126 }
127 let drawn = draw(8, false)
128 for (const [levels, isCoarse] of STEPS.slice(1)) {
129 if (drawn.boxes <= BOX_BUDGET) {
130 break
131 }
132 drawn = draw(levels, isCoarse)
133 }
134 return (
135 <Box flexDirection="column" width={cols} height={rows}>
136 {drawn.out}
137 </Box>
138 )
139}
140
141/** The ways a frame may be drawn, finest first: shades, and whether its pixels are doubled. */
142const STEPS = [
143 [8, false],
144 [6, false],
145 [4, false],
146 [6, true],
147 [4, true],
148 [3, true],
149] as const
150
151/**
152 * A drawing's memory of how fine it drew last, so it does not flicker:
153 * deciding afresh each frame, one hovering at the budget flips between
154 * resolutions several times a second.
155 */
156export type Steady = { step: number; calm: number }
157
158/** Frames a drawing must fit comfortably before it tries a finer step again. */
159const CALM = 25
160/** How far under the budget a finer step must come to be taken. */
161const ROOM = 0.82
162
163type Draw = (levels: number, isCoarse: boolean) => { out: ReturnType<typeof h>[]; boxes: number }
164
165/**
166 * With hysteresis: coarser at once when a frame will not fit; finer only
167 * once frames have fitted for a while and the finer one fits with room.
168 */
169function held(Box: Box, cols: number, rows: number, draw: Draw, steady: Steady) {
170 let step = Math.min(Math.max(0, steady.step), STEPS.length - 1)
171 const at = (i: number) => draw(STEPS[i]![0], STEPS[i]![1])
172 let drawn = at(step)
173 if (drawn.boxes > BOX_BUDGET) {
174 while (drawn.boxes > BOX_BUDGET && step < STEPS.length - 1) {
175 step += 1
176 drawn = at(step)
177 }
178 steady.calm = 0
179 } else if (step > 0 && ++steady.calm >= CALM) {
180 const finer = at(step - 1)
181 if (finer.boxes <= BOX_BUDGET * ROOM) {
182 step -= 1
183 drawn = finer
184 }
185 steady.calm = 0
186 }
187 steady.step = step
188 return (
189 <Box flexDirection="column" width={cols} height={rows}>
190 {drawn.out}
191 </Box>
192 )
193}
194
195type PixelRow = (py: number, levels: number, step: number) => ReturnType<typeof h>[]
196
197/** Full-size pixels round the focus row, coarse ones beyond it, within the budget. */
198function focused(Box: Box, cols: number, rows: number, pixelRow: PixelRow, focus: number) {
199 let chosen: Array<{ fine?: ReturnType<typeof h>[][]; coarse: ReturnType<typeof h>[] }> = []
200 for (const levels of [8, 6, 4, 3]) {
201 const coarse = Array.from({ length: rows }, (_, y) => pixelRow(y * 2, levels, 2))
202 let total = coarse.reduce((sum, row) => sum + row.length, 0)
203 chosen = coarse.map(row => ({ coarse: row }))
204 if (total > BOX_BUDGET && levels > 3) {
205 continue
206 }
207 // Outward from the focus, one row above and one below in turn, while it fits.
208 const order = Array.from({ length: rows }, (_, y) => y).sort((a, b) => Math.abs(a - focus) - Math.abs(b - focus))
209 for (const y of order) {
210 const fine = [pixelRow(y * 2, levels, 1), pixelRow(y * 2 + 1, levels, 1)]
211 const more = fine[0]!.length + fine[1]!.length - coarse[y]!.length
212 if (total + more > BOX_BUDGET) {
213 break
214 }
215 total += more
216 chosen[y]!.fine = fine
217 }
218 break
219 }
220 const out = []
221 let empty = 0
222 for (const row of chosen) {
223 const isEmpty = row.fine ? row.fine.every(h => !h.length) : !row.coarse.length
224 if (isEmpty) {
225 empty += 1
226 continue
227 }
228 if (empty) {
229 out.push(<Box height={empty} />)
230 empty = 0
231 }
232 out.push(
233 row.fine ? (
234 <Box flexDirection="column" height={1}>
235 <Box flexDirection="row" height="50%" alignItems="stretch">
236 {row.fine[0]}
237 </Box>
238 <Box flexDirection="row" height="50%" alignItems="stretch">
239 {row.fine[1]}
240 </Box>
241 </Box>
242 ) : (
243 <Box flexDirection="row" height={1} alignItems="stretch">
244 {row.coarse}
245 </Box>
246 ),
247 )
248 }
249 return (
250 <Box flexDirection="column" width={cols} height={rows}>
251 {out}
252 </Box>
253 )
254}
255