Skip to content

Commit 8121bbf

Browse files
committed
feat: implement pause menu functionality with options and screen shake settings
1 parent 4a52e8b commit 8121bbf

7 files changed

Lines changed: 738 additions & 5 deletions

File tree

src/GameScene.ts

Lines changed: 176 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@ import {
1212
type RoomDirection,
1313
} from "./level";
1414
import { PlayerControls } from "./input/PlayerControls";
15+
import { loadGameOptions, saveGameOptions, type GameOptions } from "./options";
16+
import {
17+
currentPauseOptionValue,
18+
PauseMenuChoice,
19+
PauseMenuController,
20+
type PauseActionMenu,
21+
type PauseMenuOption,
22+
type PauseOptionsMenu,
23+
} from "./pause/menu";
1524
import { clampRespawnSource, type PlayerIntroType } from "./player/intro";
1625
import { addFloat, approach, maxFloat, stepTimer, subFloat, toFloat } from "./player/math";
1726
import { Player } from "./player/Player";
1827
import { InputState, PlayerEffect } from "./player/types";
28+
import { PauseOverlay } from "./view/PauseOverlay";
1929
import { PlayerView } from "./view/PlayerView";
2030
import {
2131
baseTransitionDuration,
@@ -99,6 +109,10 @@ const EMPTY_INPUT: InputState = {
99109
dashPressed: false,
100110
grab: false,
101111
};
112+
const ON_OFF_CHOICES: readonly PauseMenuChoice<boolean>[] = [
113+
{ label: "OFF", value: false },
114+
{ label: "ON", value: true },
115+
];
102116

103117
export class GameScene extends Phaser.Scene {
104118
private player!: Player;
@@ -114,6 +128,9 @@ export class GameScene extends Phaser.Scene {
114128
private keys!: Record<string, Phaser.Input.Keyboard.Key>;
115129
private controls!: PlayerControls;
116130
private confirmBufferedFrames = 0;
131+
private readonly pauseMenu = new PauseMenuController();
132+
private pauseOverlay!: PauseOverlay;
133+
private gameOptions: GameOptions = loadGameOptions();
117134

118135
private accumulator = 0;
119136
private readonly fixedDt = toFloat(1 / 60);
@@ -190,6 +207,8 @@ export class GameScene extends Phaser.Scene {
190207
this.keys.jump.on("down", this.onJumpDown, this);
191208
this.keys.jump.on("up", this.onJumpUp, this);
192209
this.keys.dash.on("down", this.onDashDown, this);
210+
this.keys.pause = kb.addKey(Phaser.Input.Keyboard.KeyCodes.ESC);
211+
this.keys.pause.on("down", this.onPauseDown, this);
193212

194213
this.hudText = this.add
195214
.text(8, 8, "", {
@@ -206,7 +225,7 @@ export class GameScene extends Phaser.Scene {
206225
.text(
207226
VIEWPORT.width - 8,
208227
VIEWPORT.height - 8,
209-
"Move: arrow keys\nC jump X dash Z grab R reset",
228+
"Move: arrow keys\nC jump X dash Z grab R reset Esc pause",
210229
{
211230
fontFamily: "monospace",
212231
fontSize: "9px",
@@ -220,6 +239,7 @@ export class GameScene extends Phaser.Scene {
220239
.setDepth(10)
221240
.setScrollFactor(0);
222241

242+
this.pauseOverlay = new PauseOverlay(this);
223243
this.spawnInitialPlayer();
224244
const snapshot = this.player.getSnapshot();
225245
this.playerView.render(snapshot);
@@ -233,6 +253,24 @@ export class GameScene extends Phaser.Scene {
233253
this.confirmBufferedFrames--;
234254
}
235255

256+
if (this.pauseMenu.isOpen) {
257+
this.updatePauseInput();
258+
const snapshot = this.player.getSnapshot();
259+
this.playerView.render(snapshot);
260+
this.renderLighting(snapshot);
261+
this.updateHUD(snapshot, []);
262+
this.renderSpawnWipe();
263+
const current = this.pauseMenu.current;
264+
if (current) {
265+
this.pauseOverlay.render(current);
266+
} else {
267+
this.pauseOverlay.hide();
268+
}
269+
return;
270+
}
271+
272+
this.pauseOverlay.hide();
273+
236274
if (this.keys.restart.isDown && this.deathRespawnSequence === null && this.player.canRetry) {
237275
this.beginNormalRespawn();
238276
}
@@ -407,17 +445,27 @@ export class GameScene extends Phaser.Scene {
407445
addCameraImpulse(x: number, y: number): void {
408446
if (x === 0 && y === 0) return;
409447
const intensity = Phaser.Math.Clamp(Math.hypot(x, y) * 0.00045, 0.0006, 0.0018);
410-
this.cameras.main.shake(45, intensity);
448+
this.requestScreenShake(45, intensity);
449+
}
450+
451+
requestScreenShake(durationMs: number, intensity: number): void {
452+
if (!this.gameOptions.screenShakeEffects) {
453+
return;
454+
}
455+
456+
this.cameras.main.shake(durationMs, intensity);
411457
}
412458

413459
shutdown(): void {
414460
if (this.keys) {
415461
this.keys.jump.off("down", this.onJumpDown, this);
416462
this.keys.jump.off("up", this.onJumpUp, this);
417463
this.keys.dash.off("down", this.onDashDown, this);
464+
this.keys.pause?.off("down", this.onPauseDown, this);
418465
}
419466
this.controls?.reset();
420467
this.playerView?.destroy();
468+
this.pauseOverlay?.destroy();
421469
this.spawnWipe?.destroy();
422470
this.refillEmitter?.destroy();
423471
this.lighting?.destroy();
@@ -447,6 +495,11 @@ export class GameScene extends Phaser.Scene {
447495

448496
private onJumpDown(event: KeyboardEvent): void {
449497
if (event.repeat) return;
498+
if (this.pauseMenu.isOpen) {
499+
this.pauseMenu.confirm();
500+
this.afterPauseMenuInteraction();
501+
return;
502+
}
450503
this.confirmBufferedFrames = 2;
451504
if (this.deathRespawnSequence !== null) {
452505
this.requestDeathRespawnSkip();
@@ -464,12 +517,28 @@ export class GameScene extends Phaser.Scene {
464517

465518
private onDashDown(event: KeyboardEvent): void {
466519
if (event.repeat) return;
520+
if (this.pauseMenu.isOpen) {
521+
this.pauseMenu.cancel();
522+
this.afterPauseMenuInteraction();
523+
return;
524+
}
467525
if (!this.player.inControl) {
468526
return;
469527
}
470528
this.controls.queuePress("dash");
471529
}
472530

531+
private onPauseDown(event: KeyboardEvent): void {
532+
if (event.repeat) return;
533+
if (this.pauseMenu.isOpen) {
534+
this.pauseMenu.cancel();
535+
this.afterPauseMenuInteraction();
536+
return;
537+
}
538+
539+
this.openPauseMenu();
540+
}
541+
473542
private spawnInitialPlayer(): void {
474543
this.revivePlayerAtCheckpoint("none");
475544
}
@@ -609,7 +678,7 @@ export class GameScene extends Phaser.Scene {
609678
this.playerView.startDeath(snapshot);
610679
}
611680
if (transition.kind === "spike") {
612-
this.cameras.main.shake(120, 0.0026);
681+
this.requestScreenShake(120, 0.0026);
613682
}
614683
transition.exploded = true;
615684
}
@@ -1167,7 +1236,7 @@ export class GameScene extends Phaser.Scene {
11671236
for (const refill of consumed) {
11681237
this.refillEmitter.setParticleTint(this.refillColor(refill.type));
11691238
this.refillEmitter.emitParticleAt(refill.x, refill.visualY, 7);
1170-
this.cameras.main.shake(40, 0.0012);
1239+
this.requestScreenShake(40, 0.0012);
11711240
}
11721241

11731242
this.syncRefillViews();
@@ -1242,4 +1311,107 @@ export class GameScene extends Phaser.Scene {
12421311

12431312
this.hudText.setText(lines.join("\n"));
12441313
}
1314+
1315+
private openPauseMenu(): void {
1316+
this.controls.clearTransientState();
1317+
this.stopScreenShake();
1318+
this.refillEmitter.pause();
1319+
this.playerView.pauseEffects();
1320+
this.pauseMenu.open(this.createPauseRootMenu());
1321+
}
1322+
1323+
private createPauseRootMenu(): PauseActionMenu {
1324+
return {
1325+
kind: "action",
1326+
title: "PAUSED",
1327+
selectedIndex: 0,
1328+
onCancel: (controller) => {
1329+
controller.close();
1330+
},
1331+
items: [
1332+
{
1333+
label: "Resume",
1334+
activate: (controller) => {
1335+
controller.close();
1336+
},
1337+
},
1338+
{
1339+
label: "Retry",
1340+
activate: (controller) => {
1341+
controller.close();
1342+
this.retryFromPause();
1343+
},
1344+
},
1345+
{
1346+
label: "Options",
1347+
activate: (controller) => {
1348+
controller.push(this.createOptionsMenu());
1349+
},
1350+
},
1351+
],
1352+
};
1353+
}
1354+
1355+
private createOptionsMenu(): PauseOptionsMenu {
1356+
const screenShakeOption: PauseMenuOption<boolean> = {
1357+
label: "Screen Shake Effects",
1358+
values: ON_OFF_CHOICES,
1359+
valueIndex: this.gameOptions.screenShakeEffects ? 1 : 0,
1360+
};
1361+
1362+
const draft: PauseOptionsMenu = {
1363+
kind: "options",
1364+
title: "OPTIONS",
1365+
selectedIndex: 0,
1366+
onCancel: (controller) => {
1367+
const screenShakeEffects = currentPauseOptionValue(screenShakeOption);
1368+
this.gameOptions = saveGameOptions({
1369+
...this.gameOptions,
1370+
screenShakeEffects: screenShakeEffects ?? this.gameOptions.screenShakeEffects,
1371+
});
1372+
if (!this.gameOptions.screenShakeEffects) {
1373+
this.stopScreenShake();
1374+
}
1375+
controller.pop();
1376+
},
1377+
items: [screenShakeOption],
1378+
};
1379+
return draft;
1380+
}
1381+
1382+
private updatePauseInput(): void {
1383+
if (Phaser.Input.Keyboard.JustDown(this.keys.up)) {
1384+
this.pauseMenu.moveVertical(-1);
1385+
}
1386+
if (Phaser.Input.Keyboard.JustDown(this.keys.down)) {
1387+
this.pauseMenu.moveVertical(1);
1388+
}
1389+
if (Phaser.Input.Keyboard.JustDown(this.keys.left)) {
1390+
this.pauseMenu.moveHorizontal(-1);
1391+
}
1392+
if (Phaser.Input.Keyboard.JustDown(this.keys.right)) {
1393+
this.pauseMenu.moveHorizontal(1);
1394+
}
1395+
}
1396+
1397+
private afterPauseMenuInteraction(): void {
1398+
this.controls.clearTransientState();
1399+
if (!this.pauseMenu.isOpen) {
1400+
this.refillEmitter.resume();
1401+
this.playerView.resumeEffects();
1402+
this.pauseOverlay.hide();
1403+
}
1404+
}
1405+
1406+
private retryFromPause(): void {
1407+
if (this.deathRespawnSequence !== null || !this.player.canRetry) {
1408+
return;
1409+
}
1410+
1411+
this.beginNormalRespawn();
1412+
}
1413+
1414+
private stopScreenShake(): void {
1415+
this.cameras.main.resetFX();
1416+
}
12451417
}

src/options.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
export interface StorageLike {
2+
getItem(key: string): string | null;
3+
setItem(key: string, value: string): void;
4+
}
5+
6+
export interface GameOptions {
7+
screenShakeEffects: boolean;
8+
}
9+
10+
export const GAME_OPTIONS_STORAGE_KEY = "celeste-lite.options";
11+
12+
export const DEFAULT_GAME_OPTIONS: Readonly<GameOptions> = Object.freeze({
13+
screenShakeEffects: true,
14+
});
15+
16+
const memoryStorage = new Map<string, string>();
17+
18+
function fallbackStorage(): StorageLike {
19+
return {
20+
getItem(key: string): string | null {
21+
return memoryStorage.get(key) ?? null;
22+
},
23+
setItem(key: string, value: string): void {
24+
memoryStorage.set(key, value);
25+
},
26+
};
27+
}
28+
29+
function resolveStorage(storage?: StorageLike): StorageLike {
30+
if (storage) {
31+
return storage;
32+
}
33+
34+
const globalStorage = (globalThis as { localStorage?: StorageLike }).localStorage;
35+
if (
36+
globalStorage &&
37+
typeof globalStorage.getItem === "function" &&
38+
typeof globalStorage.setItem === "function"
39+
) {
40+
return globalStorage;
41+
}
42+
43+
return fallbackStorage();
44+
}
45+
46+
function normalizeGameOptions(value: Partial<GameOptions> | null | undefined): GameOptions {
47+
return {
48+
screenShakeEffects: typeof value?.screenShakeEffects === "boolean"
49+
? value.screenShakeEffects
50+
: DEFAULT_GAME_OPTIONS.screenShakeEffects,
51+
};
52+
}
53+
54+
export function loadGameOptions(storage?: StorageLike): GameOptions {
55+
const raw = resolveStorage(storage).getItem(GAME_OPTIONS_STORAGE_KEY);
56+
if (raw === null) {
57+
return normalizeGameOptions(undefined);
58+
}
59+
60+
try {
61+
const parsed = JSON.parse(raw) as Partial<GameOptions>;
62+
return normalizeGameOptions(parsed);
63+
} catch {
64+
return normalizeGameOptions(undefined);
65+
}
66+
}
67+
68+
export function saveGameOptions(options: GameOptions, storage?: StorageLike): GameOptions {
69+
const normalized = normalizeGameOptions(options);
70+
resolveStorage(storage).setItem(GAME_OPTIONS_STORAGE_KEY, JSON.stringify(normalized));
71+
return normalized;
72+
}

0 commit comments

Comments
 (0)