Skip to content

Commit e5cc43e

Browse files
committed
feat: block pause menu and retry under certain conditions
1 parent cc0c11b commit e5cc43e

5 files changed

Lines changed: 151 additions & 12 deletions

File tree

src/GameScene.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -273,8 +273,12 @@ export class GameScene extends Phaser.Scene {
273273
}
274274

275275
if (!this.pauseMenu.isOpen && !this.unpauseRecovery.active && this.actionPressed("pause")) {
276-
this.openPauseMenu();
277-
this.clearTransientKeyEdges();
276+
if (this.canOpenPauseMenu()) {
277+
this.openPauseMenu();
278+
this.clearTransientKeyEdges();
279+
} else {
280+
this.clearActionTransientKeyEdges("pause");
281+
}
278282
}
279283

280284
if (this.pauseMenu.isOpen) {
@@ -653,6 +657,13 @@ export class GameScene extends Phaser.Scene {
653657
this.gameplayEdgesConsumed = false;
654658
}
655659

660+
private clearActionTransientKeyEdges(action: KeyBindingAction): void {
661+
for (const code of this.bindingCodes(action)) {
662+
this.pressedKeyCodes.delete(code);
663+
this.releasedKeyCodes.delete(code);
664+
}
665+
}
666+
656667
private spawnInitialPlayer(): void {
657668
this.revivePlayerAtCheckpoint("none");
658669
}
@@ -1564,7 +1575,17 @@ export class GameScene extends Phaser.Scene {
15641575
this.pauseMenu.open(this.createPauseRootMenu());
15651576
}
15661577

1578+
private canOpenPauseMenu(): boolean {
1579+
const snapshot = this.player.getSnapshot();
1580+
if (this.roomTransition !== null || snapshot.dead) {
1581+
return false;
1582+
}
1583+
1584+
return this.deathRespawnSequence === null || this.deathRespawnSequence.respawnStarted;
1585+
}
1586+
15671587
private createPauseRootMenu(): PauseActionMenu {
1588+
const canRetry = this.canRetryFromPause();
15681589
return {
15691590
kind: "action",
15701591
title: "PAUSED",
@@ -1581,6 +1602,7 @@ export class GameScene extends Phaser.Scene {
15811602
},
15821603
{
15831604
label: "Retry",
1605+
disabled: !canRetry,
15841606
activate: () => {
15851607
this.closePauseMenuImmediately();
15861608
this.retryFromPause();
@@ -1834,13 +1856,17 @@ export class GameScene extends Phaser.Scene {
18341856
}
18351857

18361858
private retryFromPause(): void {
1837-
if (this.deathRespawnSequence !== null || !this.player.canRetry) {
1859+
if (!this.canRetryFromPause()) {
18381860
return;
18391861
}
18401862

18411863
this.beginNormalRespawn();
18421864
}
18431865

1866+
private canRetryFromPause(): boolean {
1867+
return this.deathRespawnSequence === null && this.player.canRetry;
1868+
}
1869+
18441870
private stopScreenShake(): void {
18451871
this.cameras.main.resetFX();
18461872
}
@@ -1866,7 +1892,13 @@ export class GameScene extends Phaser.Scene {
18661892

18671893
if (result.openPause) {
18681894
this.accumulator = 0;
1869-
this.openPauseMenu();
1895+
if (this.canOpenPauseMenu()) {
1896+
this.openPauseMenu();
1897+
} else {
1898+
this.clearActionTransientKeyEdges("pause");
1899+
this.resumePauseManagedEffects();
1900+
return false;
1901+
}
18701902
return true;
18711903
}
18721904

src/pause/menu.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface PauseMenuOption<T = unknown> {
1313
export interface PauseMenuSubmenuItem {
1414
kind: "submenu";
1515
label: string;
16+
disabled?: boolean;
1617
activate: (controller: PauseMenuController) => void;
1718
}
1819

@@ -26,6 +27,7 @@ export interface PauseMenuKeyBindingItem<TAction = unknown> {
2627
export interface PauseMenuCommandItem {
2728
kind: "command";
2829
label: string;
30+
disabled?: boolean;
2931
activate: (controller: PauseMenuController) => void;
3032
}
3133

@@ -37,6 +39,7 @@ export type PauseMenuOptionItem<T = unknown> =
3739

3840
export interface PauseMenuItem {
3941
label: string;
42+
disabled?: boolean;
4043
activate: (controller: PauseMenuController) => void;
4144
}
4245

@@ -74,6 +77,10 @@ export function isPauseCommandItem(item: PauseMenuOptionItem<unknown>): item is
7477
return item.kind === "command";
7578
}
7679

80+
export function isPauseMenuItemDisabled(item: { disabled?: boolean }): boolean {
81+
return item.disabled === true;
82+
}
83+
7784
export function currentPauseOptionValue<T>(option: PauseMenuOption<T>): T | null {
7885
return option.values[option.valueIndex]?.value ?? null;
7986
}
@@ -161,7 +168,7 @@ export class PauseMenuController {
161168

162169
if (screen.kind === "action") {
163170
const item = screen.items[screen.selectedIndex];
164-
if (!item) {
171+
if (!item || isPauseMenuItemDisabled(item)) {
165172
return;
166173
}
167174

@@ -174,7 +181,7 @@ export class PauseMenuController {
174181
return;
175182
}
176183

177-
if (isPauseSubmenuItem(item) || isPauseCommandItem(item)) {
184+
if ((isPauseSubmenuItem(item) || isPauseCommandItem(item)) && !isPauseMenuItemDisabled(item)) {
178185
item.activate(this);
179186
}
180187
}

src/view/PauseOverlay.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isPauseChoiceOption,
66
isPauseCommandItem,
77
isPauseKeyBindingItem,
8+
isPauseMenuItemDisabled,
89
isPauseSubmenuItem,
910
type PauseMenuScreen,
1011
} from "../pause/menu";
@@ -103,12 +104,12 @@ export class PauseOverlay {
103104
const rowVisible = !scrollable ||
104105
(y >= rowStartY - rowHeight && y <= rowStartY + visibleRowsHeight);
105106
const selected = i === screen.selectedIndex;
106-
const labelColor = selected ? COLOR_SELECTED : COLOR_TEXT;
107-
108-
row.label.setVisible(rowVisible).setText(screen.items[i].label).setColor(labelColor);
109-
row.label.setY(y);
110107

111108
if (screen.kind === "action") {
109+
const item = screen.items[i];
110+
const labelColor = isPauseMenuItemDisabled(item) ? COLOR_DISABLED : selected ? COLOR_SELECTED : COLOR_TEXT;
111+
row.label.setVisible(rowVisible).setText(item.label).setColor(labelColor);
112+
row.label.setY(y);
112113
row.label
113114
.setX(centerX)
114115
.setOrigin(0.5, 0)
@@ -120,7 +121,11 @@ export class PauseOverlay {
120121
}
121122

122123
const item = screen.items[i];
123-
const valueColor = selected ? COLOR_SELECTED : COLOR_TEXT;
124+
const disabled = (isPauseSubmenuItem(item) || isPauseCommandItem(item)) && isPauseMenuItemDisabled(item);
125+
const labelColor = disabled ? COLOR_DISABLED : selected ? COLOR_SELECTED : COLOR_TEXT;
126+
const valueColor = disabled ? COLOR_DISABLED : selected ? COLOR_SELECTED : COLOR_TEXT;
127+
row.label.setVisible(rowVisible).setText(item.label).setColor(labelColor);
128+
row.label.setY(y);
124129
if (!rowVisible) {
125130
row.leftArrow.setVisible(false);
126131
row.value.setVisible(false);

tests/model/game-scene-input-lifecycle.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ async function createSceneHarness(): Promise<InstanceType<GameSceneConstructor>
9191
scene.player = {
9292
inControl: true,
9393
timePaused: false,
94-
getSnapshot: () => ({}),
94+
canRetry: true,
95+
getSnapshot: () => ({ dead: false, state: "normal" }),
9596
};
9697
scene.playerView = {
9798
render() {},
@@ -142,6 +143,27 @@ describe("GameScene input edge lifecycle", () => {
142143
expect(held.jumpPressed).toBeFalse();
143144
});
144145

146+
test("pause pressed during a room transition is consumed without opening the pause menu", async () => {
147+
const scene = await createSceneHarness();
148+
let openedPause = false;
149+
scene.openPauseMenu = () => {
150+
openedPause = true;
151+
};
152+
153+
(scene.onKeyDown as (event: KeyboardEvent) => void)(keyDown("KeyP"));
154+
(scene.onKeyDown as (event: KeyboardEvent) => void)(keyDown("KeyJ"));
155+
scene.roomTransition = {};
156+
(scene.update as (time: number, delta: number) => void)(0, 16);
157+
158+
expect(openedPause).toBeFalse();
159+
expect((scene.actionPressed as (action: string) => boolean)("pause")).toBeFalse();
160+
161+
scene.roomTransition = null;
162+
const input = (scene.gatherStepInput as () => ReturnType<PlayerControls["update"]>)();
163+
expect(input.jump).toBeTrue();
164+
expect(input.jumpPressed).toBeTrue();
165+
});
166+
145167
test("freeze frames preserve dash press edges for the first resumed gameplay step", async () => {
146168
const scene = await createSceneHarness();
147169

@@ -169,6 +191,50 @@ describe("GameScene input edge lifecycle", () => {
169191
expect(input.jumpPressed).toBeTrue();
170192
});
171193

194+
test("pause stays blocked through death and wipe until respawn starts", async () => {
195+
const scene = await createSceneHarness();
196+
let openedPause = false;
197+
scene.openPauseMenu = () => {
198+
openedPause = true;
199+
};
200+
201+
(scene.onKeyDown as (event: KeyboardEvent) => void)(keyDown("KeyP"));
202+
scene.deathRespawnSequence = {
203+
revealStarted: false,
204+
respawnStarted: false,
205+
};
206+
(scene.update as (time: number, delta: number) => void)(0, 16);
207+
208+
expect(openedPause).toBeFalse();
209+
expect((scene.actionPressed as (action: string) => boolean)("pause")).toBeFalse();
210+
211+
const player = scene.player as {
212+
timePaused: boolean;
213+
getSnapshot: () => { dead: boolean; state: string };
214+
};
215+
scene.deathRespawnSequence = {
216+
revealStarted: true,
217+
respawnStarted: true,
218+
};
219+
player.timePaused = true;
220+
player.getSnapshot = () => ({ dead: false, state: "intro_respawn" });
221+
222+
(scene.onKeyDown as (event: KeyboardEvent) => void)(keyDown("KeyP"));
223+
(scene.update as (time: number, delta: number) => void)(0, 16);
224+
225+
expect(openedPause).toBeTrue();
226+
expect((scene.actionPressed as (action: string) => boolean)("pause")).toBeFalse();
227+
});
228+
229+
test("pause root disables retry when the player cannot retry", async () => {
230+
const scene = await createSceneHarness();
231+
const player = scene.player as { canRetry: boolean };
232+
player.canRetry = false;
233+
234+
const root = (scene.createPauseRootMenu as () => { items: Array<{ label: string; disabled?: boolean }> })();
235+
expect(root.items.find((item) => item.label === "Retry")?.disabled).toBeTrue();
236+
});
237+
172238
test("unpause recovery consumes held input separately and clears raw press edges", async () => {
173239
const scene = await createSceneHarness();
174240
let sampledHeld: { jump: boolean } | null = null;

tests/model/pause-menu.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,33 @@ describe("Pause menu controller", () => {
148148
controller.confirm();
149149
expect(commandRan).toBeTrue();
150150
});
151+
152+
test("disabled action menu items render in place but ignore confirm", () => {
153+
const controller = new PauseMenuController();
154+
let retried = false;
155+
156+
const root: PauseActionMenu = {
157+
kind: "action",
158+
title: "PAUSED",
159+
selectedIndex: 1,
160+
onCancel: (menu) => menu.close(),
161+
items: [
162+
{ label: "Resume", activate: () => {} },
163+
{
164+
label: "Retry",
165+
disabled: true,
166+
activate: () => {
167+
retried = true;
168+
},
169+
},
170+
{ label: "Options", activate: () => {} },
171+
],
172+
};
173+
174+
controller.open(root);
175+
controller.confirm();
176+
177+
expect(retried).toBeFalse();
178+
expect(controller.current?.selectedIndex).toBe(1);
179+
});
151180
});

0 commit comments

Comments
 (0)