| 1 | // PetSim — the portable core of the Codewhale pet. |
| 2 | // |
| 3 | // This is a faithful, DOM-free port of the consort study's particle engine |
| 4 | // (grammar.js). The same code — same constants, same order of operations — is |
| 5 | // what the Rust, Swift and Kotlin cores implement. Four rules keep the view |
| 6 | // identical on every surface: |
| 7 | // |
| 8 | // 1. The body is the same 980 points, loaded from whale-points.tsv — never |
| 9 | // re-sampled from the image on each platform. |
| 10 | // 2. Per-particle jitter phases come from mulberry32(0xC0FFEE), not from the |
| 11 | // platform's RNG. |
| 12 | // 3. All motion is a pure function of (sim clock, state): nothing accumulates |
| 13 | // noise. Two runs fed the same tape produce the same frame. |
| 14 | // 4. Colour, hollowness and brightness are computed here, once — renderers |
| 15 | // only place and stamp dots. |
| 16 | // |
| 17 | // Positions stay normalized in body space (roughly [-0.5, 0.5]²). A renderer |
| 18 | // maps them through layout() to its own medium. |
| 19 | |
| 20 | export const PET_MAX_SECONDS = 100 * 365 * 86_400; |
| 21 | |
| 22 | export interface PetState { |
| 23 | activity: number; // how much work 0..1 |
| 24 | coherence: number; // converging school vs thrashing 0..1 |
| 25 | attention: number; // interaction salience 0..1 |
| 26 | channel: string; // Whalesong semantic category |
| 27 | observed: number; // instrumentation coverage 0..1 |
| 28 | roamX: number; // tank position, -1..1 |
| 29 | roamY: number; |
| 30 | flip: number; // 1 faces right, -1 left, passing 0 = turning edge-on |
| 31 | lit: number; // sleep dimmer 0..1 |
| 32 | } |
| 33 | |
| 34 | export interface PetOpts { motion: boolean; sensitivity: number; podSlots?: readonly (readonly [number, number])[] } |
| 35 | |
| 36 | export const REST_STATE: PetState = { |
| 37 | activity: 0.35, coherence: 0.8, attention: 0, channel: 'reasoning', |
| 38 | observed: 1, roamX: 0, roamY: 0, flip: 1, lit: 1, |
| 39 | }; |
| 40 | |
| 41 | const lerp = (a: number, b: number, t: number) => a + (b - a) * t; |
| 42 | const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v)); |
| 43 | |
| 44 | // --------------------------------------------------------------------------- |
| 45 | // Whalesong's vocabulary, carried over unchanged: colour, register, and the |
| 46 | // sustained-vs-onset rule live in the same table the instrument uses. |
| 47 | export interface Channel { key: string; label: string; color: string; freq: number; sustained: boolean; arch: string; form: string } |
| 48 | |
| 49 | export const ARCH_OF: Record<string, string> = { |
| 50 | reasoning: 'gyre', memory: 'gyre', |
| 51 | tool: 'strike', code: 'strike', filesystem: 'strike', |
| 52 | network: 'cross', communication: 'cross', browser: 'cross', |
| 53 | agent: 'pod', orchestration: 'pod', |
| 54 | error: 'tear', human: 'address', other: 'drift', |
| 55 | }; |
| 56 | |
| 57 | export const CHANNELS: Channel[] = [ |
| 58 | { key: 'reasoning', label: 'Model / reasoning', color: '#73c9b5', freq: 130.81, sustained: true, arch: 'gyre', form: 'gyre · rolling' }, |
| 59 | { key: 'tool', label: 'Tool calls', color: '#74aadd', freq: 261.63, sustained: false, arch: 'strike', form: 'strike · reaching' }, |
| 60 | { key: 'memory', label: 'Memory / RAG', color: '#b6a77f', freq: 195.99, sustained: true, arch: 'gyre', form: 'gyre · scanning' }, |
| 61 | { key: 'code', label: 'Code execution', color: '#9b9ed7', freq: 164.81, sustained: false, arch: 'strike', form: 'strike · along the body' }, |
| 62 | { key: 'filesystem', label: 'Filesystem', color: '#92b9c9', freq: 440.00, sustained: false, arch: 'strike', form: 'strike · fanning' }, |
| 63 | { key: 'network', label: 'Network / API', color: '#d3ac74', freq: 523.25, sustained: false, arch: 'cross', form: 'crossing · one way' }, |
| 64 | { key: 'browser', label: 'Browser / computer', color: '#9ea9df', freq: 349.23, sustained: false, arch: 'cross', form: 'crossing · a sweep' }, |
| 65 | { key: 'communication', label: 'Agent messages', color: '#83c5c9', freq: 293.66, sustained: false, arch: 'cross', form: 'crossing · two ways' }, |
| 66 | { key: 'agent', label: 'Subagent activity', color: '#b09acb', freq: 220.00, sustained: true, arch: 'pod', form: 'pod · peers' }, |
| 67 | { key: 'orchestration', label: 'Orchestration', color: '#6c8798', freq: 98.00, sustained: true, arch: 'pod', form: 'pod · hub' }, |
| 68 | { key: 'error', label: 'Errors / exceptions',color: '#e79186', freq: 185.00, sustained: false, arch: 'tear', form: 'torn · irregular' }, |
| 69 | { key: 'human', label: 'Human interaction', color: '#c2b787', freq: 391.99, sustained: false, arch: 'address', form: 'decision · junction' }, |
| 70 | { key: 'other', label: 'Unclassified', color: '#738492', freq: 146.83, sustained: false, arch: 'drift', form: 'drifting · unformed' }, |
| 71 | ]; |
| 72 | export const CHANNEL_INDEX: Record<string, number> = Object.fromEntries(CHANNELS.map((c, i) => [c.key, i])); |
| 73 | |
| 74 | export function validatePetState(value: unknown): asserts value is PetState { |
| 75 | const s = value as PetState; |
| 76 | if (!s || typeof s !== 'object' || !Object.hasOwn(CHANNEL_INDEX, s.channel) |
| 77 | || ![s.activity, s.coherence, s.attention, s.observed, s.lit].every(n => Number.isFinite(n) && n >= 0 && n <= 1) |
| 78 | || ![s.roamX, s.roamY, s.flip].every(n => Number.isFinite(n) && Math.abs(n) <= 1)) |
| 79 | throw new Error('Invalid pet state.'); |
| 80 | } |
| 81 | |
| 82 | const hex2rgb = (h: string) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; |
| 83 | const RGB = CHANNELS.map(c => hex2rgb(c.color)); |
| 84 | const UNKNOWN_RGB = hex2rgb('#738492'); |
| 85 | const REST_RGB = [122, 214, 240]; |
| 86 | |
| 87 | // mulberry32 — a 32-bit seeded PRNG tiny enough to port by hand correctly. |
| 88 | export function mulberry32(seed: number) { |
| 89 | let a = seed >>> 0; |
| 90 | return Object.assign(() => { |
| 91 | a = (a + 0x6D2B79F5) >>> 0; |
| 92 | let t = a; |
| 93 | t = Math.imul(t ^ (t >>> 15), t | 1); |
| 94 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); |
| 95 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 96 | }, { state: () => a, restore: (state: number) => { |
| 97 | if (!Number.isSafeInteger(state) || state < 0 || state > 0xffffffff) throw new Error('Invalid pet random stream.'); |
| 98 | a = state; |
| 99 | } }); |
| 100 | } |
| 101 | |
| 102 | export interface Particle { |
| 103 | x: number; y: number; vx: number; vy: number; |
| 104 | s: number; jx: number; jy: number; pod: number; |
| 105 | hx: number; hy: number; ang: number; rad: number; tail: number; |
| 106 | tx: number; ty: number; |
| 107 | } |
| 108 | |
| 109 | export interface Frame { |
| 110 | r: number; g: number; b: number; // particle colour, 0..255 |
| 111 | alpha: number; // uniform per-dot alpha |
| 112 | hollow: boolean; // coverage gap: rings, not filled dots |
| 113 | channel: string; // active category |
| 114 | arch: string; // active archetype |
| 115 | work: number; // rest↔work blend actually applied |
| 116 | } |
| 117 | |
| 118 | export interface PetSimCheckpoint { |
| 119 | version: 1; |
| 120 | expressionVersion?: 1 | 2; |
| 121 | body: number[][]; |
| 122 | particles: number[][]; |
| 123 | phase: number; clock: number; tear: number; previous: number; current: number; |
| 124 | color: number[]; frame: Frame; |
| 125 | } |
| 126 | |
| 127 | /** Work reorganizes the same particles; no new random draws or invented facts. |
| 128 | * These are expressive fields, not diagrams of unobserved network/file topology. */ |
| 129 | function fieldTarget(q: Particle, t: number, act: number, att: number, key: string): [number, number] | undefined { |
| 130 | const u = q.s * 2 - 1, lane = q.pod - 2.5, a = q.s * Math.PI * 2; |
| 131 | const flow = t * (.35 + act * .65); |
| 132 | if (key === 'reasoning') { |
| 133 | const ring = .34 + .105 * Math.cos(a * 3 + flow + lane * .18); |
| 134 | return [ring * Math.cos(a * 2 + flow * .3), ring * Math.sin(a * 2 + flow * .3) * .7 + .10 * Math.sin(a * 3 + flow)]; |
| 135 | } |
| 136 | if (key === 'memory') return [.46 * Math.cos(a + lane * .1 + flow * .25), lane * .082 + .052 * Math.sin(a * 2 + flow)]; |
| 137 | if (key === 'code') return [u * .57, lane * .066 + .12 * Math.sin(u * 7 + flow * 2 + q.pod * Math.PI / 3)]; |
| 138 | if (key === 'filesystem') { |
| 139 | const branch = Math.max(0, (u + .3) / 1.3); |
| 140 | return [u * .56, lane * .13 * branch + .025 * Math.sin(u * 8 - flow)]; |
| 141 | } |
| 142 | if (key === 'tool') { |
| 143 | const reach = .14 + (u + 1) * .20 + .04 * Math.sin(flow * 3 - u * 4); |
| 144 | return [Math.cos(q.pod * Math.PI / 3) * reach, Math.sin(q.pod * Math.PI / 3) * reach * .8 + q.hy * .06]; |
| 145 | } |
| 146 | if (key === 'browser') return [u * .56, lane * .083 + .035 * Math.sin(u * 5 - flow * 2)]; |
| 147 | if (key === 'network' || key === 'communication') { |
| 148 | const direction = key === 'communication' && q.pod % 2 === 1 ? -1 : 1; |
| 149 | const phase = a + flow * direction; |
| 150 | return [.54 * Math.cos(phase), Math.sin(phase) * (.12 + q.pod * .035) + lane * .024]; |
| 151 | } |
| 152 | if (key === 'human') { |
| 153 | const gap = u < 0 ? -.075 : .075; |
| 154 | return [u * .47 + gap, lane * .10 * Math.abs(u) + .012 * Math.sin(flow + a) * (1 - att)]; |
| 155 | } |
| 156 | return undefined; |
| 157 | } |
| 158 | |
| 159 | // Version 1 retains the original authored gait for existing recordings. |
| 160 | function gaitTarget(q: Particle, t: number, act: number, coh: number, att: number, key: string, work: number, podSlots?: PetOpts['podSlots'], expressionVersion = 1): [number, number] { |
| 161 | const { hx, hy, ang, rad, tail, s, pod, jx, jy } = q; |
| 162 | const omega = lerp(4.6, 5.2 + act * 2.8, work); |
| 163 | const breath = 1 + Math.sin(t * 1.85) * lerp(0.048, 0.018, work); |
| 164 | const flex = Math.sin(ang * 2.05 + t * omega) * lerp(0.042, 0.016 + act * 0.028, work) * (0.18 + 0.82 * tail); |
| 165 | let px = Math.cos(ang + flex) * rad * breath; |
| 166 | let py = Math.sin(ang + flex) * rad * breath; |
| 167 | px += Math.sin(t * 0.33) * lerp(0.030, 0.014, work); |
| 168 | py += Math.cos(t * 0.21) * lerp(0.018, 0.010, work); |
| 169 | if (work < 0.02) return [px, py]; |
| 170 | |
| 171 | const arch = ARCH_OF[key] || 'drift'; |
| 172 | let gx = px, gy = py; |
| 173 | if (arch === 'gyre') { |
| 174 | if (key === 'memory') { |
| 175 | const pulse = 1 + Math.sin(t * (2.4 + act * 1.6) - rad * 11) * (0.15 + act * 0.10); |
| 176 | gx *= pulse; gy *= pulse; |
| 177 | } else { |
| 178 | const roll = Math.sin(t * (1.05 + act * 0.35)) * (0.48 + act * 0.32); |
| 179 | const c = Math.cos(roll), sn = Math.sin(roll); |
| 180 | gx = px * c - py * sn * 0.88; |
| 181 | gy = px * sn * 0.88 + py * c; |
| 182 | } |
| 183 | } else if (arch === 'strike') { |
| 184 | if (key === 'tool') { |
| 185 | const rate = 2.7 + act * 2.1; |
| 186 | const lunge = Math.pow(Math.max(0, Math.sin(t * rate)), 2); |
| 187 | gx += lunge * 0.11; |
| 188 | if (s > 0.60) { |
| 189 | const reach = Math.pow(Math.max(0, Math.sin(t * rate + pod * 0.92)), 4) * (0.30 + act * 0.24); |
| 190 | gx += Math.cos(ang) * reach; |
| 191 | gy += Math.sin(ang) * reach; |
| 192 | } |
| 193 | } else if (key === 'code') { |
| 194 | const rate = 3.2 + act * 1.8; |
| 195 | const wave = Math.sin(t * rate - tail * 7.5); |
| 196 | const bump = 0.11 + act * 0.08; |
| 197 | gx += Math.cos(ang) * wave * bump; |
| 198 | gy += Math.sin(ang) * wave * bump * 1.2; |
| 199 | gx += Math.max(0, wave) * 0.07; |
| 200 | } else { |
| 201 | const rate = 2.15 + act * 1.5; |
| 202 | const side = (pod % 2) * 2 - 1; |
| 203 | const w = Math.pow(Math.max(0, Math.sin(t * rate + pod * 0.72)), 2); |
| 204 | gx += w * 0.055; |
| 205 | gy += side * w * (0.17 + act * 0.13); |
| 206 | } |
| 207 | } else if (arch === 'cross') { |
| 208 | if (key === 'browser') { |
| 209 | const band = ((t * (0.55 + act * 0.35)) % 1) * 1.28 - 0.64; |
| 210 | const inBand = Math.max(0, 1 - Math.abs(hy - band) / 0.08); |
| 211 | gx += inBand * (0.24 + act * 0.10); |
| 212 | gy += inBand * 0.02; |
| 213 | } else { |
| 214 | const two = key === 'communication'; |
| 215 | const courier = s < (two ? 0.44 : 0.32); |
| 216 | if (courier) { |
| 217 | const dir = two ? (s < 0.22 ? 1 : -1) : 1; |
| 218 | const u = (t * (0.38 + act * 0.36) + s * 5.2) % 1; |
| 219 | const going = u < 0.5 ? u * 2 : 2 - u * 2; |
| 220 | const e = going * going * (3 - 2 * going); |
| 221 | gx = lerp(hx, dir * 0.80, e); |
| 222 | gy = hy * (1 - e * 0.38) + Math.sin(going * Math.PI) * 0.11 * dir; |
| 223 | } |
| 224 | } |
| 225 | } else if (arch === 'pod') { |
| 226 | const n = 6, member = podSlots?.length ? podSlots[pod % podSlots.length] : undefined; |
| 227 | const k = member ? member[0] : pod % n; |
| 228 | const hub = key === 'orchestration' && k === 0; |
| 229 | const spread = 0.30 + act * 0.11; |
| 230 | const orbit = t * (0.55 + act * 0.28); |
| 231 | if (hub) { gx = px * 0.70; gy = py * 0.70; } |
| 232 | else { |
| 233 | const slots = key === 'orchestration' ? n - 1 : n; |
| 234 | const a = (key === 'orchestration' ? k - 1 : k) * (Math.PI * 2 / slots) + orbit + (member ? member[1] * .04 : 0); |
| 235 | const sc = 0.34; |
| 236 | gx = hx * sc + Math.cos(a) * spread * 1.28; |
| 237 | gy = hy * sc + Math.sin(a) * spread * 0.80; |
| 238 | } |
| 239 | } else if (arch === 'tear') { |
| 240 | const side = hx + hy < 0 ? -1 : 1; |
| 241 | gx += side * (0.24 + (1 - coh) * 0.16); |
| 242 | gy += side * 0.15; |
| 243 | gx += Math.sin(t * 11.4 + s * 40) * (0.045 + act * 0.05); |
| 244 | gy += Math.cos(t * 9.2 + s * 31) * (0.040 + act * 0.045); |
| 245 | } else if (arch === 'address') { |
| 246 | const face = 0.90 + att * 0.08; |
| 247 | const th = 0.70; |
| 248 | const z = (s - 0.5) * 0.42; |
| 249 | let ax = hx * Math.cos(th) + z * Math.sin(th); |
| 250 | let ay = hy; |
| 251 | const disc = 0.48 * face; |
| 252 | ax = lerp(ax, Math.cos(ang) * Math.min(0.36, rad + 0.06) * 0.95, disc); |
| 253 | ay = lerp(ay, Math.sin(ang) * Math.min(0.36, rad + 0.06) * 1.08, disc); |
| 254 | const grow = 1.20 + Math.sin(t * 1.65) * 0.055; |
| 255 | gx = ax * grow; gy = ay * grow; |
| 256 | } else { |
| 257 | const mill = 0.13 + (1 - coh) * 0.10; |
| 258 | gx = hx * 0.52 + Math.sin(t * 0.72 + jx) * mill; |
| 259 | gy = hy * 0.52 + Math.cos(t * 0.54 + jy) * mill; |
| 260 | } |
| 261 | if (expressionVersion === 2) { |
| 262 | const field = fieldTarget(q, t, act, att, key); |
| 263 | if (field) [gx, gy] = field; |
| 264 | } |
| 265 | return [lerp(px, gx, work), lerp(py, gy, work)]; |
| 266 | } |
| 267 | |
| 268 | // Fixed reduced-motion clock per channel, so ticks still point along the gait. |
| 269 | const STILL_T: Record<string, number> = { |
| 270 | reasoning: 1.15, memory: 0.42, tool: 0.30, code: 0.18, filesystem: 0.48, |
| 271 | network: 0.72, browser: 0.95, communication: 0.58, agent: 1.25, |
| 272 | orchestration: 0.85, error: 0.35, human: 0.05, other: 0.90, |
| 273 | }; |
| 274 | |
| 275 | export class PetSim { |
| 276 | readonly p: Particle[]; |
| 277 | private phase = 0; |
| 278 | private clock = 0; |
| 279 | private tear = 0; |
| 280 | private prev: number; |
| 281 | private col = [...REST_RGB]; |
| 282 | private cur: number; |
| 283 | frame: Frame = { r: REST_RGB[0], g: REST_RGB[1], b: REST_RGB[2], alpha: 0.3, hollow: false, channel: 'reasoning', arch: 'gyre', work: 0 }; |
| 284 | |
| 285 | constructor(points: [number, number][], seed = 0xC0FFEE, readonly expressionVersion: 1 | 2 = 2) { |
| 286 | if (expressionVersion !== 1 && expressionVersion !== 2) throw new Error('Unsupported pet expression version.'); |
| 287 | const rand = mulberry32(seed); |
| 288 | this.p = points.map(([hx, hy], i) => { |
| 289 | const q: Particle = { |
| 290 | x: hx, y: hy, vx: 0, vy: 0, |
| 291 | s: rand(), jx: rand() * 6.283, jy: rand() * 6.283, pod: i % 6, |
| 292 | hx, hy, ang: 0, rad: 0, tail: 0, tx: hx, ty: hy, |
| 293 | }; |
| 294 | q.ang = Math.atan2(hy, hx); |
| 295 | q.rad = Math.hypot(hx, hy); |
| 296 | q.tail = clamp(((-hx - hy) * 0.5 + 0.22) / 0.62); |
| 297 | return q; |
| 298 | }); |
| 299 | this.cur = this.prev = CHANNEL_INDEX['reasoning']; |
| 300 | } |
| 301 | |
| 302 | checkpoint(): PetSimCheckpoint { |
| 303 | return { version: 1, expressionVersion: this.expressionVersion, body: this.p.map(p => [p.hx, p.hy, p.s]), |
| 304 | particles: this.p.map(p => [p.x, p.y, p.vx, p.vy, p.jx, p.jy, p.tx, p.ty]), |
| 305 | phase: this.phase, clock: this.clock, tear: this.tear, previous: this.prev, current: this.cur, |
| 306 | color: [...this.col], frame: { ...this.frame } }; |
| 307 | } |
| 308 | |
| 309 | /** Restore into a newly constructed sim. Authored body and seeded particle |
| 310 | * identity must match exactly; a checkpoint cannot replace the whale. */ |
| 311 | restore(value: unknown): void { |
| 312 | const c = value as PetSimCheckpoint; |
| 313 | const inRange = (n: number, low: number, high: number) => Number.isFinite(n) && n >= low && n <= high; |
| 314 | if (!c || c.version !== 1 || c.expressionVersion !== undefined && ![1, 2].includes(c.expressionVersion) || (c.expressionVersion ?? 1) !== this.expressionVersion || !Array.isArray(c.body) || c.body.length !== this.p.length |
| 315 | || c.body.some((v, i) => !Array.isArray(v) || v.length !== 3 || v[0] !== this.p[i].hx || v[1] !== this.p[i].hy || v[2] !== this.p[i].s) |
| 316 | || !Array.isArray(c.particles) || c.particles.length !== this.p.length |
| 317 | || c.particles.some(v => !Array.isArray(v) || v.length !== 8 || v.some((n, i) => !inRange(n, i === 4 || i === 5 ? 0 : -8, i === 4 || i === 5 ? 2 * PET_MAX_SECONDS : 8))) |
| 318 | || !inRange(c.phase, 0, PET_MAX_SECONDS) || !inRange(c.clock, 0, PET_MAX_SECONDS) || !inRange(c.tear, 0, 1) |
| 319 | || ![c.previous, c.current].every(n => Number.isInteger(n) && n >= 0 && n < CHANNELS.length) |
| 320 | || !Array.isArray(c.color) || c.color.length !== 3 || c.color.some(n => !inRange(n, 0, 255)) |
| 321 | || !c.frame || ![c.frame.r, c.frame.g, c.frame.b].every(n => inRange(n, 0, 255)) |
| 322 | || !inRange(c.frame.alpha, 0, 1) || !inRange(c.frame.work, 0, 1) || typeof c.frame.hollow !== 'boolean' |
| 323 | || c.frame.channel !== CHANNELS[c.current].key || c.frame.arch !== CHANNELS[c.current].arch) |
| 324 | throw new Error('Invalid pet particle checkpoint.'); |
| 325 | this.phase = c.phase; this.clock = c.clock; this.tear = c.tear; this.prev = c.previous; this.cur = c.current; |
| 326 | this.col = [...c.color]; this.frame = { ...c.frame }; |
| 327 | this.p.forEach((p, i) => { [p.x, p.y, p.vx, p.vy, p.jx, p.jy, p.tx, p.ty] = c.particles[i]; }); |
| 328 | } |
| 329 | |
| 330 | /** Advance the sim by dt seconds under `state`. Identical math on every port. */ |
| 331 | step(dt: number, state: PetState, opts: PetOpts): void { |
| 332 | const S = (v: number) => lerp(0.5, v, opts.sensitivity); |
| 333 | const act = S(state.activity), coh = S(state.coherence), att = S(state.attention); |
| 334 | const seen = S(state.observed === undefined ? 1 : state.observed); |
| 335 | const motion = opts.motion ? 1 : 0; |
| 336 | this.phase += dt * (0.18 + act * 0.55) * motion; |
| 337 | this.clock += dt * (opts.motion ? 1 : 0); |
| 338 | |
| 339 | if (CHANNEL_INDEX[state.channel] !== undefined) this.cur = CHANNEL_INDEX[state.channel]; |
| 340 | const shown = this.cur; |
| 341 | const ch = CHANNELS[shown]; |
| 342 | |
| 343 | const work = clamp((act - 0.16) / 0.18); |
| 344 | const wander = lerp(0.32, 1, Math.pow(1 - coh, 1.15)); |
| 345 | |
| 346 | if (shown !== this.prev) { if (shown === CHANNEL_INDEX['error']) this.tear = 1; this.prev = shown; } |
| 347 | this.tear = opts.motion ? Math.max(0, this.tear - dt * 1.6) : 0; |
| 348 | |
| 349 | const split = Math.pow(1 - coh, 1.6) * 0.16 + this.tear * 0.10; |
| 350 | const blur = Math.pow(1 - coh, 1.45) * 0.22 + this.tear * 0.18; |
| 351 | const pull = opts.motion ? (2.2 + coh * 5.2) : 18; |
| 352 | const tGait = opts.motion ? this.clock : (STILL_T[ch.key] ?? 0.4); |
| 353 | |
| 354 | for (const q of this.p) { |
| 355 | if (opts.motion) { |
| 356 | q.jx += dt * (0.40 + act * 1.1); |
| 357 | q.jy += dt * (0.34 + act * 0.9); |
| 358 | } |
| 359 | const [gx, gy] = gaitTarget(q, tGait, act, coh, att, ch.key, work, opts.podSlots, this.expressionVersion); |
| 360 | const podAng = q.pod * 1.047 + this.phase * 0.22; |
| 361 | const tx = gx + Math.sin(q.jx + q.s * 9) * blur * wander + Math.cos(podAng) * split; |
| 362 | const ty = gy + Math.cos(q.jy + q.s * 7) * blur * wander + Math.sin(podAng) * split * 0.55; |
| 363 | q.tx = tx; q.ty = ty; |
| 364 | if (!opts.motion) { q.x = tx; q.y = ty; q.vx = 0; q.vy = 0; continue; } |
| 365 | q.vx += (tx - q.x) * pull * dt; q.vy += (ty - q.y) * pull * dt; |
| 366 | q.vx *= 0.90; q.vy *= 0.90; |
| 367 | q.x += q.vx * dt * (opts.motion ? 2.6 : 8); q.y += q.vy * dt * (opts.motion ? 2.6 : 8); |
| 368 | } |
| 369 | |
| 370 | // ---- visual encoding: the parts of "the same view" that are not motion |
| 371 | const want = work > 0.35 ? RGB[shown] : REST_RGB; |
| 372 | const k = opts.motion ? Math.min(1, dt * 2.6) : 1; |
| 373 | for (let c = 0; c < 3; c++) this.col[c] += (lerp(UNKNOWN_RGB[c], want[c], seen) - this.col[c]) * k; |
| 374 | const lit = clamp(state.lit); |
| 375 | const alpha = (0.22 + act * 0.10) * lerp(0.50, 1, coh) * lerp(0.55, 1, seen) * lerp(0.35, 1, lit); |
| 376 | this.frame = { |
| 377 | r: this.col[0], g: this.col[1], b: this.col[2], |
| 378 | alpha: Math.min(0.92, alpha * 1.85), |
| 379 | hollow: seen < 0.92, |
| 380 | channel: ch.key, arch: ch.arch, work, |
| 381 | }; |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | /** Body-space → renderer-space. Renderers place each dot at (lx,ly) in pixels/cells. */ |
| 386 | export function layout(w: number, h: number, state: PetState) { |
| 387 | const att = state.attention; |
| 388 | const scale = Math.min(w * 0.52, h * 0.92) * (1 + att * 0.07); |
| 389 | return { |
| 390 | scale, |
| 391 | flipX: state.flip, |
| 392 | ox: w / 2 + state.roamX * w * 0.30, |
| 393 | oy: h / 2 + state.roamY * h * 0.30 + h * att * 0.05, |
| 394 | dot: Math.max(1.6, Math.min(w, h) * 0.0092) * (1 + att * 0.18), |
| 395 | }; |
| 396 | } |
| 397 | |
| 398 | // --------------------------------------------------------------------------- |
| 399 | // Conformance. A tape is a list of [dt, state] rows; run it and digest the |
| 400 | // quantized field every `every` frames. 64×32 cells over [-0.66, 0.66]². |
| 401 | // Two implementations that produce the same digest lines drew the same whale. |
| 402 | export function digest(sim: PetSim): string { |
| 403 | const W = 64, H = 32; |
| 404 | const grid = new Uint8Array(W * H); |
| 405 | for (const q of sim.p) { |
| 406 | const cx = Math.floor((q.x + 0.66) / 1.32 * W); |
| 407 | const cy = Math.floor((q.y + 0.66) / 1.32 * H); |
| 408 | if (cx >= 0 && cx < W && cy >= 0 && cy < H) grid[cy * W + cx] = Math.min(255, grid[cy * W + cx] + 1); |
| 409 | } |
| 410 | // FNV-1a 64 over the grid plus the frame encoding (rgb, hollow, alpha byte) |
| 411 | // Two unsigned halves also work in embedded engines without BigInt. |
| 412 | // FNV's prime is (256 << 32) + 435; these products stay below 2^42, |
| 413 | // so every intermediate integer is exactly representable by a JS number. |
| 414 | let hi = 0xcbf29ce4, lo = 0x84222325; |
| 415 | const mix = (b: number) => { |
| 416 | lo = (lo ^ (b & 0xff)) >>> 0; |
| 417 | const product = lo * 435; |
| 418 | hi = (hi * 435 + lo * 256 + Math.floor(product / 4294967296)) >>> 0; |
| 419 | lo = product >>> 0; |
| 420 | }; |
| 421 | for (const v of grid) mix(v); |
| 422 | mix(Math.round(sim.frame.r)); mix(Math.round(sim.frame.g)); mix(Math.round(sim.frame.b)); |
| 423 | mix(Math.round(sim.frame.alpha * 255)); mix(sim.frame.hollow ? 1 : 0); |
| 424 | return hi.toString(16).padStart(8, '0') + lo.toString(16).padStart(8, '0'); |
| 425 | } |
| 426 | |
| 427 | /** Shared tape runner. `rows` are parsed tape.tsv lines. */ |
| 428 | export function runTape(sim: PetSim, rows: string[], opts: PetOpts): string[] { |
| 429 | const out: string[] = []; |
| 430 | let f = 0; |
| 431 | for (const row of rows) { |
| 432 | const c = row.split('\t'); |
| 433 | if (c.length < 10 || c[0] === 'dt') continue; |
| 434 | const st: PetState = { |
| 435 | activity: +c[1], coherence: +c[2], attention: +c[3], channel: c[4], |
| 436 | observed: +c[5], roamX: +c[6], roamY: +c[7], flip: +c[8], lit: +c[9], |
| 437 | }; |
| 438 | sim.step(+c[0], st, opts); |
| 439 | if (f++ % 30 === 0) out.push(`f${String(f - 1).padStart(4, '0')} ${digest(sim)} ${st.channel}`); |
| 440 | } |
| 441 | out.push(`final ${digest(sim)}`); |
| 442 | return out; |
| 443 | } |
| 444 |