Skip to content
Closed
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
2 changes: 1 addition & 1 deletion art/sprites/arena-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion art/sprites/boards.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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
Loading
Loading