Skip to content

Commit e03cdcb

Browse files
committed
feat: implement pause buffering with unpause recovery model
1 parent 8121bbf commit e03cdcb

4 files changed

Lines changed: 350 additions & 11 deletions

File tree

docs/tech-checklist.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ All items in this section are currently `EXCLUDED` because required entities/sys
135135
| Cutscene Warps | EXCLUDED | No cutscene state machine |
136136
| Half Stamina Climbing | EXCLUDED | Requires a tighter audit of wallboost/climbjump timing than the local reference snapshot currently supports |
137137
| Kermit Dash | EXCLUDED | Requires transition cancellation behavior |
138-
| Pause Buffering | EXCLUDED | No pause system exists in this project yet |
138+
| Pause Buffering | WORKS | Pause/unpause includes a Celeste-style 10-frame recovery, 6-frame late hold window, and frame-11 repause path |
139139
| Roboboost | EXCLUDED | Requires moving blocks and advanced interactions |
140140
| Screen Transition Cassette Offset | EXCLUDED | No cassette/screen transition system |
141141
| Spinner Stunning | EXCLUDED | No spinner entity |

src/GameScene.ts

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type PauseMenuOption,
2222
type PauseOptionsMenu,
2323
} from "./pause/menu";
24+
import { UnpauseRecovery } from "./pause/unpauseRecovery";
2425
import { clampRespawnSource, type PlayerIntroType } from "./player/intro";
2526
import { addFloat, approach, maxFloat, stepTimer, subFloat, toFloat } from "./player/math";
2627
import { Player } from "./player/Player";
@@ -129,6 +130,7 @@ export class GameScene extends Phaser.Scene {
129130
private controls!: PlayerControls;
130131
private confirmBufferedFrames = 0;
131132
private readonly pauseMenu = new PauseMenuController();
133+
private readonly unpauseRecovery = new UnpauseRecovery();
132134
private pauseOverlay!: PauseOverlay;
133135
private gameOptions: GameOptions = loadGameOptions();
134136

@@ -271,6 +273,15 @@ export class GameScene extends Phaser.Scene {
271273

272274
this.pauseOverlay.hide();
273275

276+
let accumulatorPrimed = false;
277+
if (this.unpauseRecovery.active) {
278+
accumulatorPrimed = true;
279+
if (this.advanceUnpauseRecovery(rawFrameDt)) {
280+
this.renderPassiveFrame();
281+
return;
282+
}
283+
}
284+
274285
if (this.keys.restart.isDown && this.deathRespawnSequence === null && this.player.canRetry) {
275286
this.beginNormalRespawn();
276287
}
@@ -331,7 +342,9 @@ export class GameScene extends Phaser.Scene {
331342
return;
332343
}
333344

334-
this.accumulator = addFloat(this.accumulator, rawFrameDt);
345+
if (!accumulatorPrimed) {
346+
this.accumulator = addFloat(this.accumulator, rawFrameDt);
347+
}
335348
let steps = 0;
336349

337350
while (this.accumulator >= this.fixedDt && steps < this.maxSteps) {
@@ -464,6 +477,7 @@ export class GameScene extends Phaser.Scene {
464477
this.keys.pause?.off("down", this.onPauseDown, this);
465478
}
466479
this.controls?.reset();
480+
this.unpauseRecovery.clear();
467481
this.playerView?.destroy();
468482
this.pauseOverlay?.destroy();
469483
this.spawnWipe?.destroy();
@@ -477,7 +491,7 @@ export class GameScene extends Phaser.Scene {
477491
}
478492

479493
private gatherStepInput(): InputState {
480-
if (!this.player.inControl) {
494+
if (!this.player.inControl || this.unpauseRecovery.blocksControl) {
481495
this.controls.clearTransientState();
482496
return EMPTY_INPUT;
483497
}
@@ -500,6 +514,9 @@ export class GameScene extends Phaser.Scene {
500514
this.afterPauseMenuInteraction();
501515
return;
502516
}
517+
if (this.unpauseRecovery.blocksControl) {
518+
return;
519+
}
503520
this.confirmBufferedFrames = 2;
504521
if (this.deathRespawnSequence !== null) {
505522
this.requestDeathRespawnSkip();
@@ -522,6 +539,9 @@ export class GameScene extends Phaser.Scene {
522539
this.afterPauseMenuInteraction();
523540
return;
524541
}
542+
if (this.unpauseRecovery.blocksControl) {
543+
return;
544+
}
525545
if (!this.player.inControl) {
526546
return;
527547
}
@@ -535,6 +555,9 @@ export class GameScene extends Phaser.Scene {
535555
this.afterPauseMenuInteraction();
536556
return;
537557
}
558+
if (this.unpauseRecovery.active) {
559+
return;
560+
}
538561

539562
this.openPauseMenu();
540563
}
@@ -1313,6 +1336,7 @@ export class GameScene extends Phaser.Scene {
13131336
}
13141337

13151338
private openPauseMenu(): void {
1339+
this.unpauseRecovery.clear();
13161340
this.controls.clearTransientState();
13171341
this.stopScreenShake();
13181342
this.refillEmitter.pause();
@@ -1325,20 +1349,20 @@ export class GameScene extends Phaser.Scene {
13251349
kind: "action",
13261350
title: "PAUSED",
13271351
selectedIndex: 0,
1328-
onCancel: (controller) => {
1329-
controller.close();
1352+
onCancel: () => {
1353+
this.resumeFromPauseMenu();
13301354
},
13311355
items: [
13321356
{
13331357
label: "Resume",
1334-
activate: (controller) => {
1335-
controller.close();
1358+
activate: () => {
1359+
this.resumeFromPauseMenu();
13361360
},
13371361
},
13381362
{
13391363
label: "Retry",
1340-
activate: (controller) => {
1341-
controller.close();
1364+
activate: () => {
1365+
this.closePauseMenuImmediately();
13421366
this.retryFromPause();
13431367
},
13441368
},
@@ -1397,8 +1421,9 @@ export class GameScene extends Phaser.Scene {
13971421
private afterPauseMenuInteraction(): void {
13981422
this.controls.clearTransientState();
13991423
if (!this.pauseMenu.isOpen) {
1400-
this.refillEmitter.resume();
1401-
this.playerView.resumeEffects();
1424+
if (!this.unpauseRecovery.active) {
1425+
this.resumePauseManagedEffects();
1426+
}
14021427
this.pauseOverlay.hide();
14031428
}
14041429
}
@@ -1414,4 +1439,69 @@ export class GameScene extends Phaser.Scene {
14141439
private stopScreenShake(): void {
14151440
this.cameras.main.resetFX();
14161441
}
1442+
1443+
private renderPassiveFrame(): void {
1444+
const snapshot = this.player.getSnapshot();
1445+
this.playerView.render(snapshot);
1446+
this.renderLighting(snapshot);
1447+
this.updateHUD(snapshot, []);
1448+
if (this.deathRespawnSequence !== null) {
1449+
this.renderSpawnWipe();
1450+
} else {
1451+
this.clearSpawnWipe();
1452+
}
1453+
}
1454+
1455+
private advanceUnpauseRecovery(rawFrameDt: number): boolean {
1456+
this.accumulator = addFloat(this.accumulator, rawFrameDt);
1457+
1458+
while (this.unpauseRecovery.active && this.accumulator >= this.fixedDt) {
1459+
const result = this.unpauseRecovery.step(this.currentUnpauseRecoveryHeldState());
1460+
1461+
if (result.openPause) {
1462+
this.accumulator = 0;
1463+
this.openPauseMenu();
1464+
return true;
1465+
}
1466+
1467+
if (result.blockGameplay) {
1468+
this.accumulator = subFloat(this.accumulator, this.fixedDt);
1469+
continue;
1470+
}
1471+
1472+
if (result.queueJump) {
1473+
this.controls.queuePress("jump");
1474+
}
1475+
if (result.queueDash) {
1476+
this.controls.queuePress("dash");
1477+
}
1478+
this.resumePauseManagedEffects();
1479+
return false;
1480+
}
1481+
1482+
return this.unpauseRecovery.active;
1483+
}
1484+
1485+
private currentUnpauseRecoveryHeldState(): { pause: boolean; jump: boolean; dash: boolean } {
1486+
return {
1487+
pause: this.keys.pause.isDown,
1488+
jump: this.keys.jump.isDown,
1489+
dash: this.keys.dash.isDown,
1490+
};
1491+
}
1492+
1493+
private resumeFromPauseMenu(): void {
1494+
this.pauseMenu.close();
1495+
this.unpauseRecovery.start(this.currentUnpauseRecoveryHeldState());
1496+
}
1497+
1498+
private closePauseMenuImmediately(): void {
1499+
this.pauseMenu.close();
1500+
this.unpauseRecovery.clear();
1501+
}
1502+
1503+
private resumePauseManagedEffects(): void {
1504+
this.refillEmitter.resume();
1505+
this.playerView.resumeEffects();
1506+
}
14171507
}

src/pause/unpauseRecovery.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
export interface UnpauseRecoveryHeldState {
2+
pause: boolean;
3+
jump: boolean;
4+
dash: boolean;
5+
}
6+
7+
export interface UnpauseRecoveryStepResult {
8+
blockGameplay: boolean;
9+
openPause: boolean;
10+
queueJump: boolean;
11+
queueDash: boolean;
12+
}
13+
14+
export const UNPAUSE_INPUT_BUFFER_START_FRAME = 6;
15+
export const UNPAUSE_CONTROL_RETURN_FRAME = 10;
16+
export const UNPAUSE_REPAUSE_FRAME = 11;
17+
18+
const EMPTY_STEP_RESULT: UnpauseRecoveryStepResult = {
19+
blockGameplay: false,
20+
openPause: false,
21+
queueJump: false,
22+
queueDash: false,
23+
};
24+
25+
export class UnpauseRecovery {
26+
private frame = -1;
27+
private prevPauseDown = false;
28+
private prevJumpDown = false;
29+
private prevDashDown = false;
30+
private pauseBuffered = false;
31+
private jumpBuffered = false;
32+
private dashBuffered = false;
33+
34+
get active(): boolean {
35+
return this.frame >= 0;
36+
}
37+
38+
get blocksControl(): boolean {
39+
return this.active && this.frame < UNPAUSE_CONTROL_RETURN_FRAME;
40+
}
41+
42+
start(held: UnpauseRecoveryHeldState): void {
43+
this.frame = 0;
44+
this.prevPauseDown = held.pause;
45+
this.prevJumpDown = held.jump;
46+
this.prevDashDown = held.dash;
47+
this.pauseBuffered = false;
48+
this.jumpBuffered = false;
49+
this.dashBuffered = false;
50+
}
51+
52+
clear(): void {
53+
this.frame = -1;
54+
this.prevPauseDown = false;
55+
this.prevJumpDown = false;
56+
this.prevDashDown = false;
57+
this.pauseBuffered = false;
58+
this.jumpBuffered = false;
59+
this.dashBuffered = false;
60+
}
61+
62+
step(held: UnpauseRecoveryHeldState): UnpauseRecoveryStepResult {
63+
if (!this.active) {
64+
return EMPTY_STEP_RESULT;
65+
}
66+
67+
const frame = this.frame;
68+
const pausePressed = held.pause && !this.prevPauseDown;
69+
const jumpPressed = held.jump && !this.prevJumpDown;
70+
const dashPressed = held.dash && !this.prevDashDown;
71+
72+
if (frame >= UNPAUSE_INPUT_BUFFER_START_FRAME && frame < UNPAUSE_CONTROL_RETURN_FRAME) {
73+
if (jumpPressed) {
74+
this.jumpBuffered = true;
75+
}
76+
if (dashPressed) {
77+
this.dashBuffered = true;
78+
}
79+
}
80+
81+
if (frame >= UNPAUSE_INPUT_BUFFER_START_FRAME && frame <= UNPAUSE_REPAUSE_FRAME && pausePressed) {
82+
this.pauseBuffered = true;
83+
}
84+
85+
if (!held.pause) {
86+
this.pauseBuffered = false;
87+
}
88+
if (!held.jump) {
89+
this.jumpBuffered = false;
90+
}
91+
if (!held.dash) {
92+
this.dashBuffered = false;
93+
}
94+
95+
let result: UnpauseRecoveryStepResult;
96+
97+
if (frame >= UNPAUSE_REPAUSE_FRAME) {
98+
result = {
99+
blockGameplay: this.pauseBuffered && held.pause,
100+
openPause: this.pauseBuffered && held.pause,
101+
queueJump: false,
102+
queueDash: false,
103+
};
104+
this.clear();
105+
return result;
106+
}
107+
108+
if (frame === UNPAUSE_CONTROL_RETURN_FRAME) {
109+
result = {
110+
blockGameplay: false,
111+
openPause: false,
112+
queueJump: this.jumpBuffered && held.jump,
113+
queueDash: this.dashBuffered && held.dash,
114+
};
115+
this.frame++;
116+
this.prevPauseDown = held.pause;
117+
this.prevJumpDown = held.jump;
118+
this.prevDashDown = held.dash;
119+
return result;
120+
}
121+
122+
this.frame++;
123+
this.prevPauseDown = held.pause;
124+
this.prevJumpDown = held.jump;
125+
this.prevDashDown = held.dash;
126+
return {
127+
blockGameplay: true,
128+
openPause: false,
129+
queueJump: false,
130+
queueDash: false,
131+
};
132+
}
133+
}

0 commit comments

Comments
 (0)