import type { Stage } from '../scenes/stage'; /** * The marionette. A control bar (the player's proxy) hangs 5 strings — head, * two hands, two knees — over a 9-segment silhouette puppet. The bar snaps to * the mouse instantly; the LAG the whole game is built on comes from soft * string constraints (stiffness < 1), not from any input buffer. The head * string bears the posture; hands and knees are floppy on purpose. * * Bodies live in the Stage's Matter world; the sim owns the control + readouts. */ // --- tuning knobs (the calibration surface — tune by harness, not by guess) --- const K = { // string stiffness / damping. head + back hold posture; limbs dangle. headK: 0.14, headD: 0.09, backK: 0.11, backD: 0.08, handK: 0.045, handD: 0.06, kneeK: 0.07, kneeD: 0.06, jointK: 0.8, // near-rigid pin joints between segments gripK: 0.75, // clamp stiffness when a hand grabs gripReach: 60, // px a hand can grab across barHomeY: 175, reachDrop: 130, // extra bar lowering while LMB held group: -7, // shared negative collision group: puppet never collides with itself railMin: 30, railMax: 1250, // bar x clamp barCeil: 40, barFloor: 380, // bar y clamp (before reach) tiltGain: 0.012, tiltMax: 0.5, // horizontal bar speed -> lean radians }; type Seg = | 'head' | 'chest' | 'hips' | 'uArmL' | 'lArmL' | 'uArmR' | 'lArmR' | 'legL' | 'legR'; /** Unlockable strings — difficulty modifiers, not new mechanics. Multipliers on * string stiffness/damping only; the puppet itself never changes. */ export type StringStyle = 'hemp' | 'rubber' | 'chain' | 'silk'; export const STRING_STYLES: Record = { hemp: { k: 1, d: 1, blurb: 'the honest string' }, rubber: { k: 0.45, d: 0.5, blurb: 'bouncy. +lag +comedy' }, chain: { k: 1.7, d: 2.4, blurb: 'heavy. slow. precise' }, silk: { k: 1.35, d: 1.15, blurb: 'tighter, for cowards' }, }; type Vec = { x: number; y: number }; export interface Pose { head: number; chest: number; hips: number; armL: number; armR: number; legL: number; legR: number; lean: number; // chest tilt from vertical, degrees upright: boolean; } export class Marionette { readonly seg = {} as Record; private strings = {} as Record<'head' | 'back' | 'handL' | 'handR' | 'kneeL' | 'kneeR', MatterJS.ConstraintType>; private gripCon: (MatterJS.ConstraintType | null)[] = [null, null]; // [L, R] private gripped = false; cut = false; // strings severed → ragdoll barX: number; barY = K.barHomeY; tilt = 0; readonly barHomeY = K.barHomeY; private prevBarX: number; private home0 = {} as Record; /** Bodies the hands can grab (crate, later food). Stage fills this. */ gripTargets: MatterJS.BodyType[] = []; constructor(private stage: Stage, homeX = 400, public style: StringStyle = 'hemp') { this.barX = homeX; this.prevBarX = homeX; const m = stage.matter; const g = { collisionFilter: { group: K.group } }; const rect = (x: number, y: number, w: number, h: number) => m.add.rectangle(x, y, w, h, { ...g, frictionAir: 0.05, friction: 0.7 }); const view = (b: MatterJS.BodyType, w: number, h: number) => stage.addBody(b, w, h, 0x111111); // 9 segments laid out standing, feet planted on the floor (~642) so the rig // has traction and self-rights against the strings instead of pure-pendulum. const hx = homeX; this.seg.head = m.add.circle(hx, 388, 20, { ...g, frictionAir: 0.05 }); stage.addCircle(this.seg.head, 20, 0x111111); this.seg.chest = rect(hx, 437, 44, 46); view(this.seg.chest, 44, 46); this.seg.hips = rect(hx, 482, 44, 40); view(this.seg.hips, 44, 40); this.seg.uArmL = rect(hx - 30, 439, 13, 46); view(this.seg.uArmL, 13, 46); this.seg.lArmL = rect(hx - 30, 486, 11, 46); view(this.seg.lArmL, 11, 46); this.seg.uArmR = rect(hx + 30, 439, 13, 46); view(this.seg.uArmR, 13, 46); this.seg.lArmR = rect(hx + 30, 486, 11, 46); view(this.seg.lArmR, 11, 46); this.seg.legL = rect(hx - 12, 587, 16, 110); view(this.seg.legL, 16, 110); this.seg.legR = rect(hx + 12, 587, 16, 110); view(this.seg.legR, 16, 110); // pin joints (length 0 = hinge around shared point) const pin = (a: MatterJS.BodyType, pa: Vec, b: MatterJS.BodyType, pb: Vec) => m.add.constraint(a, b, 0, K.jointK, { pointA: pa, pointB: pb, damping: 0.1 }); pin(this.seg.chest, { x: 0, y: -23 }, this.seg.head, { x: 0, y: 20 }); // neck pin(this.seg.hips, { x: 0, y: -20 }, this.seg.chest, { x: 0, y: 23 }); // spine pin(this.seg.chest, { x: -22, y: -16 }, this.seg.uArmL, { x: 0, y: -23 }); pin(this.seg.uArmL, { x: 0, y: 23 }, this.seg.lArmL, { x: 0, y: -23 }); pin(this.seg.chest, { x: 22, y: -16 }, this.seg.uArmR, { x: 0, y: -23 }); pin(this.seg.uArmR, { x: 0, y: 23 }, this.seg.lArmR, { x: 0, y: -23 }); pin(this.seg.hips, { x: -14, y: 20 }, this.seg.legL, { x: 0, y: -55 }); pin(this.seg.hips, { x: 14, y: 20 }, this.seg.legR, { x: 0, y: -55 }); this.buildStrings(); for (const key of Object.keys(this.seg) as Seg[]) { this.home0[key] = { x: this.seg[key].position.x, y: this.seg[key].position.y }; } this.setBar(this.barX, K.barHomeY, 0); } /** (Re)create the 6 control strings from the bar to the puppet. Called at * build and again after a string cut when the puppet is restrung. */ private buildStrings(): void { const m = this.stage.matter; const s = STRING_STYLES[this.style]; const string = (b: MatterJS.BodyType, pb: Vec, length: number, k: number, d: number) => m.add.worldConstraint(b, length, Math.min(0.95, k * s.k), { pointA: { x: this.barX, y: K.barHomeY }, pointB: pb, damping: d * s.d, render: { visible: false }, }); // rest lengths match the standing pose so the strings hold posture without // hauling the feet off the floor. head + back together fix torso rotation. this.strings.head = string(this.seg.head, { x: 0, y: -20 }, 195, K.headK, K.headD); this.strings.back = string(this.seg.chest, { x: 0, y: -23 }, 240, K.backK, K.backD); this.strings.handL = string(this.seg.lArmL, { x: 0, y: 23 }, 335, K.handK, K.handD); this.strings.handR = string(this.seg.lArmR, { x: 0, y: 23 }, 335, K.handK, K.handD); this.strings.kneeL = string(this.seg.legL, { x: 0, y: -15 }, 395, K.kneeK, K.kneeD); this.strings.kneeR = string(this.seg.legR, { x: 0, y: -15 }, 395, K.kneeK, K.kneeD); this.cut = false; } /** SHUBATTO. Every control string deleted in one frame — the puppet becomes a * ragdoll (pin joints survive, so it stays connected as it collapses). Drops * anything held. The samurai's fail-state; the whole game points at this. */ cutStrings(): void { if (this.cut) return; this.setGrip(false); for (const key of Object.keys(this.strings) as (keyof Marionette['strings'])[]) { this.stage.matter.world.remove(this.strings[key]); } this.strings = {} as Marionette['strings']; this.cut = true; } /** Teleport every segment back to its start pose and drop anything held. * Harness-only — lets tests run back to back from a known state. Restrings * the puppet if a string cut had happened. */ reset(homeX = 400): void { this.setGrip(false); if (this.cut) this.buildStrings(); const b = this.stage.matter.body; const dx = homeX - 400; for (const key of Object.keys(this.seg) as Seg[]) { b.setPosition(this.seg[key], { x: this.home0[key].x + dx, y: this.home0[key].y }, false); b.setAngle(this.seg[key], 0, false); b.setVelocity(this.seg[key], { x: 0, y: 0 }); b.setAngularVelocity(this.seg[key], 0); } this.prevBarX = homeX; this.setBar(homeX, K.barHomeY, 0); } /** Anchor offsets from the bar centre, before tilt rotation. */ private static ANCHOR: Record = { head: { x: 0, y: -8 }, back: { x: 0, y: 8 }, handL: { x: -46, y: 12 }, handR: { x: 46, y: 12 }, kneeL: { x: -22, y: 26 }, kneeR: { x: 22, y: 26 }, }; /** Move the control bar. tilt (radians) rotates the whole string array — a * fast horizontal move tilts the bar and the puppet leans into it. */ setBar(x: number, y: number, tilt: number): void { this.barX = x; this.barY = y; this.tilt = tilt; const c = Math.cos(tilt), s = Math.sin(tilt); for (const key of Object.keys(this.strings) as (keyof Marionette['strings'])[]) { const o = Marionette.ANCHOR[key]; const p = this.strings[key].pointA as Vec; p.x = x + o.x * c - o.y * s; p.y = y + o.x * s + o.y * c; } } /** Raw pointer/harness input → bar move + grip. Tilt comes from how fast the * bar is travelling sideways; LMB (reach) drops the bar to grab low. */ applyInput(mouseX: number, mouseY: number, reach: boolean, grip: boolean): void { if (this.cut) return; // no strings to pull const clamp = (v: number, a: number, b: number) => (v < a ? a : v > b ? b : v); const x = clamp(mouseX, K.railMin, K.railMax); const y = clamp(mouseY, K.barCeil, K.barFloor) + (reach ? K.reachDrop : 0); const tilt = clamp((x - this.prevBarX) * K.tiltGain, -K.tiltMax, K.tiltMax); this.prevBarX = x; this.setBar(x, y, tilt); this.setGrip(grip); } /** hand tip world positions. */ handWorld(): { L: Vec; R: Vec } { return { L: this.handTipWorld(this.seg.lArmL), R: this.handTipWorld(this.seg.lArmR) }; } /** The 6 strings as bar-anchor → body-attach world segments, for drawing. * Empty once the strings are cut. */ stringSegments(): { ax: number; ay: number; bx: number; by: number }[] { if (this.cut) return []; const out: { ax: number; ay: number; bx: number; by: number }[] = []; for (const key of Object.keys(this.strings) as (keyof Marionette['strings'])[]) { const c = this.strings[key] as unknown as { pointA: Vec; pointB: Vec; bodyB: MatterJS.BodyType }; const a = c.pointA, pb = c.pointB, body = c.bodyB; const co = Math.cos(body.angle), si = Math.sin(body.angle); out.push({ ax: a.x, ay: a.y, bx: body.position.x + pb.x * co - pb.y * si, by: body.position.y + pb.x * si + pb.y * co }); } return out; } /** The wooden control bar as a segment (centre ± half-width, tilted). */ barSegment(half = 58): { x1: number; y1: number; x2: number; y2: number } { const c = Math.cos(this.tilt), s = Math.sin(this.tilt); return { x1: this.barX - half * c, y1: this.barY - half * s, x2: this.barX + half * c, y2: this.barY + half * s }; } get gripping(): boolean { return this.gripped; } get holding(): boolean { return this.gripCon.some((c) => c !== null); } /** The bodies currently clamped in the hands (grab constraints are arm→target, * so the target is bodyB). */ held(): MatterJS.BodyType[] { const out: MatterJS.BodyType[] = []; for (const c of this.gripCon) { const t = (c as unknown as { bodyB?: MatterJS.BodyType } | null)?.bodyB; if (t) out.push(t); } return out; } /** Both hands want to clamp. Turning grip OFF drops whatever's held. The * actual grab happens in update(): while gripped, any empty hand latches * onto the nearest target the instant it comes within reach — so you can * lower a closed hand onto food and it catches on contact. */ setGrip(on: boolean): void { if (on === this.gripped) return; this.gripped = on; if (!on) { for (const c of this.gripCon) if (c) this.stage.matter.world.remove(c); this.gripCon = [null, null]; } } /** Per-frame: latch empty gripping hands onto anything in reach. */ update(): void { if (!this.gripped) return; const arms = [this.seg.lArmL, this.seg.lArmR]; for (let i = 0; i < 2; i++) { if (this.gripCon[i]) continue; const tipW = this.handTipWorld(arms[i]); const t = this.nearestTarget(tipW); if (!t) continue; this.gripCon[i] = this.stage.matter.add.constraint(arms[i], t, 0, K.gripK, { pointA: { x: 0, y: 23 }, pointB: this.toLocal(t, tipW), damping: 0.1, }); } } private handTipWorld(arm: MatterJS.BodyType): Vec { const a = arm.angle; return { x: arm.position.x - Math.sin(a) * 23, y: arm.position.y + Math.cos(a) * 23 }; } private nearestTarget(p: Vec): MatterJS.BodyType | null { let best: MatterJS.BodyType | null = null, bd = K.gripReach; for (const t of this.gripTargets) { const d = Math.hypot(t.position.x - p.x, t.position.y - p.y); if (d < bd) { bd = d; best = t; } } return best; } private toLocal(body: MatterJS.BodyType, world: Vec): Vec { const dx = world.x - body.position.x, dy = world.y - body.position.y; const c = Math.cos(-body.angle), s = Math.sin(-body.angle); return { x: dx * c - dy * s, y: dx * s + dy * c }; } private deg(rad: number): number { return +((rad * 180) / Math.PI).toFixed(1); } pose(): Pose { const lean = this.deg(this.seg.chest.angle); const upright = this.seg.head.position.y < this.seg.hips.position.y && Math.abs(lean) < 45; return { head: this.deg(this.seg.head.angle), chest: this.deg(this.seg.chest.angle), hips: this.deg(this.seg.hips.angle), armL: this.deg(this.seg.lArmL.angle), armR: this.deg(this.seg.lArmR.angle), legL: this.deg(this.seg.legL.angle), legR: this.deg(this.seg.legR.angle), lean, upright, }; } /** hips x — the thing that moves when the puppet walks. */ get torsoX(): number { return this.seg.hips.position.x; } /** peak speed of any segment — 0 ≈ fully settled. */ restSpeed(): number { let v = 0; for (const key of Object.keys(this.seg) as Seg[]) { v = Math.max(v, Math.hypot(this.seg[key].velocity.x, this.seg[key].velocity.y)); } return v; } }