- Sando is zone-DETECTION now: the stack = food bodies physically resting in the plate zone, bottom->top. Hand-stacked and harness-stacked sandos are judged identically (sando.kinds() is what the judge reads). - The full live loop: Game.state + updateLive() — ticket clock with late-anger (1 bump/3s overdue), bow-dwell serve (hold still 0.5s over a matching standing sando -> scorecard), click -> next day; string cut -> ragdoll -> fail card -> click retries the same day with fresh strings. setupDay stages bread + fillings per order; sins fire live off cutter.onCut + offstage sweep. - String unlockables: HEMP/RUBBER/CHAIN/SILK picker on the title (stiffness/ damping multipliers, registry-persisted). Rubber visibly stretches. - Days 6-8: Tamago (it rolls), Tofu (fragile), THE TOWER (5 layers) + tofu food. - samurai_bow (same seed): an S serve swaps in the single approving nod. - Battery: M1 87f/8.41u/10-10, M2 cv0 clean, M3 both, days SSSSSSSS, live serve commits at 31f (~0.5s), death->retry + serve->advance verified. - Known: embedded preview pane never fires rAF (visibilityState hidden) — live loop freezes there by browser design; harness steps manually. Real tabs fine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
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<StringStyle, { k: number; d: number; blurb: string }> = {
|
|
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<Seg, MatterJS.BodyType>;
|
|
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<Seg, { x: number; y: number }>;
|
|
|
|
/** 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<keyof Marionette['strings'], Vec> = {
|
|
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;
|
|
}
|
|
}
|