From b352c47862fe73c12f50eaf63c83d3fda4710eb7 Mon Sep 17 00:00:00 2001 From: arudaev Date: Sun, 25 Oct 2026 11:12:49 +0100 Subject: [PATCH] refactor(story): Jenny's ram and Dexter's hop and shove timed by their boards #156, one timing source: the ram sequence's frames (boss-jenny, hero-hurt), Dexter's hop length and his shove's contact frame (dexter) now come from the loaded anims.json; the 12 fps values stay only as documented fallbacks for a board that is not loaded. Refs #156 --- game/src/enemies/jennyRam.ts | 16 +++++++++++----- game/src/story/Flow.ts | 4 +++- game/src/story/Prologue.ts | 10 ++++++++-- game/src/story/prologueGuide.ts | 15 ++++++++++----- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/game/src/enemies/jennyRam.ts b/game/src/enemies/jennyRam.ts index e819321..e80de07 100644 --- a/game/src/enemies/jennyRam.ts +++ b/game/src/enemies/jennyRam.ts @@ -1,4 +1,5 @@ import { JENNY_BOX } from './jenny'; +import { frameAt, frameCount, type Clips } from '../combat/clips'; /** * Jenny's ram on the clean creek path (docs/03 region 1, #54, P5) as one readable event, timed in seconds: @@ -49,7 +50,7 @@ export function ramPlan(heroX: number, viewRight: number, heroHalfWidth = 14): R /** Everything the ram shows `t` seconds after the trigger: pure, so the choreography is tested and replayable. */ export interface RamFrame { phase: 'hush' | 'lights' | 'rev' | 'charge' | 'thrown' | 'cross'; - /** Her centre x and frame name (boss-jenny atlas, 12 fps clips). */ + /** Her centre x and frame name (boss-jenny atlas, at its clips' own speed). */ jennyX: number; jennyFrame: string; /** Headlight alpha 0…1 and the x of the lamps (the right edge before she is seen, her nose after). */ lights: number; lightsX: number; @@ -57,10 +58,15 @@ export interface RamFrame { hero?: { x: number; y: number; angle: number; frame: string }; } -export function ramFrame(t: number, plan: RamPlan, hero: { x: number; y: number }, viewRight: number): RamFrame { +/** + * `clips`: the loaded boss-jenny and hero-hurt anims.json (#156: one timing source); without them the authored + * 12 fps and frame counts. + */ +export function ramFrame(t: number, plan: RamPlan, hero: { x: number; y: number }, viewRight: number, + clips: { jenny?: Clips; hurt?: Clips } = {}): RamFrame { const clip = (name: string, since: number, loop = true) => { - const n = Math.floor(Math.max(0, t - since) * 12); - return `${name}-0${loop ? n % 4 : Math.min(3, n)}`; + const n = frameAt(clips.jenny, name, Math.max(0, t - since)), frames = frameCount(clips.jenny, name, 4); + return `${name}-${String(loop ? n % frames : Math.min(frames - 1, n)).padStart(2, '0')}`; }; const nose = (x: number) => Math.min(viewRight - 8, x - JENNY_BOX.w / 2); if (t < RAM.hush) { @@ -86,7 +92,7 @@ export function ramFrame(t: number, plan: RamPlan, hero: { x: number; y: number return { phase: t < plan.crossAt ? 'thrown' : 'cross', jennyX: x, jennyFrame: clip('crash', plan.impactAt, false), lights: 1 - s * 0.6, lightsX: x - JENNY_BOX.w / 2, - hero: { ...thrown, frame: `hit-0${Math.min(7, Math.floor((t - plan.impactAt) * 12))}` }, + hero: { ...thrown, frame: `hit-${String(Math.min(frameCount(clips.hurt, 'hit', 8) - 1, frameAt(clips.hurt, 'hit', t - plan.impactAt))).padStart(2, '0')}` }, }; } diff --git a/game/src/story/Flow.ts b/game/src/story/Flow.ts index 4e2ad97..8b9c1f0 100644 --- a/game/src/story/Flow.ts +++ b/game/src/story/Flow.ts @@ -104,6 +104,8 @@ export class Flow extends Phaser.Scene { const camera = stage.cameras.main; const viewRight = camera.scrollX + camera.width; const plan = ramPlan(hero.x, viewRight); + // Her clips and the hero's hit at their own speed (#156: one timing source). + const clips = { jenny: stage.cache.json.get('boss-jenny.anims'), hurt: stage.cache.json.get('hero-hurt.anims') }; const lights = headlights(stage); const jenny = stage.add.sprite(plan.startX, hero.y, 'boss-jenny', 'rev-00').setOrigin(0.5, 1).setDepth(150); let thrown: Phaser.GameObjects.Sprite | undefined; @@ -114,7 +116,7 @@ export class Flow extends Phaser.Scene { // Driven by the stage clock (not tweens), so it pauses with the stage and steps in tests. const step = (_time: number, delta: number) => { t += delta / 1000; - const f = ramFrame(t, plan, hero, viewRight); + const f = ramFrame(t, plan, hero, viewRight, clips); const entered = f.phase !== last; last = f.phase; jenny.setPosition(Math.round(f.jennyX), hero.y + (f.phase === 'rev' ? Math.floor(t * 24) % 2 : 0)).setFrame(f.jennyFrame); diff --git a/game/src/story/Prologue.ts b/game/src/story/Prologue.ts index 964698f..cffa12b 100644 --- a/game/src/story/Prologue.ts +++ b/game/src/story/Prologue.ts @@ -58,6 +58,10 @@ export type Crash404 = (hero: CrossingHero, onLand: () => void) => void; * `act`, the `secret` box with its item, the roof glint, and the `anomaly` box at the jetty edge. */ export class Prologue { + /** The shove clip's speed, from the dexter board (#156: one timing source). */ + private get shoveFps(): number { + return (this.stage.cache.json.get('dexter.anims') as Record | undefined)?.shove?.fps ?? 12; + } readonly guide: DexterGuide; readonly finale: FinaleSequence; private dexter: Phaser.GameObjects.Sprite; @@ -77,7 +81,9 @@ export class Prologue { const boxes = worlds.flatMap(w => w.interact); const spawns = worlds.flatMap(w => w.spawn); const bench = spawns.find(s => s.id === 'dexter-bench') ?? spawns.find(s => s.kind === 'dexter')!; - this.guide = new DexterGuide({ bench, lines, ...stopsFrom(boxes) }); + // The hop's length is the dexter board's hop clip (#156: one timing source). + const hop = (stage.cache.json.get('dexter.anims') as Record | undefined)?.hop; + this.guide = new DexterGuide({ bench, lines, ...stopsFrom(boxes), ...(hop && { hopClip: hop.frames / hop.fps * 1000 }) }); this.pondEdge = boxes.find(b => b.kind === 'anomaly'); this.finale = new FinaleSequence(() => this.toCrash()); createAnims(stage, ANIMS); @@ -135,7 +141,7 @@ export class Prologue { } if (phase === 'shove') { if (entered) this.play('dexter:shove'); - if (!this.proxy && t * FINALE.shove >= (FINALE.contactFrame * 1000) / 12) this.knock(player); + if (!this.proxy && t * FINALE.shove >= FINALE.contactFrame * 1000 / this.shoveFps) this.knock(player); } if (phase === 'fall') { if (entered) { diff --git a/game/src/story/prologueGuide.ts b/game/src/story/prologueGuide.ts index b24332d..a90a107 100644 --- a/game/src/story/prologueGuide.ts +++ b/game/src/story/prologueGuide.ts @@ -27,13 +27,16 @@ export interface GuideSetup { stops: Stop[]; /** walk + oneWay polylines in stage px. */ lines: Pt[][]; + /** His hop clip's length (ms), from dexter.anims.json (#156: one timing source); HOP_CLIP without it. */ + hopClip?: number; } /** The stage's scale (docs/09: the 76 px hero is 1.83 m) and Dexter's height in it (docs/03: a 27 px plush). */ export const PX_PER_M = 43; export const PLUSH = 27; /** His hop clip: dexter `hop`, 4 frames at 12 fps. */ -const HOP_CLIP = (4 / 12) * 1000; +/** His hop clip's length (ms) when the dexter board's anims.json is not given: 4 frames at 12 fps. */ +export const HOP_CLIP = (4 / 12) * 1000; export const GUIDE = { /** He keeps this far ahead of the hero (px): four plush-lengths, ~2.5 m. */ @@ -153,6 +156,8 @@ export class DexterGuide { private lastHeroX?: number; readonly route: Route; + /** His hop's length (ms): the hop clip's, from the board. */ + private get hopMs() { return this.setup.hopClip ?? GUIDE.hopTime; } constructor(private readonly setup: GuideSetup) { this.x = setup.bench.x; this.y = setup.bench.y; @@ -232,7 +237,7 @@ export class DexterGuide { const toY = this.route.groundAt(toX, this.y + PLUSH) ?? this.y; this.facing = 1; this.enter('hop-off'); - this.hop = { from: { x: this.x, y: this.y }, to: { x: toX, y: toY }, t: 0, duration: GUIDE.hopOff, height: GUIDE.hopLift + 6 }; + this.hop = { from: { x: this.x, y: this.y }, to: { x: toX, y: toY }, t: 0, duration: this.hopMs * 1.4, height: GUIDE.hopLift + 6 }; } private waitFor(resumeAt: number): void { @@ -276,7 +281,7 @@ export class DexterGuide { const aheadX = this.x + GUIDE.lookAhead; const ahead = this.route.groundAt(aheadX, this.y); if (ahead !== undefined && Math.abs(this.y - ahead) <= GUIDE.stepRise) return false; - const duration = GUIDE.hopTime * 0.75; + const duration = this.hopMs * 0.75; const reach = (this.hopSpeed() * duration) / 1000; let toX = this.x + Math.max(GUIDE.lookAhead + 16, reach); let toY = this.route.groundAt(toX, ahead ?? this.y); @@ -297,7 +302,7 @@ export class DexterGuide { /** The hero closed in: a hop ahead along the route at sprint speed (one hop clip long). */ private hopAhead(): boolean { - const distance = Math.min(this.end() - this.x, (this.hopSpeed() * GUIDE.hopTime) / 1000); + const distance = Math.min(this.end() - this.x, (this.hopSpeed() * this.hopMs) / 1000); if (distance < PLUSH) return false; let toX = this.x + distance; let toY = this.route.groundAt(toX, this.y); @@ -308,7 +313,7 @@ export class DexterGuide { toY = this.route.groundAt(toX, this.y) ?? this.y; } this.v = Math.max(this.v, this.heroV, GUIDE.walk); - this.jump({ x: toX, y: toY }, GUIDE.hopTime, GUIDE.hopLift + Math.max(0, this.y - toY)); + this.jump({ x: toX, y: toY }, this.hopMs, GUIDE.hopLift + Math.max(0, this.y - toY)); return true; }