From f30aff87a6c19fad0610b2d9426db66857000786 Mon Sep 17 00:00:00 2001 From: arudaev Date: Sun, 25 Oct 2026 10:12:46 +0100 Subject: [PATCH] =?UTF-8?q?refactor(combat):=20one=20timing=20source=20?= =?UTF-8?q?=E2=80=93=20clip=20speeds=20from=20the=20boards'=20anims.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #156: the chair's moves (sweeps, overhead, low sweep, guard and its push) and the rover, crab and drone timed their frames as n / 12. Each model now takes its board's anims.json (combat/clips.ts: frameTime, frameCount, clipTime, frameAt); the chair and the enemy row pass the loaded tables, and without one the authored 12 fps stays. The unused lifePose / paintLife (generic white tint) are gone. Refs #156 --- game/src/combat/chair.ts | 15 +++++++++++---- game/src/combat/clips.ts | 19 +++++++++++++++++++ game/src/combat/guard.ts | 26 +++++++++++++++++--------- game/src/combat/life-visuals.ts | 15 --------------- game/src/combat/lowSweep.ts | 11 +++++++---- game/src/combat/overhead.ts | 17 +++++++++++------ game/src/combat/sweeps.ts | 10 +++++++--- game/src/enemies/crab.ts | 19 ++++++++++++------- game/src/enemies/drone.ts | 12 +++++++----- game/src/enemies/rover.ts | 13 ++++++++----- game/src/enemies/rovers.ts | 6 +++--- game/src/player/player.ts | 3 ++- 12 files changed, 104 insertions(+), 62 deletions(-) create mode 100644 game/src/combat/clips.ts diff --git a/game/src/combat/chair.ts b/game/src/combat/chair.ts index 693cbac..0a6057b 100644 --- a/game/src/combat/chair.ts +++ b/game/src/combat/chair.ts @@ -4,6 +4,7 @@ import type { Player } from '../player/player'; import { sound } from '../fx/sound/Audio'; import { HitStop, Sweeps, SWEEP_CONTACT, type Damageable, type Rect } from './sweeps'; import { Overhead, SLAM_SPEED } from './overhead'; +import type { Clips } from './clips'; import { Guard } from './guard'; import { BODY_HEIGHT, BODY_WIDTH } from '../player/body'; import { actionEdges, attackClick, AttackBuffer } from './action-input'; @@ -19,12 +20,12 @@ const FIXTURE_KNOCK = 8; // docs/02: development fixture displacement, not real const GRAIN = 2; // docs/09: two-pixel grain; retain this overlap after the fixture moves. export class ChairCombat { - readonly sweeps = new Sweeps(); + readonly sweeps: Sweeps; readonly pause = new HitStop(); - readonly overhead = new Overhead(); - readonly guard = new Guard(); + readonly overhead: Overhead; + readonly guard: Guard; /** ↓ + Act on the ground: the chair along the floor (lowSweep.ts). */ - readonly low = new LowSweep(); + readonly low: LowSweep; private targets = new Map(); private key: Phaser.Input.Keyboard.Key; /** The second attack bind (playtest 24 Oct): X, for players on the arrow keys. */ @@ -46,6 +47,12 @@ export class ChairCombat { private visible = false; enabled: boolean; constructor(private scene: Phaser.Scene, private player: Player, _region: string) { + // Every move's timing from its board's anims.json (#156: one timing source). + const clips = (board: string) => scene.cache.json.get(`${board}.anims`) as Clips | undefined; + this.sweeps = new Sweeps(clips('chair-side')); + this.overhead = new Overhead(clips('chair-overhead')); + this.low = new LowSweep(clips('chair-overhead')); + this.guard = new Guard(clips('chair-block')); // Armed once the hero picked the chair up (chairPickup.ts), in every region after that. this.enabled = carriesChair(scene); this.key = scene.input.keyboard!.addKey('J'); diff --git a/game/src/combat/clips.ts b/game/src/combat/clips.ts new file mode 100644 index 0000000..68b71ca --- /dev/null +++ b/game/src/combat/clips.ts @@ -0,0 +1,19 @@ +import type { AnimationTable } from '../stage/assets'; + +/** + * One timing source for the chair's moves (#156): each clip's speed and length come from its board's + * `anims.json` (export_atlas.py), never from numbers in the gameplay code. A model built without a table (tests, + * a board not loaded yet) uses the boards' authored defaults: 12 fps and the frame counts it names. + */ +export type Clips = Partial; +export const DEFAULT_FPS = 12; + +/** Seconds per frame of a clip. */ +export const frameTime = (clips: Clips | undefined, name: string) => 1 / (clips?.[name]?.fps ?? DEFAULT_FPS); +/** A clip's frame count, or the authored one. */ +export const frameCount = (clips: Clips | undefined, name: string, authored: number) => clips?.[name]?.frames ?? authored; +/** Seconds for `frames` frames of a clip. */ +export const clipTime = (clips: Clips | undefined, name: string, frames: number) => frames * frameTime(clips, name); +/** The frame a clip shows `elapsed` seconds in (not clamped). */ +export const frameAt = (clips: Clips | undefined, name: string, elapsed: number) => + Math.floor((elapsed + 1e-9) / frameTime(clips, name)); diff --git a/game/src/combat/guard.ts b/game/src/combat/guard.ts index 69ec78c..08e1d0c 100644 --- a/game/src/combat/guard.ts +++ b/game/src/combat/guard.ts @@ -1,6 +1,9 @@ import { BODY_WIDTH } from '../player/body'; +import { clipTime, frameAt, type Clips } from './clips'; export const GUARD_DISTANCE = BODY_WIDTH / 2; // docs/02: small push = half the 28 px body. -export const GUARD_PUSH_TIME = 3 / 12; // docs/02: three authored frames, kinematic collision retained. +/** docs/02: the push lasts the impact's first three frames (0.25 s at 12 fps); kinematic collision retained. */ +export const guardPushTime = (clips?: Clips) => clipTime(clips, 'guard-impact', 3); +export const GUARD_PUSH_TIME = guardPushTime(); type Phase = 'idle' | 'up' | 'hold' | 'impact' | 'break'; export class Guard { @@ -10,7 +13,9 @@ export class Guard { private elapsed = 0; private consumed = false; private engaged = false; - get active() { return this.phase === 'hold' || this.phase === 'impact' || this.phase === 'up' && this.elapsed + 1e-9 >= 2 / 12; } + /** `clips`: chair-block.anims.json (guard-up, guard-hold, guard-impact, guard-break). */ + constructor(private clips?: Clips) {} + get active() { return this.phase === 'hold' || this.phase === 'impact' || this.phase === 'up' && this.elapsed + 1e-9 >= clipTime(this.clips, 'guard-up', 2); } press(direction: -1 | 1) { if (this.phase !== 'idle') return false; this.direction = direction; this.phase = 'up'; this.elapsed = 0; this.blocks = 0; @@ -25,9 +30,10 @@ export class Guard { step(dt: number) { this.elapsed += dt; if (this.active) this.engaged = true; - if ((this.phase === 'up' || this.phase === 'impact') && this.elapsed + 1e-9 >= 4 / 12) { - this.phase = 'hold'; this.elapsed = Math.max(0, this.elapsed - 4 / 12); - } else if (this.phase === 'break' && this.elapsed + 1e-9 >= 4 / 12) this.cancel(); + const length = clipTime(this.clips, `guard-${this.phase}`, 4); + if ((this.phase === 'up' || this.phase === 'impact') && this.elapsed + 1e-9 >= length) { + this.phase = 'hold'; this.elapsed = Math.max(0, this.elapsed - length); + } else if (this.phase === 'break' && this.elapsed + 1e-9 >= length) this.cancel(); } receive(damage: number, sourceX: number, playerX: number) { if (!Number.isInteger(damage) || damage <= 0) throw new Error('Damage must be a positive integer'); @@ -41,8 +47,8 @@ export class Guard { } get pose() { if (this.phase === 'idle') return undefined; - const clip = this.phase === 'up' ? 'guard-up' : `guard-${this.phase}`; - const frame = Math.floor((this.elapsed + 1e-9) * 12); + const clip = `guard-${this.phase}`; + const frame = frameAt(this.clips, clip, this.elapsed); return `${clip}-${String(this.phase === 'hold' ? frame % 3 : Math.min(3, frame)).padStart(2, '0')}`; } } @@ -50,11 +56,13 @@ export class Guard { export class GuardPush { remaining = 0; private direction = 1; - start(direction: number) { this.remaining = GUARD_PUSH_TIME; this.direction = direction < 0 ? -1 : 1; } + private readonly time: number; + constructor(clips?: Clips) { this.time = guardPushTime(clips); } + start(direction: number) { this.remaining = this.time; this.direction = direction < 0 ? -1 : 1; } cancel() { this.remaining = 0; } step(dt: number) { const active = Math.min(dt, this.remaining); this.remaining = Math.max(0, this.remaining - active); if (this.remaining < 1e-9) this.remaining = 0; - return this.direction * GUARD_DISTANCE / GUARD_PUSH_TIME * active; + return this.direction * GUARD_DISTANCE / this.time * active; } } diff --git a/game/src/combat/life-visuals.ts b/game/src/combat/life-visuals.ts index c448514..e59a768 100644 --- a/game/src/combat/life-visuals.ts +++ b/game/src/combat/life-visuals.ts @@ -1,21 +1,6 @@ import type Phaser from 'phaser'; import type { Position } from '../core/events'; -import type { Life } from './life'; -export function lifePose(life: Life) { - if (life.dead > 0) return { board: 'hero-death', frame: `death-${String(Math.min(11, Math.floor((1 - life.dead) * 12))).padStart(2, '0')}` }; - if (life.knock > 0) { - const time = 1 - life.invulnerable; - return { board: 'hero-hurt', frame: time < 0.08 ? 'hit-00' - : `knockback-${String(Math.min(2, Math.floor((time - 0.08) * 12))).padStart(2, '0')}` }; - } - return null; -} -export function paintLife(sprite: Phaser.GameObjects.Sprite, life: Life, time: number) { - if (life.dead === 0 && life.invulnerable > 0.92) sprite.setTintFill(0xffffff); - else sprite.clearTint(); - sprite.setAlpha(life.dead === 0 && life.invulnerable > 0 && Math.floor(time / 80) % 2 ? 0.35 : 1); -} /** Small game particles; no raster assets or changes to the approved art. */ export function deathDust(scene: Phaser.Scene, position: Position) { for (let i = 0; i < 18; i++) { diff --git a/game/src/combat/lowSweep.ts b/game/src/combat/lowSweep.ts index 22f9ae5..b85a4ae 100644 --- a/game/src/combat/lowSweep.ts +++ b/game/src/combat/lowSweep.ts @@ -1,9 +1,10 @@ import { intersects, type Damageable, type Rect } from './sweeps'; +import { frameTime, type Clips } from './clips'; /** * The low sweep (playtest 24 Oct: rovers and crabs crawl under a chest-high swing): ↓ + Act on the ground drives the * chair down along the floor. No new art – it plays the slam's own floor poses from `chair-overhead` (02 the chair - * coming down, 03 planted on the floor with its dust, 04 the follow-through), five poses at 12 fps. + * coming down, 03 planted on the floor with its dust, 04 the follow-through), five poses at the slam's own speed. * Measured on the 1.5x slam-03 (cell 160×187, feet 80,187): seat and legs run 12…56 px ahead, the dust to ~60 px, * the seat 51 px up – a floor-level box that reaches a little further than the side swing (50 px). */ @@ -11,7 +12,6 @@ export const LOW_CONTACT = { inner: 12, reach: 75, top: 51, bottom: 0 } as const export const LOW_POSES = ['slam-02', 'slam-03', 'slam-03', 'slam-04', 'slam-04'] as const; /** The poses with the chair on the floor. */ export const LOW_HIT_FRAMES: readonly number[] = [1, 2]; -const FRAME = 1 / 12; export class LowSweep { active = false; @@ -19,6 +19,8 @@ export class LowSweep { direction: -1 | 1 = 1; private elapsed = 0; private struck = new Set(); + /** `clips`: chair-overhead.anims.json – the poses are the slam's. */ + constructor(private clips?: Clips) {} begin(direction: -1 | 1) { if (this.active) return false; this.active = true; this.direction = direction; this.frame = 0; this.elapsed = 0; this.struck.clear(); @@ -28,8 +30,9 @@ export class LowSweep { step(dt: number) { if (!this.active) return; this.elapsed += dt; - while (this.active && this.elapsed + 1e-9 >= FRAME) { - this.elapsed = Math.max(0, this.elapsed - FRAME); this.frame++; + const frame = frameTime(this.clips, 'slam'); + while (this.active && this.elapsed + 1e-9 >= frame) { + this.elapsed = Math.max(0, this.elapsed - frame); this.frame++; if (this.frame >= LOW_POSES.length) this.cancel(); } } diff --git a/game/src/combat/overhead.ts b/game/src/combat/overhead.ts index 4bed37c..1af5c5f 100644 --- a/game/src/combat/overhead.ts +++ b/game/src/combat/overhead.ts @@ -1,6 +1,7 @@ import { intersects, type Damageable, type Rect } from './sweeps'; import type { Position } from '../core/events'; import { RUN_SPEED } from '../player/movement'; +import { clipTime, frameAt, frameCount, type Clips } from './clips'; /** * docs/02: measured on the 1.5x chair-overhead frames (24 Oct), cell 160×187, foot pivot (80,187). In up-02/03 the @@ -18,6 +19,8 @@ export class Overhead { private elapsed = 0; private queued?: { kind: 'up' | 'slam'; direction: -1 | 1 }; private struck = new Set(); + /** `clips`: chair-overhead.anims.json (up, slam, pogo). */ + constructor(private clips?: Clips) {} begin(kind: 'up' | 'slam', direction: -1 | 1) { if (this.phase === 'pogo' || this.phase === 'descend') { this.queued = { kind, direction }; return true; } if (this.phase !== 'idle') return false; @@ -27,10 +30,12 @@ export class Overhead { cancel() { this.enter('idle'); this.struck.clear(); this.queued = undefined; } step(dt: number) { this.elapsed += dt; - if (this.phase === 'windup' && this.elapsed + 1e-9 >= 3 / 12) this.enter('descend'); - else if (this.phase === 'up' && this.elapsed + 1e-9 >= 6 / 12 - || this.phase === 'land' && this.elapsed + 1e-9 >= 3 / 12 - || this.phase === 'pogo' && this.elapsed + 1e-9 >= 4 / 12) { + const done = (clip: string, frames: number) => this.elapsed + 1e-9 >= clipTime(this.clips, clip, frames); + // slam: 00–02 the windup, 03 the descent (held until the floor), 04–06 the landing + if (this.phase === 'windup' && done('slam', 3)) this.enter('descend'); + else if (this.phase === 'up' && done('up', frameCount(this.clips, 'up', 6)) + || this.phase === 'land' && done('slam', 3) + || this.phase === 'pogo' && done('pogo', frameCount(this.clips, 'pogo', 4))) { const queued = this.phase === 'pogo' ? this.queued : undefined; this.cancel(); if (queued) this.begin(queued.kind, queued.direction); } @@ -38,8 +43,8 @@ export class Overhead { land(grounded: boolean) { if (grounded && this.phase === 'descend') this.enter('land'); } get pose() { if (this.phase === 'idle') return undefined; - const frame = Math.floor((this.elapsed + 1e-9) * 12); const clip = this.phase === 'up' ? 'up' : this.phase === 'pogo' ? 'pogo' : 'slam'; + const frame = frameAt(this.clips, clip, this.elapsed); const index = this.phase === 'descend' ? 3 : this.phase === 'land' ? Math.min(6, 4 + frame) : this.phase === 'windup' ? Math.min(2, frame) : Math.min(clip === 'up' ? 5 : 3, frame); return `${clip}-${String(index).padStart(2, '0')}`; @@ -48,7 +53,7 @@ export class Overhead { return { x: position.x + (this.direction > 0 ? left : -right), y, w: right - left, h }; } box(position: Position, previousY: number): Rect | undefined { - const frame = Math.floor((this.elapsed + 1e-9) * 12); + const frame = frameAt(this.clips, 'up', this.elapsed); if (this.phase === 'up' && (frame === 2 || frame === 3)) { const c = UP_CONTACT; return this.rect(position, c.left, c.right, position.y - c.top, c.top - c.bottom); } diff --git a/game/src/combat/sweeps.ts b/game/src/combat/sweeps.ts index 4af3dbc..dee55a9 100644 --- a/game/src/combat/sweeps.ts +++ b/game/src/combat/sweeps.ts @@ -1,3 +1,5 @@ +import { frameCount, frameTime, type Clips } from './clips'; + export interface Rect { x: number; y: number; w: number; h: number } export interface ChairHit { damage: number; direction: -1 | 1; position: { x: number; y: number }; kind: 'sweep' | 'up' | 'slam' } export interface Damageable { id: string; active?(): boolean; bounds(): Rect; hit(hit: ChairHit): boolean } @@ -32,6 +34,8 @@ export class Sweeps { private elapsed = 0; private queued: -1 | 1 | null = null; private struck = new Set(); + /** `clips`: chair-side.anims.json (sweep-a, sweep-b, recoil). */ + constructor(private clips?: Clips) {} private begin(direction: -1 | 1) { this.phase = this.nextB ? 'sweep-b' : 'sweep-a'; this.nextB = !this.nextB; this.direction = direction; this.frame = 0; this.elapsed = 0; this.struck.clear(); @@ -44,9 +48,9 @@ export class Sweeps { step(dt: number) { if (this.phase === 'idle') return; this.elapsed += dt; - while (this.elapsed + 1e-9 >= 1 / 12 && this.phase !== 'idle') { - this.elapsed = Math.max(0, this.elapsed - 1 / 12); this.frame++; - if (this.frame < (this.phase === 'recoil' ? 2 : 8)) continue; + while (this.phase !== 'idle' && this.elapsed + 1e-9 >= frameTime(this.clips, this.phase)) { + this.elapsed = Math.max(0, this.elapsed - frameTime(this.clips, this.phase)); this.frame++; + if (this.frame < frameCount(this.clips, this.phase, this.phase === 'recoil' ? 2 : 8)) continue; if (this.phase !== 'recoil') { this.phase = 'recoil'; this.frame = 0; } else { this.phase = 'idle'; this.frame = 0; diff --git a/game/src/enemies/crab.ts b/game/src/enemies/crab.ts index cfbb1bf..954313b 100644 --- a/game/src/enemies/crab.ts +++ b/game/src/enemies/crab.ts @@ -1,3 +1,4 @@ +import { clipTime, frameAt, frameCount, type Clips } from '../combat/clips'; import { BODY_HEIGHT, BODY_WIDTH } from '../player/body'; import { TURN_SECONDS } from './rover'; import { WALK_SPEED } from '../player/movement'; @@ -20,7 +21,7 @@ export const STAB_REACH = 52; * half-width (less a 4 px bite). Further off – up to CRAB_SPOT – it creeps toward the hero instead of stabbing air. */ export const CRAB_STRIKE = CRAB_WIDTH / 2 + STAB_REACH + BODY_WIDTH / 2 - 4; -const STAB_SECONDS = 5 / 12, COLLAPSE_SECONDS = 5 / 12, HIT_SECONDS = 3 / 12, RECOVER = 0.5; +const RECOVER = 0.5; export type CrabPhase = 'walk' | 'tell' | 'stab' | 'recover' | 'hit' | 'collapse' | 'dead'; export class Crab { @@ -35,13 +36,15 @@ export class Crab { turning = 0; /** Whether it moved in the last step (its legs walk only then). */ private moving = true; - constructor(readonly spawn: Position, readonly patrol: [number, number]) { this.x = spawn.x; this.y = spawn.y; } + /** `clips`: enemy-crab.anims.json (idle, walk, stab, collapse). */ + constructor(readonly spawn: Position, readonly patrol: [number, number], private clips?: Clips) { this.x = spawn.x; this.y = spawn.y; } + private seconds(clip: string, authored: number) { return clipTime(this.clips, clip, frameCount(this.clips, clip, authored)); } private enter(phase: CrabPhase) { this.phase = phase; this.elapsed = 0; this.turning = 0; this.moving = true; } reset() { this.x = this.spawn.x; this.health = CRAB_HEALTH; this.direction = -1; this.enter('walk'); } get bounds(): Rect { return { x: this.x - CRAB_WIDTH / 2, y: this.y - CRAB_HEIGHT, w: CRAB_WIDTH, h: CRAB_HEIGHT }; } /** The stab's reach while its legs are out (frames 2–3 of 5), else undefined. */ get attack(): Rect | undefined { - if (this.phase !== 'stab' || this.elapsed < 2 / 12 || this.elapsed >= 4 / 12) return undefined; + if (this.phase !== 'stab' || this.elapsed < clipTime(this.clips, 'stab', 2) || this.elapsed >= clipTime(this.clips, 'stab', 4)) return undefined; const front = this.x + this.direction * CRAB_WIDTH / 2; return { x: this.direction > 0 ? front : front - STAB_REACH, y: this.y - CRAB_HEIGHT, w: STAB_REACH, h: CRAB_HEIGHT }; } @@ -81,19 +84,21 @@ export class Crab { break; } case 'tell': if (this.elapsed >= CRAB_TELL) this.enter('stab'); break; - case 'stab': if (this.elapsed >= STAB_SECONDS) this.enter('recover'); break; + case 'stab': if (this.elapsed >= this.seconds('stab', 5)) this.enter('recover'); break; case 'recover': if (this.elapsed >= RECOVER) this.enter('walk'); break; case 'hit': this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x + this.hitDirection * 40 * dt)); - if (this.elapsed >= HIT_SECONDS) this.enter('recover'); + if (this.elapsed >= clipTime(this.clips, 'idle', 3)) this.enter('recover'); break; - case 'collapse': if (this.elapsed >= COLLAPSE_SECONDS) { this.enter('dead'); defeated = true; } break; + case 'collapse': if (this.elapsed >= this.seconds('collapse', 5)) { this.enter('dead'); defeated = true; } break; } } return defeated; } get pose() { - const n = Math.floor((this.elapsed + 1e-9) * 12); + const clip = this.phase === 'walk' && this.moving ? 'walk' : this.phase === 'tell' || this.phase === 'stab' ? 'stab' + : this.phase === 'collapse' ? 'collapse' : 'idle'; + const n = frameAt(this.clips, clip, this.elapsed); const f = (clip: string, i: number) => `${clip}-${String(i).padStart(2, '0')}`; switch (this.phase) { case 'walk': return this.moving ? f('walk', n % 8) : f('idle', n % 4); diff --git a/game/src/enemies/drone.ts b/game/src/enemies/drone.ts index 0f36448..d2ce3f2 100644 --- a/game/src/enemies/drone.ts +++ b/game/src/enemies/drone.ts @@ -1,3 +1,4 @@ +import { clipTime, frameAt, frameCount, type Clips } from '../combat/clips'; import type { Position } from '../core/events'; import type { Rect } from '../combat/sweeps'; @@ -15,7 +16,7 @@ export const DRONE_WIDTH = 77, DRONE_HEIGHT = 77, DRONE_HEALTH = 8; export const DRONE_SPEED = 70, DRONE_TELL = 0.5, DIVE_SPEED = 380, CLIMB_SPEED = 160; /** It dives when the hero is this close beneath it (px, horizontally). */ export const DRONE_SPOT = 50; -const CRASH_SECONDS = 5 / 12, HIT_SECONDS = 3 / 12, DIVE_MAX = 0.6; +const DIVE_MAX = 0.6; export type DronePhase = 'hover' | 'tell' | 'dive' | 'climb' | 'hit' | 'crash' | 'dead'; export class Drone { @@ -26,7 +27,8 @@ export class Drone { direction: -1 | 1 = 1; elapsed = 0; private target = 0; - constructor(readonly spawn: Position, readonly patrol: [number, number]) { this.x = spawn.x; this.y = spawn.y; } + /** `clips`: enemy-drone.anims.json (hover, dive, crash, screw). */ + constructor(readonly spawn: Position, readonly patrol: [number, number], private clips?: Clips) { this.x = spawn.x; this.y = spawn.y; } private enter(phase: DronePhase) { this.phase = phase; this.elapsed = 0; } reset() { this.x = this.spawn.x; this.y = this.spawn.y; this.health = DRONE_HEALTH; this.enter('hover'); } get bounds(): Rect { return { x: this.x - DRONE_WIDTH / 2, y: this.y - DRONE_HEIGHT, w: DRONE_WIDTH, h: DRONE_HEIGHT }; } @@ -57,18 +59,18 @@ export class Drone { break; case 'climb': case 'hit': this.y = Math.max(this.spawn.y, this.y - CLIMB_SPEED * dt); - if (this.phase === 'hit' ? this.elapsed >= HIT_SECONDS : this.y <= this.spawn.y) this.enter(this.y <= this.spawn.y ? 'hover' : 'climb'); + if (this.phase === 'hit' ? this.elapsed >= clipTime(this.clips, 'hover', 3) : this.y <= this.spawn.y) this.enter(this.y <= this.spawn.y ? 'hover' : 'climb'); break; case 'crash': this.y += 200 * dt; - if (this.elapsed >= CRASH_SECONDS) { this.enter('dead'); defeated = true; } + if (this.elapsed >= clipTime(this.clips, 'crash', frameCount(this.clips, 'crash', 5))) { this.enter('dead'); defeated = true; } break; } } return defeated; } get pose() { - const n = Math.floor((this.elapsed + 1e-9) * 12); + const n = frameAt(this.clips, this.phase === 'tell' || this.phase === 'dive' ? 'dive' : this.phase === 'crash' ? 'crash' : 'hover', this.elapsed); const f = (clip: string, i: number) => `${clip}-${String(i).padStart(2, '0')}`; if (this.phase === 'tell' || this.phase === 'dive') return f('dive', Math.min(2, n)); if (this.phase === 'crash') return f('crash', Math.min(4, n)); diff --git a/game/src/enemies/rover.ts b/game/src/enemies/rover.ts index c8cf6fd..9ea4c6e 100644 --- a/game/src/enemies/rover.ts +++ b/game/src/enemies/rover.ts @@ -1,6 +1,7 @@ import { WALK_SPEED, RUN_SPEED } from '../player/movement'; import { BODY_HEIGHT } from '../player/body'; import type { Position } from '../core/events'; +import { clipTime, frameAt, frameCount, type Clips } from '../combat/clips'; // docs/05: measured idle-00 alpha bounds and controller tuning. // 3x since 24 Oct (Lex: the small robots were barely visible next to the 114 px hero): 75 px drawn. @@ -21,7 +22,8 @@ export class Rover { private hitDirection = 1; /** Seconds left idling at a patrol end before it turns. */ turning = 0; - constructor(readonly spawn: Position, readonly patrol: [number, number]) { + /** `clips`: enemy-rover.anims.json (idle, drive, charge, wreck). */ + constructor(readonly spawn: Position, readonly patrol: [number, number], private clips?: Clips) { this.x = spawn.x; this.y = spawn.y; } private enter(phase: RoverPhase) { this.phase = phase; this.elapsed = 0; this.turning = 0; } @@ -66,16 +68,17 @@ export class Rover { break; case 'recover': if (this.elapsed + 1e-9 >= TELL_SECONDS) this.enter('patrol'); break; case 'hit': - this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x + this.hitDirection * ROVER_WIDTH / 2 / (3 / 12) * dt)); - if (this.elapsed + 1e-9 >= 3 / 12) this.enter('patrol'); + // knocked back half its width over three frames of its idle clip, flashing + this.x = Math.max(this.patrol[0], Math.min(this.patrol[1], this.x + this.hitDirection * ROVER_WIDTH / 2 / clipTime(this.clips, 'idle', 3) * dt)); + if (this.elapsed + 1e-9 >= clipTime(this.clips, 'idle', 3)) this.enter('patrol'); break; - case 'wreck': if (this.elapsed + 1e-9 >= 5 / 12) { this.enter('dead'); defeated = true; } break; + case 'wreck': if (this.elapsed + 1e-9 >= clipTime(this.clips, 'wreck', frameCount(this.clips, 'wreck', 5))) { this.enter('dead'); defeated = true; } break; } } return defeated; } get pose() { - const frame = Math.floor((this.elapsed + 1e-9) * 12); + const frame = frameAt(this.clips, this.phase === 'patrol' && !this.turning ? 'drive' : this.phase === 'wreck' ? 'wreck' : this.phase === 'charge' ? 'charge' : 'idle', this.elapsed); if (this.phase === 'wreck' || this.phase === 'dead') return `wreck-${String(this.phase === 'dead' ? 4 : Math.min(4, frame)).padStart(2, '0')}`; if (this.phase === 'hit') return 'idle-00'; // The renderer flashes the living body white. if (this.phase === 'tell') return `charge-0${this.elapsed + 1e-9 < TELL_SECONDS / 2 ? 0 : 1}`; diff --git a/game/src/enemies/rovers.ts b/game/src/enemies/rovers.ts index 4fcb342..c366651 100644 --- a/game/src/enemies/rovers.ts +++ b/game/src/enemies/rovers.ts @@ -72,13 +72,13 @@ export class Rovers { const at = { x: spawn.x, y: spawn.y }; let row: Omit; if (kind === 'rover') { - const model = new Rover(at, patrol); + const model = new Rover(at, patrol, scene.cache.json.get('enemy-rover.anims')); row = { model, harmful: () => harmfulPhase('rover', model.phase), onBlocked: () => model.deflect(model.x >= this.player.body.x ? 1 : -1) }; } else if (kind === 'crab') { - const model = new Crab(at, patrol); + const model = new Crab(at, patrol, scene.cache.json.get('enemy-crab.anims')); row = { model, harmful: () => harmfulPhase('crab', model.phase), attack: () => model.attack }; } else { - const model = new Drone(at, patrol); + const model = new Drone(at, patrol, scene.cache.json.get('enemy-drone.anims')); row = { model, harmful: () => harmfulPhase('drone', model.phase) }; } const model = row.model; diff --git a/game/src/player/player.ts b/game/src/player/player.ts index 9c9e9d8..19d05a9 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -36,7 +36,7 @@ export class Player { onHealthChanged: (health: number) => void = () => {}; onHazards: (boxes: Box[]) => boolean = () => false; onGuard: (damage: number, sourceX: number) => boolean = () => false; - private guardPush = new GuardPush(); + private guardPush: GuardPush; /** The dodge-dash (dodge.ts); `dodgeWas` is last frame's button, `ghost` the time to the next after-image. */ readonly dodge = new Dodge(); private dodgeWas = false; @@ -106,6 +106,7 @@ export class Player { scene.events.once('shutdown', () => scene.events.off('postupdate', settle)); this.keys = scene.input.keyboard!.addKeys('A,D,W,S,LEFT,RIGHT,UP,DOWN,SPACE,K,Z,J,X,L,C,SHIFT') as Record; this.physical = physicalKeys(scene); + this.guardPush = new GuardPush(scene.cache.json.get('chair-block.anims')); this.debug = scene.add.graphics().setDepth(10001); this.label = scene.add.text(20, 135, '', { fontFamily: 'monospace', fontSize: '18px', backgroundColor: '#17242d' }) .setScrollFactor(0).setDepth(10002).setVisible(false);