SLOPSHOPPER

exported-kraken

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

paneband
A shopper browsing a rack in a slop shop
README

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

Source 11 files
hooks/register.tsx 150 lines
1// 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}
150
hooks/draw.tsx 45 lines
1// 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}
45
hooks/drawing.ts 46 lines
1// The drawing interface (docs/spec/engram-plugin.md, "The hand-off"): what
2// draws a creature from the hand-off. Each bundled Engram's drawing module,
3// engrams/<id>/hooks/draw.tsx, is one of these, and Eidola draws the bundled
4// three through it as it asks an Engram plugin. scripts/export-engram.mjs
5// copies a bundled Engram's drawing module, and this file with it, into an
6// Engram plugin of its own, whose hooks call the very same function with the
7// hand-off Eidola gives them.
8
9import type { Elements, RenderElement } from 'claude-code'
10
11import type { EidolaHandoff } from './eidola-types'
12import { PX_PER_ROW } from './stage'
13
14/** The elements a drawing draws with: a desktop's, where a Client can run. */
15export type DrawEls = Pick<Elements['desktop'], 'Box' | 'Svg' | 'Client'>
16
17/** What the hook drawing the creature gives it besides the hand-off. */
18export type DrawKit = {
19  /** The Engram's name, as its engram.json gives it: for the drawing's alt text. */
20  name: string
21  /** The clock now (ms): a still backdrop's loops are locked to it when built. */
22  now: number
23  /**
24   * A still backdrop's SVG by key, built once and kept while the key holds:
25   * a rebuilt one (a new clock) would reload the drawing on the surface.
26   */
27  memo: (key: string, build: () => string) => string
28}
29
30/**
31 * A creature's drawing, `columns` × `rows` cells of the hand-off: one Box,
32 * as wide and tall, holding the creature. The Engram plugin's hooks key it
33 * `eidola:<id>` before they answer it; Eidola places a bundled one as it is.
34 */
35export type Draw = (els: DrawEls, handoff: EidolaHandoff, kit: DrawKit) => RenderElement
36
37/**
38 * A pane backdrop's height in px: a row taller than its rows at 19 px each,
39 * clipped to them. A row is drawn a fraction of a pixel taller than 19, so a
40 * backdrop of exactly rows × 19 stops a few pixels short of the HUD's bottom
41 * brackets, which sit on the rows; drawn taller and clipped it meets them.
42 */
43export function overdrawn(rows: number): number {
44  return (rows + 1) * PX_PER_ROW
45}
46
hooks/eidola-types.ts 128 lines
1// Eidola's public types (contract 1), copied from the Eidola mod's
2// types/index.d.ts by scripts/export-engram.mjs: what the drawing reads off
3// the hand-off and Eidola's state. Types alone; $.eidola and Eidola's state
4// are typed by the contract the engine lays into .claude-plugin/types/eidola/
5// once this plugin loads beside Eidola.
6
7export type VesselState =
8  | 'idle'
9  | 'thinking'
10  | 'working'
11  | 'attention'
12  | 'success'
13  | 'responding'
14  | 'error'
15  | 'waiting'
16  | 'sleeping'
17
18/**
19 * What the drawings follow: three moods, set by the turn rather than by each
20 * tool, so a turn changes the drawing two or three times, not dozens.
21 */
22export type Mood = 'idle' | 'working' | 'attention'
23
24/** What the status line shows: the state and a short line of detail under it. */
25export type VesselView = { state: VesselState; detail: string }
26
27/**
28 * An Engram page's HUD preview: the mood its chips set, or the error its Error
29 * button shows, drawn in the preview alone and never in the session.
30 */
31export type Preview = {
32  mood: Mood
33  /** The Error button was pressed since the last mood chip. */
34  isError: boolean
35  /** How many times the Error button was pressed: the preview's own ink. */
36  ink: number
37  /** When it was last pressed, and when the preview's mood last changed (ms). */
38  inkAt: number
39  moodAt: number
40  /** The preview's mood before the last change. */
41  moodBefore: Mood
42}
43
44/** The version of the public contract this Eidola keeps. */
45export type EidolaContract = 1
46
47/** What an Engram plugin passes to `$.eidola.announce`. */
48export type EidolaAnnounceInput = {
49  /** The plugin's own folder, the one holding engram.json: `$.plugin.root`. */
50  root: string
51}
52
53/** One reason Eidola could not take an Engram plugin's engram.json. */
54export type EidolaIssue = { file: string; problem: string; fix: string }
55
56/** What `$.eidola.announce` answers. */
57export type EidolaAnnounced = {
58  /** The contract this Eidola keeps. */
59  contract: EidolaContract
60  /** The Engram's id when it is in the registry; null when it isn't. */
61  engram: string | null
62  /** Why it isn't: each file, problem and fix. Empty when it is. */
63  issues: EidolaIssue[]
64}
65
66/** What an Engram plugin passes to `$.eidola.fault` when its own drawing fails. */
67export type EidolaFaultInput = {
68  /** The Engram's id. */
69  engram: string
70  /** What went wrong, in a line. */
71  message: string
72  /** The file that failed, relative to the plugin's folder (`hooks/creature.tsx`). */
73  file?: string
74  /** The line in that file, when known. */
75  line?: number
76  /** The error's stack, when there is one: Eidola reads the line off it when `line` is left out. */
77  stack?: string
78}
79
80/** What `$.eidola.fault` answers: whether Eidola marked the Engram not loading. */
81export type EidolaFaulted = { isMarked: boolean }
82
83/** Where Eidola is handing the drawing off: its pane, or the band above the prompt. */
84export type EidolaWhere = 'pane' | 'band'
85
86/**
87 * The hand-off, on `e.props.eidola` of the `ui.render` Eidola passes to
88 * `next`: present only when Eidola asks the awake Engram's plugin for its
89 * drawing. Draw `columns` × `rows` cells, in one Box keyed `eidola:<engram>`.
90 */
91export type EidolaHandoff = {
92  contract: EidolaContract
93  /** The awake Engram's id: draw only when it is yours. */
94  engram: string
95  where: EidolaWhere
96  /** The drawing area inside Eidola's frame, in cells. */
97  columns: number
98  rows: number
99  mood: Mood
100  moodBefore: Mood
101  moodAt: number
102  ink: number
103  inkAt: number
104  epoch: number
105}
106
107/** The `eidola` noun Eidola adds to `$`: what an Engram plugin calls. */
108export type Eidola = {
109  /**
110   * Tells Eidola an Engram plugin is here. Call it from `session.start`:
111   * Eidola reads `<root>/engram.json`, checks it, and adds the Engram to its
112   * registry. Works whichever plugin loaded first.
113   *
114   * @example
115   * on('session.start', async ($, e, next) => {
116   *   await $.eidola.announce({ root: $.plugin.root })
117   *   return next(e)
118   * })
119   */
120  announce: (input: EidolaAnnounceInput) => Promise<EidolaAnnounced>
121  /**
122   * Tells Eidola the plugin's own drawing failed: Eidola draws the Engram it
123   * was remixed from (or the resting sprite) instead, and marks it not
124   * loading with the file and line, until the plugin announces again.
125   */
126  fault: (input: EidolaFaultInput) => Promise<EidolaFaulted>
127}
128
hooks/sprite.ts 296 lines
1// Built-in sprite: 20x20 pixel frames composed from layers, emitted as one
2// SVG whose frames cycle with CSS steps(). No redraw per frame: the pane
3// redraws only when the state changes. Bundled Engrams now ship baked SVGs
4// from the Vessel Forge; this is the fallback and the media test's control.
5
6import type { VesselState } from './eidola-types'
7
8export const GRID = 20
9
10/** Character -> palette slot. '.' is transparent. */
11export type Palette = {
12  o: string // outline
13  b: string // body
14  h: string // highlight
15  e: string // eye
16  w: string // eye shine
17  c: string // cheek
18  m: string // mouth
19  a: string // accent (thought dots, z's)
20  r: string // alert
21  y: string // sparkle
22  d: string // sweat drop
23}
24
25/**
26 * The built-in sprite's colors. It draws the media test's control and
27 * stands in when a baked Vessel's file can't be read.
28 */
29export const SPRITE_PALETTE: Palette = {
30  o: '#3b2f8f',
31  b: '#cfc6ff',
32  h: '#f4f1ff',
33  e: '#1a1c2b',
34  w: '#ffffff',
35  c: '#ff9ec4',
36  m: '#3b2f8f',
37  a: '#9d93ff',
38  r: '#ff5c5c',
39  y: '#ffd25e',
40  d: '#6fc3ff',
41}
42
43type Grid = string[][]
44type Dot = [x: number, y: number, ch: keyof Palette]
45
46// The body: a domed spirit. Rows 0-4 stay free for accessories.
47const BODY = [
48  '....................',
49  '....................',
50  '....................',
51  '....................',
52  '....................',
53  '.......oooooo.......',
54  '.....oohhbbbboo.....',
55  '....ohhbbbbbbbbo....',
56  '...ohbbbbbbbbbbbo...',
57  '...obbbbbbbbbbbbo...',
58  '...obbbbbbbbbbbbo...',
59  '...obbbbbbbbbbbbo...',
60  '...obbbbbbbbbbbbo...',
61  '...obbbbbbbbbbbbo...',
62  '...obbbbbbbbbbbbo...',
63  '...obbbbbbbbbbbbo...',
64  '...obbbbbbbbbbbbo...',
65  '...obbbbbbbbbbbbo...',
66]
67
68// Two tail poses; frames alternate them so the spirit always drifts.
69const TAILS: [string[], string[]] = [
70  ['...obbbobbbbobbbo...', '....ooo.oooo.ooo....'],
71  ['...obbbbobbbbobbo...', '....oooo.oooo.oo....'],
72]
73
74// Faces: dots drawn over the body.
75const EYES = {
76  open: [
77    [6, 10, 'w'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
78    [12, 10, 'w'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
79  ],
80  closed: [[6, 11, 'e'], [7, 11, 'e'], [12, 11, 'e'], [13, 11, 'e']],
81  happy: [[6, 11, 'e'], [7, 10, 'e'], [8, 11, 'e'], [11, 11, 'e'], [12, 10, 'e'], [13, 11, 'e']],
82  up: [
83    [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'],
84    [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'],
85  ],
86  focus: [[6, 11, 'e'], [7, 11, 'e'], [6, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'], [13, 10, 'e']],
87  wide: [
88    [6, 9, 'w'], [7, 9, 'e'], [6, 10, 'e'], [7, 10, 'e'], [6, 11, 'e'], [7, 11, 'e'],
89    [12, 9, 'w'], [13, 9, 'e'], [12, 10, 'e'], [13, 10, 'e'], [12, 11, 'e'], [13, 11, 'e'],
90  ],
91  left: [
92    [5, 10, 'w'], [6, 10, 'e'], [5, 11, 'e'], [6, 11, 'e'],
93    [11, 10, 'w'], [12, 10, 'e'], [11, 11, 'e'], [12, 11, 'e'],
94  ],
95  right: [
96    [7, 10, 'w'], [8, 10, 'e'], [7, 11, 'e'], [8, 11, 'e'],
97    [13, 10, 'w'], [14, 10, 'e'], [13, 11, 'e'], [14, 11, 'e'],
98  ],
99  x: [
100    [5, 9, 'e'], [7, 9, 'e'], [6, 10, 'e'], [5, 11, 'e'], [7, 11, 'e'],
101    [12, 9, 'e'], [14, 9, 'e'], [13, 10, 'e'], [12, 11, 'e'], [14, 11, 'e'],
102  ],
103} satisfies Record<string, Dot[]>
104
105const CHEEKS: Dot[] = [[5, 12, 'c'], [14, 12, 'c']]
106
107const MOUTH = {
108  smile: [[8, 13, 'm'], [9, 14, 'm'], [10, 14, 'm'], [11, 13, 'm']],
109  small: [[9, 14, 'm'], [10, 14, 'm']],
110  open: [[9, 13, 'm'], [10, 13, 'm'], [9, 14, 'm'], [10, 14, 'm']],
111  frown: [[8, 14, 'm'], [9, 13, 'm'], [10, 13, 'm'], [11, 14, 'm']],
112  o: [[9, 14, 'm']],
113} satisfies Record<string, Dot[]>
114
115function plus(x: number, y: number, ch: keyof Palette): Dot[] {
116  return [[x, y - 1, ch], [x - 1, y, ch], [x, y, ch], [x + 1, y, ch], [x, y + 1, ch]]
117}
118
119const BANG: Dot[] = [[9, 0, 'r'], [10, 0, 'r'], [9, 1, 'r'], [10, 1, 'r'], [9, 2, 'r'], [10, 2, 'r'], [9, 4, 'r'], [10, 4, 'r']]
120const DROP: Dot[] = [[16, 6, 'd'], [16, 7, 'd'], [17, 7, 'd'], [16, 8, 'd']]
121const Z_SMALL: Dot[] = [[16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'], [17, 4, 'a'], [16, 5, 'a'], [17, 5, 'a'], [18, 5, 'a']]
122const Z_BIG: Dot[] = [
123  [15, 0, 'a'], [16, 0, 'a'], [17, 0, 'a'], [18, 0, 'a'], [17, 1, 'a'], [16, 2, 'a'],
124  [15, 3, 'a'], [16, 3, 'a'], [17, 3, 'a'], [18, 3, 'a'],
125]
126
127/** One frame: which tail, and which dots on top. */
128type Frame = { tail: 0 | 1; dots: Dot[] }
129
130export type Animation = {
131  frames: Frame[]
132  /** Seconds each frame shows. */
133  frameS: number
134  /** Seconds per bob up and down; 0 holds still. */
135  bobS: number
136}
137
138const face = (...parts: Dot[][]): Dot[] => parts.flat()
139
140export const ANIMATIONS: Record<VesselState, Animation> = {
141  idle: {
142    frameS: 0.5,
143    bobS: 2,
144    frames: [0, 1, 0, 1, 0, 1].map((tail, i) => ({
145      tail: tail as 0 | 1,
146      dots: face(i === 5 ? EYES.closed : EYES.open, CHEEKS, MOUTH.small),
147    })),
148  },
149  thinking: {
150    frameS: 0.45,
151    bobS: 1.6,
152    frames: [
153      { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a']]) },
154      { tail: 1, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a']]) },
155      { tail: 0, dots: face(EYES.up, CHEEKS, MOUTH.o, [[14, 5, 'a'], [16, 3, 'a'], [18, 1, 'a']]) },
156    ],
157  },
158  working: {
159    frameS: 0.18,
160    bobS: 0.5,
161    frames: [
162      { tail: 0, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(18, 7, 'y')) },
163      { tail: 1, dots: face(EYES.focus, CHEEKS, MOUTH.small, plus(1, 9, 'y')) },
164    ],
165  },
166  attention: {
167    frameS: 0.4,
168    bobS: 0.8,
169    frames: [
170      { tail: 0, dots: face(EYES.wide, CHEEKS, MOUTH.o, BANG) },
171      { tail: 1, dots: face(EYES.wide, CHEEKS, MOUTH.o) },
172    ],
173  },
174  success: {
175    frameS: 0.3,
176    bobS: 0.6,
177    frames: [
178      { tail: 0, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(2, 4, 'y'), [[17, 2, 'y']]) },
179      { tail: 1, dots: face(EYES.happy, CHEEKS, MOUTH.smile, plus(17, 3, 'y'), [[2, 6, 'y']]) },
180    ],
181  },
182  responding: {
183    frameS: 0.2,
184    bobS: 1.2,
185    frames: [
186      { tail: 0, dots: face(EYES.open, CHEEKS, MOUTH.open) },
187      { tail: 1, dots: face(EYES.open, CHEEKS, MOUTH.small) },
188    ],
189  },
190  error: {
191    frameS: 0.6,
192    bobS: 0,
193    frames: [
194      { tail: 0, dots: face(EYES.x, MOUTH.frown, DROP) },
195      { tail: 1, dots: face(EYES.x, MOUTH.frown) },
196    ],
197  },
198  waiting: {
199    frameS: 0.45,
200    bobS: 1.6,
201    frames: [
202      { tail: 0, dots: face(EYES.left, CHEEKS, MOUTH.small) },
203      { tail: 1, dots: face(EYES.right, CHEEKS, MOUTH.small) },
204    ],
205  },
206  sleeping: {
207    frameS: 0.9,
208    bobS: 3.6,
209    frames: [
210      { tail: 0, dots: face(EYES.closed, MOUTH.o, Z_SMALL) },
211      { tail: 1, dots: face(EYES.closed, MOUTH.o, Z_BIG) },
212    ],
213  },
214}
215
216export function compose(frame: Frame): Grid {
217  const rows = [...BODY, ...TAILS[frame.tail]]
218  const grid = rows.map(row => row.split(''))
219  for (const [x, y, ch] of frame.dots) {
220    const row = grid[y]
221    if (row !== undefined && x >= 0 && x < GRID) {
222      row[x] = ch
223    }
224  }
225  return grid
226}
227
228/** Horizontal runs of one color become one rect, to keep the SVG small. */
229export function rects(grid: Grid, palette: Palette): string {
230  const out: string[] = []
231  grid.forEach((row, y) => {
232    let x = 0
233    while (x < row.length) {
234      const ch = row[x]
235      if (ch === '.') {
236        x += 1
237        continue
238      }
239      let end = x + 1
240      while (end < row.length && row[end] === ch) end += 1
241      const fill = palette[ch as keyof Palette] ?? '#ff00ff'
242      out.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${fill}"/>`)
243      x = end
244    }
245  })
246  return out.join('')
247}
248
249const r = (n: number) => Math.round(n * 1000) / 1000
250
251/** The whole animated Vessel for one state, as SVG markup. */
252export function vesselSvg(state: VesselState, palette: Palette, size: number): string {
253  const anim = ANIMATIONS[state]
254  const n = anim.frames.length
255  const cycle = r(anim.frameS * n)
256  const slice = r(100 / n)
257
258  const delays = anim.frames
259    .map((_, i) => `.d${i}{animation-delay:-${r(((n - i) % n) * anim.frameS)}s}`)
260    .join('')
261  const bob =
262    anim.bobS > 0
263      ? `.bob{animation:bob ${anim.bobS}s steps(1,end) infinite}` +
264        '@keyframes bob{0%{transform:translateY(0)}50%{transform:translateY(-1px)}100%{transform:translateY(0)}}'
265      : ''
266  const css =
267    `.f{opacity:0;animation:fr ${cycle}s steps(1,end) infinite}.f0{opacity:1}` +
268    `@keyframes fr{0%{opacity:1}${slice}%{opacity:0}100%{opacity:0}}` +
269    delays +
270    bob +
271    '@media (prefers-reduced-motion:reduce){.f,.bob{animation:none}}'
272
273  const frames = anim.frames
274    .map((frame, i) => `<g class="f f${i === 0 ? '0' : 'n'} d${i}">${rects(compose(frame), palette)}</g>`)
275    .join('')
276
277  return (
278    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1 ${GRID} ${GRID + 1}" ` +
279    `width="${size}" height="${Math.round((size * (GRID + 1)) / GRID)}" shape-rendering="crispEdges">` +
280    `<style>${css}</style><g class="bob">${frames}</g></svg>`
281  )
282}
283
284/** Terminal fallback: one line per state. */
285export const FACES: Record<VesselState, string> = {
286  idle: '( o_o )',
287  thinking: '( o_o ) . o O',
288  working: '( >_< ) *',
289  attention: '( O_O ) !',
290  success: '( ^_^ ) +',
291  responding: '( o0o )',
292  error: '( x_x )',
293  waiting: '( o_o)>',
294  sleeping: '( -_- ) z',
295}
296
hooks/field.ts 383 lines
1// The field engine. A scene is a sampler: for a point and a frame it gives
2// a layer and a brightness. The engine samples it onto a grid sized to the
3// slot, once per frame, and an ink turns the grid into SVG:
4//
5//   glyph     a character per cell, from a ramp of dim to dense
6//   pixel     a block per cell, dithered between a layer's shades
7//   particle  a dot per lit cell, sized by brightness
8//
9// Each layer names its ink, so one scene can mix them: a pixel creature in
10// particle water. Frames cycle by CSS, locked to the wall clock, so a
11// redraw for a new state carries the motion on unbroken.
12
13export type Ink = 'glyph' | 'pixel' | 'particle'
14export const INKS: readonly Ink[] = ['glyph', 'pixel', 'particle']
15
16/** How one layer of a scene looks in each ink. */
17export type LayerStyle = {
18  /** glyph: characters from dim to dense; a space draws nothing. */
19  chars: string
20  /** pixel: shades from dim to bright. */
21  shades: string[]
22  /** glyph and particle: the fill, a colour or a paint server. */
23  fill: string
24  opacity?: number
25  /** Drawn with a soft bloom. */
26  isGlowing?: boolean
27  /** The ink this layer draws in; the scene's when absent. */
28  ink?: Ink
29}
30
31/** [layer, brightness]: layer 0 draws nothing; brightness runs 0 to 1. */
32export type Cell = readonly [number, number]
33export const EMPTY: Cell = [0, 0]
34
35export type FieldScene = {
36  width: number
37  height: number
38  /** Index 0 is the empty layer and is never drawn. */
39  layers: LayerStyle[]
40  /** The ink of any layer that names none. */
41  ink: Ink
42  /**
43   * A point in pixels, a frame of `frames`: what moves there, or EMPTY.
44   * `want` is the layers this pass draws; a scene may skip work for others.
45   */
46  sample: (x: number, y: number, frame: number, frames: number, want: ReadonlySet<number>) => Cell
47  /**
48   * What lies still behind it: sampled once and drawn outside the frame
49   * cycle, so a backdrop costs one frame however many the scene has. A
50   * cell the moving layers reach in any frame is left out of it.
51   */
52  still?: (x: number, y: number, want: ReadonlySet<number>) => Cell
53  /**
54   * How the still backdrop moves: a small drift side to side (the default),
55   * or a sway from the top edge, as light shafts hang from the surface.
56   */
57  stillMotion?: 'drift' | 'sway'
58  /** Seconds for one pass through the frames. */
59  period: number
60  /** Markup drawn under the field and over it; defs and CSS the scene needs. */
61  under: string
62  over: string
63  defs: string[]
64  css: string[]
65}
66
67/** Cell size in pixels and frame count per ink: what the budget allows. */
68export const GRID: Record<Ink, { cw: number; ch: number; frames: number }> = {
69  glyph: { cw: 8, ch: 14, frames: 8 },
70  pixel: { cw: 8, ch: 8, frames: 3 },
71  particle: { cw: 9, ch: 9, frames: 3 },
72}
73
74const r1 = (n: number) => Math.round(n * 10) / 10
75
76/** A stable value in [0, 1) for a cell: the same every frame and every draw. */
77export function hash(x: number, y: number): number {
78  const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
79  return s - Math.floor(s)
80}
81
82/** Smooth value noise in [0, 1). */
83export function noise(x: number, y: number): number {
84  const xi = Math.floor(x)
85  const yi = Math.floor(y)
86  const xf = x - xi
87  const yf = y - yi
88  const u = xf * xf * (3 - 2 * xf)
89  const v = yf * yf * (3 - 2 * yf)
90  const a = hash(xi, yi)
91  const b = hash(xi + 1, yi)
92  const c = hash(xi, yi + 1)
93  const d = hash(xi + 1, yi + 1)
94  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
95}
96
97export const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n)
98export const smoothstep = (a: number, b: number, x: number) => {
99  const t = clamp01((x - a) / (b - a))
100  return t * t * (3 - 2 * t)
101}
102
103/** 4×4 ordered dither thresholds. */
104const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(n => (n + 0.5) / 16)
105
106type Sampled = { cols: number; rows: number; layer: Uint8Array[]; value: Float32Array[]; still?: { layer: Uint8Array; value: Float32Array } }
107
108/** The scene sampled on one ink's grid, keeping only the layers drawn in that ink. */
109function sampleAll(scene: FieldScene, cw: number, ch: number, frames: number, keep: Set<number>): Sampled {
110  const cols = Math.floor(scene.width / cw)
111  const rows = Math.floor(scene.height / ch)
112  const layer: Uint8Array[] = []
113  const value: Float32Array[] = []
114  for (let f = 0; f < frames; f += 1) {
115    const L = new Uint8Array(cols * rows)
116    const V = new Float32Array(cols * rows)
117    for (let y = 0; y < rows; y += 1) {
118      for (let x = 0; x < cols; x += 1) {
119        const [l, v] = scene.sample((x + 0.5) * cw, (y + 0.5) * ch, f, frames, keep)
120        if (l > 0 && v > 0 && keep.has(l)) {
121          L[y * cols + x] = l
122          V[y * cols + x] = clamp01(v)
123        }
124      }
125    }
126    layer.push(L)
127    value.push(V)
128  }
129  let still: Sampled['still']
130  if (scene.still) {
131    const L = new Uint8Array(cols * rows)
132    const V = new Float32Array(cols * rows)
133    for (let y = 0; y < rows; y += 1) {
134      for (let x = 0; x < cols; x += 1) {
135        const i = y * cols + x
136        if (layer.some(f => f[i] !== 0)) {
137          continue
138        }
139        const [l, v] = scene.still((x + 0.5) * cw, (y + 0.5) * ch, keep)
140        if (l > 0 && v > 0 && keep.has(l)) {
141          L[i] = l
142          V[i] = clamp01(v)
143        }
144      }
145    }
146    still = { layer: L, value: V }
147  }
148  return { cols, rows, layer, value, still }
149}
150
151function glyphFrame(scene: FieldScene, s: Grid, cw: number, ch: number): string {
152  const out: string[] = []
153  scene.layers.forEach((style, l) => {
154    if (l === 0) {
155      return
156    }
157    const texts: string[] = []
158    for (let y = 0; y < s.rows; y += 1) {
159      let line = ''
160      let first = -1
161      let last = -1
162      for (let x = 0; x < s.cols; x += 1) {
163        const i = y * s.cols + x
164        let c = ' '
165        if (s.layer[i] === l) {
166          // Lifted and jittered a little, so mid-tones don't band into rows of one character.
167          const v = clamp01(Math.pow(s.value[i]!, 0.75) + (hash(x, y) - 0.5) * 0.14)
168          c = style.chars[Math.min(style.chars.length - 1, Math.floor(v * style.chars.length))]!
169        }
170        if (c !== ' ') {
171          if (first < 0) {
172            first = x
173          }
174          last = x
175        }
176        line += c
177      }
178      if (first >= 0) {
179        texts.push(`<text x="${first * cw}" y="${r1(y * ch + ch * 0.78)}" textLength="${(last - first + 1) * cw}">${line.slice(first, last + 1)}</text>`)
180      }
181    }
182    if (texts.length) {
183      out.push(layerGroup(style, texts.join(''), style.fill))
184    }
185  })
186  return out.join('')
187}
188
189function pixelFrame(scene: FieldScene, s: Grid): string {
190  const out: string[] = []
191  scene.layers.forEach((style, l) => {
192    if (l === 0) {
193      return
194    }
195    const n = style.shades.length
196    const runs: string[][] = style.shades.map(() => [])
197    for (let y = 0; y < s.rows; y += 1) {
198      let runLevel = -1
199      let runStart = 0
200      const flush = (end: number) => {
201        if (runLevel >= 0) {
202          const len = end - runStart
203          runs[runLevel]!.push(`M${runStart} ${y}h${len}v1h-${len}z`)
204        }
205      }
206      for (let x = 0; x < s.cols; x += 1) {
207        const i = y * s.cols + x
208        let level = -1
209        if (s.layer[i] === l) {
210          const at = s.value[i]! * (n - 1)
211          const base = Math.floor(at)
212          level = Math.min(n - 1, at - base > BAYER[(y % 4) * 4 + (x % 4)]! ? base + 1 : base)
213        }
214        if (level !== runLevel) {
215          flush(x)
216          runLevel = level
217          runStart = x
218        }
219      }
220      flush(s.cols)
221    }
222    const paths = runs.map((d, k) => (d.length ? `<path fill="${style.shades[k]}" d="${d.join('')}"/>` : '')).join('')
223    if (paths) {
224      out.push(layerGroup(style, paths))
225    }
226  })
227  return out.join('')
228}
229
230/** One grid's worth: a frame, or the still backdrop. */
231type Grid = { cols: number; rows: number; layer: Uint8Array; value: Float32Array }
232
233const DOT_SIZES = [0.32, 0.55, 0.85]
234
235function particleFrame(scene: FieldScene, s: Grid, cw: number, ch: number): string {
236  const out: string[] = []
237  scene.layers.forEach((style, l) => {
238    if (l === 0) {
239      return
240    }
241    const buckets: string[][] = DOT_SIZES.map(() => [])
242    for (let y = 0; y < s.rows; y += 1) {
243      for (let x = 0; x < s.cols; x += 1) {
244        const i = y * s.cols + x
245        if (s.layer[i] !== l) {
246          continue
247        }
248        const v = s.value[i]!
249        if (v < 0.08) {
250          continue
251        }
252        const k = v < 0.4 ? 0 : v < 0.72 ? 1 : 2
253        const jx = (hash(x, y) - 0.5) * cw * 0.6
254        const jy = (hash(y, x) - 0.5) * ch * 0.6
255        buckets[k]!.push(`M${Math.round((x + 0.5) * cw + jx)} ${Math.round((y + 0.5) * ch + jy)}h0`)
256      }
257    }
258    const paths = buckets
259      .map((d, k) => (d.length ? `<path stroke="${style.fill}" stroke-width="${r1(DOT_SIZES[k]! * cw)}" d="${d.join('')}"/>` : ''))
260      .join('')
261    if (paths) {
262      out.push(layerGroup(style, paths))
263    }
264  })
265  return out.join('')
266}
267
268function layerGroup(style: LayerStyle, body: string, fill?: string): string {
269  const attrs =
270    (fill ? ` fill="${fill}"` : '') +
271    (style.opacity !== undefined ? ` opacity="${style.opacity}"` : '') +
272    (style.isGlowing ? ' filter="url(#bloom)"' : '')
273  return `<g${attrs}>${body}</g>`
274}
275
276const BLOOM =
277  '<filter id="bloom" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>'
278
279/** What a drawing may come to: under the Svg element's 131,072, with room to spare. */
280export const BUDGET = 120_000
281
282/** The slot area GRID's cell sizes were set for: a 412 × 1442 dock. */
283const REFERENCE_AREA = 412 * 1442
284
285/**
286 * The scene drawn, its frames cycling from where the clock says. Runs of
287 * layers that share an ink are one pass, each on its own grid, drawn in
288 * layer order. `only` draws every layer in one ink, to compare inks.
289 *
290 * Cells grow with the slot so a larger one costs no more: from a scale set
291 * by its area, and coarser again until the drawing fits the budget.
292 */
293export function renderScene(scene: FieldScene, nowMs: number, only?: Ink): string {
294  let scale = Math.max(1, Math.sqrt((scene.width * scene.height) / REFERENCE_AREA))
295  let svg = renderAt(scene, nowMs, only, scale)
296  for (let tries = 0; svg.length > BUDGET && tries < 6; tries += 1) {
297    scale *= 1.2
298    svg = renderAt(scene, nowMs, only, scale)
299  }
300  return svg
301}
302
303function renderAt(scene: FieldScene, nowMs: number, only: Ink | undefined, scale: number): string {
304  const W = Math.round(scene.width)
305  const H = Math.round(scene.height)
306  const P = scene.period
307  const clock = (nowMs / 1000) % P
308
309  const passes: Array<{ ink: Ink; layers: number[] }> = []
310  scene.layers.forEach((style, l) => {
311    if (l === 0) {
312      return
313    }
314    const ink = only ?? style.ink ?? scene.ink
315    const last = passes[passes.length - 1]
316    if (last?.ink === ink) {
317      last.layers.push(l)
318    } else {
319      passes.push({ ink, layers: [l] })
320    }
321  })
322
323  const css: string[] = []
324  const reduced: string[] = []
325  const body = passes
326    .map((pass, p) => {
327      const base = GRID[pass.ink]
328      const cw = Math.round(base.cw * scale)
329      const ch = Math.round(base.ch * scale)
330      const frames = base.frames
331      const s = sampleAll(scene, cw, ch, frames, new Set(pass.layers))
332      const draw = (g: Grid) =>
333        pass.ink === 'glyph' ? glyphFrame(scene, g, cw, ch) : pass.ink === 'pixel' ? pixelFrame(scene, g) : particleFrame(scene, g, cw, ch)
334      const slot = P / frames
335
336      let stillMarkup = s.still ? draw({ cols: s.cols, rows: s.rows, ...s.still }) : ''
337      if (stillMarkup) {
338        // The still backdrop drifts a little, so it never reads as frozen.
339        // Pixel ink draws in cell units, the others in pixels.
340        const drift = pass.ink === 'pixel' ? 0.5 : r1(cw * 0.5)
341        const delay = r1(-((nowMs / 1000) % 18))
342        css.push(
343          scene.stillMotion === 'sway'
344            ? `.s${p}{transform-box:view-box;transform-origin:${r1(W / 2)}px 0;animation:s${p} 7s ${delay}s ease-in-out infinite alternate}@keyframes s${p}{from{transform:skewX(-2.5deg)}to{transform:skewX(2.5deg)}}`
345            : `.s${p}{animation:s${p} 9s ${delay}s ease-in-out infinite alternate}@keyframes s${p}{from{transform:translate(-${drift}px,0)}to{transform:translate(${drift}px,0)}}`,
346        )
347        reduced.push(`.s${p}`)
348        stillMarkup = `<g class="s${p}">${stillMarkup}</g>`
349      }
350
351      const frameBodies = Array.from({ length: frames }, (_, f) => draw({ cols: s.cols, rows: s.rows, layer: s.layer[f]!, value: s.value[f]! }))
352      let frameMarkup = ''
353      if (frameBodies.some(b => b)) {
354        css.push(`.p${p}{opacity:0;animation:p${p} ${P}s steps(1,end) infinite}@keyframes p${p}{0%{opacity:1}${r1(100 / frames)}%{opacity:0}100%{opacity:0}}`)
355        reduced.push(`.p${p}`)
356        frameMarkup = frameBodies
357          .map((b, f) => {
358            const delay = r1(-(((P - f * slot) % P) + clock) % P)
359            return `<g class="p${p}${f === 0 ? ' f0' : ''}" style="animation-delay:${delay}s">${b}</g>`
360          })
361          .join('')
362      }
363
364      const attrs =
365        pass.ink === 'glyph'
366          ? ` font-family="ui-monospace,Menlo,Consolas,monospace" font-size="${r1(ch * 1.02)}" font-weight="700" style="white-space:pre"`
367          : pass.ink === 'pixel'
368            ? ` transform="scale(${cw} ${ch})" shape-rendering="crispEdges"`
369            : ' fill="none" stroke-linecap="round"'
370      return stillMarkup || frameMarkup ? `<g${attrs}>${stillMarkup}${frameMarkup}</g>` : ''
371    })
372    .join('')
373
374  if (reduced.length) {
375    css.push(`@media (prefers-reduced-motion:reduce){${reduced.join(',')}{animation:none}.f0{opacity:1}}`)
376  }
377  return (
378    `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
379    `<style>${css.join('')}${scene.css.join('')}</style><defs>${BLOOM}${scene.defs.join('')}</defs>` +
380    `${scene.under}${body}${scene.over}</svg>`
381  )
382}
383
hooks/kraken.ts 525 lines
1// 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}
525
hooks/stage.ts 199 lines
1// The stage: the Vessel set in a scene the size of the pane body, so the
2// pane reads as a place rather than a small image in a large box. A glow
3// and a wash in the state's colour, a soft floor shadow, motes rising at
4// the state's tempo. Every layer fades to transparent before the edge, so
5// an estimate a few pixels off never shows a seam, in either theme.
6
7import type { VesselState } from './eidola-types'
8
9// Measured on Claude Code Desktop with /eidola measure (2026-10-16): a
10// 52 × 76 cell dock body drew 412 × 1443 px, a 95-column band 756 px.
11export const PX_PER_COLUMN = 8
12export const PX_PER_ROW = 19
13
14/** The dark behind a band view drawn across the middle of the pane. */
15export const BACKDROP = '#07090c'
16
17/** The veil drawn over the resting scene while nothing is awake. */
18export const VEIL = '#000'
19
20/** The Svg element's limit on `source`. */
21export const SVG_LIMIT = 131072
22
23/**
24 * The band's square stage, in CSS pixels. Desktop draws the band at least
25 * about 170px tall whatever its tree, so this fills it rather than float.
26 */
27export const BAND_STAGE_PX = 140
28
29/** Inline the pane fits its tree, so the stage asks for no more than this. */
30const INLINE_ROWS = 10
31
32export type StageSize = { width: number; height: number }
33
34export const ACCENTS: Record<VesselState, string> = {
35  idle: '#8fa3bf',
36  thinking: '#8c7bff',
37  working: '#ffb547',
38  attention: '#ffd84a',
39  success: '#5fd38d',
40  responding: '#4fd1c5',
41  error: '#ff5c5c',
42  waiting: '#b39ddb',
43  sleeping: '#4a5a80',
44}
45
46/** Seconds per glow breath. */
47const BREATH: Record<VesselState, number> = {
48  idle: 6,
49  thinking: 3,
50  working: 1.6,
51  attention: 1.2,
52  success: 2.4,
53  responding: 2.4,
54  error: 1,
55  waiting: 4,
56  sleeping: 9,
57}
58
59/** Seconds for a mote to rise; busier states rise faster. */
60const RISE: Record<VesselState, number> = {
61  idle: 14,
62  thinking: 9,
63  working: 5,
64  attention: 7,
65  success: 6,
66  responding: 8,
67  error: 10,
68  waiting: 12,
69  sleeping: 22,
70}
71
72const MOTES = 18
73
74/** The pane body in CSS pixels, a cell short each way so it never overflows. */
75export function stageSize(columns: number, rows: number, placement: 'dock' | 'inline'): StageSize {
76  const usable = placement === 'inline' ? Math.min(rows, INLINE_ROWS) : rows
77  return {
78    width: Math.max(96, (columns - 1) * PX_PER_COLUMN),
79    height: Math.max(96, (usable - 1) * PX_PER_ROW),
80  }
81}
82
83/** A scene band's height: it fills the band's own minimum height. */
84export const BAND_SCENE_PX = 140
85
86/**
87 * The whole pane body in CSS pixels, for a scene that fills it edge to edge.
88 * The measured dock body was 4px under columns × 8 and 2px under rows × 19.
89 */
90export function sceneSize(columns: number, rows: number, placement: 'dock' | 'inline'): StageSize {
91  const usable = placement === 'inline' ? Math.min(rows, INLINE_ROWS) : rows
92  return {
93    width: Math.max(96, columns * PX_PER_COLUMN - 4),
94    height: Math.max(96, usable * PX_PER_ROW - 2),
95  }
96}
97
98type Box = { x: number; y: number; w: number; h: number }
99
100function viewBoxOf(svg: string): Box | undefined {
101  const m = /viewBox="([-\d.]+)[ ,]+([-\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)"/.exec(svg)
102  if (!m) {
103    return undefined
104  }
105  const [x, y, w, h] = [m[1], m[2], m[3], m[4]].map(Number) as [number, number, number, number]
106  return w > 0 && h > 0 ? { x, y, w, h } : undefined
107}
108
109/** A fixed sequence in [0, 1): the motes sit in the same places every draw. */
110function seeded(seed: number): () => number {
111  let a = seed >>> 0
112  return () => {
113    a = (a + 0x6d2b79f5) >>> 0
114    let t = a
115    t = Math.imul(t ^ (t >>> 15), t | 1)
116    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
117    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
118  }
119}
120
121const r1 = (n: number) => Math.round(n * 10) / 10
122
123/**
124 * The Vessel's SVG set on a stage of `size`, or undefined when it can't be:
125 * no viewBox to place it by, or the result past the Svg limit. The caller
126 * then draws the Vessel alone.
127 */
128export function stageSvg(vessel: string, state: VesselState, size: StageSize): string | undefined {
129  const box = viewBoxOf(vessel)
130  if (!box) {
131    return undefined
132  }
133  const { width: W, height: H } = size
134  const accent = ACCENTS[state]
135
136  // A tall dock seats the Vessel above centre, leaving room for its shadow.
137  const isTall = H > W * 1.2
138  const scale = Math.min((W * 0.72) / box.w, (H * (isTall ? 0.42 : 0.78)) / box.h)
139  const vw = box.w * scale
140  const vh = box.h * scale
141  const cx = W / 2
142  const cy = isTall ? H * 0.42 : H / 2
143  const vx = cx - vw / 2
144  const vy = cy - vh / 2
145  const reach = Math.max(vw, vh)
146  // The wash is an ellipse that ends at the nearest edge on each axis, and
147  // the glow a circle inside it, so neither tints an edge into a seam.
148  const rx = Math.max(1, Math.min(cx, W - cx))
149  const ry = Math.max(1, Math.min(cy, H - cy))
150  const glowR = Math.min(reach * 0.95, rx, ry)
151
152  const random = seeded(7)
153  const rise = Math.round(Math.min(H * 0.55, reach * 2.2))
154  const motes: string[] = []
155  for (let i = 0; i < MOTES; i += 1) {
156    const x = r1(cx + (random() - 0.5) * Math.min(W * 0.85, reach * 2))
157    const y = r1(cy + vh * 0.2 + random() * vh * 0.5)
158    const r = r1(0.8 + random() * 1.6)
159    const dur = r1(RISE[state] * (0.7 + random() * 0.6))
160    const delay = r1(-random() * dur)
161    motes.push(`<circle class="eds-mote" cx="${x}" cy="${y}" r="${r}" style="animation-duration:${dur}s;animation-delay:${delay}s"/>`)
162  }
163
164  // The Vessel nested at its place: its own width/height dropped, the slot's set.
165  const nested = vessel.replace(/<svg\b([^>]*)>/, (_match, attrs: string) => {
166    const kept = attrs.replace(/\s(width|height|x|y|preserveAspectRatio)="[^"]*"/g, '')
167    return `<svg x="${r1(vx)}" y="${r1(vy)}" width="${r1(vw)}" height="${r1(vh)}" preserveAspectRatio="xMidYMid meet"${kept}>`
168  })
169
170  const css =
171    `.eds-glow{animation:eds-breath ${BREATH[state]}s ease-in-out infinite alternate}` +
172    '@keyframes eds-breath{from{opacity:.6}to{opacity:1}}' +
173    `.eds-mote{fill:${accent};opacity:0;animation-name:eds-rise;animation-timing-function:linear;animation-iteration-count:infinite}` +
174    `@keyframes eds-rise{0%{transform:translateY(0);opacity:0}15%{opacity:.75}80%{opacity:.4}100%{transform:translateY(-${rise}px);opacity:0}}` +
175    '@media (prefers-reduced-motion:reduce){.eds-glow,.eds-mote{animation:none}.eds-mote{opacity:.35}}'
176
177  const out =
178    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
179    `<style>${css}</style>` +
180    '<defs>' +
181    `<radialGradient id="eds-wash" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1" ` +
182    `gradientTransform="translate(${r1(cx)} ${r1(cy)}) scale(${r1(rx)} ${r1(ry)})">` +
183    `<stop offset="0" stop-color="${accent}" stop-opacity=".14"/><stop offset=".45" stop-color="${accent}" stop-opacity=".07"/>` +
184    `<stop offset=".75" stop-color="${accent}" stop-opacity=".02"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
185    `<radialGradient id="eds-glow" gradientUnits="userSpaceOnUse" cx="${r1(cx)}" cy="${r1(cy)}" r="${r1(glowR)}">` +
186    `<stop offset="0" stop-color="${accent}" stop-opacity=".42"/><stop offset=".35" stop-color="${accent}" stop-opacity=".2"/>` +
187    `<stop offset=".65" stop-color="${accent}" stop-opacity=".06"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></radialGradient>` +
188    '<radialGradient id="eds-floor"><stop offset="0" stop-color="#000" stop-opacity=".45"/><stop offset="1" stop-color="#000" stop-opacity="0"/></radialGradient>' +
189    '</defs>' +
190    `<rect width="${W}" height="${H}" fill="url(#eds-wash)"/>` +
191    `<rect class="eds-glow" width="${W}" height="${H}" fill="url(#eds-glow)"/>` +
192    `<ellipse cx="${r1(cx)}" cy="${r1(vy + vh * 1.04)}" rx="${r1(vw * 0.45)}" ry="${r1(Math.max(3, vh * 0.05))}" fill="url(#eds-floor)"/>` +
193    `<g>${motes.join('')}</g>` +
194    nested +
195    '</svg>'
196
197  return out.length <= SVG_LIMIT ? out : undefined
198}
199
hooks/kraken-live.tsx 839 lines
1// 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
839
hooks/kraken3d.ts 464 lines
1// 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}
464
hooks/live-pixels.tsx 255 lines
1// 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