Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions game/src/combat/chair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, Damageable>();
private key: Phaser.Input.Keyboard.Key;
/** The second attack bind (playtest 24 Oct): X, for players on the arrow keys. */
Expand All @@ -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');
Expand Down
19 changes: 19 additions & 0 deletions game/src/combat/clips.ts
Original file line number Diff line number Diff line change
@@ -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<AnimationTable>;
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));
26 changes: 17 additions & 9 deletions game/src/combat/guard.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand All @@ -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');
Expand All @@ -41,20 +47,22 @@ 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')}`;
}
}

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;
}
}
15 changes: 0 additions & 15 deletions game/src/combat/life-visuals.ts
Original file line number Diff line number Diff line change
@@ -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++) {
Expand Down
11 changes: 7 additions & 4 deletions game/src/combat/lowSweep.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
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).
*/
export const LOW_CONTACT = { inner: 12, reach: 75, top: 51, bottom: 0 } as const; // 30 % past the drawn chair, as the side swing
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;
frame = 0;
direction: -1 | 1 = 1;
private elapsed = 0;
private struck = new Set<string>();
/** `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();
Expand All @@ -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();
}
}
Expand Down
17 changes: 11 additions & 6 deletions game/src/combat/overhead.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +19,8 @@ export class Overhead {
private elapsed = 0;
private queued?: { kind: 'up' | 'slam'; direction: -1 | 1 };
private struck = new Set<string>();
/** `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;
Expand All @@ -27,19 +30,21 @@ 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);
}
}
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')}`;
Expand All @@ -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);
}
Expand Down
10 changes: 7 additions & 3 deletions game/src/combat/sweeps.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -32,6 +34,8 @@ export class Sweeps {
private elapsed = 0;
private queued: -1 | 1 | null = null;
private struck = new Set<string>();
/** `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();
Expand All @@ -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;
Expand Down
19 changes: 12 additions & 7 deletions game/src/enemies/crab.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading