diff --git a/art/sprites/arena-platform.md b/art/sprites/arena-platform.md index cb716f1..b838b24 100644 --- a/art/sprites/arena-platform.md +++ b/art/sprites/arena-platform.md @@ -15,7 +15,7 @@ On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground sh ## Inputs - [coffee-machine-preview.png](images/coffee-machine-preview.png) -- [girder-ref.png](../../tmp/girder-ref.png) +- [girder-reference.png](../stages/rusty-creek/arena/girder-reference.png) - [style-master-preview.png](../direction/images/style-master-preview.png) Model `gpt-image-2.5-sunburst` · pixel factor 2 · source image `images/arena-platform.png` · rebuild: `python scripts/make_boards.py draw arena-platform --redo --config art/sprites/boards.json` diff --git a/art/sprites/boards.json b/art/sprites/boards.json index e95017f..27b0a3a 100644 --- a/art/sprites/boards.json +++ b/art/sprites/boards.json @@ -1188,7 +1188,7 @@ "factor": 2, "inputs": [ "repo:art/sprites/images/coffee-machine-preview.png", - "repo:tmp/girder-ref.png", + "repo:art/stages/rusty-creek/arena/girder-reference.png", "repo:art/direction/images/style-master-preview.png" ], "prompt": "On a flat, solid, pure magenta #FF00FF background (no paper colour, no ground shadows, no dust under the objects), with NO text at all - no titles, no captions, no frame numbers, no labels, no readable words or logos: Image 1 is an approved prop sheet from this game: draw in exactly that pixel-art look - solid three-dimensional objects drawn as detailed 2D pixel art with volume, the same pixel grain, palette and warm rim light. Image 2 shows the rusty steel girders of the level these props hang in: copy their material exactly - pale blue-grey painted steel eaten by orange rust, riveted flanges, a dark grated underside. Prop sprite sheet, 2 rows. Row 1, exactly 1 frame: ONE short girder PLATFORM segment hung like a stage rigging bar, seen from slightly below like image 2: a long flat box beam about FIVE times as long as it is tall, its front face with rivets, a lit top flange, a dark grated underside with depth, solid end caps showing its thickness; on top, near each end, a heavy steel eye-bolt with the lowest three links of a rusted chain rising straight up from it. Row 2, exactly 1 frame: ONE long straight vertical rusted steel CHAIN hanging perfectly straight up and down, many identical links, about as long as the platform in row 1, the same chain as on the platform. Both rows at the SAME pixel scale. Rows separated by a wide empty magenta band. No people, no scenery, no sky.", diff --git a/art/stages/rusty-creek/arena/girder-reference.png b/art/stages/rusty-creek/arena/girder-reference.png new file mode 100644 index 0000000..644ee24 Binary files /dev/null and b/art/stages/rusty-creek/arena/girder-reference.png differ 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 7b4bb2a..c4c5d91 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; @@ -102,6 +102,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);