From 87902ba5abcb3d6bb294a4d54eb985909dba85cf Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 28 Sep 2026 19:02:08 -0700 Subject: [PATCH] fiapup: a flat puppy in a flat yard, on oskiewar's object pipeline the pup, yard, ball, rope, hand and treat are KidLisp objects in the flat dialect; embed.mjs seals object-lisp.mjs, frame-vm.mjs and the objects into one global script the console host can load as-is. --- xbox/fiapup/embed.mjs | 98 + xbox/fiapup/fiapup.js | 2604 +++++++++++++++++++++++++++ xbox/fiapup/objects/ball-flat.lisp | 16 + xbox/fiapup/objects/hand-flat.lisp | 24 + xbox/fiapup/objects/puppy-flat.lisp | 87 + xbox/fiapup/objects/rope-flat.lisp | 12 + xbox/fiapup/objects/treat-flat.lisp | 6 + xbox/fiapup/objects/yard-flat.lisp | 77 + 8 files changed, 2924 insertions(+) create mode 100644 xbox/fiapup/embed.mjs create mode 100644 xbox/fiapup/fiapup.js create mode 100644 xbox/fiapup/objects/ball-flat.lisp create mode 100644 xbox/fiapup/objects/hand-flat.lisp create mode 100644 xbox/fiapup/objects/puppy-flat.lisp create mode 100644 xbox/fiapup/objects/rope-flat.lisp create mode 100644 xbox/fiapup/objects/treat-flat.lisp create mode 100644 xbox/fiapup/objects/yard-flat.lisp diff --git a/xbox/fiapup/embed.mjs b/xbox/fiapup/embed.mjs new file mode 100644 index 0000000000..78227dc163 --- /dev/null +++ b/xbox/fiapup/embed.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// embed.mjs — seal what fiapup.js borrows from oskiewar's pipeline into it. +// +// fiapup.js is one global script, like oskiewar.js, because that is all the +// console host loads. It carries three things it doesn't own, each read from +// the file oskiewar uses so the two games share one pipeline: +// +// xbox/live/object-lisp.mjs the object compiler (KidLisp → baked sketches) +// xbox/live/frame-vm.mjs the frame-program interpreter, run in-game on +// hosts that have no `frame` (the console until +// R6, and this game's own web shell) +// xbox/fiapup/objects/*.lisp the pup, the yard and the props +// +// It writes them between `// ` and `// ` and nothing else. +// +// node xbox/fiapup/embed.mjs rewrite the block +// node xbox/fiapup/embed.mjs --check exit 1 if the block is stale + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const here = new URL("./", import.meta.url); +const live = new URL("../live/", import.meta.url); +const read = (url) => readFileSync(url, "utf8"); +export const gamePath = fileURLToPath(new URL("fiapup.js", here)); +export const start = "// "; +export const end = "// "; + +// What the game draws, by the name it asks for, and the file each compiles from. +export const objects = { puppy: "puppy-flat", yard: "yard-flat", ball: "ball-flat", + rope: "rope-flat", hand: "hand-flat", treat: "treat-flat" }; + +// A module's source with its `export` keywords taken off, and the names it +// exported. `export default x;` lines go; the named export already covers x. +function unexport(source, file) { + const names = []; + const body = source.replace(/^export default .*$/gm, "") + .replace(/^export (async function\*?|function\*?|const|let|class) (\w+)/gm, (_, kind, name) => { + names.push(name); + return `${kind} ${name}`; + }); + if (/^import /m.test(body)) throw new Error(`${file}: the game can't carry imports`); + return { body, names }; +} + +const indent = (text) => text.trimEnd().split("\n").map((line) => (line ? " " + line : line)).join("\n"); + +export function generate() { + const lisp = unexport(read(new URL("object-lisp.mjs", live)), "object-lisp.mjs"); + const vm = unexport(read(new URL("frame-vm.mjs", live)), "frame-vm.mjs"); + const sources = Object.entries(objects).map(([name, file]) => + ` ${name}: ${JSON.stringify(read(new URL(`objects/${file}.lisp`, here)))},`); + return [ + start, + "// Generated by xbox/fiapup/embed.mjs from xbox/live/object-lisp.mjs,", + "// xbox/live/frame-vm.mjs and xbox/fiapup/objects/*.lisp. Don't edit here:", + "// edit those and rerun the tool.", + "const fiapupObjects = (() => {", + " const objectLisp = (() => {", + indent(lisp.body), + ` return { ${lisp.names.join(", ")} };`, + " })();", + " const sources = {", + ...sources, + " };", + " const compiled = {};", + " for (const name in sources) compiled[name] = objectLisp.compile(sources[name], name);", + " return compiled;", + "})();", + "const fiapupFrameVm = (() => {", + indent(vm.body), + ` return { ${vm.names.join(", ")} };`, + "})();", + end, + ].join("\n"); +} + +export function embedded(source = read(gamePath)) { + const a = source.indexOf(start), b = source.indexOf(end); + return a < 0 || b < 0 ? null : source.slice(a, b + end.length); +} + +function write() { + const source = read(gamePath), block = generate(), current = embedded(source); + if (!current) throw new Error("fiapup.js: no // block to fill"); + const next = source.replace(current, () => block); + if (next !== source) writeFileSync(gamePath, next); + return next !== source; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + if (process.argv.includes("--check")) { + const fresh = embedded() === generate(); + console.log(fresh ? "fiapup.js sealed block is current" : "fiapup.js sealed block is stale: run node xbox/fiapup/embed.mjs"); + process.exit(fresh ? 0 : 1); + } + console.log(write() ? "sealed the compiler, the frame vm and the objects into fiapup.js" : "sealed block already current"); +} diff --git a/xbox/fiapup/fiapup.js b/xbox/fiapup/fiapup.js new file mode 100644 index 0000000000..6cb31ad53b --- /dev/null +++ b/xbox/fiapup/fiapup.js @@ -0,0 +1,2604 @@ +// fiapup — a puppy in a backyard, and your hand. (xbox/FIAPUP.md) +// +// One global script, like oskiewar.js: the console host (xbox/native-bios) +// evaluates it as-is and calls boot/sim/paint/act/leave; the web shell +// (xbox/fiapup/index.html) does the same through `new Function`. It reads the +// pad through `gamepad(0)` and draws by writing one frame program a paint +// (xbox/OSKIEWAR-HOSTS.md §3.0). A host with `frame` runs the program itself; +// one without it (the console until R6, and the web shell) runs the copy of +// frame-vm.mjs sealed in below against its own `triangle3d`. +// +// The pup is a KidLisp object (objects/puppy-flat.lisp): balls and limbs hung +// on a small skeleton, every shape baked, only the bones moving. A tick hands +// it a pose; the pose comes from the behaviour below. + +// +// Generated by xbox/fiapup/embed.mjs from xbox/live/object-lisp.mjs, +// xbox/live/frame-vm.mjs and xbox/fiapup/objects/*.lisp. Don't edit here: +// edit those and rerun the tool. +const fiapupObjects = (() => { + const objectLisp = (() => { + // object-lisp.mjs — oskiewar objects written in KidLisp, compiled once. + // + // An object (a monowheel, a hat, a weapon) is a small .lisp program that says + // what the thing looks like, in its own space: x forward, y up, z to the + // owner's right. `compile` does all the work when the object loads: + // + // - every part that doesn't change tick to tick is run once, at each of three + // levels of detail, and baked into meshes (`object.meshes`), for the host to + // keep as ASSETs; + // - what is left is closures, never the tree-walker, because the tree-walker + // repaints a 61-line program about every 6 s on the oven. + // + // A tick, `object(inputs, place, out)`, then emits one MODEL op per baked part + // (`out.model`: a mesh by handle under a 12-number placement) and WORLD faces + // (`out.face`) only for what really moves. The high-level forms (revolve, + // radial, mirror) compile away here; hosts see faces and meshes and never + // expand anything. The dialect: xbox/OBJECT-DIALECT.md. + // + // No imports and plain top-level exports: xbox/tools/embed-objects.mjs (next + // pass) seals this file into oskiewar.js the way embed-spine.mjs does, and + // every host (QuickJS, JavaScriptCore, the browser) runs the same compiler. + + // What the game hands an object each tick. Seconds, world units, radians; + // `hit` and `land` count seconds since the event (large when it never was). + const objectInputs = ["time", "distance", "speed", "lean", "heading", + "pitch", "turbo", "hit", "land"]; + const unset = [0, 0, 0, 0, 0, 0, 0, 1e9, 1e9]; + // Switches are 0 or 1, so a baked part that reads one is baked once per value. + const switches = new Set(["turbo"]); + // `detail` is the level a baked part is drawn at: 0 near, 2 far. The host + // picks it per MODEL op; outside baked parts it reads 0. + const detailSlot = objectInputs.length; + const objectLevels = 3; + + // The game's sun (`globalLight` in oskiewar.js) and its flat-shading rule: + // .72 ambient plus .28 of the face turned toward the light, decided in world + // space from the face's own winding — so an object shades exactly like a + // worldQuad beside it. tests/object-lisp.test.mjs holds the two equal. + const sun = (() => { + const x = -.42, y = 1, z = -.28, m = Math.hypot(x, y, z); + return [x / m, y / m, z / m]; + })(); + const objectLight = sun; + + + // ——— reading: KidLisp's own rules, so Aesel's KidLisp authoring applies ——— + // A bare line that starts with a word is a call (`ring 12` → `(ring 12)`), + // commas separate calls on a line, `;` comments, missing `)` auto-close. + // tests hold this reader to KidLisp's `parse` on every object in objects/. + + const tokenPattern = /\s*(;.*|[(),]|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s()";',]+)/g; + + // Strings and comments masked with "_" (quotes and ";" kept), so line passes + // can count parens and find commas without cutting through text. + function mask(text) { + let out = "", i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === ";") { out += ";" + "_".repeat(text.length - i - 1); break; } + if (ch === '"' || ch === "'") { + let j = i + 1; + while (j < text.length && text[j] !== ch) j += text[j] === "\\" ? 2 : 1; + if (j < text.length) { out += ch + "_".repeat(j - i - 1) + ch; i = j + 1; continue; } + } + out += ch; i++; + } + return out; + } + const startsWord = (text) => /^[a-zA-Z_]\w*/.test(text); + const count = (text, ch) => text.split(ch).length - 1; + + function read(source) { + const lines = source.split("\n").map((line) => { + const cut = mask(line).indexOf(";"); + return (cut < 0 ? line : line.slice(0, cut)).trim(); + }).filter(Boolean); + const wrapped = lines.map((line, index) => { + const masked = mask(line); + if (masked.includes(",")) { + const parts = []; + let from = 0; + for (let i = 0; i <= masked.length; i++) + if (i === masked.length || masked[i] === ",") { parts.push(line.slice(from, i).trim()); from = i + 1; } + return parts.filter(Boolean).map((part) => + part.startsWith("(") && part.endsWith(")") ? part : /^[a-zA-Z_$]\w*/.test(part) ? `(${part})` : part).join(" "); + } + // A word-led line inside an open call is a continuation, not a new call. + const before = index > 0 ? mask(lines[index - 1]) : ""; + const continues = index > 0 && count(before, "(") > count(before, ")"); + return !line.startsWith("(") && startsWord(line) && !continues ? `(${line})` : line; + }).join(" "); + const tokens = []; + for (const match of wrapped.matchAll(tokenPattern)) + if (!match[1].startsWith(";")) tokens.push(match[1]); + let open = 0; + for (const t of tokens) open += t === "(" ? 1 : t === ")" ? -1 : 0; + while (open-- > 0) tokens.push(")"); + let at = 0; + const form = () => { + const t = tokens[at++]; + if (t === ")") throw new Error("unexpected )"); + // Numbers read as KidLisp's do; its timing words (`1s`, `2s...`) stay words. + if (t !== "(") { const n = parseFloat(t); return Number.isNaN(n) || /^\d*\.?\d+s/.test(t) ? t : n; } + const list = []; + while (at < tokens.length && tokens[at] !== ")") { + if (tokens[at] === ",") { at++; continue; } + list.push(form()); + } + at++; + return list; + }; + const forms = []; + while (at < tokens.length) { + if (tokens[at] === ",") { at++; continue; } + forms.push(form()); + } + return forms; + } + + + // ——— compiling ——— + + const show = (form) => Array.isArray(form) ? `(${form.map(show).join(" ")})` : String(form); + + // Pure math the dialect knows. Everything is a number; comparisons are 1 or 0. + const math = { + "+": (...v) => v.reduce((a, b) => a + b, 0), + "-": (a, ...v) => v.length ? v.reduce((x, y) => x - y, a) : -a, + "*": (...v) => v.reduce((a, b) => a * b, 1), + "/": (a, b) => a / b, + "%": (a, b) => ((a % b) + b) % b, + min: Math.min, max: Math.max, abs: Math.abs, sign: Math.sign, + sin: Math.sin, cos: Math.cos, tan: Math.tan, atan: Math.atan2, sqrt: Math.sqrt, + pow: Math.pow, floor: Math.floor, round: Math.round, + clamp: (x, lo, hi) => x < lo ? lo : x > hi ? hi : x, + mix: (a, b, t) => a + (b - a) * t, + "=": (a, b) => +(a === b), "<": (a, b) => +(a < b), ">": (a, b) => +(a > b), + "<=": (a, b) => +(a <= b), ">=": (a, b) => +(a >= b), + and: (...v) => +v.every(Boolean), or: (...v) => +v.some(Boolean), not: (a) => +!a, + }; + const words = { pi: Math.PI, tau: Math.PI * 2 }; + // A few names for `ink`; anything else is three numbers. + const inks = { white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128], + red: [255, 0, 0], pink: [255, 105, 180], cyan: [0, 255, 255], yellow: [255, 255, 0] }; + + const maxDepth = 16; + const owner = -1; // a read of the owner's pose, which moves every tick + const shapes = { tri: 9, quad: 12, disc: 1, hoop: 2, band: 2, capsule: 7, line: 6 }; + // Flat shapes: object-space anchors, projected here, drawn as 2D ops. + const flats = { ball: 4, limb: 7, ring: 2, drum: 3, stroke: 7, plate: 9, slab: 6 }; + const forms = new Set(["def", "let", "if", "repeat", "ink", "glow", "move", "rotate", + "scale", "radial", "mirror", "revolve", "outline", "nudge", "toward"]); + const isStatement = (f) => Array.isArray(f) && (forms.has(f[0]) || f[0] in shapes || f[0] in flats); + const union = (...sets) => { const out = new Set(); for (const s of sets) for (const x of s) out.add(x); return out; }; + + function compile(source, name = "object") { + const program = typeof source === "string" ? read(source) : source; + const fail = (why, form) => { throw new Error(`${name}: ${why}${form === undefined ? "" : ` in ${show(form)}`}`); }; + let slots = detailSlot + 1; + // Per slot: does it change from tick to tick? Switches and `detail` don't. + const moving = objectInputs.map((n) => !switches.has(n)); + moving[detailSlot] = false; + const isSwitch = (slot) => slot >= 0 && slot < objectInputs.length && switches.has(objectInputs[slot]); + const ticks = (reads) => { for (const r of reads) if (r < 0 || moving[r]) return true; return false; }; + const parts = []; + + // A scope maps a name to a slot or a constant (a `def`). + const lookup = (scope, word) => { + for (let s = scope; s; s = s.up) if (word in s.names) return s.names[word]; + return word in words ? { value: words[word] } : null; + }; + const top = { names: Object.fromEntries([...objectInputs, "detail"].map((n, i) => [n, { slot: i }])), up: null }; + + // An expression folds to a constant, or is a closure with the slots it reads. + function expr(form, scope) { + if (typeof form === "number") return { value: form }; + if (typeof form === "string") { + const found = lookup(scope, form); + if (!found) fail(`unknown word \`${form}\``); + if ("value" in found) return found; + const i = found.slot; + return { run: (s) => s.v[i], reads: new Set([i]) }; + } + if (!Array.isArray(form) || !form.length) fail("empty expression", form); + const [head, ...rest] = form; + if (head === "owner") { + // (owner part axis): a point of the owner's pose, handed over in object space. + const part = rest[0], axis = "xyz".indexOf(rest[1]); + if (typeof part !== "string" || axis < 0) fail("owner wants a part and x, y or z", form); + return { run: (s) => s.owner?.[part]?.[axis] ?? 0, reads: new Set([owner]) }; + } + const fn = math[head]; + if (!fn) fail(`unknown function \`${head}\``, form); + const args = rest.map((a) => expr(a, scope)); + if (args.every((a) => "value" in a)) return { value: fn(...args.map((a) => a.value)) }; + const reads = union(...args.map((a) => a.reads || [])); + const run = args.map((a) => "value" in a ? () => a.value : a.run); + if (run.length === 1) { const [a] = run; return { run: (s) => fn(a(s)), reads }; } + if (run.length === 2) { const [a, b] = run; return { run: (s) => fn(a(s), b(s)), reads }; } + if (run.length === 3) { const [a, b, c] = run; return { run: (s) => fn(a(s), b(s), c(s)), reads }; } + return { run: (s) => fn(...run.map((r) => r(s))), reads }; + } + // A number as a closure; what it reads is added to `reads`. + const num = (form, scope, reads) => { + const e = expr(form, scope); + if ("value" in e) return () => e.value; + for (const r of e.reads) reads.add(r); + return e.run; + }; + + // Every statement compiles to a node: its closure, the slots it reads from + // outside itself, whether it draws, and how it uses the sticky ink (reads + // the ink it came in with; sets it 0 never, 1 maybe, 2 always). `ctx` + // follows the ink and glow in effect as the compile walks in run order. + const inkIn = (nodes) => { for (const n of nodes) { if (n.inkIn) return true; if (n.inkSets === 2) return false; } return false; }; + function body(list, scope, depth, ctx) { + const inner = { names: {}, up: scope }; + const nodes = [], entries = []; + for (const form of list) { + const entry = { ink: ctx.ink, glow: ctx.glow, edge: ctx.edge, nudge: ctx.nudge }, first = parts.length; + const node = statement(form, inner, depth, ctx); + if (node) { node.parts = [first, parts.length]; nodes.push(node); entries.push(entry); } + } + const steps = bakeRuns(nodes, entries, depth); + const bound = union(...nodes.map((n) => n.binds || [])); + const reads = union(...nodes.map((n) => n.reads)); + for (const b of bound) reads.delete(b); + return { run: (s) => { for (let i = 0; i < steps.length; i++) steps[i](s); }, + reads, draws: nodes.some((n) => n.draws), inkIn: inkIn(nodes), + inkSets: Math.max(0, ...nodes.map((n) => n.inkSets)) }; + } + + // Runs of statements that don't move from tick to tick become baked parts. + function bakeRuns(nodes, entries, depth) { + const steps = []; + for (let i = 0; i < nodes.length;) { + if (ticks(nodes[i].reads)) { steps.push(nodes[i].run); i++; continue; } + let j = i; + while (j < nodes.length && !ticks(nodes[j].reads)) j++; + const group = nodes.slice(i, j), part = partOf(group, entries[i], depth); + if (part) steps.push(part); + else for (const n of group) steps.push(n.run); + i = j; + } + return steps; + } + + // A part bakes if everything it reads is a switch, `detail`, or bound + // inside it, and it knows the ink it starts with. + function partOf(group, entry, depth) { + if (!group.some((n) => n.draws)) return null; + const bound = union(...group.map((n) => n.binds || [])); + const free = union(...group.map((n) => n.reads)); + for (const b of bound) free.delete(b); + for (const r of free) if (!isSwitch(r) && r !== detailSlot) return null; + if (inkIn(group) && !entry.ink) return null; + // An outline or nudge that moves per tick can't be baked into it either. + if (!entry.edge || entry.nudge === null) return null; + // Parts inside this one are baked into it, never emitted on their own. + for (const n of group) for (let i = n.parts[0]; i < n.parts[1]; i++) parts[i].inner = true; + const part = { depth, runs: group.map((n) => n.run), lets: group.filter((n) => n.binds).map((n) => n.run), + switches: [...free].filter(isSwitch), detail: free.has(detailSlot), + ink: entry.ink || [255, 255, 255], glow: entry.glow, edge: entry.edge, nudge: entry.nudge, variants: null }; + parts.push(part); + const { runs, lets } = part, at = depth * 13; + return (s) => { + if (!part.variants || s.rec || (part.meshed && !s.model) || (part.sketched && !s.sketch)) { + for (let k = 0; k < runs.length; k++) runs[k](s); + return; + } + for (let k = 0; k < lets.length; k++) lets[k](s); // what follows may read them + let index = 0; + for (let k = 0; k < part.switches.length; k++) if (s.v[part.switches[k]] >= .5) index |= 1 << k; + const v = part.variants[index]; + if (v.levels[0] >= 0) s.model(v.radius, v.levels[0], v.levels[1], v.levels[2], s.m, at); + if (v.shapes >= 0) s.sketch(v.shapes, s.m, at); + s.r = v.ink[0]; s.g = v.ink[1]; s.b = v.ink[2]; + }; + } + + // A transform pushes a new frame at a depth known here, so nesting is + // checked once and the tick never counts. + function framed(form, scope, depth, ctx, arity, apply) { + if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form); + const reads = new Set(); + const args = form.slice(1, arity + 1).map((a) => num(a, scope, reads)); + const inside = body(form.slice(arity + 1), scope, depth + 1, ctx); + const from = depth * 13, to = from + 13, run = inside.run; + return { ...inside, reads: union(reads, inside.reads), run: (s) => { + const m = s.m; + for (let i = 0; i < 13; i++) m[to + i] = m[from + i]; + apply(m, to, args, s); + run(s); + } }; + } + // An iterator slot: it moves only if the count it walks does. + function iterator(scope, word, countReads, rest) { + const named = typeof word === "string" && rest > 0 && !lookup(scope, word); + if (!named) return -1; + const slot = slots++; + moving[slot] = ticks(countReads); + scope.names[word] = { slot }; + return slot; + } + // A body that might run zero times or twice leaves the ink unknown after. + const maybe = (ctx, run) => { + const inside = run({ ...ctx }); + if (inside.inkSets) ctx.ink = null; + return inside; + }; + + function statement(form, scope, depth, ctx) { + if (!Array.isArray(form)) fail(`\`${show(form)}\` on its own does nothing`); + const [head, ...rest] = form; + switch (head) { + case "def": { + // KidLisp's def binds once. Here that is at compile time, so a def + // can't read a per-tick input — that is what `let` is for. + let value; + try { value = expr(rest[1], scope); } catch (e) { fail(e.message.replace(`${name}: `, ""), form); } + if (!("value" in value)) fail("def binds once and can't read a per-tick input; use let", form); + scope.names[rest[0]] = value; + return null; + } + case "let": { + const reads = new Set(), run = num(rest[1], scope, reads), slot = slots++; + moving[slot] = ticks(reads); + scope.names[rest[0]] = { slot }; + return { run: (s) => { s.v[slot] = run(s); }, reads, binds: [slot], inkSets: 0 }; + } + case "if": { + // No else, as in KidLisp: the body is every form after the test. + const reads = new Set(), test = num(rest[0], scope, reads); + const inside = maybe(ctx, (c) => body(rest.slice(1), scope, depth, c)), run = inside.run; + return { ...inside, reads: union(reads, inside.reads), inkSets: inside.inkSets ? 1 : 0, + run: (s) => { if (test(s)) run(s); } }; + } + case "repeat": { + const reads = new Set(), times = num(rest[0], scope, reads); + const inner = { names: {}, up: scope }; + const slot = iterator(inner, rest[1], reads, rest.length - 2); + const inside = maybe(ctx, (c) => body(rest.slice(slot >= 0 ? 2 : 1), inner, depth, c)), run = inside.run; + const own = union(reads, inside.reads); + own.delete(slot); + return { ...inside, reads: own, inkSets: inside.inkSets ? 1 : 0, run: (s) => { + const n = times(s); + for (let i = 0; i < n; i++) { if (slot >= 0) s.v[slot] = i; run(s); } + } }; + } + case "ink": { + if (rest.length === 1 && inks[rest[0]]) { + const [r, g, b] = inks[rest[0]]; + ctx.ink = [r, g, b]; + return { run: (s) => { s.r = r; s.g = g; s.b = b; }, reads: new Set(), inkSets: 2 }; + } + if (rest.length !== 3) fail("ink wants a name or r g b", form); + const known = rest.map((a) => expr(a, scope)); + ctx.ink = known.every((e) => "value" in e) ? known.map((e) => e.value) : null; + const reads = new Set(), [r, g, b] = rest.map((a) => num(a, scope, reads)); + return { run: (s) => { s.r = r(s); s.g = g(s); s.b = b(s); }, reads, inkSets: 2 }; + } + case "glow": { + // Unlit inside: lamps, turbo trim, anything that makes its own light. + const was = ctx.glow; + ctx.glow = true; + const inside = body(rest, scope, depth, ctx), run = inside.run; + ctx.glow = was; + return { ...inside, run: (s) => { const before = s.glow; s.glow = true; run(s); s.glow = before; } }; + } + case "move": return framed(form, scope, depth, ctx, 3, move); + case "rotate": { + const axis = "xyz".indexOf(rest[0]); + if (axis < 0) fail("rotate wants x, y or z first", form); + return framed(["rotate", ...rest.slice(1)], scope, depth, ctx, 1, + (m, at, [a], s) => rotate(m, at, axis, a(s))); + } + case "scale": { + // One number scales evenly; three scale each axis (a negative one + // mirrors, and faces keep facing out). + const three = rest.length >= 3 && !isStatement(rest[1]); + return framed(three ? form : ["scale", rest[0], rest[0], rest[0], ...rest.slice(1)], + scope, depth, ctx, 3, scale); + } + case "radial": { + // (radial axis n [k] body…): the body n times, turned evenly about the axis. + const axis = "xyz".indexOf(rest[0]); + if (axis < 0) fail("radial wants x, y or z first", form); + if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form); + const reads = new Set(), times = num(rest[1], scope, reads); + const inner = { names: {}, up: scope }; + const slot = iterator(inner, rest[2], reads, rest.length - 3); + const inside = maybe(ctx, (c) => body(rest.slice(slot >= 0 ? 3 : 2), inner, depth + 1, c)), run = inside.run; + const own = union(reads, inside.reads); + own.delete(slot); + const from = depth * 13, to = from + 13; + return { ...inside, reads: own, inkSets: inside.inkSets ? 1 : 0, run: (s) => { + const n = times(s), m = s.m; + for (let i = 0; i < n; i++) { + for (let k = 0; k < 13; k++) m[to + k] = m[from + k]; + rotate(m, to, axis, i / n * Math.PI * 2); + if (slot >= 0) s.v[slot] = i; + run(s); + } + } }; + } + case "mirror": { + // (mirror axis body…): the body, then its reflection across that axis. + const axis = "xyz".indexOf(rest[0]); + if (axis < 0) fail("mirror wants x, y or z first", form); + if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form); + const inside = body(rest.slice(1), scope, depth + 1, ctx), run = inside.run; + const from = depth * 13, to = from + 13; + return { ...inside, run: (s) => { + const m = s.m; + for (const side of [1, -1]) { + for (let k = 0; k < 13; k++) m[to + k] = m[from + k]; + if (side < 0) { for (let k = 0; k < 3; k++) m[to + 3 + axis * 3 + k] *= -1; m[to + 12] *= -1; } + run(s); + } + } }; + } + case "outline": { + // (outline w [r g b] body…): the body's flat shapes drawn with an ink + // edge w world units wide, sized where the scope starts. + const lead = rest.findIndex((a) => isStatement(a)); + const opening = rest.slice(0, lead < 0 ? rest.length : lead); + if (opening.length !== 1 && opening.length !== 4) fail("outline wants w, or w r g b", form); + const reads = new Set(), [w, r, g, b] = opening.map((a) => num(a, scope, reads)); + const known = opening.map((a) => expr(a, scope)), wasEdge = ctx.edge; + ctx.edge = !wasEdge || !known.every((e) => "value" in e) ? null + : [known[0].value, ...(known.length === 4 ? known.slice(1).map((e) => e.value) : wasEdge.slice(1))]; + const inside = body(rest.slice(opening.length), scope, depth, ctx), run = inside.run; + ctx.edge = wasEdge; + return { ...inside, reads: union(reads, inside.reads), run: (s) => { + const was = s.outline.slice(); + // Baked, the width stays in world units and the host sizes it. + s.outline[0] = s.sketching ? w(s) * frameSize(s.m, depth * 13) : w(s) * scaleAt(s, depth * 13); + if (r) { s.outline[1] = r(s); s.outline[2] = g(s); s.outline[3] = b(s); } + run(s); + s.outline.splice(0, 4, ...was); + } }; + } + case "nudge": { + // (nudge d body…): the body's flat shapes d world units further back, + // to settle what covers what where two shapes share a depth. + const reads = new Set(), d = num(rest[0], scope, reads); + const known = expr(rest[0], scope), wasNudge = ctx.nudge; + ctx.nudge = wasNudge === null || !("value" in known) ? null : wasNudge + known.value; + const inside = body(rest.slice(1), scope, depth, ctx), run = inside.run; + ctx.nudge = wasNudge; + return { ...inside, reads: union(reads, inside.reads), run: (s) => { + const was = s.nudge; + s.nudge += d(s); + run(s); + s.nudge = was; + } }; + } + case "toward": { + // (toward axis body…): the body on whichever side of that axis faces + // the camera, so a wheel shows the face you can see. + const axis = "xyz".indexOf(rest[0]); + if (axis < 0) fail("toward wants x, y or z first", form); + if (depth + 1 >= maxDepth) fail(`nested deeper than ${maxDepth}`, form); + const inside = body(rest.slice(1), scope, depth + 1, ctx), run = inside.run; + const from = depth * 13, to = from + 13; + const flip = (m) => { for (let k = 0; k < 3; k++) m[to + 3 + axis * 3 + k] *= -1; m[to + 12] *= -1; }; + return { ...inside, run: (s) => { + const m = s.m; + if (s.sketching) { + // Baked: both faces, each marked one-sided, and the host shows the + // one turned its way. + for (const side of [1, -1]) { + for (let k = 0; k < 13; k++) m[to + k] = m[from + k]; + if (side < 0) flip(m); + const was = s.facing; + s.facing = [m[to + 3 + axis * 3], m[to + 4 + axis * 3], m[to + 5 + axis * 3]]; + run(s); + s.facing = was; + } + return; + } + const V = s.view; + for (let k = 0; k < 13; k++) m[to + k] = m[from + k]; + let away = 0; + for (let k = 0; k < 3; k++) away += m[to + 3 + axis * 3 + k] * (m[to + k] - V[k]); + if (away > 0) flip(m); + run(s); + } }; + } + case "revolve": { + // (revolve axis [turn] r h r h …): a closed profile turned about the axis. + const axis = "xyz".indexOf(rest[0]); + if (axis < 0) fail("revolve wants x, y or z first", form); + if (rest.length < 7) fail("revolve wants at least three r h points", form); + const reads = new Set(), args = rest.slice(1).map((a) => num(a, scope, reads)); + const values = new Float64Array(args.length); + return { reads, draws: true, inkIn: true, inkSets: 0, run: (s) => { + for (let i = 0; i < args.length; i++) values[i] = args[i](s); + revolve(s, depth * 13, axis, values); + } }; + } + } + if (head in flats) { + if (rest.length < flats[head]) fail(`${head} wants ${flats[head]} arguments`, form); + const axial = head === "ring" || head === "drum"; + const axis = axial ? "xyz".indexOf(rest[0]) : -1; + if (axial && axis < 0) fail(`${head} wants x, y or z first`, form); + const reads = new Set(), args = rest.slice(axial ? 1 : 0).map((a) => num(a, scope, reads)); + const values = new Float64Array(args.length), draw = flatShapes[head], record = recorders[head]; + return { reads, draws: true, inkIn: true, inkSets: 0, run: (s) => { + for (let i = 0; i < args.length; i++) values[i] = args[i](s); + if (s.sketching) record(s, depth * 13, values, axis); + else { inkUp(s); draw(s, depth * 13, values, axis); } + } }; + } + if (head in shapes) { + const want = shapes[head]; + if (rest.length < want) fail(`${head} wants ${want} numbers`, form); + const reads = new Set(), args = rest.map((a) => num(a, scope, reads)); + const values = new Float64Array(args.length); + const draw = primitives[head]; + return { reads, draws: true, inkIn: true, inkSets: 0, run: (s) => { + for (let i = 0; i < args.length; i++) values[i] = args[i](s); + draw(s, depth * 13, values); + } }; + } + fail(`unknown form \`${head}\``, form); + } + + // What the compile knows is in effect as it walks: the ink, glow, and the + // ink edge and nudge a part would bake with (null when they move per tick). + const run = body(program, top, 0, { ink: [255, 255, 255], glow: false, edge: [0, 24, 20, 30], nudge: 0 }).run; + const state = { v: new Float64Array(slots), m: new Float64Array(maxDepth * 13), + r: 255, g: 255, b: 255, glow: false, face: null, model: null, owner: null, rec: null, + out: null, view: null, nudge: 0, outline: [0, 24, 20, 30], sketch: null, sketching: null, facing: null, + inked: [0, 0, 0, 0] }; + + // Bake every part: per switch value, per level, run once into a mesh. Twin + // meshes (a level that didn't change anything) share one handle. + const meshes = []; + const store = (mesh) => { + const same = meshes.findIndex((o) => o.count === mesh.count && + o.vertices.every((x, i) => x === mesh.vertices[i]) && o.faces.every((x, i) => x === mesh.faces[i])); + return same >= 0 ? same : meshes.push(mesh) - 1; + }; + const sketches = []; + const keep = (shapes) => { + const same = sketches.findIndex((o) => o.count === shapes.count && o.records.length === shapes.records.length && + o.records.every((x, i) => x === shapes.records[i])); + return same >= 0 ? same : sketches.push(shapes) - 1; + }; + const outer = parts.filter((part) => !part.inner); + for (const part of outer) { + part.variants = []; + for (let index = 0; index < 1 << part.switches.length; index++) { + part.switches.forEach((slot, k) => { state.v[slot] = (index >> k) & 1; }); + const levels = []; + let radius = 0, ink = part.ink, shapes = -1; + for (let level = 0; level < objectLevels; level++) { + if (level && !part.detail) { levels.push(levels[0]); continue; } + state.v[detailSlot] = level; + state.m.set(identity, part.depth * 13); + [state.r, state.g, state.b] = part.ink; + state.glow = part.glow; + state.rec = builder(); + state.sketching = sketcher(); + state.outline.splice(0, 4, ...part.edge); + state.nudge = part.nudge; + state.facing = null; + for (const step of part.runs) step(state); + const mesh = state.rec.done(), sketch = state.sketching.done(); + state.rec = state.sketching = null; + if (!level) { + ink = [state.r, state.g, state.b]; + // Flat shapes don't change with level: the host picks their sides. + shapes = sketch.count ? keep(sketch) : -1; + } + radius = Math.max(radius, mesh.radius); + levels.push(mesh.count ? store(mesh) : -1); + } + part.meshed ||= levels[0] >= 0; + part.sketched ||= shapes >= 0; + part.variants.push({ levels, radius, ink, shapes }); + } + } + state.v.fill(0); + + // place: origin, then where object x, y and z point, in world space — + // twelve numbers, as the game's rig frames already know them. out: a face + // function (everything drawn as WORLD faces, level 0), or { face, model }. + function object(inputs, place, out) { + const v = state.v, m = state.m; + for (let i = 0; i < objectInputs.length; i++) { + const x = inputs[objectInputs[i]]; + v[i] = x === undefined ? unset[i] : +x; + } + v[detailSlot] = 0; + for (let i = 0; i < 12; i++) m[i] = place[i]; + m[12] = handedness(m, 0); + state.r = state.g = state.b = 255; + state.glow = false; + state.face = typeof out === "function" ? out : out.face; + state.model = typeof out === "function" ? null : out.model || null; + state.sketch = typeof out === "function" ? null : out.sketch || null; + state.owner = inputs.owner || null; + state.out = out; + state.view = out.view || null; + state.nudge = 0; + state.outline[0] = 0; + state.inked[0] = 0; + run(state); + if (state.inked[0]) out.outline(0, 0, 0, 0); // no ink edge left on for what follows + } + object.meshes = meshes; + object.sketches = sketches; + object.parts = outer.length; + return object; + } + + const identity = [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1]; + + // ——— frames: 13 numbers each, origin · x axis · y axis · z axis · winding ——— + // The winding flag is -1 under a mirror, so a face that faced out still does. + + function handedness(m, at) { + const ax = m[at + 3], ay = m[at + 4], az = m[at + 5]; + const bx = m[at + 6], by = m[at + 7], bz = m[at + 8]; + const cx = m[at + 9], cy = m[at + 10], cz = m[at + 11]; + return ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx) < 0 ? -1 : 1; + } + function move(m, at, [x, y, z], s) { + const dx = x(s), dy = y(s), dz = z(s); + for (let k = 0; k < 3; k++) m[at + k] += dx * m[at + 3 + k] + dy * m[at + 6 + k] + dz * m[at + 9 + k]; + } + function scale(m, at, [x, y, z], s) { + const fx = x(s), fy = y(s), fz = z(s); + for (let k = 0; k < 3; k++) { m[at + 3 + k] *= fx; m[at + 6 + k] *= fy; m[at + 9 + k] *= fz; } + m[at + 12] = handedness(m, at); + } + // Right-handed turns: about x carries y toward z, about y carries z toward x, + // about z carries x toward y. + function rotate(m, at, axis, angle) { + const c = Math.cos(angle), sn = Math.sin(angle); + const p = at + 3 + ((axis + 1) % 3) * 3, q = at + 3 + ((axis + 2) % 3) * 3; + for (let k = 0; k < 3; k++) { + const u = m[p + k], w = m[q + k]; + m[p + k] = u * c + w * sn; + m[q + k] = w * c - u * sn; + } + } + + // ——— faces ——— + + // A baked mesh in the ASSET layout: vertices (x y z), and per face four ids + // (a triangle repeats its third), the unlit rgb, and a unit normal — zero for + // a glowing face, which a host draws unlit. + function builder() { + const vertices = [], faces = [], ids = new Map(); + let radius = 0; + const id = (w, o) => { + const key = `${w[o]},${w[o + 1]},${w[o + 2]}`; + let i = ids.get(key); + if (i === undefined) { + i = vertices.length / 3; + ids.set(key, i); + vertices.push(w[o], w[o + 1], w[o + 2]); + radius = Math.max(radius, Math.hypot(w[o], w[o + 1], w[o + 2])); + } + return i; + }; + return { + add(w, n, glow, r, g, b, nx, ny, nz) { + const a = id(w, 0), bb = id(w, 3), c = id(w, 6); + faces.push(a, bb, c, n === 4 ? id(w, 9) : c, r, g, b, glow ? 0 : nx, glow ? 0 : ny, glow ? 0 : nz); + }, + done: () => ({ vertices: Float64Array.from(vertices), faces: Float64Array.from(faces), + count: faces.length / 10, radius }), + }; + } + + const world = new Float64Array(12); + function put(m, at, o, x, y, z) { + for (let k = 0; k < 3; k++) world[o + k] = m[at + k] + x * m[at + 3 + k] + y * m[at + 6 + k] + z * m[at + 9 + k]; + } + // A triangle (n 3) or quad (n 4) already in `world`, wound to face out: shaded + // by the game's rule and emitted, or kept for a bake. No area, nothing drawn. + function finish(s, n) { + const w = world; + const ux = w[3] - w[0], uy = w[4] - w[1], uz = w[5] - w[2]; + const vx = w[6] - w[0], vy = w[7] - w[1], vz = w[8] - w[2]; + const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; + const length = Math.hypot(nx, ny, nz); + if (!length) return; + if (s.rec) { s.rec.add(w, n, s.glow, s.r, s.g, s.b, nx / length, ny / length, nz / length); return; } + let r = s.r, g = s.g, b = s.b; + if (!s.glow) { + const toward = -(nx * sun[0] + ny * sun[1] + nz * sun[2]) / length; + const k = .72 + (toward > 0 ? toward : 0) * .28; + r = Math.round(r * k); g = Math.round(g * k); b = Math.round(b * k); + } + s.face(w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], w[8], r, g, b); + if (n === 4) s.face(w[0], w[1], w[2], w[6], w[7], w[8], w[9], w[10], w[11], r, g, b); + } + function tri(s, at, ax, ay, az, bx, by, bz, cx, cy, cz) { + const m = s.m; + put(m, at, 0, ax, ay, az); + if (m[at + 12] < 0) { put(m, at, 3, cx, cy, cz); put(m, at, 6, bx, by, bz); } + else { put(m, at, 3, bx, by, bz); put(m, at, 6, cx, cy, cz); } + finish(s, 3); + } + // A quad is two faces lit as one, off its first three corners, as worldQuad + // does. A corner on an axis (a revolve's cap) makes it a triangle. + function quad(s, at, ax, ay, az, bx, by, bz, cx, cy, cz, dx, dy, dz) { + const same = (x1, y1, z1, x2, y2, z2) => x1 === x2 && y1 === y2 && z1 === z2; + if (same(ax, ay, az, bx, by, bz)) return tri(s, at, ax, ay, az, cx, cy, cz, dx, dy, dz); + if (same(bx, by, bz, cx, cy, cz) || same(cx, cy, cz, dx, dy, dz)) return tri(s, at, ax, ay, az, bx, by, bz, dx, dy, dz); + if (same(dx, dy, dz, ax, ay, az)) return tri(s, at, ax, ay, az, bx, by, bz, cx, cy, cz); + const m = s.m; + put(m, at, 0, ax, ay, az); + if (m[at + 12] < 0) { put(m, at, 3, dx, dy, dz); put(m, at, 6, cx, cy, cz); put(m, at, 9, bx, by, bz); } + else { put(m, at, 3, bx, by, bz); put(m, at, 6, cx, cy, cz); put(m, at, 9, dx, dy, dz); } + finish(s, 4); + } + // Sides asked for (at least `least`), or picked by radius and level — the + // only place a level changes a shape. + const levelShare = [1, .67, .5]; + const sidesFor = (s, r, v, i, least = 3) => v.length > i ? Math.min(64, Math.max(least, Math.floor(v[i]))) + : Math.max(3, Math.round((r < 6 ? 6 : r < 13 ? 8 : r < 26 ? 12 : 16) * levelShare[s.v[detailSlot]])); + + const primitives = { + tri: (s, at, v) => tri(s, at, v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8]), + quad: (s, at, v) => quad(s, at, v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]), + // (disc r [sides]) — flat, at the origin, facing +z. + disc: (s, at, v) => { + const r = v[0], n = sidesFor(s, r, v, 1); + for (let i = 0; i < n; i++) { + const a = i / n * Math.PI * 2, b = (i + 1) / n * Math.PI * 2; + tri(s, at, 0, 0, 0, Math.cos(a) * r, Math.sin(a) * r, 0, Math.cos(b) * r, Math.sin(b) * r, 0); + } + }, + // (hoop inner outer [sides]) — a flat ring facing +z. + hoop: (s, at, v) => { + const r1 = v[0], r2 = v[1], n = sidesFor(s, r2, v, 2); + for (let i = 0; i < n; i++) { + const a = i / n * Math.PI * 2, b = (i + 1) / n * Math.PI * 2; + const ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b); + quad(s, at, ca * r1, sa * r1, 0, ca * r2, sa * r2, 0, cb * r2, sb * r2, 0, cb * r1, sb * r1, 0); + } + }, + // (band radius width [sides] [turn]) — a tube's outside around z; `turn` + // (0–1) draws only that much of it, from +x toward +y. + band: (s, at, v) => { + const r = v[0], h = v[1] / 2, n = sidesFor(s, r, v, 2, 1), turn = v.length > 3 ? v[3] : 1; + for (let i = 0; i < n; i++) { + const a = i / n * Math.PI * 2 * turn, b = (i + 1) / n * Math.PI * 2 * turn; + const ca = Math.cos(a) * r, sa = Math.sin(a) * r, cb = Math.cos(b) * r, sb = Math.sin(b) * r; + quad(s, at, ca, sa, -h, cb, sb, -h, cb, sb, h, ca, sa, h); + } + }, + // (capsule x1 y1 z1 x2 y2 z2 width [sides]) — a rod between two points. + capsule: (s, at, v) => rod(s, at, v, v[6], v.length > 7 ? v[7] : 6), + // (line x1 y1 z1 x2 y2 z2 [width]) — a thin three-sided rod. + line: (s, at, v) => rod(s, at, v, v.length > 6 ? v[6] : 1.5, 3), + }; + + // A closed (radius, height) profile turned about an axis. Walked either way: + // it is turned counter-clockwise (radius right, height up) so faces face out. + // An odd count leads with `turn`, the share of a full circle to sweep. + const corner = new Float64Array(12); + function revolve(s, at, axis, v) { + const turn = v.length % 2 ? v[0] : 1, from = v.length % 2, n = (v.length - from) / 2; + let area = 0, reach = 0; + for (let i = 0; i < n; i++) { + const r = v[from + i * 2], h = v[from + i * 2 + 1]; + const r2 = v[from + ((i + 1) % n) * 2], h2 = v[from + ((i + 1) % n) * 2 + 1]; + area += r * h2 - r2 * h; + reach = Math.max(reach, r); + } + const sides = Math.max(1, Math.round(sidesFor(s, reach, [], 0) * turn)); + const u = (axis + 1) % 3, w = (axis + 2) % 3; + const point = (o, angle, r, h) => { + corner[o + u] = Math.cos(angle) * r; corner[o + w] = Math.sin(angle) * r; corner[o + axis] = h; + }; + for (let e = 0; e < n; e++) { + const p = area < 0 ? n - 1 - e : e, q = area < 0 ? (2 * n - 2 - e) % n : (e + 1) % n; + const r1 = v[from + p * 2], h1 = v[from + p * 2 + 1], r2 = v[from + q * 2], h2 = v[from + q * 2 + 1]; + if (!r1 && !r2) continue; + for (let i = 0; i < sides; i++) { + const a = i / sides * Math.PI * 2 * turn, b = (i + 1) / sides * Math.PI * 2 * turn; + point(0, a, r1, h1); point(3, b, r1, h1); point(6, b, r2, h2); point(9, a, r2, h2); + const c = corner; + quad(s, at, c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7], c[8], c[9], c[10], c[11]); + } + } + } + + function rod(s, at, v, width, sides) { + let dx = v[3] - v[0], dy = v[4] - v[1], dz = v[5] - v[2]; + const length = Math.hypot(dx, dy, dz); + if (length < 1e-6) return; + dx /= length; dy /= length; dz /= length; + // u: any unit vector across the rod; w = d × u completes the frame. + let ux = -dy, uy = dx, uz = 0; + if (Math.abs(dz) > .9) { ux = 0; uy = -dz; uz = dy; } + const um = Math.hypot(ux, uy, uz); ux /= um; uy /= um; uz /= um; + const wx = dy * uz - dz * uy, wy = dz * ux - dx * uz, wz = dx * uy - dy * ux; + const r = width / 2, n = Math.max(3, Math.floor(sides)); + for (let i = 0; i < n; i++) { + const a = i / n * Math.PI * 2, b = (i + 1) / n * Math.PI * 2; + const ca = Math.cos(a) * r, sa = Math.sin(a) * r, cb = Math.cos(b) * r, sb = Math.sin(b) * r; + const ax = ux * ca + wx * sa, ay = uy * ca + wy * sa, az = uz * ca + wz * sa; + const bx = ux * cb + wx * sb, by = uy * cb + wy * sb, bz = uz * cb + wz * sb; + quad(s, at, v[0] + ax, v[1] + ay, v[2] + az, v[0] + bx, v[1] + by, v[2] + bz, + v[3] + bx, v[4] + by, v[5] + bz, v[3] + ax, v[4] + ay, v[5] + az); + } + } + + // ——— flat shapes ——— + // Anchors go through the camera the frame program already carries (the + // CAMERA op's 24 numbers, `out.view`), by frame-vm's own projection, and come + // out as 2D ops with one flat depth each: out.disc, out.capsule, + // out.ellipse, out.plate, out.outline. No lighting; ink is the colour. + + const seen = new Float64Array(4 * 16); + // Project a point of the current frame: x, y, depth, and px per world unit. + function see(s, at, x, y, z, o) { + put(s.m, at, 0, x, y, z); + const V = s.view; + const dx = world[0] - V[0], dy = world[1] - V[1], dz = world[2] - V[2]; + const vz = dx * V[9] + dy * V[10] + dz * V[11]; + if (vz < V[19]) return false; + const vx = dx * V[3] + dy * V[4] + dz * V[5], vy = dx * V[6] + dy * V[7] + dz * V[8]; + const k = V[14] + (V[15] / vz - V[14]) * V[16]; + seen[o] = V[12] + vx * k; seen[o + 1] = V[13] - vy * k; seen[o + 2] = vz; seen[o + 3] = k; + return true; + } + const depthOf = (s, vz) => { + const z = (vz + s.nudge) * s.view[17] + s.view[18]; + return z < -1.499 ? -1.499 : z > 1.4 ? 1.4 : z; + }; + // How big a unit of this frame is in world units (its axes' mean length). + function frameSize(m, at) { + const ax = m[at + 3], ay = m[at + 4], az = m[at + 5], bx = m[at + 6], by = m[at + 7], bz = m[at + 8]; + const cx = m[at + 9], cy = m[at + 10], cz = m[at + 11]; + return Math.cbrt(Math.abs(ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx))); + } + // Px per unit of this frame at its origin. + function scaleAt(s, at) { return see(s, at, 0, 0, 0, 0) ? seen[3] * frameSize(s.m, at) : 0; } + // A circle about `axis`, `along` it from the frame's origin, as its centre + // (seen 0) and two conjugate half-axes (the points at seen 4 and seen 8). + function circle(s, at, axis, r, along = 0) { + const u = (axis + 1) % 3, w = (axis + 2) % 3, c = [0, 0, 0]; + c[axis] = along; + const p = c.slice(), q = c.slice(); + p[u] = r; q[w] = r; + return see(s, at, c[0], c[1], c[2], 0) && see(s, at, p[0], p[1], p[2], 4) && see(s, at, q[0], q[1], q[2], 8); + } + // A sketch being baked: records in the SHAPES layout (frame-vm.mjs, op 18), + // anchors in the part's own space. + function sketcher() { + const records = []; + let count = 0; + return { + add(s, kind, ...geometry) { + const f = s.facing || [0, 0, 0]; + records.push(kind, s.outline[0], s.outline[1], s.outline[2], s.outline[3], s.nudge, + s.r, s.g, s.b, f[0], f[1], f[2], ...geometry); + count++; + }, + done: () => ({ count, records: Float64Array.from(records) }), + }; + } + const SHAPE = { ball: 1, limb: 2, ring: 3, plate: 4, drum: 5 }; + // A point of the frame, in the part's space (baking) — into world[o…]. + const at3 = (s, at, x, y, z) => { put(s.m, at, 0, x, y, z); return [world[0], world[1], world[2]]; }; + // An axis of the frame times a length: a vector in the part's space. + const axis3 = (s, at, axis, length) => [0, 1, 2].map((k) => s.m[at + 3 + axis * 3 + k] * length); + const recorders = { + ball: (s, at, v) => s.sketching.add(s, SHAPE.ball, ...at3(s, at, v[0], v[1], v[2]), v[3] * frameSize(s.m, at)), + limb: (s, at, v) => s.sketching.add(s, SHAPE.limb, ...at3(s, at, v[0], v[1], v[2]), ...at3(s, at, v[3], v[4], v[5]), + v[6] * frameSize(s.m, at)), + ring: (s, at, v, axis) => s.sketching.add(s, SHAPE.ring, ...at3(s, at, 0, 0, 0), + ...axis3(s, at, (axis + 1) % 3, v[0]), ...axis3(s, at, (axis + 2) % 3, v[0])), + drum: (s, at, v, axis) => s.sketching.add(s, SHAPE.drum, ...at3(s, at, 0, 0, 0), + ...axis3(s, at, (axis + 1) % 3, v[0]), ...axis3(s, at, (axis + 2) % 3, v[0]), ...axis3(s, at, axis, v[1] / 2)), + stroke: (s, at, v) => { + for (let i = 1; i + 5 < v.length; i += 3) + s.sketching.add(s, SHAPE.limb, ...at3(s, at, v[i], v[i + 1], v[i + 2]), ...at3(s, at, v[i + 3], v[i + 4], v[i + 5]), + v[0] / 2 * frameSize(s.m, at)); + }, + plate: (s, at, v) => { + const n = Math.min(16, Math.floor(v.length / 3)), points = []; + for (let i = 0; i < n; i++) points.push(...at3(s, at, v[i * 3], v[i * 3 + 1], v[i * 3 + 2])); + s.sketching.add(s, SHAPE.plate, n, ...points); + }, + // A box baked as its six faces, each one-sided: the host shows the three + // turned its way, each outlined, which reads as a drawn box. + slab: (s, at, v) => { + const lo = [v[0], v[1], v[2]], hi = [v[3], v[4], v[5]], was = s.facing; + for (let axis = 0; axis < 3; axis++) for (const end of [lo, hi]) { + const u = (axis + 1) % 3, w = (axis + 2) % 3, sign = end === hi ? 1 : -1; + const corner = (a, b) => { const c = [0, 0, 0]; c[axis] = end[axis]; c[u] = a ? hi[u] : lo[u]; c[w] = b ? hi[w] : lo[w]; return at3(s, at, ...c); }; + s.facing = axis3(s, at, axis, sign * Math.sign(hi[axis] - lo[axis] || 1)); + s.sketching.add(s, SHAPE.plate, 4, ...corner(0, 0), ...corner(1, 0), ...corner(1, 1), ...corner(0, 1)); + } + s.facing = was; + }, + }; + // The ink edge in effect, sent as an OUTLINE op only when a shape drawn this + // tick needs a different one than the host already has. + function inkUp(s) { + const o = s.outline, sent = s.inked; + if (o[0] === sent[0] && (!o[0] || (o[1] === sent[1] && o[2] === sent[2] && o[3] === sent[3]))) return; + sent[0] = o[0]; sent[1] = o[1]; sent[2] = o[2]; sent[3] = o[3]; + s.out.outline(o[0], o[1], o[2], o[3]); + } + const flatShapes = { + // (ball x y z r) + ball: (s, at, v) => { + if (!see(s, at, v[0], v[1], v[2], 0)) return; + // As an ELLIPSE, the fan a baked ball gets, so both paths draw it alike. + const rad = v[3] * seen[3] * frameSize(s.m, at); + s.out.ellipse(seen[0], seen[1], depthOf(s, seen[2]), rad, 0, 0, rad, s.r, s.g, s.b); + }, + // (limb x1 y1 z1 x2 y2 z2 r): a stadium between two ends + limb: (s, at, v) => { + if (!see(s, at, v[0], v[1], v[2], 0) || !see(s, at, v[3], v[4], v[5], 4)) return; + s.out.capsule(seen[0], seen[1], seen[4], seen[5], depthOf(s, (seen[2] + seen[6]) / 2), + v[6] * (seen[3] + seen[7]) * frameSize(s.m, at), s.r, s.g, s.b); + }, + // (ring axis r): a circle about the axis, as its projected ellipse + ring: (s, at, v, axis) => { + if (!circle(s, at, axis, v[0])) return; + s.out.ellipse(seen[0], seen[1], depthOf(s, seen[2]), seen[4] - seen[0], seen[5] - seen[1], + seen[8] - seen[0], seen[9] - seen[1], s.r, s.g, s.b); + }, + // (drum axis r width): a cylinder as its silhouette — the far end's + // ellipse, the band between the two ends' tangent points, the near end's + // ellipse — each at its own depth, so the near end covers the band. + drum: (s, at, v, axis) => { + const caps = []; + for (const side of [-1, 1]) { + if (!circle(s, at, axis, v[0], side * v[1] / 2)) return; + caps.push({ x: seen[0], y: seen[1], vz: seen[2], ax: seen[4] - seen[0], ay: seen[5] - seen[1], + bx: seen[8] - seen[0], by: seen[9] - seen[1] }); + } + caps.sort((p, q) => q.vz - p.vz); + const [far, near] = caps, cap = (c) => s.out.ellipse(c.x, c.y, depthOf(s, c.vz), c.ax, c.ay, c.bx, c.by, s.r, s.g, s.b); + cap(far); + // Where each end's ellipse runs parallel to the drum's length. + const dx = near.x - far.x, dy = near.y - far.y; + if (Math.hypot(dx, dy) > .5) { + const tangent = (c) => { + const t = Math.atan2(c.bx * dy - c.by * dx, c.ax * dy - c.ay * dx); + return [c.ax * Math.cos(t) + c.bx * Math.sin(t), c.ay * Math.cos(t) + c.by * Math.sin(t)]; + }; + const [fx, fy] = tangent(far), [nx, ny] = tangent(near); + s.out.plate(4, [far.x + fx, far.y + fy, near.x + nx, near.y + ny, near.x - nx, near.y - ny, + far.x - fx, far.y - fy], depthOf(s, (far.vz + near.vz) / 2), s.r, s.g, s.b); + } + cap(near); + }, + // (stroke w x y z x y z …): a thick polyline, w world units wide + stroke: (s, at, v) => { + for (let i = 1; i + 5 < v.length; i += 3) { + if (!see(s, at, v[i], v[i + 1], v[i + 2], 0) || !see(s, at, v[i + 3], v[i + 4], v[i + 5], 4)) continue; + s.out.capsule(seen[0], seen[1], seen[4], seen[5], depthOf(s, (seen[2] + seen[6]) / 2), + v[0] * (seen[3] + seen[7]) / 2 * frameSize(s.m, at), s.r, s.g, s.b); + } + }, + // (plate x y z …): a flat polygon through projected points + plate: (s, at, v) => { + const n = Math.min(16, Math.floor(v.length / 3)), points = []; + let vz = 0; + for (let i = 0; i < n; i++) { + if (!see(s, at, v[i * 3], v[i * 3 + 1], v[i * 3 + 2], 0)) return; + points.push(seen[0], seen[1]); + vz += seen[2]; + } + s.out.plate(n, points, depthOf(s, vz / n), s.r, s.g, s.b); + }, + // (slab x1 y1 z1 x2 y2 z2): a box as its silhouette — the hull of its + // eight projected corners, one flat plate. + slab: (s, at, v) => { + const corners = []; + let vz = 0; + for (let i = 0; i < 8; i++) { + if (!see(s, at, v[i & 1 ? 3 : 0], v[i & 2 ? 4 : 1], v[i & 4 ? 5 : 2], 0)) return; + corners.push([seen[0], seen[1]]); + vz += seen[2]; + } + const hull = convexHull(corners); + s.out.plate(hull.length, hull.flat(), depthOf(s, vz / 8), s.r, s.g, s.b); + }, + }; + // Andrew's monotone chain. + function convexHull(points) { + const p = points.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + const lower = [], upper = []; + for (const q of p) { while (lower.length > 1 && cross(lower.at(-2), lower.at(-1), q) <= 0) lower.pop(); lower.push(q); } + for (const q of p.reverse()) { while (upper.length > 1 && cross(upper.at(-2), upper.at(-1), q) <= 0) upper.pop(); upper.push(q); } + return lower.slice(0, -1).concat(upper.slice(0, -1)); + } + return { objectInputs, objectLevels, objectLight, read, compile }; + })(); + const sources = { + puppy: "; puppy-flat — fia's pup, drawn flat: balls and limbs hung on a small\n; skeleton, inked at the silhouette. x forward (nose), y up, z to the pup's\n; right, the origin on the floor under the middle of the body.\n;\n; Every shape is still; only the frames move. The game hands the pose over as\n; the owner's parts (three numbers each, radians unless noted), so each bone\n; is one baked sketch under a moving frame: a tick is about a dozen SKETCH\n; ops and nothing is projected in JS.\n;\n; body bob (units) · pitch about the hips (nose up +) · roll along the spine\n; head yaw · nod (up +) · cock (the curious tilt)\n; ears flop (out from the head) · perk (forward)\n; tail wag · droop (down +)\n; fl fr bl br swing (paw forward +) · splay (out to the side +)\n; face eyes open (0/1) · mouth open (0/1) · tongue (0/1)\n;\n; fiapup.js mirrors the measurements it needs (neck, snout) in `pupRig`;\n; change them together.\n\ndef stand 21\ndef hip 9\ndef side 5.2\ndef leg 14\ndef edge 1.3\n\n(let bob (owner body x))\n(let pitch (owner body y))\n(let roll (owner body z))\n\n; the shadow stays on the floor, behind the pup's own shapes\n(nudge 40\n (ink 58 104 60)\n (scale 1.9 1 1.25 (ring y 13)))\n\n(move 0 (+ stand bob) 0\n (rotate x roll\n (move (- hip) 0 0 (rotate z pitch (move hip 0 0\n\n ; far legs first, near legs last: depth sorts them anyway, the order\n ; only settles ties\n (move hip -3 (- side) (rotate z (owner fl x) (rotate x (owner fl y))\n (ink 226 170 110) (limb 0 0 0 0 (- leg) 0 3.1)\n (ink 250 236 214) (ball .8 (- -.5 leg) 0 3.4)))\n (move hip -3 side (rotate z (owner fr x) (rotate x (- (owner fr y)))\n (ink 226 170 110) (limb 0 0 0 0 (- leg) 0 3.1)\n (ink 250 236 214) (ball .8 (- -.5 leg) 0 3.4)))\n (move (- hip) -2 (- side) (rotate z (owner bl x) (rotate x (owner bl y))\n (ink 214 156 98) (limb 0 0 0 0 (- -2.4 leg) 0 3.5)\n (ink 250 236 214) (ball .8 (- -3.4 leg) 0 3.6)))\n (move (- hip) -2 side (rotate z (owner br x) (rotate x (- (owner br y)))\n (ink 214 156 98) (limb 0 0 0 0 (- -2.4 leg) 0 3.5)\n (ink 250 236 214) (ball .8 (- -3.4 leg) 0 3.6)))\n\n ; the body: a bean with a cream chest, a brown saddle, a red collar\n (ink 232 178 118)\n (outline edge 74 44 30 (limb -8 0 0 8 1 0 9.5))\n (nudge -1.5\n (ink 250 236 214) (ball 9 -2.5 0 6.5)\n (ink 168 104 62) (ball -4 5.5 0 6))\n (nudge -3\n (ink 222 58 64) (limb 12 5 -5 12 5 5 1.7)\n (ink 255 208 70) (ball 13.6 2.5 0 1.6))\n\n ; the tail, from the rump, up and back\n (move -15 3 0 (rotate y (owner tail x) (rotate z (owner tail y)\n (ink 214 156 98)\n (outline edge 74 44 30 (limb 0 0 0 -6 6 0 2.3))\n (ink 250 236 214) (ball -6.5 6.8 0 2.4))))\n\n ; the head on the neck, turned, nodded and cocked\n (move 14 9 0 (rotate y (owner head x) (rotate z (owner head y) (rotate x (owner head z)\n (ink 232 178 118)\n (outline edge 74 44 30 (ball 0 0 0 9))\n (ink 250 236 214) (ball 7 -3 0 5.4)\n (nudge -1 (ink 40 28 30) (ball 12 -1.4 0 2.3))\n (if (> (owner face x) .5)\n (ink 36 26 30) (ball 5.2 2.6 -4.3 1.8) (ball 5.2 2.6 4.3 1.8)\n (nudge -.5 (ink 255 255 255) (ball 6 3.3 -4.5 .6) (ball 6 3.3 4.5 .6)))\n (if (< (owner face x) .5)\n (ink 60 38 34) (limb 4.4 2.3 -5 6.6 2.3 -3.6 .5) (limb 4.4 2.3 5 6.6 2.3 3.6 .5))\n (if (> (owner face z) .5)\n (ink 236 96 120) (limb 9 -6 0 10 -9.5 0 1.9))\n ; floppy ears, the brown of the saddle\n (move -1 5.5 -6 (rotate x (owner ears x) (rotate z (owner ears y)\n (ink 168 104 62) (outline edge 74 44 30 (limb 0 0 0 0 -8 -2.4 3.3)))))\n (move -1 5.5 6 (rotate x (- (owner ears x)) (rotate z (owner ears y)\n (ink 168 104 62) (outline edge 74 44 30 (limb 0 0 0 0 -8 2.4 3.3))))))))))))))\n", + yard: "; yard-flat — the pup's backyard, drawn flat. World space: x across, y up,\n; z toward the camera; the lawn runs from the fence (z -220) forward past\n; where the camera stands. All of it is still, so it bakes into one sketch\n; and a tick sends one SKETCH for the whole yard.\n;\n; Big flat shapes carry one depth each, so the ground is pushed far back\n; (nudge) and everything that stands on it draws over it. The fence and the\n; hills behind it are pushed back less than the lawn but more than anything\n; the pup can reach.\n\ndef tile 80\ndef fence -220\n\n; past the fence: a meadow, and a hedge along it\n(nudge 6000\n (ink 146 188 108)\n (plate -1600 0 -1500 1600 0 -1500 1600 0 -222 -1600 0 -222))\n(nudge 3200\n (outline 1.6 52 96 58\n (repeat 19 i\n (ink (mix 104 92 (% i 2)) (mix 166 152 (% i 2)) (mix 92 84 (% i 2)))\n (ball (- (* i 70) 630) 18 -262 (+ 30 (* 6 (% i 3)))))))\n\n; a round little tree over the fence, back left\n(nudge 3400\n (ink 132 92 66) (limb -250 0 -300 -250 120 -300 7)\n (ink 96 158 88)\n (outline 1.6 52 96 58\n (ball -250 150 -300 40) (ball -216 130 -290 28) (ball -284 132 -290 30)))\n\n; the fence: posts and two rails across the back and down both sides\n(nudge 3000\n (ink 244 232 208)\n (outline 1.2 150 120 96\n (repeat 9 i\n (plate (- (* i tile) 326) 0 fence (- (* i tile) 314) 0 fence\n (- (* i tile) 314) 84 fence (- (* i tile) 326) 84 fence))\n (plate -330 50 fence 330 50 fence 330 58 fence -330 58 fence)\n (plate -330 20 fence 330 20 fence 330 28 fence -330 28 fence)\n (mirror x\n (repeat 5 j\n (plate 320 0 (- (* (+ j 1) tile) 226) 320 0 (- (* (+ j 1) tile) 214)\n 320 84 (- (* (+ j 1) tile) 214) 320 84 (- (* (+ j 1) tile) 226)))\n (plate 320 50 fence 320 50 180 320 58 180 320 58 fence)\n (plate 320 20 fence 320 20 180 320 28 180 320 28 fence))))\n\n; flowers along the fence\n(nudge 2600\n (repeat 7 i\n (ink 88 140 70) (limb (- (* i 90) 280) 0 -206 (- (* i 90) 280) 18 -206 1)\n (ink (mix 255 250 (% i 2)) (mix 150 214 (% i 2)) (mix 190 90 (% i 2)))\n (ball (- (* i 90) 280) 20 -206 4.5)))\n\n; the lawn, mown in squares\n(nudge 5000\n (repeat 16 i\n (repeat 9 j\n (ink (mix 128 116 (% (+ i j) 2)) (mix 184 170 (% (+ i j) 2)) (mix 98 88 (% (+ i j) 2)))\n (plate (- (* i tile) 640) 0 (+ fence (* j tile))\n (- (* i tile) 560) 0 (+ fence (* j tile))\n (- (* i tile) 560) 0 (+ fence (* (+ j 1) tile))\n (- (* i tile) 640) 0 (+ fence (* (+ j 1) tile))))))\n\n; the bed, back left: a cushion and its soft middle, a little behind what\n; lies on it\n(move -230 0 -140\n (nudge 30\n (ink 110 140 196)\n (outline 1.4 50 60 96 (move 0 5 0 (drum y 34 10)))\n (ink 176 198 232) (move 0 10.3 0 (ring y 25))))\n\n; the water bowl, back right\n(move 230 0 -150\n (nudge 12\n (ink 222 84 72)\n (outline 1.2 110 40 36 (move 0 3.5 0 (drum y 11 7)))\n (ink 130 196 240) (move 0 7.2 0 (ring y 8))))\n", + ball: "; ball-flat — the fetch ball. The origin is on the floor under the ball;\n; x points the way it rolls. `distance` rolls it; the owner's `ball x` lifts\n; it (a throw, or the pup's mouth). The band is a flat bar across the face,\n; so turning it is what reads as rolling.\n\ndef r 5\n\n(let roll (/ distance r))\n\n(nudge 20 (ink 58 104 60) (scale 1.2 1 1 (ring y 4.4)))\n\n(move 0 (+ r (owner ball x)) 0\n (rotate z (- roll)\n (ink 232 72 76)\n (outline 1 96 30 36 (ball 0 0 0 r))\n (nudge -1 (ink 255 214 84) (limb 0 -4.3 0 0 4.3 0 1.3))))\n", + rope: "; rope-flat — the tug rope: a knot at each end and the rope between. The\n; origin is the first knot; the owner's `end` is the second knot, in world\n; units from the first (the object is placed unturned). The first knot bakes;\n; the rope and the far knot move every tick, so they go out as a CAPSULE and\n; an ELLIPSE — the one object here that uses the per-tick flat path.\n\n(ink 70 118 206)\n(outline 1 30 50 96 (ball 0 0 0 3.4))\n(ink 244 244 250)\n(limb 0 0 0 (owner end x) (owner end y) (owner end z) 1.5)\n(ink 70 118 206)\n(outline 1 30 50 96 (ball (owner end x) (owner end y) (owner end z) 3.4))\n", + hand: "; hand-flat — you: a white-gloved hand over the lawn, fingers reaching into\n; the yard (x; the game turns it in from the right), palm down, thumb on -z. The origin is on the floor\n; under the hand; the owner's `hand` is height · grip (0 open, 1 fist).\n; The shadow stays on the floor so you can tell where the hand is.\n\n(let h (owner hand x))\n\n(nudge 30 (ink 58 104 60) (scale 1.5 1 1.1 (ring y 8)))\n\n(move 0 h 0\n (rotate z -.3\n ; the cuff sits under the palm, whatever the depth of its middle says\n (nudge 22 (ink 236 96 128) (outline 1.2 96 50 70 (limb -15 0 0 -8 0 0 5.2)))\n (ink 255 252 246)\n (outline 1.2 96 90 100 (ball 0 0 0 7.4))\n (if (< (owner hand y) .5)\n (nudge -6 (outline 1.2 96 90 100\n (limb 4 .5 -4.6 13 -1 -5.6 2.1) (limb 5 .5 -1.5 15 -1 -1.8 2.1)\n (limb 5 .5 1.5 15 -1 1.8 2.1) (limb 4 .5 4.6 12 -1 5.4 2)\n (limb 0 0 -6 5 1 -11 2.2))))\n (if (> (owner hand y) .5)\n (nudge -6 (outline 1.2 96 90 100\n (ball 6 -1 -4 2.6) (ball 7 -1 -1.3 2.6) (ball 7 -1 1.3 2.6) (ball 6 -1 4 2.5)\n (limb 1 0 -6 5 1 -6.5 2.2))))))\n", + treat: "; treat-flat — a bone biscuit, lying along z. The origin is its middle.\n\n(ink 214 160 98)\n(outline 1 110 70 40\n (limb 0 0 -4 0 0 4 1.6)\n (ball 0 1 -5 1.9) (ball 0 -1 -5 1.9) (ball 0 1 5 1.9) (ball 0 -1 5 1.9))\n", + }; + const compiled = {}; + for (const name in sources) compiled[name] = objectLisp.compile(sources[name], name); + return compiled; +})(); +const fiapupFrameVm = (() => { + // The web's interpreter for the oskiewar frame program. + // + // The game hands the shell one Float32Array per paint — a program of + // scene-level ops — and this runs it against the shell's own drawing + // functions. It is one reader of the format; the game's immediate path and + // the console's native interpreter are the others. Keep the op table equal + // to the one at the head of the frame-program section in oskiewar.js: + // + // 1 VIEW clipX clipY clipW clipH NaN clipX = the whole stage + // 2 FACE x1 y1 z1 x2 y2 z2 x3 y3 z3 r g b + // 3 DISC x y depth radius r g b + // 4 CAPSULE x1 y1 x2 y2 depth width r g b + // 5 TEXT font x y size r g b string font 0 comic 1 ywft 2 system 3 block + // 6 BOX x y w h r g b a + // 7 LINE x1 y1 x2 y2 width r g b + // 8 WIPE r g b + // 9 CAMERA position(3) right(3) up(3) forward(3) centerX centerY + // orthoScale focal perspective depthSlope depthBase near + // bandMinX bandMaxX bandMinY bandMaxY + // 10 WORLD x1 y1 z1 x2 y2 z2 x3 y3 z3 r g b a world-space face + // 11 DEPTH mode value WORLD depth from here on: 0 projected, 1 flat at + // value, 2 projected plus value + // 12 ASSET handle vertexCount faceCount, vertices (x y z), faces (four + // ids, r g b, normal x y z) a retained mesh, kept by handle + // 13 MESH handle lightX lightY lightZ draw a retained mesh, each quad + // lit .72 + .28 * max(0, -normal·light), as two WORLD faces + // 14 MODEL radius handle0 handle1 handle2 · origin(3) x(3) y(3) z(3) · + // lightX lightY lightZ a retained mesh placed by a matrix, at + // one of three baked levels: 0 while `radius` projects to 56 px + // or more, 1 down to 20 px, 2 below. Vertices go to origin + x·X + // + y·Y + z·Z; a normal goes by the cofactor matrix (exact under + // any scale or mirror) and lights as MESH does, except a zero + // normal, which is unlit. A face whose fourth id repeats its + // third is one triangle. Nothing is tessellated here: the levels + // are meshes the game baked (xbox/live/object-lisp.mjs). + // 15 ELLIPSE x y depth · ax ay bx by · r g b a projected circle: the + // points x + a·cos t + b·sin t, a and b conjugate half-axes + // 16 PLATE n · n×(x y) · depth · r g b a flat convex polygon, n 3–16 + // 17 OUTLINE width r g b from here on, every DISC, CAPSULE, ELLIPSE and + // PLATE is drawn first in this ink, `width` px bigger, a hair + // behind its own depth; width 0 stops. Flat objects draw so. + // 18 SHAPES handle count length · records an object's flat shapes, kept + // by handle: per record kind (1 ball, 2 limb, 3 ring, 4 plate, + // 5 drum) · outline width (world units) and its ink rgb · + // nudge (world units back) · rgb · facing xyz (zero = both + // sides), then object-space anchors: ball x y z radius; limb + // two ends and a radius; ring centre and two radius vectors; + // plate n and n points; drum centre, two radius vectors and + // half its length as a vector + // 19 SKETCH handle · origin(3) x(3) y(3) z(3) draw kept shapes under a + // placement: anchors projected here, filled flat, outlined, + // one-sided ones skipped when turned away, ellipse sides picked + // by projected size. Nothing else is expanded here. + // + // A WORLD face is taken to the current CAMERA here: to camera space, cut at + // the near plane (Sutherland-Hodgman, before the divide), projected with the + // ortho-to-perspective blend, cut to the band, and fanned. That is the one + // clip rule for world geometry; the game no longer projects it. + // Discs and capsules arrive whole and are fanned here; a face inside a second + // view (the P1 inset) is scissored to that view's rectangle here. The host's + // own coordinate limit is honoured the way the console's is: a face that + // asks for a coordinate past ±32200 is dropped, not drawn. + + const FRAME_VIEW = 1, FRAME_FACE = 2, FRAME_DISC = 3, FRAME_CAPSULE = 4, + FRAME_TEXT = 5, FRAME_BOX = 6, FRAME_LINE = 7, FRAME_WIPE = 8, + FRAME_CAMERA = 9, FRAME_WORLD = 10, FRAME_DEPTH = 11, FRAME_ASSET = 12, + FRAME_MESH = 13, FRAME_MODEL = 14, FRAME_ELLIPSE = 15, FRAME_PLATE = 16, + FRAME_OUTLINE = 17, FRAME_SHAPES = 18, FRAME_SKETCH = 19; + // Fixed sizes; ASSET is variable and measured from its own header. + const opSize = [0, 5, 13, 8, 10, 9, 9, 9, 4, 25, 13, 3, 0, 5, 20, 11, 0, 5, 0, 14]; + const sizeAt = (p, at) => p[at] === FRAME_ASSET ? 4 + p[at + 2] * 3 + p[at + 3] * 10 + : p[at] === FRAME_PLATE ? 6 + p[at + 1] * 2 : p[at] === FRAME_SHAPES ? 4 + p[at + 3] : opSize[p[at]] || 0; + // An outline sits this far behind its own shape, so the shape covers it and + // anything nearer covers both. + const outlineBehind = 2e-6; + + // Ring tables by radius bucket, as the game fans them: a small disc is a + // hexagon, a large one thirty-two sides. + const discRings = [6, 8, 12, 16, 24, 32].map((sides) => { + const ring = []; + for (let side = 0; side < sides; side++) { + const a = side * Math.PI * 2 / sides; + ring.push(Math.cos(a), Math.sin(a)); + } + return ring; + }); + const discRingFor = (radius) => discRings[ + radius < 6 ? 0 : radius < 13 ? 1 : radius < 26 ? 2 + : radius < 52 ? 3 : radius < 110 ? 4 : 5]; + const capsuleArcs = Object.fromEntries([3, 4, 6, 8, 12].map((n) => [n, + Array.from({ length: n + 1 }, (_, i) => + [Math.cos(i * Math.PI / n), Math.sin(i * Math.PI / n)]).flat()])); + + const safe = (value) => value > -32200 && value < 32200; + + function createFrameVm(host) { + const { triangle3d, triangle, box, line, wipe, write, systemWrite, + comicWrite, ywftWrite } = host; + const fonts = [ + comicWrite || ywftWrite || systemWrite, + ywftWrite || systemWrite, + systemWrite, + write || systemWrite, + ]; + // A face with its own depth per vertex, or, on a host without a depth + // buffer, the flat triangle the game drew before it had one. + const face = triangle3d + ? triangle3d + : (x1, y1, z1, x2, y2, z2, x3, y3, z3, r, g, b) => + triangle(x1, y1, x2, y2, x3, y3, r, g, b); + let clip = null; + const scissorPoly = new Float64Array(28), scissorNext = new Float64Array(28); + + function flatFace(x1, y1, x2, y2, x3, y3, depth, r, g, b) { + if (!(safe(x1) && safe(y1) && safe(x2) && safe(y2) && safe(x3) && safe(y3))) return; + if (!clip) { face(x1, y1, depth, x2, y2, depth, x3, y3, depth, r, g, b); return; } + const left = clip.x, right = clip.x + clip.w, top = clip.y, bottom = clip.y + clip.h; + const inside = (x, y) => x >= left && x <= right && y >= top && y <= bottom; + if (inside(x1, y1) && inside(x2, y2) && inside(x3, y3)) { + face(x1, y1, depth, x2, y2, depth, x3, y3, depth, r, g, b); + return; + } + if ((x1 < left && x2 < left && x3 < left) || (x1 > right && x2 > right && x3 > right) || + (y1 < top && y2 < top && y3 < top) || (y1 > bottom && y2 > bottom && y3 > bottom)) return; + let poly = scissorPoly, next = scissorNext, n = 3; + poly[0] = x1; poly[1] = y1; poly[2] = x2; poly[3] = y2; poly[4] = x3; poly[5] = y3; + for (let edge = 0; edge < 4; edge++) { + const axis = edge < 2 ? 0 : 1; + const bound = edge === 0 ? left : edge === 1 ? right : edge === 2 ? top : bottom; + const sign = edge === 0 || edge === 2 ? 1 : -1; + let m = 0; + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + const ax = poly[i * 2], ay = poly[i * 2 + 1], bx = poly[j * 2], by = poly[j * 2 + 1]; + const da = ((axis ? ay : ax) - bound) * sign, db = ((axis ? by : bx) - bound) * sign; + if (da >= 0) { next[m * 2] = ax; next[m * 2 + 1] = ay; m++; } + if ((da >= 0) !== (db >= 0)) { + const t = da / (da - db); + next[m * 2] = ax + (bx - ax) * t; next[m * 2 + 1] = ay + (by - ay) * t; m++; + } + } + const swap = poly; poly = next; next = swap; n = m; + if (n < 3) return; + } + for (let i = 1; i + 1 < n; i++) + face(poly[0], poly[1], depth, poly[i * 2], poly[i * 2 + 1], depth, + poly[i * 2 + 2], poly[i * 2 + 3], depth, r, g, b); + } + + // A FACE op carries a depth per vertex; scissoring flattens it to the first + // vertex's depth, which is what the game's own scissor did for the inset. + function fullFace(x1, y1, z1, x2, y2, z2, x3, y3, z3, r, g, b) { + if (!clip) { + if (safe(x1) && safe(y1) && safe(z1) && safe(x2) && safe(y2) && safe(z2) && + safe(x3) && safe(y3) && safe(z3)) + face(x1, y1, z1, x2, y2, z2, x3, y3, z3, r, g, b); + return; + } + flatFace(x1, y1, x2, y2, x3, y3, z1, r, g, b); + } + + function disc(x, y, depth, radius, r, g, b) { + const ring = discRingFor(radius); + const originX = x + ring[0] * radius, originY = y + ring[1] * radius; + let lastX = x + ring[2] * radius, lastY = y + ring[3] * radius; + for (let side = 4; side < ring.length; side += 2) { + const nextX = x + ring[side] * radius; + const nextY = y + ring[side + 1] * radius; + flatFace(originX, originY, lastX, lastY, nextX, nextY, depth, r, g, b); + lastX = nextX; + lastY = nextY; + } + } + + function capsule(x1, y1, x2, y2, depth, width, r, g, b) { + const dx = x2 - x1, dy = y2 - y1; + const length = Math.hypot(dx, dy); + const radius = width / 2; + if (length < .001) { disc(x1, y1, depth, radius, r, g, b); return; } + const nx = -dy / length * radius, ny = dx / length * radius; + flatFace(x1 + nx, y1 + ny, x1 - nx, y1 - ny, x2 + nx, y2 + ny, depth, r, g, b); + flatFace(x1 - nx, y1 - ny, x2 - nx, y2 - ny, x2 + nx, y2 + ny, depth, r, g, b); + const steps = radius < 6 ? 3 : radius < 13 ? 4 : radius < 26 ? 6 : radius < 52 ? 8 : 12; + const ring = capsuleArcs[steps], ux = dx / length, uy = dy / length; + for (const end of [-1, 1]) { + const cx = end < 0 ? x1 : x2, cy = end < 0 ? y1 : y2; + let ax = cx + nx, ay = cy + ny; + for (let i = 1; i <= steps; i++) { + const side = ring[i * 2], along = ring[i * 2 + 1] * end * radius; + const bx = cx + nx * side + ux * along, by = cy + ny * side + uy * along; + flatFace(cx, cy, ax, ay, bx, by, depth, r, g, b); + ax = bx; ay = by; + } + } + } + + // The camera WORLD faces are seen through; filled by a CAMERA op. + const cam = new Float64Array(24); + let hasCamera = false; + const lerp = (a, b, t) => a + (b - a) * t; + const toView = (x, y, z) => { + const dx = x - cam[0], dy = y - cam[1], dz = z - cam[2]; + return { x: dx * cam[3] + dy * cam[4] + dz * cam[5], + y: dx * cam[6] + dy * cam[7] + dz * cam[8], + z: dx * cam[9] + dy * cam[10] + dz * cam[11] }; + }; + const project = (v) => { + const depth = Math.max(cam[19], v.z); + const perspective = cam[16]; + const orthoX = cam[12] + v.x * cam[14], orthoY = cam[13] - v.y * cam[14]; + const perspectiveX = cam[12] + v.x * cam[15] / depth; + const perspectiveY = cam[13] - v.y * cam[15] / depth; + const z = depth * cam[17] + cam[18]; + return { x: lerp(orthoX, perspectiveX, perspective), + y: lerp(orthoY, perspectiveY, perspective), + z: z < -1.499 ? -1.499 : z > 1.4 ? 1.4 : z }; + }; + const mixVertex = (a, b, t) => ({ x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), + z: lerp(a.z, b.z, t) }); + function clipPolygon(polygon, distance) { + const kept = []; + for (let i = 0; i < polygon.length; i++) { + const current = polygon[i], next = polygon[(i + 1) % polygon.length]; + const here = distance(current), there = distance(next); + if (here >= 0) kept.push(current); + if ((here >= 0) !== (there >= 0)) kept.push(mixVertex(current, next, here / (here - there))); + } + return kept; + } + const bandEdges = [ + (v) => v.x - cam[20], (v) => cam[21] - v.x, + (v) => v.y - cam[22], (v) => cam[23] - v.y, + ]; + const inBand = (v) => v.x >= cam[20] && v.x <= cam[21] && v.y >= cam[22] && v.y <= cam[23]; + let depthMode = 0, depthValue = 0; + function emitProjected(a, b, c, r, g, bl) { + if (!(safe(a.x) && safe(a.y) && safe(a.z) && safe(b.x) && safe(b.y) && safe(b.z) && + safe(c.x) && safe(c.y) && safe(c.z))) return; + if (depthMode === 0) { face(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z, r, g, bl); return; } + const flat = depthMode === 1; + face(a.x, a.y, flat ? depthValue : a.z + depthValue, + b.x, b.y, flat ? depthValue : b.z + depthValue, + c.x, c.y, flat ? depthValue : c.z + depthValue, r, g, bl); + } + function worldFace(ax, ay, az, bx, by, bz, cx, cy, cz, r, g, b) { + if (!hasCamera) return; + const va = toView(ax, ay, az); + const vb = toView(bx, by, bz); + const vc = toView(cx, cy, cz); + const near = cam[19]; + let polygon; + if (va.z >= near && vb.z >= near && vc.z >= near) { + const pa = project(va), pb = project(vb), pc = project(vc); + if (inBand(pa) && inBand(pb) && inBand(pc)) { emitProjected(pa, pb, pc, r, g, b); return; } + polygon = [pa, pb, pc]; + } else { + const cut = clipPolygon([va, vb, vc], (v) => v.z - near); + if (cut.length < 3) return; + polygon = cut.map(project); + if (polygon.some((v) => !Number.isFinite(v.x) || !Number.isFinite(v.y))) return; + } + for (const edge of bandEdges) { + if (polygon.length < 3) return; + polygon = clipPolygon(polygon, edge); + } + for (let corner = 2; corner < polygon.length; corner++) + emitProjected(polygon[0], polygon[corner - 1], polygon[corner], r, g, b); + } + + // Retained meshes, by the handle the game gave them. They live as long as + // this interpreter does; a page has one. + const meshes = new Map(); + function storeMesh(p, at) { + const handle = p[at + 1], vc = p[at + 2], fc = p[at + 3]; + const vertices = Float64Array.from(p.subarray(at + 4, at + 4 + vc * 3)); + const faces = Float64Array.from(p.subarray(at + 4 + vc * 3, at + 4 + vc * 3 + fc * 10)); + meshes.set(handle, { vertices, faces, count: fc }); + } + function drawMesh(handle, lx, ly, lz) { + const mesh = meshes.get(handle); + if (!mesh) return; + const v = mesh.vertices, f = mesh.faces; + for (let i = 0; i < mesh.count; i++) { + const o = i * 10; + const a = f[o] * 3, b = f[o + 1] * 3, c = f[o + 2] * 3, d = f[o + 3] * 3; + const k = .72 + Math.max(0, -f[o + 7] * lx - f[o + 8] * ly - f[o + 9] * lz) * .28; + const r = Math.round(f[o + 4] * k), g = Math.round(f[o + 5] * k), bl = Math.round(f[o + 6] * k); + worldFace(v[a], v[a + 1], v[a + 2], v[b], v[b + 1], v[b + 2], v[c], v[c + 1], v[c + 2], r, g, bl); + worldFace(v[a], v[a + 1], v[a + 2], v[c], v[c + 1], v[c + 2], v[d], v[d + 1], v[d + 2], r, g, bl); + } + } + + // The level a MODEL draws at, from how big its radius looks here. + function levelOf(p, at) { + if (!hasCamera) return 0; + const v = toView(p[at + 5], p[at + 6], p[at + 7]); + if (v.z <= cam[19]) return 0; + const px = p[at + 1] * lerp(cam[14], cam[15] / v.z, cam[16]); + return px >= 56 ? 0 : px >= 20 ? 1 : 2; + } + const placed = new Float64Array(12); + function drawModel(p, at) { + const mesh = meshes.get(p[at + 2 + levelOf(p, at)]); + if (!mesh) return; + const ox = p[at + 5], oy = p[at + 6], oz = p[at + 7]; + const xx = p[at + 8], xy = p[at + 9], xz = p[at + 10]; + const yx = p[at + 11], yy = p[at + 12], yz = p[at + 13]; + const zx = p[at + 14], zy = p[at + 15], zz = p[at + 16]; + const lx = p[at + 17], ly = p[at + 18], lz = p[at + 19]; + // Cofactor columns (Y×Z, Z×X, X×Y) carry normals; a mirror flips them + // back out and swaps the winding, as the object's own faces do. + const ax = yy * zz - yz * zy, ay = yz * zx - yx * zz, az = yx * zy - yy * zx; + const bx = zy * xz - zz * xy, by = zz * xx - zx * xz, bz = zx * xy - zy * xx; + const cx = xy * yz - xz * yy, cy = xz * yx - xx * yz, cz = xx * yy - xy * yx; + const flip = xx * ax + xy * ay + xz * az < 0 ? -1 : 1; + const v = mesh.vertices, f = mesh.faces, w = placed; + const put = (o, id) => { + const x = v[id * 3], y = v[id * 3 + 1], z = v[id * 3 + 2]; + w[o] = ox + x * xx + y * yx + z * zx; + w[o + 1] = oy + x * xy + y * yy + z * zy; + w[o + 2] = oz + x * xz + y * yz + z * zz; + }; + for (let i = 0; i < mesh.count; i++) { + const o = i * 10, tri = f[o + 3] === f[o + 2]; + put(0, f[o]); + if (flip < 0) { put(3, f[o + (tri ? 2 : 3)]); put(6, f[o + (tri ? 1 : 2)]); put(9, f[o + 1]); } + else { put(3, f[o + 1]); put(6, f[o + 2]); put(9, f[o + 3]); } + const nx = f[o + 7], ny = f[o + 8], nz = f[o + 9]; + let k = 1; + if (nx || ny || nz) { + const wx = (nx * ax + ny * bx + nz * cx) * flip, wy = (nx * ay + ny * by + nz * cy) * flip; + const wz = (nx * az + ny * bz + nz * cz) * flip; + const toward = -(wx * lx + wy * ly + wz * lz) / (Math.hypot(wx, wy, wz) || 1); + k = .72 + (toward > 0 ? toward : 0) * .28; + } + const r = Math.round(f[o + 4] * k), g = Math.round(f[o + 5] * k), bl = Math.round(f[o + 6] * k); + worldFace(w[0], w[1], w[2], w[3], w[4], w[5], w[6], w[7], w[8], r, g, bl); + if (!tri) worldFace(w[0], w[1], w[2], w[6], w[7], w[8], w[9], w[10], w[11], r, g, bl); + } + } + + // Flat shapes. An ellipse gets as many sides as keep its chords within + // 2 px of the curve (4 to 24): a lamp far off is a diamond, a near wheel + // round. One under a pixel isn't drawn at all. + const sidesFor = (radius) => radius < 3 ? 4 : radius < 6 ? 5 + : Math.max(6, Math.min(24, Math.ceil(Math.PI / Math.acos(Math.max(-1, 1 - 2 / radius))))); + // `half`: only the half from angle `from` round to `from + π`, as a fan + // from the centre — a drum's far end, whose other half the band covers. + function ellipse(x, y, depth, ax, ay, bx, by, r, g, b, grow = 0, half = false, from = 0) { + if (grow) { + const ka = 1 + grow / (Math.hypot(ax, ay) || 1), kb = 1 + grow / (Math.hypot(bx, by) || 1); + ax *= ka; ay *= ka; bx *= kb; by *= kb; + } + const reach = Math.max(Math.hypot(ax, ay), Math.hypot(bx, by)); + if (reach < .75) return; + // Sides by its mean radius, so a long thin ellipse isn't paid for as a circle. + const n = sidesFor(Math.max(Math.sqrt(Math.abs(ax * by - ay * bx)), reach * .35)); + if (half) { + const steps = Math.max(2, Math.ceil(n / 2)); + let lx = x + ax * Math.cos(from) + bx * Math.sin(from), ly = y + ay * Math.cos(from) + by * Math.sin(from); + for (let i = 1; i <= steps; i++) { + const t = from + i / steps * Math.PI, nx = x + ax * Math.cos(t) + bx * Math.sin(t), ny = y + ay * Math.cos(t) + by * Math.sin(t); + flatFace(x, y, lx, ly, nx, ny, depth, r, g, b); + lx = nx; ly = ny; + } + return; + } + const ox = x + ax, oy = y + ay; + let lx = x + ax * Math.cos(Math.PI * 2 / n) + bx * Math.sin(Math.PI * 2 / n); + let ly = y + ay * Math.cos(Math.PI * 2 / n) + by * Math.sin(Math.PI * 2 / n); + for (let i = 2; i < n; i++) { + const c = Math.cos(i / n * Math.PI * 2), s = Math.sin(i / n * Math.PI * 2); + const nx = x + ax * c + bx * s, ny = y + ay * c + by * s; + flatFace(ox, oy, lx, ly, nx, ny, depth, r, g, b); + lx = nx; ly = ny; + } + } + // A convex polygon, fanned; `grow` pushes each corner out from the middle. + function plate(p, at, n, depth, r, g, b, grow = 0) { + let cx = 0, cy = 0; + if (grow) { for (let i = 0; i < n; i++) { cx += p[at + i * 2] / n; cy += p[at + i * 2 + 1] / n; } } + const x = (i) => { const v = p[at + i * 2]; if (!grow) return v; const dx = v - cx, dy = p[at + i * 2 + 1] - cy; return v + dx / (Math.hypot(dx, dy) || 1) * grow; }; + const y = (i) => { const v = p[at + i * 2 + 1]; if (!grow) return v; const dx = p[at + i * 2] - cx, dy = v - cy; return v + dy / (Math.hypot(dx, dy) || 1) * grow; }; + for (let i = 2; i < n; i++) flatFace(x(0), y(0), x(i - 1), y(i - 1), x(i), y(i), depth, r, g, b); + } + const ink = { width: 0, r: 0, g: 0, b: 0 }; + function outlined(op, p, at) { + const w = ink.width, d = outlineBehind; + if (op === FRAME_DISC) disc(p[at + 1], p[at + 2], p[at + 3] + d, p[at + 4] + w, ink.r, ink.g, ink.b); + else if (op === FRAME_CAPSULE) capsule(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5] + d, p[at + 6] + w * 2, ink.r, ink.g, ink.b); + else if (op === FRAME_ELLIPSE) + ellipse(p[at + 1], p[at + 2], p[at + 3] + d, p[at + 4], p[at + 5], p[at + 6], p[at + 7], ink.r, ink.g, ink.b, w); + else plate(p, at + 2, p[at + 1], p[at + 2 + p[at + 1] * 2] + d, ink.r, ink.g, ink.b, w); + } + + // Sketches: an object's flat shapes, kept by handle (SHAPES) and drawn under + // a placement (SKETCH). Each record: kind · outline width, ink rgb · nudge · + // rgb · facing (3; zero = both sides), then its anchors in object space. + const sketches = new Map(); + function storeShapes(p, at) { + sketches.set(p[at + 1], { count: p[at + 2], records: Float64Array.from(p.subarray(at + 4, at + 4 + p[at + 3])) }); + } + const seen = new Float64Array(4 * 4), flatPoly = new Float64Array(16); + // A placed point to the screen: x, y, view depth, px per world unit. + function seePlaced(m, x, y, z, o) { + const wx = m[0] + x * m[3] + y * m[6] + z * m[9], wy = m[1] + x * m[4] + y * m[7] + z * m[10]; + const wz = m[2] + x * m[5] + y * m[8] + z * m[11]; + const dx = wx - cam[0], dy = wy - cam[1], dz = wz - cam[2]; + const vz = dx * cam[9] + dy * cam[10] + dz * cam[11]; + if (vz < cam[19]) return false; + const k = cam[14] + (cam[15] / vz - cam[14]) * cam[16]; + seen[o] = cam[12] + (dx * cam[3] + dy * cam[4] + dz * cam[5]) * k; + seen[o + 1] = cam[13] - (dx * cam[6] + dy * cam[7] + dz * cam[8]) * k; + seen[o + 2] = vz; seen[o + 3] = k; + return true; + } + const flatDepth = (vz, nudge) => { + const z = (vz + nudge) * cam[17] + cam[18]; + return z < -1.499 ? -1.499 : z > 1.4 ? 1.4 : z; + }; + const shapeSize = (R, i) => 12 + [0, 4, 7, 9, 1 + 3 * R[i + 12], 12][R[i]]; + const placedModel = new Float64Array(12); + function drawSketch(p, at) { + const sketch = sketches.get(p[at + 1]); + if (!sketch || !hasCamera) return; + const m = placedModel; + for (let k = 0; k < 12; k++) m[k] = p[at + 2 + k]; + const unit = Math.cbrt(Math.abs(m[3] * (m[7] * m[11] - m[8] * m[10]) - m[4] * (m[6] * m[11] - m[8] * m[9]) + + m[5] * (m[6] * m[10] - m[7] * m[9]))); + const R = sketch.records; + for (let n = 0, i = 0; n < sketch.count; n++, i += shapeSize(R, i)) { + const kind = R[i], line = R[i + 1], nudge = R[i + 5], r = R[i + 6], g = R[i + 7], b = R[i + 8], a = i + 12; + const ax = kind === 4 ? R[a + 1] : R[a], ay = kind === 4 ? R[a + 2] : R[a + 1], az = kind === 4 ? R[a + 3] : R[a + 2]; + // One-sided: skip it when its facing turns from the camera. + const fx = R[i + 9], fy = R[i + 10], fz = R[i + 11]; + if (fx || fy || fz) { + const wx = fx * m[3] + fy * m[6] + fz * m[9], wy = fx * m[4] + fy * m[7] + fz * m[10], wz = fx * m[5] + fy * m[8] + fz * m[11]; + const px = m[0] + ax * m[3] + ay * m[6] + az * m[9], py = m[1] + ax * m[4] + ay * m[7] + az * m[10]; + const pz = m[2] + ax * m[5] + ay * m[8] + az * m[11]; + if (wx * (px - cam[0]) + wy * (py - cam[1]) + wz * (pz - cam[2]) > 0) continue; + } + const shape = (grow, depthShift, cr, cg, cb) => { + if (kind === 1) { + if (!seePlaced(m, R[a], R[a + 1], R[a + 2], 0)) return; + const rad = R[a + 3] * seen[3] * unit; + ellipse(seen[0], seen[1], flatDepth(seen[2], nudge) + depthShift, rad, 0, 0, rad, cr, cg, cb, grow); + } else if (kind === 2) { + if (!seePlaced(m, R[a], R[a + 1], R[a + 2], 0) || !seePlaced(m, R[a + 3], R[a + 4], R[a + 5], 4)) return; + capsule(seen[0], seen[1], seen[4], seen[5], flatDepth((seen[2] + seen[6]) / 2, nudge) + depthShift, + R[a + 6] * (seen[3] + seen[7]) * unit + grow * 2, cr, cg, cb); + } else if (kind === 3) { + if (!seePlaced(m, R[a], R[a + 1], R[a + 2], 0) || !seePlaced(m, R[a] + R[a + 3], R[a + 1] + R[a + 4], R[a + 2] + R[a + 5], 4) || + !seePlaced(m, R[a] + R[a + 6], R[a + 1] + R[a + 7], R[a + 2] + R[a + 8], 8)) return; + ellipse(seen[0], seen[1], flatDepth(seen[2], nudge) + depthShift, seen[4] - seen[0], seen[5] - seen[1], + seen[8] - seen[0], seen[9] - seen[1], cr, cg, cb, grow); + } else if (kind === 4) { + const count = R[a]; + let vz = 0; + for (let k = 0; k < count; k++) { + if (!seePlaced(m, R[a + 1 + k * 3], R[a + 2 + k * 3], R[a + 3 + k * 3], 0)) return; + flatPoly[k * 2] = seen[0]; flatPoly[k * 2 + 1] = seen[1]; vz += seen[2]; + } + plate(flatPoly, 0, count, flatDepth(vz / count, nudge) + depthShift, cr, cg, cb, grow); + } else drum(m, R, a, nudge, grow, depthShift, cr, cg, cb); + }; + // The ink edge is world units wide, sized where the shape is anchored. + // Under half a pixel it is left off: a far object keeps its fills. + if (line && seePlaced(m, ax, ay, az, 12) && line * seen[15] * unit >= .5) + shape(line * seen[15] * unit, outlineBehind, R[i + 2], R[i + 3], R[i + 4]); + shape(0, 0, r, g, b); + } + } + // A drum (a cylinder: centre, two radius vectors, half its length along the + // axis): the far end's ellipse, the band between the ends' tangent points, + // the near end's ellipse, each at its own depth. + const drumEnds = [new Float64Array(7), new Float64Array(7)]; + function drum(m, R, a, nudge, grow, depthShift, cr, cg, cb) { + for (const side of [0, 1]) { + const s = side ? 1 : -1, e = drumEnds[side]; + const cx = R[a] + R[a + 9] * s, cy = R[a + 1] + R[a + 10] * s, cz = R[a + 2] + R[a + 11] * s; + if (!seePlaced(m, cx, cy, cz, 0) || !seePlaced(m, cx + R[a + 3], cy + R[a + 4], cz + R[a + 5], 4) || + !seePlaced(m, cx + R[a + 6], cy + R[a + 7], cz + R[a + 8], 8)) return; + e[0] = seen[0]; e[1] = seen[1]; e[2] = seen[2]; + e[3] = seen[4] - seen[0]; e[4] = seen[5] - seen[1]; e[5] = seen[8] - seen[0]; e[6] = seen[9] - seen[1]; + } + const [far, near] = drumEnds[0][2] > drumEnds[1][2] ? drumEnds : [drumEnds[1], drumEnds[0]]; + const cap = (e, half, from) => ellipse(e[0], e[1], flatDepth(e[2], nudge) + depthShift, + e[3], e[4], e[5], e[6], cr, cg, cb, grow, half, from); + const dx = near[0] - far[0], dy = near[1] - far[1]; + if (Math.hypot(dx, dy) <= .5) cap(far); + else { + const t = (e) => Math.atan2(e[5] * dy - e[6] * dx, e[3] * dy - e[4] * dx); + const tf = t(far), tn = t(near); + // Only the far end's outer half shows past the band. + const mid = tf + Math.PI / 2; + const outward = (far[3] * Math.cos(mid) + far[5] * Math.sin(mid)) * dx + (far[4] * Math.cos(mid) + far[6] * Math.sin(mid)) * dy < 0; + cap(far, true, outward ? tf : tf + Math.PI); + const fx = far[3] * Math.cos(tf) + far[5] * Math.sin(tf), fy = far[4] * Math.cos(tf) + far[6] * Math.sin(tf); + const nx = near[3] * Math.cos(tn) + near[5] * Math.sin(tn), ny = near[4] * Math.cos(tn) + near[6] * Math.sin(tn); + flatPoly[0] = far[0] + fx; flatPoly[1] = far[1] + fy; flatPoly[2] = near[0] + nx; flatPoly[3] = near[1] + ny; + flatPoly[4] = near[0] - nx; flatPoly[5] = near[1] - ny; flatPoly[6] = far[0] - fx; flatPoly[7] = far[1] - fy; + plate(flatPoly, 0, 4, flatDepth((far[2] + near[2]) / 2, nudge) + depthShift, cr, cg, cb, grow); + } + cap(near); + } + + const clipRect = { x: 0, y: 0, w: 0, h: 0 }; + function run(p, length, strings) { + clip = null; + hasCamera = false; + depthMode = 0; + depthValue = 0; + ink.width = 0; + let at = 0; + while (at < length) { + const op = p[at]; + switch (op) { + case FRAME_VIEW: + if (Number.isNaN(p[at + 1])) clip = null; + else { + clipRect.x = p[at + 1]; clipRect.y = p[at + 2]; + clipRect.w = p[at + 3]; clipRect.h = p[at + 4]; + clip = clipRect; + } + at += 5; break; + case FRAME_FACE: + fullFace(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], + p[at + 7], p[at + 8], p[at + 9], p[at + 10], p[at + 11], p[at + 12]); + at += 13; break; + case FRAME_DISC: + if (ink.width) outlined(op, p, at); + disc(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7]); + at += 8; break; + case FRAME_CAPSULE: + if (ink.width) outlined(op, p, at); + capsule(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], + p[at + 7], p[at + 8], p[at + 9]); + at += 10; break; + case FRAME_TEXT: + fonts[p[at + 1]](strings[p[at + 8]], p[at + 2], p[at + 3], p[at + 4], + p[at + 5], p[at + 6], p[at + 7]); + at += 9; break; + case FRAME_BOX: + if (p[at + 8] >= 255) box(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7]); + else box(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7], p[at + 8]); + at += 9; break; + case FRAME_LINE: + line(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7], p[at + 8]); + at += 9; break; + case FRAME_WIPE: + wipe(p[at + 1], p[at + 2], p[at + 3]); + at += 4; break; + case FRAME_CAMERA: + for (let i = 0; i < 24; i++) cam[i] = p[at + 1 + i]; + hasCamera = true; + at += 25; break; + case FRAME_WORLD: + worldFace(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], + p[at + 7], p[at + 8], p[at + 9], p[at + 10], p[at + 11], p[at + 12]); + at += 13; break; + case FRAME_DEPTH: + depthMode = p[at + 1]; + depthValue = p[at + 2]; + at += 3; break; + case FRAME_ASSET: + storeMesh(p, at); + at += sizeAt(p, at); break; + case FRAME_MESH: + drawMesh(p[at + 1], p[at + 2], p[at + 3], p[at + 4]); + at += 5; break; + case FRAME_MODEL: + drawModel(p, at); + at += 20; break; + case FRAME_ELLIPSE: + if (ink.width) outlined(op, p, at); + ellipse(p[at + 1], p[at + 2], p[at + 3], p[at + 4], p[at + 5], p[at + 6], p[at + 7], + p[at + 8], p[at + 9], p[at + 10]); + at += 11; break; + case FRAME_PLATE: { + const n = p[at + 1]; + if (ink.width) outlined(op, p, at); + plate(p, at + 2, n, p[at + 2 + n * 2], p[at + 3 + n * 2], p[at + 4 + n * 2], p[at + 5 + n * 2]); + at += 6 + n * 2; break; + } + case FRAME_SHAPES: + storeShapes(p, at); + at += 4 + p[at + 3]; break; + case FRAME_SKETCH: + drawSketch(p, at); + at += 14; break; + case FRAME_OUTLINE: + ink.width = p[at + 1]; ink.r = p[at + 2]; ink.g = p[at + 3]; ink.b = p[at + 4]; + at += 5; break; + default: + // An op this interpreter does not know ends the program rather than + // walking into its arguments as if they were ops. + throw new Error(`frame program: unknown op ${op} at ${at}`); + } + } + } + + // Decode a program into plain objects, for tests and recorders. + function decode(p, length, strings) { + const ops = []; + let at = 0; + while (at < length) { + const op = p[at]; + const size = sizeAt(p, at); + if (!size) throw new Error(`frame program: unknown op ${op} at ${at}`); + const args = Array.from(p.subarray(at + 1, at + size)); + if (op === FRAME_TEXT) args[7] = strings[args[7]]; + ops.push({ op, args }); + at += size; + } + return ops; + } + + return { run, decode }; + } + return { FRAME_VIEW, createFrameVm }; +})(); +// + +// ——— the yard, in world units (about a centimetre) ——— + +const yard = { minX: -300, maxX: 300, minZ: -196, maxZ: 170 }; +const bed = { x: -230, z: -140 }; +const bowl = { x: 230, z: -150 }; +const tick = 1 / 60; +const gravity = 640; + +// Where the pup's neck and mouth sit, in the frames puppy-flat.lisp builds +// them in; `pupRig` walks the same chain so a carried ball lands in its mouth. +const rig = { stand: 21, hip: 9, neck: [14, 9, 0], mouth: [11, -5.5, 0] }; + +// A small seeded generator, so a run and its test replay alike. +function seeded(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); +const lerp = (a, b, t) => a + (b - a) * t; +const ease = (rate, dt) => 1 - Math.exp(-rate * dt); +const wrapAngle = (a) => Math.atan2(Math.sin(a), Math.cos(a)); +const dist = (ax, az, bx, bz) => Math.hypot(bx - ax, bz - az); + +// ——— the world ——— + +let world = null; + +function freshWorld(seed = 7) { + return { + t: 0, rng: seeded(seed), frames: 0, + pup: { x: 40, z: -40, heading: Math.PI * .8, speed: 0, gait: 0, + state: "idle", time: 0, phase: "", goal: null, energy: .7, joy: .55, + carrying: null, pettedFor: 0, unpetted: 0, lastBark: -9, blinkAt: 2, + pose: freshPose() }, + hand: { x: 0, z: 90, vx: 0, vz: 0, height: 38, grip: 0, holding: null, + petting: false, rub: 0 }, + ball: { x: 110, y: 0, z: 30, vx: 0, vy: 0, vz: 0, dir: 0, roll: 0, heldBy: null, thrown: false }, + rope: { x: -120, z: 70, angle: .4, heldBy: null }, + treat: { onFloor: false, x: 0, y: 0, z: 0, vy: 0 }, + input: { down: new Set(), was: new Set(), x: 0, y: 0 }, + sounds: [], heard: [], log: [], hearts: [], + camera: { x: 20, z: 10, reach: 300 }, + }; +} + +function freshPose() { + return { bob: 0, pitch: 0, roll: 0, crouch: 0, yaw: 0, nod: 0, cock: 0, + flop: .25, perk: 0, wag: 0, wagAmp: .3, wagRate: 8, droop: 0, + legs: [0, 0, 0, 0], splay: [0, 0, 0, 0], eyes: 1, mouth: 0, tongue: 0, shake: 0 }; +} + +// ——— sound: one-shot sines, which every host has ——— + +function sound(f, d, after = 0) { world.sounds.push({ at: world.t + after, f, d }); } +const noises = { + bark: () => { sound(700, .06); sound(540, .09, .09); }, + yip: () => { sound(1100, .05); sound(1320, .05, .07); }, + whistle: () => { sound(1250, .1); sound(1650, .12, .12); }, + squeak: () => sound(1500, .04), + growl: () => sound(130 + world.rng() * 30, .22), + chomp: () => { sound(320, .03); sound(280, .03, .12); sound(320, .03, .24); }, + snore: () => sound(92, .35), + whine: () => { sound(900, .14); sound(760, .2, .16); }, + yawn: () => { sound(420, .2); sound(330, .3, .2); }, +}; +function flushSounds() { + const due = world.sounds.filter((s) => s.at <= world.t); + if (!due.length) return; + world.sounds = world.sounds.filter((s) => s.at > world.t); + for (const s of due) { + world.heard.push(s.f); + if (typeof synth === "function") synth(s.f, s.d); + } +} + +// ——— input: pad 0, sticks and buttons; the web shell maps keys onto it ——— + +const pressed = (name) => world.input.down.has(name) && !world.input.was.has(name); +const released = (name) => !world.input.down.has(name) && world.input.was.has(name); +const holding = (name) => world.input.down.has(name); + +function readPad() { + const input = world.input; + input.was = input.down; + // A staged run (fiapup.stage) presses buttons through a script instead. + const script = globalThis.__fiapupScript; + const pad = script ? script : typeof gamepad === "function" ? gamepad(0) : null; + input.down = new Set(pad?.down || []); + let x = pad?.leftX || 0, y = pad?.leftY || 0; + if (Math.hypot(x, y) < .2) { x = 0; y = 0; } + if (input.down.has("ArrowLeft")) x = -1; + if (input.down.has("ArrowRight")) x = 1; + if (input.down.has("ArrowUp")) y = 1; + if (input.down.has("ArrowDown")) y = -1; + input.x = x; input.y = y; +} + +// ——— the hand: you ——— + +const reachBall = 26, reachRope = 26, reachPup = 44; + +function tickHand(dt) { + const { hand, ball, rope, pup, input } = world; + const speed = 250; + hand.vx = input.x * speed; hand.vz = -input.y * speed; + hand.x = clamp(hand.x + hand.vx * dt, yard.minX, yard.maxX); + hand.z = clamp(hand.z + hand.vz * dt, yard.minZ - 10, yard.maxZ + 20); + + if (pressed("A")) { + if (hand.holding === "ball") throwBall(); + else if (hand.holding === "rope") { + hand.holding = null; + if (rope.heldBy === "both") { rope.heldBy = "pup"; pup.carrying = "rope"; } + else { rope.heldBy = null; rope.x = hand.x; rope.z = hand.z - 10; } + } else if (!hand.holding && !ball.heldBy && ball.y < 8 && dist(hand.x, hand.z, ball.x, ball.z) < reachBall) { + hand.holding = "ball"; ball.heldBy = "hand"; noises.squeak(); + } else if (!hand.holding && !rope.heldBy && dist(hand.x, hand.z, rope.x, rope.z) < reachRope) { + hand.holding = "rope"; rope.heldBy = "hand"; + } + } + if (pressed("B")) call(); + if (pressed("Y")) play(); + if (pressed("X") && !hand.holding) hand.holding = "treat"; + if (released("X") && hand.holding === "treat") dropTreat(); + + hand.petting = holding("A") && !hand.holding && dist(hand.x, hand.z, pup.x, pup.z) < reachPup; + hand.rub = hand.petting ? hand.rub + dt : 0; + const lift = hand.holding === "treat" ? 50 : hand.petting ? 31 + Math.sin(hand.rub * 10) * 3 : 38; + hand.height = lerp(hand.height, lift, ease(12, dt)); + hand.grip = lerp(hand.grip, hand.holding ? 1 : 0, ease(18, dt)); +} + +// The hand reaches in from the right: fingers along `handAxis` (into the +// yard and to the left), so the camera sees them beside the palm. +const handAxis = [-.6, 0, -.8]; +const fingertip = () => ({ x: world.hand.x + handAxis[0] * 13, y: world.hand.height - 3, + z: world.hand.z + handAxis[2] * 13 }); + +function throwBall() { + const { hand, ball } = world; + const tip = fingertip(); + let dx = hand.vx, dz = hand.vz; + if (Math.hypot(dx, dz) < 40) { dx = 0; dz = -1; } + const m = Math.hypot(dx, dz); + Object.assign(ball, { x: tip.x, y: tip.y, z: tip.z, vx: dx / m * 330, vz: dz / m * 330, + vy: 170, heldBy: null, thrown: true }); + hand.holding = null; + noises.squeak(); +} + +function dropTreat() { + const tip = fingertip(); + Object.assign(world.treat, { onFloor: true, x: tip.x, y: tip.y, z: tip.z, vy: 0 }); + world.hand.holding = null; +} + +function call() { + const pup = world.pup; + noises.whistle(); + if (["nap", "sleepy"].includes(pup.state)) enter("stretch", "come"); + else if (["idle", "sniff", "parade"].includes(pup.state)) enter("come"); +} + +function play() { + const pup = world.pup; + if (!["idle", "sniff", "come"].includes(pup.state)) return; + if (pup.energy > .35) enter("playbow"); + else { noises.yawn(); pup.pose.mouth = 1; } +} + +// ——— the ball and the treat fall; the ball rolls ——— + +function tickBall(dt) { + const b = world.ball; + if (b.heldBy) return; + b.vy -= gravity * dt; + b.x += b.vx * dt; b.y += b.vy * dt; b.z += b.vz * dt; + if (b.y <= 0) { + b.y = 0; + b.vy = Math.abs(b.vy) > 60 ? -b.vy * .45 : 0; + const drag = Math.exp(-1.4 * dt); + b.vx *= drag; b.vz *= drag; + if (Math.hypot(b.vx, b.vz) < 6) { b.vx = 0; b.vz = 0; b.thrown = false; } + } + if (b.x < yard.minX || b.x > yard.maxX) { b.x = clamp(b.x, yard.minX, yard.maxX); b.vx = -b.vx * .6; } + if (b.z < yard.minZ || b.z > yard.maxZ) { b.z = clamp(b.z, yard.minZ, yard.maxZ); b.vz = -b.vz * .6; } + const v = Math.hypot(b.vx, b.vz); + if (v > 1) b.dir = Math.atan2(b.vz, b.vx); + b.roll += v * dt; +} + +function tickTreat(dt) { + const t = world.treat; + if (!t.onFloor || t.y <= 2) return; + t.vy -= gravity * dt; + t.y = Math.max(2, t.y + t.vy * dt); +} + +// ——— the pup: what it wants, and how it moves toward it ——— + +// Turn toward a spot and go at `pace`, easing in as it arrives. True once +// within `stop` of it. +function goTo(gx, gz, pace, stop, dt) { + const p = world.pup; + const d = dist(p.x, p.z, gx, gz); + if (d <= stop) { p.speed = lerp(p.speed, 0, ease(10, dt)); return true; } + const want = Math.atan2(gz - p.z, gx - p.x); + const off = wrapAngle(want - p.heading); + const turn = (pace > 150 ? 7 : 5) * dt; + p.heading = wrapAngle(p.heading + clamp(off, -turn, turn)); + const target = pace * clamp((d - stop) / 40, .25, 1) * clamp(Math.cos(off), .2, 1); + p.speed = lerp(p.speed, target, ease(6, dt)); + return false; +} + +function stand(dt) { world.pup.speed = lerp(world.pup.speed, 0, ease(8, dt)); } + +function turnTo(gx, gz, dt, rate = 4) { + const p = world.pup; + const off = wrapAngle(Math.atan2(gz - p.z, gx - p.x) - p.heading); + p.heading = wrapAngle(p.heading + clamp(off, -rate * dt, rate * dt)); +} + +function enter(state, then = null) { + const p = world.pup; + if (p.state !== state) world.log.push(state); + p.state = state; p.time = 0; p.phase = ""; p.then = then; p.goal = null; +} + +// What the pup notices, in order. Returns the state it should drop into, or +// null. Each state says which of these may interrupt it. +function notice(allow) { + const { pup, hand, ball, rope, treat } = world; + const near = (x, z, r) => dist(pup.x, pup.z, x, z) < r; + if (allow.treat && treat.onFloor) return "eat"; + if (allow.treat && hand.holding === "treat" && near(hand.x, hand.z, 320)) return "beg"; + if (allow.pet && hand.petting) return "petted"; + if (allow.ball && ball.thrown && !ball.heldBy) return "fetch"; + if (allow.rope && hand.holding === "rope" && rope.heldBy === "hand" && near(hand.x, hand.z, 240)) return "tug"; + if (allow.tired && pup.energy < .22) return "sleepy"; + if (allow.zoom && pup.energy > .8 && pup.joy > .75 && world.rng() < tick * .08) return "playbow"; + return null; +} +const all = { treat: 1, pet: 1, ball: 1, rope: 1, tired: 1, zoom: 1 }; + +// Each state: `tick(dt)` moves the pup and may `enter` another state; +// `pose(t)` says how it holds itself (poseFor fills the rest). +const states = { + idle: { + tick(dt) { + const p = world.pup, h = world.hand; + const next = notice(all); + if (next) return enter(next); + stand(dt); + turnTo(h.x, h.z, dt, 1.2); + if (p.time > 4 + world.rng() * 40 && world.rng() < tick * .5) enter("sniff"); + }, + pose: (p) => ({ sit: p.time > 1.2 ? 1 : 0, look: "hand", wag: .35 + p.joy * .5 }), + }, + sniff: { + tick(dt) { + const p = world.pup; + const next = notice(all); + if (next) return enter(next); + if (!p.goal) { + const a = world.rng() * Math.PI * 2, r = 50 + world.rng() * 90; + p.goal = { x: clamp(p.x + Math.cos(a) * r, yard.minX + 20, yard.maxX - 20), + z: clamp(p.z + Math.sin(a) * r, yard.minZ + 20, yard.maxZ - 20) }; + } + if (p.phase !== "smell") { if (goTo(p.goal.x, p.goal.z, 55, 6, dt)) { p.phase = "smell"; p.time = 0; } } + else { stand(dt); if (p.time > 1.4) enter("idle"); } + }, + pose: (p) => ({ nose: true, wag: .3, sniffing: p.phase === "smell" }), + }, + come: { + tick(dt) { + const h = world.hand; + const next = notice({ ...all, zoom: 0, tired: 0 }); + if (next) return enter(next); + if (goTo(h.x, h.z - 6, 180, 34, dt)) { world.pup.joy = Math.min(1, world.pup.joy + .05); enter("idle"); } + }, + pose: () => ({ look: "hand", wag: .9, ears: "bounce" }), + }, + fetch: { + tick(dt) { + const p = world.pup, b = world.ball, h = world.hand; + const next = notice({ treat: 1 }); + if (next) return enter(next); + if (!p.phase) p.phase = "chase"; + if (p.phase === "chase") { + if (b.heldBy === "hand") return enter("idle"); + const lead = .25; + goTo(b.x + b.vx * lead, b.z + b.vz * lead, 200, 0, dt); + const m = pupRig(p).mouth; + if (dist(m.x, m.z, b.x, b.z) < 10 && b.y < 12) { + b.heldBy = "pup"; b.thrown = false; p.carrying = "ball"; p.phase = "return"; noises.squeak(); + } + } else if (p.phase === "return") { + if (goTo(h.x, h.z - 4, 170, 32, dt)) { p.phase = "drop"; p.time = 0; } + } else { + stand(dt); turnTo(h.x, h.z, dt); + if (p.time > .35 && p.carrying === "ball") { + const m = pupRig(p).mouth; + Object.assign(b, { heldBy: null, x: m.x, y: Math.max(0, m.y - 5), z: m.z, vx: 0, vy: 0, vz: 0 }); + p.carrying = null; p.joy = Math.min(1, p.joy + .12); p.energy -= .06; noises.yip(); + } + if (p.time > .9) enter("idle"); + } + }, + pose: (p) => ({ look: p.phase === "chase" ? "ball" : "hand", wag: 1, ears: "bounce", + tongue: p.phase === "return" ? 0 : 1 }), + }, + tug: { + tick(dt) { + const p = world.pup, h = world.hand, r = world.rope; + if (r.heldBy === "pup") { p.carrying = "rope"; return enter("parade"); } + if (r.heldBy !== "hand" && r.heldBy !== "both") return enter("idle"); + const end = ropeGrip(); + if (!p.phase) p.phase = "approach"; + if (p.phase === "approach") { + const m = pupRig(p).mouth; + goTo(end.x, end.z, 170, 0, dt); + if (dist(m.x, m.z, end.x, end.z) < 11) { p.phase = "pull"; p.time = 0; r.heldBy = "both"; noises.growl(); } + return; + } + // Hold the rope taut: back away while it is slack, get dragged when + // the hand pulls past the rope's length. + turnTo(h.x, h.z, dt, 6); + const d = dist(p.x, p.z, h.x, h.z); + const away = Math.atan2(p.z - h.z, p.x - h.x); + const want = 58 + Math.sin(p.time * 2.3) * 8; + const step = clamp(want - d, -90 * dt, 40 * dt); + p.x = clamp(p.x + Math.cos(away) * step, yard.minX, yard.maxX); + p.z = clamp(p.z + Math.sin(away) * step, yard.minZ, yard.maxZ); + p.speed = Math.abs(step) / dt * .4; + p.energy -= .03 * dt; p.joy = Math.min(1, p.joy + .05 * dt); + if (Math.floor((p.time - dt) / .5) !== Math.floor(p.time / .5)) noises.growl(); + if (p.energy < .15) { r.heldBy = "hand"; p.carrying = null; enter("idle"); } + }, + pose: (p) => ({ look: "hand", wag: 1, shake: p.phase === "pull" ? 1 : 0, lean: p.phase === "pull" ? 1 : 0 }), + }, + parade: { + tick(dt) { + const p = world.pup, r = world.rope; + const next = notice({ treat: 1 }); + if (next) { dropCarried(); return enter(next); } + const a = p.time * 1.8; + goTo(p.x + Math.cos(p.heading + .9) * 40, p.z + Math.sin(p.heading + .9) * 40, 110, 0, dt); + if (p.time > 3.2 + Math.sin(a) * .1) { dropCarried(); noises.bark(); enter("idle"); } + }, + pose: () => ({ wag: 1, ears: "bounce", chin: 1 }), + }, + petted: { + tick(dt) { + const p = world.pup, h = world.hand; + const next = notice({ treat: 1 }); + if (next) return enter(next); + stand(dt); turnTo(h.x, h.z, dt, 1.5); + if (world.hand.petting) { p.pettedFor += dt; p.unpetted = 0; } else p.unpetted += dt; + p.joy = Math.min(1, p.joy + .12 * dt); + if (p.pettedFor > 1.8) return enter("rollover"); + if (p.unpetted > .4) { p.pettedFor = 0; enter("idle"); } + if (world.rng() < dt * 1.2) world.hearts.push({ x: p.x, y: 40, z: p.z, t: 0 }); + }, + pose: () => ({ sit: 1, look: "hand", wag: 1.2, eyes: 0, lean: .5, ears: "soft" }), + }, + rollover: { + tick(dt) { + const p = world.pup; + stand(dt); + if (world.hand.petting) { p.unpetted = 0; p.joy = Math.min(1, p.joy + .15 * dt); } else p.unpetted += dt; + if (world.rng() < dt * 2) world.hearts.push({ x: p.x, y: 26, z: p.z, t: 0 }); + if (p.unpetted > 1.1) { p.pettedFor = 0; enter("idle"); } + }, + pose: () => ({ roll: 1, eyes: 0, wag: .8, tongue: 1 }), + }, + beg: { + tick(dt) { + const p = world.pup, h = world.hand; + if (world.treat.onFloor) return enter("eat"); + if (h.holding !== "treat") return enter("idle"); + if (goTo(h.x, h.z - 4, 150, 24, dt)) { + turnTo(h.x, h.z, dt, 5); + p.phase = "beg"; + if (Math.floor((p.time - dt) / 2.2) !== Math.floor(p.time / 2.2)) noises.whine(); + } else p.phase = ""; + }, + pose: (p) => ({ beg: p.phase === "beg" ? 1 : 0, look: "treat", wag: 1.1, ears: "perk" }), + }, + eat: { + tick(dt) { + const p = world.pup, t = world.treat; + if (!t.onFloor) return enter("idle"); + if (!p.phase) { + const m = pupRig(p).mouth; + goTo(t.x, t.z, 160, 0, dt); + if (dist(m.x, m.z, t.x, t.z) < 9) { p.phase = "chomp"; p.time = 0; noises.chomp(); } + return; + } + stand(dt); + if (p.time > 1.2) { + t.onFloor = false; p.joy = Math.min(1, p.joy + .25); p.energy = Math.min(1, p.energy + .08); + noises.yip(); enter("idle"); + } + }, + pose: (p) => ({ nose: true, chomp: p.phase === "chomp" ? 1 : 0, wag: 1.1 }), + }, + sleepy: { + tick(dt) { + const next = notice({ treat: 1 }); + if (next) return enter(next); + if (goTo(bed.x, bed.z, 60, 6, dt)) enter("nap"); + if (world.pup.time < dt * 1.5) noises.yawn(); + }, + pose: () => ({ wag: .1, droop: 1, ears: "soft", yawn: 1 }), + }, + nap: { + tick(dt) { + const p = world.pup; + stand(dt); + p.energy = Math.min(1, p.energy + .07 * dt); + if (Math.floor((p.time - dt) / 2.6) !== Math.floor(p.time / 2.6)) noises.snore(); + if (p.energy > .97) enter("stretch"); + }, + pose: () => ({ lie: 1, eyes: 0, wag: 0, curl: 1, breathe: .4 }), + }, + stretch: { + tick(dt) { + const p = world.pup; + stand(dt); + if (p.time > 1.3) { const then = p.then; enter(then || "idle"); if (!then) noises.yip(); } + }, + pose: () => ({ bow: 1, wag: .6, yawn: 1 }), + }, + playbow: { + tick(dt) { + stand(dt); turnTo(world.hand.x, world.hand.z, dt, 5); + if (world.pup.time < dt * 1.5) noises.bark(); + if (world.pup.time > .8) enter("zoomies"); + }, + pose: () => ({ bow: 1, wag: 1.4, ears: "perk", look: "hand" }), + }, + zoomies: { + tick(dt) { + const p = world.pup; + const next = notice({ treat: 1 }); + if (next) return enter(next); + const cx = 0, cz = -15, r = 125; + const a = Math.atan2(p.z - cz, p.x - cx) + .55; + goTo(cx + Math.cos(a) * r, cz + Math.sin(a) * r, 270, 0, dt); + p.energy -= .06 * dt; p.joy = Math.min(1, p.joy + .04 * dt); + if (p.time > 4.5) enter("flop"); + }, + pose: () => ({ wag: 1.3, ears: "flap", tongue: 1 }), + }, + flop: { + tick(dt) { + stand(dt); + if (world.pup.time > 1.4) enter("idle"); + }, + pose: () => ({ lie: 1, tongue: 1, wag: .6, pant: 1 }), + }, +}; + +function dropCarried() { + const p = world.pup, m = pupRig(p).mouth; + if (p.carrying === "rope") Object.assign(world.rope, { heldBy: null, x: m.x, z: m.z, angle: p.heading + Math.PI }); + if (p.carrying === "ball") Object.assign(world.ball, { heldBy: null, x: m.x, y: Math.max(0, m.y - 5), z: m.z }); + p.carrying = null; +} + +// Where the pup takes the rope from the hand: a rope-length toward the pup. +function ropeGrip() { + const { pup, hand } = world, tip = fingertip(); + const a = Math.atan2(pup.z - tip.z, pup.x - tip.x); + return { x: tip.x + Math.cos(a) * 30, z: tip.z + Math.sin(a) * 30 }; +} + +function tickPup(dt) { + const p = world.pup; + p.time += dt; + const moving = p.speed > 12; + p.energy = clamp(p.energy - dt * (p.speed > 150 ? .025 : moving ? .008 : -.002), 0, 1); + p.joy = clamp(lerp(p.joy, .5, dt * .01), 0, 1); + states[p.state].tick(dt); + // Walk: the heading carries it; the yard keeps it. + p.x = clamp(p.x + Math.cos(p.heading) * p.speed * dt, yard.minX, yard.maxX); + p.z = clamp(p.z + Math.sin(p.heading) * p.speed * dt, yard.minZ, yard.maxZ); + p.gait += p.speed * dt / 42 * Math.PI * 2; + if (p.carrying === "ball") { + const m = pupRig(p).mouth; + Object.assign(world.ball, { x: m.x, y: Math.max(0, m.y - 5), z: m.z, dir: p.heading }); + } + world.hearts = world.hearts.filter((h) => (h.t += dt) < 1.4); + settlePose(dt); +} + +// ——— the pose: what each state wants, eased, with breath, blinks and a tail ——— + +function settlePose(dt) { + const p = world.pup, o = p.pose, want = states[p.state].pose(p); + const t = world.t, k = ease(9, dt); + const run = clamp(p.speed / 150, 0, 1); + const target = { bob: 0, pitch: 0, roll: 0, yaw: 0, nod: 0, cock: 0, flop: .25, perk: 0, + droop: 0, eyes: 1, mouth: 0, tongue: 0, legs: [0, 0, 0, 0], splay: [0, 0, 0, 0] }; + + // Where the eyes go: the hand, the ball, the treat, or ahead. + const lookAt = want.look === "ball" ? world.ball : want.look === "treat" ? fingertip() + : want.look === "hand" ? { x: world.hand.x, y: world.hand.height, z: world.hand.z } : null; + if (lookAt) { + target.yaw = clamp(wrapAngle(p.heading - Math.atan2(lookAt.z - p.z, lookAt.x - p.x)), -.9, .9); + const rise = (lookAt.y ?? 0) - 30, far = Math.max(20, dist(p.x, p.z, lookAt.x, lookAt.z)); + target.nod = clamp(Math.atan2(rise, far), -.4, .7); + } + if (want.nose) target.nod = -.6; + if (want.sit) { target.pitch = .5 * want.sit; target.bob = -4; target.legs = [-.5, -.5, 1.05, 1.05]; } + if (want.beg) { target.pitch = 1.05; target.bob = -3; target.legs = [1, 1, -.45, -.45]; target.nod = -.4; } + if (want.lie) { + target.bob = -11.5; target.legs = [1.45, 1.45, -1.35, -1.35]; target.splay = [.25, .25, .3, .3]; + target.nod = want.curl ? -.45 : -.1; target.yaw = want.curl ? .5 : 0; + } + if (want.bow) { target.pitch = -.32; target.bob = -3; target.legs = [.75, .75, .3, .3]; target.nod = .45; } + if (want.roll) { + target.roll = Math.PI; target.bob = -10.5; target.cock = -.9; target.nod = .3; + target.legs = [0, 1, 2, 3].map((i) => Math.sin(t * 7 + i * 1.7) * .45); + target.splay = [.4, .4, .4, .4]; + } + if (want.lean) target.cock = .32 * want.lean; + if (want.droop) { target.nod -= .3; target.droop = .8; } + if (want.chin) target.nod = .25; + if (want.eyes === 0) target.eyes = 0; + if (want.tongue || (run > .6 && p.state !== "fetch")) target.tongue = 1; + if (want.yawn && p.time < 1) target.mouth = 1; + if (want.chomp) target.nod = -.7; + target.perk = want.ears === "perk" ? .35 : 0; + target.flop = want.ears === "soft" ? .05 : want.ears === "perk" ? .15 : .25; + + for (const key of ["bob", "pitch", "roll", "yaw", "nod", "cock", "flop", "perk", "droop"]) + o[key] = lerp(o[key], target[key], key === "roll" ? ease(5, dt) : k); + for (let i = 0; i < 4; i++) { + o.legs[i] = lerp(o.legs[i], target.legs[i], k); + o.splay[i] = lerp(o.splay[i], target.splay[i], k); + } + o.eyes = target.eyes; o.mouth = target.mouth; o.tongue = target.tongue; + + // The gait rides on top: a trot (diagonal pairs) that becomes a gallop. + const amp = clamp(p.speed / 160, 0, .75), g = p.gait; + if (amp > .02) { + const gallop = p.speed > 190 ? 1 : 0; + const front = [g, g + Math.PI - gallop * (Math.PI - .5)], back = [g + Math.PI, g + gallop * .5 * 1]; + o.legs[0] += Math.sin(front[0]) * amp; o.legs[1] += Math.sin(front[1]) * amp; + o.legs[2] += Math.sin(back[0]) * amp; o.legs[3] += Math.sin(back[1]) * amp; + o.bob -= Math.abs(Math.sin(g)) * amp * 3.2; + o.pitch += Math.sin(g) * amp * .08 * (1 + gallop * 2); + } + + // Breath, a blink every few seconds, sniffs and head shakes. + const breathe = want.breathe ?? 1; + o.bob += Math.sin(t * (want.pant ? 12 : 2.6 * breathe)) * (want.pant ? .6 : .45); + if (t > p.blinkAt) { if (t > p.blinkAt + .12) p.blinkAt = t + 2.5 + world.rng() * 3; else o.eyes = 0; } + o.shake = want.shake ? Math.sin(t * 15) * .38 : want.sniffing ? Math.sin(t * 22) * .06 : 0; + if (want.chomp) o.shake = Math.sin(t * 18) * .1; + if (want.chomp) o.mouth = Math.sin(t * 14) > 0 ? 1 : 0; + + // The tail: wag rate and swing climb with joy and with what's happening. + const wag = want.wag ?? .4, joy = p.joy; + o.wagRate = lerp(o.wagRate, 6 + wag * 9 + joy * 5, ease(4, dt)); + o.wagAmp = lerp(o.wagAmp, clamp(wag * (.35 + joy * .5), 0, .95), ease(4, dt)); + o.wagPhase = (o.wagPhase || 0) + o.wagRate * dt; + o.wag = Math.sin(o.wagPhase) * o.wagAmp; + o.droopTail = want.lie ? .3 : -.1 + o.droop; + // Ears bounce with the gait and stream out when running. + o.earFlop = o.flop + (want.ears === "flap" ? .5 + Math.sin(t * 18) * .35 : 0) + + (want.ears === "bounce" ? Math.abs(Math.sin(g)) * .3 * amp : 0) + run * .3; + o.earPerk = o.perk - run * .6; +} + +// ——— the rig in JS: where the head and mouth are, for carried things ——— +// A frame is origin(3) + x(3) y(3) z(3), turned as object-lisp.mjs turns them. + +function frameMove(m, x, y, z) { for (let k = 0; k < 3; k++) m[k] += x * m[3 + k] + y * m[6 + k] + z * m[9 + k]; } +function frameTurn(m, axis, a) { + const c = Math.cos(a), s = Math.sin(a); + // about z carries x toward y; about x carries y toward z; about y carries z toward x + const [u, v] = axis === "z" ? [3, 6] : axis === "x" ? [6, 9] : [9, 3]; + for (let k = 0; k < 3; k++) { + const pu = m[u + k], pv = m[v + k]; + m[u + k] = pu * c + pv * s; m[v + k] = pv * c - pu * s; + } +} +const framePoint = (m, x, y, z) => ({ x: m[0] + x * m[3] + y * m[6] + z * m[9], + y: m[1] + x * m[4] + y * m[7] + z * m[10], z: m[2] + x * m[5] + y * m[8] + z * m[11] }); + +function pupPlace(p, m = new Float64Array(12)) { + const c = Math.cos(p.heading), s = Math.sin(p.heading); + m[0] = p.x; m[1] = 0; m[2] = p.z; + m[3] = c; m[4] = 0; m[5] = s; + m[6] = 0; m[7] = 1; m[8] = 0; + m[9] = -s; m[10] = 0; m[11] = c; + return m; +} + +function pupRig(p) { + const o = p.pose, m = pupPlace(p); + frameMove(m, 0, rig.stand + o.bob, 0); + frameTurn(m, "x", o.roll); + frameMove(m, -rig.hip, 0, 0); frameTurn(m, "z", o.pitch); frameMove(m, rig.hip, 0, 0); + frameMove(m, ...rig.neck); + frameTurn(m, "y", o.yaw + o.shake); frameTurn(m, "z", o.nod); frameTurn(m, "x", o.cock); + return { head: framePoint(m, 0, 0, 0), mouth: framePoint(m, ...rig.mouth) }; +} + +// What the lisp reads as the owner's parts. +function pupOwner(p) { + const o = p.pose; + return { + body: [o.bob, o.pitch, o.roll], head: [o.yaw + o.shake, o.nod, o.cock], + ears: [o.earFlop, o.earPerk, 0], tail: [o.wag, o.droopTail, 0], + fl: [o.legs[0], o.splay[0], 0], fr: [o.legs[1], o.splay[1], 0], + bl: [o.legs[2], o.splay[2], 0], br: [o.legs[3], o.splay[3], 0], + face: [o.eyes, o.mouth, o.tongue], + }; +} + +// ——— one tick ——— + +function step(dt = tick) { + world.t += dt; + readPad(); + tickHand(dt); + tickBall(dt); + tickTreat(dt); + tickPup(dt); + followCamera(dt); + flushSounds(); +} + +// ——— the camera: above and behind, framing the pup and the hand ——— + +const camera = new Float64Array(24); +let screenW = 1920, screenH = 1080; + +function followCamera(dt) { + const c = world.camera, p = world.pup, h = world.hand; + const tx = lerp(p.x, h.x, .35), tz = lerp(p.z, h.z, .35); + const reach = clamp(215 + dist(p.x, p.z, h.x, h.z) * .75, 215, 470); + const k = ease(2.5, dt); + c.x = lerp(c.x, tx, k); c.z = lerp(c.z, tz, k); c.reach = lerp(c.reach, reach, k); +} + +function aimCamera() { + const c = world.camera, tilt = .5, scale = screenH / 1080; + const s = Math.sin(tilt), co = Math.cos(tilt); + const m = camera; + m[0] = c.x; m[1] = 16 + s * c.reach; m[2] = c.z + co * c.reach; + m[3] = 1; m[4] = 0; m[5] = 0; // right + m[6] = 0; m[7] = co; m[8] = -s; // up + m[9] = 0; m[10] = -s; m[11] = -co; // forward + m[12] = screenW / 2; m[13] = screenH * .54; + m[14] = 1; m[15] = 1300 * scale; m[16] = 1; // orthoScale, focal, all perspective + m[17] = 2.8 / 16000; m[18] = -1.4; m[19] = 12; // depth slope and base, near + m[20] = -screenW * .25; m[21] = screenW * 1.25; m[22] = -screenH * .25; m[23] = screenH * 1.25; +} + +// A world point on the screen (for hearts and zzz), or null behind the lens. +function onScreen(x, y, z) { + const m = camera, dx = x - m[0], dy = y - m[1], dz = z - m[2]; + const vz = dx * m[9] + dy * m[10] + dz * m[11]; + if (vz < m[19]) return null; + const k = m[15] / vz; + return { x: m[12] + (dx * m[3] + dy * m[4] + dz * m[5]) * k, + y: m[13] - (dx * m[6] + dy * m[7] + dz * m[8]) * k, depth: clamp(vz * m[17] + m[18], -1.499, 1.4), k }; +} + +// ——— the frame program ——— + +const OP = { FACE: 2, DISC: 3, CAPSULE: 4, TEXT: 5, WIPE: 8, CAMERA: 9, ELLIPSE: 15, + PLATE: 16, OUTLINE: 17, SHAPES: 18, SKETCH: 19 }; +let program = new Float32Array(8192), length = 0, strings = []; +const stats = { numbers: 0, ops: {} }; + +function room(n) { + if (length + n <= program.length) return; + const grown = new Float32Array(Math.max(program.length * 2, length + n)); + grown.set(program.subarray(0, length)); + program = grown; +} +function op(code, ...args) { + room(args.length + 1); + program[length++] = code; + for (const a of args) program[length++] = a; + stats.ops[code] = (stats.ops[code] || 0) + 1; +} + +// Each object's baked sketches get handles once; a handle goes up as SHAPES +// the first time a host sees it. +const handles = new Map(); +let sent = new Set(), nextHandle = 1; +function handleFor(object, index) { + let list = handles.get(object); + if (!list) handles.set(object, list = []); + return list[index] || (list[index] = nextHandle++); +} + +function outFor(object) { + return { + view: camera, + face() {}, + sketch(index, frame, at) { + const handle = handleFor(object, index); + if (!sent.has(handle)) { + const s = object.sketches[index]; + room(4 + s.records.length); + program[length++] = OP.SHAPES; program[length++] = handle; + program[length++] = s.count; program[length++] = s.records.length; + program.set(s.records, length); length += s.records.length; + sent.add(handle); + } + room(14); + program[length++] = OP.SKETCH; program[length++] = handle; + for (let k = 0; k < 12; k++) program[length++] = frame[at + k]; + stats.ops[OP.SKETCH] = (stats.ops[OP.SKETCH] || 0) + 1; + }, + ellipse: (x, y, d, ax, ay, bx, by, r, g, b) => op(OP.ELLIPSE, x, y, d, ax, ay, bx, by, r, g, b), + capsule: (x1, y1, x2, y2, d, w, r, g, b) => op(OP.CAPSULE, x1, y1, x2, y2, d, w, r, g, b), + disc: (x, y, d, rad, r, g, b) => op(OP.DISC, x, y, d, rad, r, g, b), + plate: (n, points, d, r, g, b) => op(OP.PLATE, n, ...points.slice(0, n * 2), d, r, g, b), + outline: (w, r, g, b) => op(OP.OUTLINE, w, r, g, b), + }; +} + +function text(s, x, y, size, r, g, b) { + strings.push(s); + op(OP.TEXT, 3, x, y, size, r, g, b, strings.length - 1); +} +// A flat screen quad in front of everything (HUD panels): triangles, not +// `box`, because the console draws boxes under the scene. +function panel(x, y, w, h, r, g, b, depth = -1.49) { + op(OP.FACE, x, y, depth, x + w, y, depth, x + w, y + h, depth, r, g, b); + op(OP.FACE, x, y, depth, x + w, y + h, depth, x, y + h, depth, r, g, b); +} + +const objects = fiapupObjects; +const outs = Object.fromEntries(Object.keys(objects).map((name) => [name, outFor(objects[name])])); +const identityAt = (x, y, z, m = new Float64Array(12)) => { m.set([x, y, z, 1, 0, 0, 0, 1, 0, 0, 0, 1]); return m; }; +const turnedAt = (x, y, z, a, m = new Float64Array(12)) => { + const c = Math.cos(a), s = Math.sin(a); + m.set([x, y, z, c, 0, s, 0, 1, 0, -s, 0, c]); return m; +}; +const inputs = { owner: null, distance: 0, time: 0 }; + +function drawYard() { objects.yard(inputs, identityAt(0, 0, 0), outs.yard); } + +function drawPup() { + const p = world.pup; + inputs.owner = pupOwner(p); + objects.puppy(inputs, pupPlace(p), outs.puppy); +} + +function drawBall() { + const b = world.ball; + let x = b.x, y = b.y, z = b.z; + if (b.heldBy === "hand") { const tip = fingertip(); x = tip.x; y = tip.y - 6; z = tip.z; } + inputs.owner = { ball: [y, 0, 0] }; + inputs.distance = b.roll; + objects.ball(inputs, turnedAt(x, 0, z, b.dir), outs.ball); +} + +function drawRope() { + const r = world.rope, p = world.pup; + let a, e; + const mouth = () => pupRig(p).mouth; + if (r.heldBy === "both") { const t = fingertip(); a = t; const m = mouth(); e = [m.x - t.x, m.y - t.y, m.z - t.z]; } + else if (r.heldBy === "hand") { a = fingertip(); e = [0, 3.4 - a.y, 14]; } + else if (r.heldBy === "pup") { + a = mouth(); + e = [-Math.cos(p.heading) * 18 + Math.sin(p.heading) * 8, 3.4 - a.y, -Math.sin(p.heading) * 18 - Math.cos(p.heading) * 8]; + } else { a = { x: r.x, y: 3.4, z: r.z }; e = [Math.cos(r.angle) * 30, 0, Math.sin(r.angle) * 30]; } + inputs.owner = { end: e }; + objects.rope(inputs, identityAt(a.x, a.y, a.z), outs.rope); +} + +function drawTreat() { + const t = world.treat, h = world.hand; + if (h.holding === "treat") { const tip = fingertip(); objects.treat(inputs, identityAt(tip.x, tip.y - 2, tip.z - 2), outs.treat); } + else if (t.onFloor) objects.treat(inputs, turnedAt(t.x, t.y, t.z, .6), outs.treat); +} + +function drawHand() { + const h = world.hand; + inputs.owner = { hand: [h.height, h.grip, 0] }; + // Object x along handAxis, y up, z = x × y. + const [ax, , az] = handAxis; + const m = new Float64Array([h.x, 0, h.z, ax, 0, az, 0, 1, 0, -az, 0, ax]); + objects.hand(inputs, m, outs.hand); +} + +function drawHearts() { + for (const heart of world.hearts) { + const at = onScreen(heart.x + Math.sin(heart.t * 5) * 4, heart.y + heart.t * 30, heart.z); + if (!at) continue; + const s = at.k * 3.2 * (1 - heart.t / 1.6), d = at.depth - .002; + op(OP.DISC, at.x - s * .55, at.y, d, s * .62, 240, 80, 110); + op(OP.DISC, at.x + s * .55, at.y, d, s * .62, 240, 80, 110); + op(OP.FACE, at.x - s * 1.12, at.y + s * .2, d, at.x + s * 1.12, at.y + s * .2, d, at.x, at.y + s * 1.5, d, 240, 80, 110); + } + const p = world.pup; + if (p.state === "nap") { + const head = pupRig(p).head; + for (let i = 0; i < 3; i++) { + const u = (world.t * .45 + i / 3) % 1; + const at = onScreen(head.x + u * 14, head.y + 8 + u * 34, head.z); + if (at) text("z", at.x, at.y, 26 + u * 26, 90, 90, 150); + } + } +} + +const moods = { + idle: "hangin' out", sniff: "sniff sniff", come: "coming!", fetch: "fetch!", tug: "tug of war!", + parade: "mine mine mine", petted: "good pup", rollover: "belly rubs!", beg: "please?", + eat: "nom nom", sleepy: "sleepy...", nap: "zzz", stretch: "big stretch", playbow: "wanna play?", + zoomies: "ZOOMIES!", flop: "phew", +}; + +function drawHud() { + const p = world.pup, s = screenH / 1080; + panel(40 * s, 34 * s, 420 * s, 118 * s, 255, 250, 238); + text("fiapup", 64 * s, 50 * s, 40 * s, 120, 72, 50); + text(moods[p.state] || p.state, 64 * s, 104 * s, 26 * s, 90, 110, 150); + // joy and energy, as two little bars + const bar = (y, v, r, g, b) => { + panel(300 * s, y, 132 * s, 16 * s, 226, 220, 208, -1.491); + panel(300 * s, y, 132 * s * clamp(v, 0, 1), 16 * s, r, g, b, -1.492); + }; + bar(62 * s, p.joy, 240, 90, 120); + bar(96 * s, p.energy, 110, 180, 110); + const hint = world.hand.holding === "ball" ? "A throw" : world.hand.holding === "rope" ? "A let go" + : world.hand.holding === "treat" ? "let go of X to drop it" : "A pet / pick up B call X treat Y play"; + text(hint, 64 * s, screenH - 76 * s, 26 * s, 255, 255, 255); +} + +let vm = null, batch = null, batched = 0; + +// The console's `triangles3d` takes a frame's faces in one crossing; collect +// the interpreter's faces into it when the host has it. +function hostFace(x1, y1, z1, x2, y2, z2, x3, y3, z3, r, g, b) { + if (batched >= 8192) flushFaces(); + const at = batched++ * 12; + batch[at] = x1; batch[at + 1] = y1; batch[at + 2] = z1; batch[at + 3] = x2; batch[at + 4] = y2; batch[at + 5] = z2; + batch[at + 6] = x3; batch[at + 7] = y3; batch[at + 8] = z3; batch[at + 9] = r; batch[at + 10] = g; batch[at + 11] = b; +} +function flushFaces() { if (batched) triangles3d(batch, batched); batched = 0; } + +function interpreter() { + if (vm) return vm; + const face = typeof triangles3d === "function" ? (batch = new Float32Array(8192 * 12), hostFace) + : typeof triangle3d === "function" ? triangle3d : undefined; + const none = () => {}; + vm = fiapupFrameVm.createFrameVm({ + triangle3d: face, triangle: typeof triangle === "function" ? triangle : undefined, + box: typeof box === "function" ? box : none, line: typeof line === "function" ? line : none, + wipe: typeof wipe === "function" ? wipe : none, write: typeof write === "function" ? write : undefined, + }); + return vm; +} + +function paintFrame() { + const run = typeof runtime === "function" ? runtime() : null; + screenW = run?.width || 1920; screenH = run?.height || 1080; + length = 0; strings = []; stats.ops = {}; + aimCamera(); + op(OP.WIPE, 196, 224, 240); + op(OP.CAMERA, ...camera); + drawYard(); + drawRope(); + drawTreat(); + drawBall(); + drawPup(); + drawHand(); + drawHearts(); + drawHud(); + stats.numbers = length; + if (typeof frame === "function") frame(program, length, strings); + else { interpreter().run(program, length, strings); flushFaces(); } +} + +// ——— lifecycle ——— + +let clockUs = null, owed = 0; + +function boot() { + world = freshWorld(); + sent = new Set(); +} + +// The host calls sim once a paint; the pup lives on a fixed 60 Hz clock. +function sim() { + const now = typeof runtime === "function" ? runtime().monotonicUs : null; + if (now == null || clockUs == null) { clockUs = now; step(); return; } + owed = Math.min(owed + (now - clockUs) / 1e6, tick * 4); + clockUs = now; + while (owed >= tick) { owed -= tick; step(); } +} + +function paint() { paintFrame(); } +function act() {} +function leave() {} + +// For tests and the web shell: the world, one tick, a staged moment. +const fiapup = { + get world() { return world; }, step, paint: paintFrame, stats, rig: pupRig, owner: pupOwner, + program: () => program.subarray(0, length), strings: () => strings, states: Object.keys(states), + resend() { sent = new Set(); vm = null; }, + // Put the yard in a named moment and let it run `seconds`. The shell's + // ?stage= and the screenshot tool use these; the game never does. + stage(name, seconds = 2) { + world = freshWorld(); + const w = world, p = w.pup, press = []; + const hold = (buttons, t) => press.push({ buttons, t }); + if (name === "fetch") { w.hand.x = 60; w.hand.z = 110; w.hand.holding = "ball"; w.ball.heldBy = "hand"; hold(["A"], .05); } + if (name === "pet") { p.x = 0; p.z = 60; p.heading = Math.PI / 2; w.hand.x = 8; w.hand.z = 62; hold(["A"], 99); } + if (name === "beg") { w.hand.x = 30; w.hand.z = 40; hold(["X"], 99); } + if (name === "nap") { p.energy = .1; p.x = -200; p.z = -120; } + if (name === "zoomies") { p.energy = .95; p.joy = .9; hold(["Y"], .05); } + if (name === "tug") { w.hand.x = 20; w.hand.z = 60; w.hand.holding = "rope"; w.rope.heldBy = "hand"; p.x = 10; p.z = -20; } + const script = { down: [] }; + globalThis.__fiapupScript = script; + for (let t = 0; t < seconds; t += tick) { + script.down = press.filter((q) => t < q.t).flatMap((q) => q.buttons); + step(); + } + globalThis.__fiapupScript = null; + return world; + }, +}; diff --git a/xbox/fiapup/objects/ball-flat.lisp b/xbox/fiapup/objects/ball-flat.lisp new file mode 100644 index 0000000000..e303ee6854 --- /dev/null +++ b/xbox/fiapup/objects/ball-flat.lisp @@ -0,0 +1,16 @@ +; ball-flat — the fetch ball. The origin is on the floor under the ball; +; x points the way it rolls. `distance` rolls it; the owner's `ball x` lifts +; it (a throw, or the pup's mouth). The band is a flat bar across the face, +; so turning it is what reads as rolling. + +def r 5 + +(let roll (/ distance r)) + +(nudge 20 (ink 58 104 60) (scale 1.2 1 1 (ring y 4.4))) + +(move 0 (+ r (owner ball x)) 0 + (rotate z (- roll) + (ink 232 72 76) + (outline 1 96 30 36 (ball 0 0 0 r)) + (nudge -1 (ink 255 214 84) (limb 0 -4.3 0 0 4.3 0 1.3)))) diff --git a/xbox/fiapup/objects/hand-flat.lisp b/xbox/fiapup/objects/hand-flat.lisp new file mode 100644 index 0000000000..cd48ff1904 --- /dev/null +++ b/xbox/fiapup/objects/hand-flat.lisp @@ -0,0 +1,24 @@ +; hand-flat — you: a white-gloved hand over the lawn, fingers reaching into +; the yard (x; the game turns it in from the right), palm down, thumb on -z. The origin is on the floor +; under the hand; the owner's `hand` is height · grip (0 open, 1 fist). +; The shadow stays on the floor so you can tell where the hand is. + +(let h (owner hand x)) + +(nudge 30 (ink 58 104 60) (scale 1.5 1 1.1 (ring y 8))) + +(move 0 h 0 + (rotate z -.3 + ; the cuff sits under the palm, whatever the depth of its middle says + (nudge 22 (ink 236 96 128) (outline 1.2 96 50 70 (limb -15 0 0 -8 0 0 5.2))) + (ink 255 252 246) + (outline 1.2 96 90 100 (ball 0 0 0 7.4)) + (if (< (owner hand y) .5) + (nudge -6 (outline 1.2 96 90 100 + (limb 4 .5 -4.6 13 -1 -5.6 2.1) (limb 5 .5 -1.5 15 -1 -1.8 2.1) + (limb 5 .5 1.5 15 -1 1.8 2.1) (limb 4 .5 4.6 12 -1 5.4 2) + (limb 0 0 -6 5 1 -11 2.2)))) + (if (> (owner hand y) .5) + (nudge -6 (outline 1.2 96 90 100 + (ball 6 -1 -4 2.6) (ball 7 -1 -1.3 2.6) (ball 7 -1 1.3 2.6) (ball 6 -1 4 2.5) + (limb 1 0 -6 5 1 -6.5 2.2)))))) diff --git a/xbox/fiapup/objects/puppy-flat.lisp b/xbox/fiapup/objects/puppy-flat.lisp new file mode 100644 index 0000000000..2e2ef89993 --- /dev/null +++ b/xbox/fiapup/objects/puppy-flat.lisp @@ -0,0 +1,87 @@ +; puppy-flat — fia's pup, drawn flat: balls and limbs hung on a small +; skeleton, inked at the silhouette. x forward (nose), y up, z to the pup's +; right, the origin on the floor under the middle of the body. +; +; Every shape is still; only the frames move. The game hands the pose over as +; the owner's parts (three numbers each, radians unless noted), so each bone +; is one baked sketch under a moving frame: a tick is about a dozen SKETCH +; ops and nothing is projected in JS. +; +; body bob (units) · pitch about the hips (nose up +) · roll along the spine +; head yaw · nod (up +) · cock (the curious tilt) +; ears flop (out from the head) · perk (forward) +; tail wag · droop (down +) +; fl fr bl br swing (paw forward +) · splay (out to the side +) +; face eyes open (0/1) · mouth open (0/1) · tongue (0/1) +; +; fiapup.js mirrors the measurements it needs (neck, snout) in `pupRig`; +; change them together. + +def stand 21 +def hip 9 +def side 5.2 +def leg 14 +def edge 1.3 + +(let bob (owner body x)) +(let pitch (owner body y)) +(let roll (owner body z)) + +; the shadow stays on the floor, behind the pup's own shapes +(nudge 40 + (ink 58 104 60) + (scale 1.9 1 1.25 (ring y 13))) + +(move 0 (+ stand bob) 0 + (rotate x roll + (move (- hip) 0 0 (rotate z pitch (move hip 0 0 + + ; far legs first, near legs last: depth sorts them anyway, the order + ; only settles ties + (move hip -3 (- side) (rotate z (owner fl x) (rotate x (owner fl y)) + (ink 226 170 110) (limb 0 0 0 0 (- leg) 0 3.1) + (ink 250 236 214) (ball .8 (- -.5 leg) 0 3.4))) + (move hip -3 side (rotate z (owner fr x) (rotate x (- (owner fr y))) + (ink 226 170 110) (limb 0 0 0 0 (- leg) 0 3.1) + (ink 250 236 214) (ball .8 (- -.5 leg) 0 3.4))) + (move (- hip) -2 (- side) (rotate z (owner bl x) (rotate x (owner bl y)) + (ink 214 156 98) (limb 0 0 0 0 (- -2.4 leg) 0 3.5) + (ink 250 236 214) (ball .8 (- -3.4 leg) 0 3.6))) + (move (- hip) -2 side (rotate z (owner br x) (rotate x (- (owner br y))) + (ink 214 156 98) (limb 0 0 0 0 (- -2.4 leg) 0 3.5) + (ink 250 236 214) (ball .8 (- -3.4 leg) 0 3.6))) + + ; the body: a bean with a cream chest, a brown saddle, a red collar + (ink 232 178 118) + (outline edge 74 44 30 (limb -8 0 0 8 1 0 9.5)) + (nudge -1.5 + (ink 250 236 214) (ball 9 -2.5 0 6.5) + (ink 168 104 62) (ball -4 5.5 0 6)) + (nudge -3 + (ink 222 58 64) (limb 12 5 -5 12 5 5 1.7) + (ink 255 208 70) (ball 13.6 2.5 0 1.6)) + + ; the tail, from the rump, up and back + (move -15 3 0 (rotate y (owner tail x) (rotate z (owner tail y) + (ink 214 156 98) + (outline edge 74 44 30 (limb 0 0 0 -6 6 0 2.3)) + (ink 250 236 214) (ball -6.5 6.8 0 2.4)))) + + ; the head on the neck, turned, nodded and cocked + (move 14 9 0 (rotate y (owner head x) (rotate z (owner head y) (rotate x (owner head z) + (ink 232 178 118) + (outline edge 74 44 30 (ball 0 0 0 9)) + (ink 250 236 214) (ball 7 -3 0 5.4) + (nudge -1 (ink 40 28 30) (ball 12 -1.4 0 2.3)) + (if (> (owner face x) .5) + (ink 36 26 30) (ball 5.2 2.6 -4.3 1.8) (ball 5.2 2.6 4.3 1.8) + (nudge -.5 (ink 255 255 255) (ball 6 3.3 -4.5 .6) (ball 6 3.3 4.5 .6))) + (if (< (owner face x) .5) + (ink 60 38 34) (limb 4.4 2.3 -5 6.6 2.3 -3.6 .5) (limb 4.4 2.3 5 6.6 2.3 3.6 .5)) + (if (> (owner face z) .5) + (ink 236 96 120) (limb 9 -6 0 10 -9.5 0 1.9)) + ; floppy ears, the brown of the saddle + (move -1 5.5 -6 (rotate x (owner ears x) (rotate z (owner ears y) + (ink 168 104 62) (outline edge 74 44 30 (limb 0 0 0 0 -8 -2.4 3.3))))) + (move -1 5.5 6 (rotate x (- (owner ears x)) (rotate z (owner ears y) + (ink 168 104 62) (outline edge 74 44 30 (limb 0 0 0 0 -8 2.4 3.3)))))))))))))) diff --git a/xbox/fiapup/objects/rope-flat.lisp b/xbox/fiapup/objects/rope-flat.lisp new file mode 100644 index 0000000000..4eaa5e1459 --- /dev/null +++ b/xbox/fiapup/objects/rope-flat.lisp @@ -0,0 +1,12 @@ +; rope-flat — the tug rope: a knot at each end and the rope between. The +; origin is the first knot; the owner's `end` is the second knot, in world +; units from the first (the object is placed unturned). The first knot bakes; +; the rope and the far knot move every tick, so they go out as a CAPSULE and +; an ELLIPSE — the one object here that uses the per-tick flat path. + +(ink 70 118 206) +(outline 1 30 50 96 (ball 0 0 0 3.4)) +(ink 244 244 250) +(limb 0 0 0 (owner end x) (owner end y) (owner end z) 1.5) +(ink 70 118 206) +(outline 1 30 50 96 (ball (owner end x) (owner end y) (owner end z) 3.4)) diff --git a/xbox/fiapup/objects/treat-flat.lisp b/xbox/fiapup/objects/treat-flat.lisp new file mode 100644 index 0000000000..fa5f4a89e2 --- /dev/null +++ b/xbox/fiapup/objects/treat-flat.lisp @@ -0,0 +1,6 @@ +; treat-flat — a bone biscuit, lying along z. The origin is its middle. + +(ink 214 160 98) +(outline 1 110 70 40 + (limb 0 0 -4 0 0 4 1.6) + (ball 0 1 -5 1.9) (ball 0 -1 -5 1.9) (ball 0 1 5 1.9) (ball 0 -1 5 1.9)) diff --git a/xbox/fiapup/objects/yard-flat.lisp b/xbox/fiapup/objects/yard-flat.lisp new file mode 100644 index 0000000000..aac1d91f29 --- /dev/null +++ b/xbox/fiapup/objects/yard-flat.lisp @@ -0,0 +1,77 @@ +; yard-flat — the pup's backyard, drawn flat. World space: x across, y up, +; z toward the camera; the lawn runs from the fence (z -220) forward past +; where the camera stands. All of it is still, so it bakes into one sketch +; and a tick sends one SKETCH for the whole yard. +; +; Big flat shapes carry one depth each, so the ground is pushed far back +; (nudge) and everything that stands on it draws over it. The fence and the +; hills behind it are pushed back less than the lawn but more than anything +; the pup can reach. + +def tile 80 +def fence -220 + +; past the fence: a meadow, and a hedge along it +(nudge 6000 + (ink 146 188 108) + (plate -1600 0 -1500 1600 0 -1500 1600 0 -222 -1600 0 -222)) +(nudge 3200 + (outline 1.6 52 96 58 + (repeat 19 i + (ink (mix 104 92 (% i 2)) (mix 166 152 (% i 2)) (mix 92 84 (% i 2))) + (ball (- (* i 70) 630) 18 -262 (+ 30 (* 6 (% i 3))))))) + +; a round little tree over the fence, back left +(nudge 3400 + (ink 132 92 66) (limb -250 0 -300 -250 120 -300 7) + (ink 96 158 88) + (outline 1.6 52 96 58 + (ball -250 150 -300 40) (ball -216 130 -290 28) (ball -284 132 -290 30))) + +; the fence: posts and two rails across the back and down both sides +(nudge 3000 + (ink 244 232 208) + (outline 1.2 150 120 96 + (repeat 9 i + (plate (- (* i tile) 326) 0 fence (- (* i tile) 314) 0 fence + (- (* i tile) 314) 84 fence (- (* i tile) 326) 84 fence)) + (plate -330 50 fence 330 50 fence 330 58 fence -330 58 fence) + (plate -330 20 fence 330 20 fence 330 28 fence -330 28 fence) + (mirror x + (repeat 5 j + (plate 320 0 (- (* (+ j 1) tile) 226) 320 0 (- (* (+ j 1) tile) 214) + 320 84 (- (* (+ j 1) tile) 214) 320 84 (- (* (+ j 1) tile) 226))) + (plate 320 50 fence 320 50 180 320 58 180 320 58 fence) + (plate 320 20 fence 320 20 180 320 28 180 320 28 fence)))) + +; flowers along the fence +(nudge 2600 + (repeat 7 i + (ink 88 140 70) (limb (- (* i 90) 280) 0 -206 (- (* i 90) 280) 18 -206 1) + (ink (mix 255 250 (% i 2)) (mix 150 214 (% i 2)) (mix 190 90 (% i 2))) + (ball (- (* i 90) 280) 20 -206 4.5))) + +; the lawn, mown in squares +(nudge 5000 + (repeat 16 i + (repeat 9 j + (ink (mix 128 116 (% (+ i j) 2)) (mix 184 170 (% (+ i j) 2)) (mix 98 88 (% (+ i j) 2))) + (plate (- (* i tile) 640) 0 (+ fence (* j tile)) + (- (* i tile) 560) 0 (+ fence (* j tile)) + (- (* i tile) 560) 0 (+ fence (* (+ j 1) tile)) + (- (* i tile) 640) 0 (+ fence (* (+ j 1) tile)))))) + +; the bed, back left: a cushion and its soft middle, a little behind what +; lies on it +(move -230 0 -140 + (nudge 30 + (ink 110 140 196) + (outline 1.4 50 60 96 (move 0 5 0 (drum y 34 10))) + (ink 176 198 232) (move 0 10.3 0 (ring y 25)))) + +; the water bowl, back right +(move 230 0 -150 + (nudge 12 + (ink 222 84 72) + (outline 1.2 110 40 36 (move 0 3.5 0 (drum y 11 7))) + (ink 130 196 240) (move 0 7.2 0 (ring y 8)))) -- 2.51.2