|
| 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 | +}); |
0 commit comments