Skip to content

Commit c77d2a0

Browse files
committed
fix: stop clearing key edges for gameplay updates and add regression tests
1 parent 1b9e67d commit c77d2a0

4 files changed

Lines changed: 256 additions & 5 deletions

File tree

src/GameScene.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,6 @@ export class GameScene extends Phaser.Scene {
323323
this.updateHUD(snapshot, effects);
324324
this.renderDebugOverlay(snapshot);
325325
this.clearSpawnWipe();
326-
this.clearTransientKeyEdges();
327326
return;
328327
}
329328

@@ -358,7 +357,6 @@ export class GameScene extends Phaser.Scene {
358357
this.updateHUD(snapshot, effects);
359358
this.renderDebugOverlay(snapshot);
360359
this.clearSpawnWipe();
361-
this.clearTransientKeyEdges();
362360
return;
363361
}
364362

@@ -373,7 +371,6 @@ export class GameScene extends Phaser.Scene {
373371
this.updateHUD(snapshot, effects);
374372
this.renderDebugOverlay(snapshot);
375373
this.clearSpawnWipe();
376-
this.clearTransientKeyEdges();
377374
return;
378375
}
379376

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
UnpauseRecovery,
4+
UNPAUSE_CONTROL_RETURN_FRAME,
5+
UNPAUSE_INPUT_BUFFER_START_FRAME,
6+
UNPAUSE_REPAUSE_FRAME,
7+
} from "../../src/pause/unpauseRecovery.ts";
8+
9+
describe("Checklist pause buffering", () => {
10+
test("late-held pause and inputs follow the frame 0-11 pause-buffer table", () => {
11+
const recovery = new UnpauseRecovery();
12+
recovery.start({ pause: false, jump: false, dash: false, crouchDash: false });
13+
14+
for (let frame = 0; frame <= UNPAUSE_REPAUSE_FRAME; frame++) {
15+
const held = frame >= UNPAUSE_INPUT_BUFFER_START_FRAME;
16+
const result = recovery.step({
17+
pause: held,
18+
jump: held,
19+
dash: held,
20+
crouchDash: false,
21+
});
22+
23+
if (frame < UNPAUSE_INPUT_BUFFER_START_FRAME) {
24+
expect(result.blockGameplay).toBeTrue();
25+
expect(result.queueJump).toBeFalse();
26+
expect(result.queueDash).toBeFalse();
27+
expect(result.openPause).toBeFalse();
28+
} else if (frame < UNPAUSE_CONTROL_RETURN_FRAME) {
29+
expect(result.blockGameplay).toBeTrue();
30+
expect(result.queueJump).toBeFalse();
31+
expect(result.queueDash).toBeFalse();
32+
expect(result.openPause).toBeFalse();
33+
} else if (frame === UNPAUSE_CONTROL_RETURN_FRAME) {
34+
expect(result.blockGameplay).toBeFalse();
35+
expect(result.queueJump).toBeTrue();
36+
expect(result.queueDash).toBeTrue();
37+
expect(result.openPause).toBeFalse();
38+
} else {
39+
expect(result.blockGameplay).toBeTrue();
40+
expect(result.queueJump).toBeFalse();
41+
expect(result.queueDash).toBeFalse();
42+
expect(result.openPause).toBeTrue();
43+
}
44+
}
45+
});
46+
47+
test("pause held before frame 6 is not treated as a frame-11 repause buffer", () => {
48+
const recovery = new UnpauseRecovery();
49+
recovery.start({ pause: false, jump: false, dash: false, crouchDash: false });
50+
51+
for (let frame = 0; frame <= UNPAUSE_REPAUSE_FRAME; frame++) {
52+
const result = recovery.step({
53+
pause: frame >= UNPAUSE_INPUT_BUFFER_START_FRAME - 1,
54+
jump: false,
55+
dash: false,
56+
crouchDash: false,
57+
});
58+
59+
if (frame === UNPAUSE_REPAUSE_FRAME) {
60+
expect(result.blockGameplay).toBeFalse();
61+
expect(result.openPause).toBeFalse();
62+
}
63+
}
64+
});
65+
});
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { describe, expect, mock, test } from "bun:test";
2+
import { DEFAULT_KEY_BINDINGS, normalizeKeyBindings } from "../../src/input/keybindings.ts";
3+
import { PlayerControls } from "../../src/input/PlayerControls.ts";
4+
5+
type GameSceneConstructor = typeof import("../../src/GameScene.ts").GameScene;
6+
7+
const TEST_BINDINGS = normalizeKeyBindings({
8+
...DEFAULT_KEY_BINDINGS,
9+
jump: ["KeyJ"],
10+
dash: ["KeyK"],
11+
crouchDash: ["KeyL"],
12+
confirm: ["Enter"],
13+
cancel: ["Escape"],
14+
pause: ["KeyP"],
15+
});
16+
17+
let gameScenePromise: Promise<GameSceneConstructor> | null = null;
18+
19+
function phaserMock() {
20+
class Scene {}
21+
class Vector2 {
22+
constructor(
23+
public x = 0,
24+
public y = 0,
25+
) {}
26+
}
27+
28+
return {
29+
default: {
30+
Scene,
31+
WEBGL: "WEBGL",
32+
BlendModes: {
33+
ADD: "ADD",
34+
},
35+
Input: {
36+
Keyboard: {
37+
KeyCodes: {
38+
BACKTICK: 192,
39+
},
40+
},
41+
},
42+
Math: {
43+
Clamp: (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)),
44+
Linear: (from: number, to: number, t: number) => from + (to - from) * t,
45+
Vector2,
46+
Easing: {
47+
Cubic: {
48+
Out: (t: number) => t,
49+
},
50+
},
51+
},
52+
Scenes: {
53+
Events: {
54+
SHUTDOWN: "shutdown",
55+
},
56+
},
57+
},
58+
};
59+
}
60+
61+
async function loadGameScene(): Promise<GameSceneConstructor> {
62+
if (gameScenePromise === null) {
63+
mock.module("phaser", phaserMock);
64+
gameScenePromise = import("../../src/GameScene.ts").then((module) => module.GameScene);
65+
}
66+
67+
return gameScenePromise;
68+
}
69+
70+
async function createSceneHarness(): Promise<InstanceType<GameSceneConstructor> & Record<string, unknown>> {
71+
const GameScene = await loadGameScene();
72+
const scene = Object.create(GameScene.prototype) as InstanceType<GameSceneConstructor> & Record<string, unknown>;
73+
74+
scene.gameOptions = {
75+
keyboardBindings: TEST_BINDINGS,
76+
screenShakeEffects: true,
77+
dynamicHair: false,
78+
infiniteStamina: false,
79+
airDashes: "default",
80+
invincibility: false,
81+
};
82+
scene.controls = new PlayerControls();
83+
scene.heldKeyCodes = new Set<string>();
84+
scene.pressedKeyCodes = new Set<string>();
85+
scene.releasedKeyCodes = new Set<string>();
86+
scene.gameplayEdgesConsumed = false;
87+
scene.confirmBufferedFrames = 0;
88+
scene.pauseMenu = { isOpen: false };
89+
scene.unpauseRecovery = { active: false };
90+
scene.pauseOverlay = { hide() {} };
91+
scene.player = {
92+
inControl: true,
93+
timePaused: false,
94+
getSnapshot: () => ({}),
95+
};
96+
scene.playerView = {
97+
render() {},
98+
advanceDeathRespawn() {},
99+
};
100+
scene.cameras = { main: {} };
101+
scene.roomTransition = null;
102+
scene.deathRespawnSequence = null;
103+
scene.freezeTimer = 0;
104+
scene.forceCameraUpdate = false;
105+
scene.renderLighting = () => {};
106+
scene.updateHUD = () => {};
107+
scene.renderDebugOverlay = () => {};
108+
scene.renderSpawnWipe = () => {};
109+
scene.clearSpawnWipe = () => {};
110+
scene.updateRoomTransition = () => {};
111+
scene.updateDeathRespawnSequence = () => {};
112+
scene.updateCamera = () => {};
113+
scene.advancePlayerOnly = () => {};
114+
scene.renderPassiveFrame = () => {};
115+
return scene;
116+
}
117+
118+
function keyDown(code: string): KeyboardEvent {
119+
return {
120+
code,
121+
repeat: false,
122+
preventDefault() {},
123+
} as KeyboardEvent;
124+
}
125+
126+
describe("GameScene input edge lifecycle", () => {
127+
test("room transitions preserve gameplay press edges until the next fixed-step input gather", async () => {
128+
const scene = await createSceneHarness();
129+
130+
(scene.onKeyDown as (event: KeyboardEvent) => void)(keyDown("KeyJ"));
131+
scene.roomTransition = {};
132+
(scene.update as (time: number, delta: number) => void)(0, 16);
133+
134+
scene.roomTransition = null;
135+
const input = (scene.gatherStepInput as () => ReturnType<PlayerControls["update"]>)();
136+
expect(input.jump).toBeTrue();
137+
expect(input.jumpPressed).toBeTrue();
138+
139+
(scene.clearTransientKeyEdges as () => void)();
140+
const held = (scene.gatherStepInput as () => ReturnType<PlayerControls["update"]>)();
141+
expect(held.jump).toBeTrue();
142+
expect(held.jumpPressed).toBeFalse();
143+
});
144+
145+
test("freeze frames preserve dash press edges for the first resumed gameplay step", async () => {
146+
const scene = await createSceneHarness();
147+
148+
(scene.onKeyDown as (event: KeyboardEvent) => void)(keyDown("KeyK"));
149+
scene.freezeTimer = 0.05;
150+
(scene.update as (time: number, delta: number) => void)(0, 16);
151+
152+
scene.freezeTimer = 0;
153+
const input = (scene.gatherStepInput as () => ReturnType<PlayerControls["update"]>)();
154+
expect(input.dash).toBeTrue();
155+
expect(input.dashPressed).toBeTrue();
156+
});
157+
158+
test("player time-pause frames preserve gameplay press edges for the first resumed gameplay step", async () => {
159+
const scene = await createSceneHarness();
160+
const player = scene.player as { timePaused: boolean };
161+
162+
(scene.onKeyDown as (event: KeyboardEvent) => void)(keyDown("KeyJ"));
163+
player.timePaused = true;
164+
(scene.update as (time: number, delta: number) => void)(0, 16);
165+
166+
player.timePaused = false;
167+
const input = (scene.gatherStepInput as () => ReturnType<PlayerControls["update"]>)();
168+
expect(input.jump).toBeTrue();
169+
expect(input.jumpPressed).toBeTrue();
170+
});
171+
172+
test("unpause recovery consumes held input separately and clears raw press edges", async () => {
173+
const scene = await createSceneHarness();
174+
let sampledHeld: { jump: boolean } | null = null;
175+
176+
scene.unpauseRecovery = { active: true };
177+
scene.advanceUnpauseRecovery = function advanceUnpauseRecovery(this: Record<string, unknown>) {
178+
sampledHeld = (this.currentUnpauseRecoveryHeldState as () => { jump: boolean })();
179+
return true;
180+
};
181+
182+
(scene.onKeyDown as (event: KeyboardEvent) => void)(keyDown("KeyJ"));
183+
(scene.update as (time: number, delta: number) => void)(0, 16);
184+
185+
expect(sampledHeld?.jump).toBeTrue();
186+
expect((scene.actionHeld as (action: string) => boolean)("jump")).toBeTrue();
187+
expect((scene.actionPressed as (action: string) => boolean)("jump")).toBeFalse();
188+
});
189+
});

tests/model/unpause-recovery.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe("Unpause recovery", () => {
1111
const recovery = new UnpauseRecovery();
1212
recovery.start({ pause: true, jump: false, dash: false, crouchDash: false });
1313

14-
for (let frame = 0; frame < UNPAUSE_REPAUSE_FRAME; frame++) {
14+
for (let frame = 0; frame <= UNPAUSE_REPAUSE_FRAME; frame++) {
1515
const holdingPause = frame >= UNPAUSE_INPUT_BUFFER_START_FRAME;
1616
const result = recovery.step({
1717
pause: holdingPause,
@@ -37,7 +37,7 @@ describe("Unpause recovery", () => {
3737
const recovery = new UnpauseRecovery();
3838
recovery.start({ pause: false, jump: false, dash: false, crouchDash: false });
3939

40-
for (let frame = 0; frame < UNPAUSE_REPAUSE_FRAME; frame++) {
40+
for (let frame = 0; frame <= UNPAUSE_REPAUSE_FRAME; frame++) {
4141
const result = recovery.step({
4242
pause: frame >= UNPAUSE_INPUT_BUFFER_START_FRAME - 1,
4343
jump: false,

0 commit comments

Comments
 (0)