Skip to content

Commit fe619a5

Browse files
committed
feat: implement player intro and respawn mechanics
1 parent ec87d3d commit fe619a5

12 files changed

Lines changed: 773 additions & 367 deletions

src/GameScene.ts

Lines changed: 145 additions & 94 deletions
Large diffs are not rendered by default.

src/player/Player.ts

Lines changed: 207 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ import {
1717
toFloat,
1818
} from "./math";
1919
import { Actor, type MoveCollisionResult } from "./Actor";
20+
import {
21+
introDuration,
22+
isActivePlayerIntroSpec,
23+
type ActivePlayerIntroSpec,
24+
type PlayerIntroSpec,
25+
samplePlayerIntroState,
26+
type PlayerIntroType,
27+
} from "./intro";
2028
import { StateMachine } from "./StateMachine";
2129
import { InputState, PlayerEffect, PlayerSnapshot, PlayerState } from "./types";
2230

@@ -29,6 +37,7 @@ const USED_HAIR_LERP_RATE = 6;
2937
const BOUNCE_AUTO_JUMP_TIME = 0.1;
3038
const BOUNCE_VAR_JUMP_TIME = 0.2;
3139
const BOUNCE_SPEED = -140;
40+
const ZERO_DIRECTION = { x: 0, y: 0 };
3241
const EMPTY_INPUT: InputState = {
3342
x: 0,
3443
y: 0,
@@ -112,6 +121,14 @@ export class Player extends Actor {
112121
private liftVy = 0;
113122
private liftTimer = 0;
114123

124+
private dead = false;
125+
private justRespawned = false;
126+
private lastDeathDirection: Readonly<{ x: number; y: number }> = ZERO_DIRECTION;
127+
private introType: PlayerIntroType = "none";
128+
private introElapsed = 0;
129+
private introDuration = 0;
130+
private introSourceX: number | null = null;
131+
private introSourceY: number | null = null;
115132
private wasOnGround = false;
116133
private effects: PlayerEffect[] = [];
117134

@@ -151,6 +168,20 @@ export class Player extends Actor {
151168
() => this.dashBegin(),
152169
() => this.dashEnd(),
153170
);
171+
this.stateMachine.setCallbacks(
172+
"intro_start",
173+
() => this.introStartUpdate(),
174+
undefined,
175+
() => this.introStartBegin(),
176+
() => this.introEnd(),
177+
);
178+
this.stateMachine.setCallbacks(
179+
"intro_respawn",
180+
() => this.introRespawnUpdate(),
181+
undefined,
182+
() => this.introRespawnBegin(),
183+
() => this.introEnd(),
184+
);
154185
this.stateMachine.forceState("normal");
155186
}
156187

@@ -162,6 +193,11 @@ export class Player extends Actor {
162193
dt = toFloat(dt);
163194
this.frameDt = dt;
164195
this.input = input;
196+
if (this.dead) {
197+
this.refreshEnvironment();
198+
return;
199+
}
200+
165201
this.refreshEnvironment();
166202
this.wallDustDir = 0;
167203

@@ -282,6 +318,10 @@ export class Player extends Actor {
282318
this.refreshEnvironment();
283319
this.updateHairState(dt);
284320

321+
if (this.justRespawned && (this.vx !== 0 || this.vy !== 0)) {
322+
this.justRespawned = false;
323+
}
324+
285325
this.wasOnGround = this.onGround;
286326
}
287327

@@ -329,6 +369,8 @@ export class Player extends Actor {
329369
const drawH = this.ducking
330370
? (PLAYER_GEOMETRY.drawH * PLAYER_GEOMETRY.crouchHitboxH) / PLAYER_GEOMETRY.hitboxH
331371
: PLAYER_GEOMETRY.drawH;
372+
const centerX = this.x;
373+
const centerY = bounds.y + hitboxH * 0.5;
332374

333375
return {
334376
x: this.x,
@@ -337,8 +379,8 @@ export class Player extends Actor {
337379
top: bounds.y,
338380
right: bounds.x + hitboxW,
339381
bottom: bounds.y + hitboxH,
340-
centerX: this.x,
341-
centerY: bounds.y + hitboxH * 0.5,
382+
centerX,
383+
centerY,
342384
vx: this.vx,
343385
vy: this.vy,
344386
state: this.state,
@@ -356,6 +398,10 @@ export class Player extends Actor {
356398
drawH,
357399
isCrouched: this.ducking,
358400
isFastFalling: this.isFastFalling,
401+
dead: this.dead,
402+
justRespawned: this.justRespawned,
403+
inControl: this.inControl,
404+
intro: this.currentIntroSnapshot(centerX, centerY),
359405
};
360406
}
361407

@@ -365,12 +411,13 @@ export class Player extends Actor {
365411
this.liftTimer = toFloat(this.cfg.lift.momentumStoreTime);
366412
}
367413

368-
resetStateAt(x: number, y: number): void {
414+
private resetStateAt(x: number, y: number): void {
369415
this.x = x;
370416
this.y = y;
371417
this.vx = 0;
372418
this.vy = 0;
373419
this.clearMovementRemainders();
420+
this.stateMachine.locked = false;
374421

375422
this.facing = 1;
376423
this.collider = this.normalHitbox;
@@ -429,24 +476,99 @@ export class Player extends Actor {
429476
this.liftVy = 0;
430477
this.liftTimer = 0;
431478

479+
this.dead = false;
480+
this.lastDeathDirection = ZERO_DIRECTION;
481+
this.justRespawned = false;
482+
this.clearIntroState();
432483
this.effects = [];
433484
this.stateMachine.forceState("normal");
434485
}
435486

436487
// Scene-managed teleports bypass the normal update loop, so collision-derived
437488
// state needs an explicit refresh before normal simulation resumes.
438-
syncStateAfterExternalMove(): void {
489+
private syncStateAfterExternalMove(): void {
439490
this.refreshEnvironment();
440491
this.wasOnGround = this.onGround;
441492
if (this.onGround) {
442493
this.jumpGraceTimer = toFloat(this.cfg.jump.graceTime);
443494
}
444495
}
445496

497+
die(direction: Readonly<{ x: number; y: number }>): boolean {
498+
if (this.dead) {
499+
return false;
500+
}
501+
502+
this.dead = true;
503+
this.justRespawned = false;
504+
this.lastDeathDirection = {
505+
x: Math.sign(direction.x),
506+
y: Math.sign(direction.y),
507+
};
508+
this.vx = 0;
509+
this.vy = 0;
510+
this.clearMovementRemainders();
511+
this.clearIntroState();
512+
this.effects = [];
513+
this.stateMachine.locked = true;
514+
return true;
515+
}
516+
517+
reviveAt(
518+
x: number,
519+
y: number,
520+
intro:
521+
| PlayerIntroType
522+
| PlayerIntroSpec = "none",
523+
): void {
524+
const introSpec: PlayerIntroSpec = typeof intro === "string" ? { type: intro } : intro;
525+
this.resetStateAt(x, y);
526+
this.syncStateAfterExternalMove();
527+
this.alignFacingForIntro(x, introSpec.type);
528+
529+
if (!isActivePlayerIntroSpec(introSpec)) {
530+
return;
531+
}
532+
533+
this.beginIntro(introSpec);
534+
}
535+
446536
get state(): PlayerState {
447537
return this.stateMachine.state;
448538
}
449539

540+
get canRetry(): boolean {
541+
return !this.dead && !this.timePaused;
542+
}
543+
544+
get timePaused(): boolean {
545+
if (this.dead) {
546+
return true;
547+
}
548+
549+
switch (this.stateMachine.state) {
550+
case "intro_start":
551+
case "intro_respawn":
552+
return true;
553+
default:
554+
return false;
555+
}
556+
}
557+
558+
get inControl(): boolean {
559+
if (this.dead) {
560+
return false;
561+
}
562+
563+
switch (this.stateMachine.state) {
564+
case "intro_start":
565+
case "intro_respawn":
566+
return false;
567+
default:
568+
return true;
569+
}
570+
}
571+
450572
forceState(state: PlayerState): void {
451573
this.stateMachine.forceState(state);
452574
}
@@ -499,6 +621,87 @@ export class Player extends Actor {
499621
this.wallDir = this.world.wallDirAt(body.x, body.y, body.w, body.h);
500622
}
501623

624+
private currentIntroSnapshot(centerX: number, centerY: number) {
625+
if (this.introType === "none") {
626+
return null;
627+
}
628+
629+
const duration = Math.max(this.introDuration, Number.EPSILON);
630+
return samplePlayerIntroState(
631+
this.introType,
632+
this.introElapsed / duration,
633+
centerX,
634+
centerY,
635+
this.introSourceX ?? centerX,
636+
this.introSourceY ?? centerY,
637+
);
638+
}
639+
640+
private beginIntro(spec: ActivePlayerIntroSpec): void {
641+
this.introType = spec.type;
642+
this.introElapsed = 0;
643+
this.introDuration = spec.duration ?? introDuration(spec.type);
644+
this.introSourceX = spec.sourceX ?? null;
645+
this.introSourceY = spec.sourceY ?? null;
646+
this.justRespawned = spec.type === "respawn";
647+
this.stateMachine.forceState(spec.type === "respawn" ? "intro_respawn" : "intro_start");
648+
}
649+
650+
private clearIntroState(): void {
651+
this.introType = "none";
652+
this.introElapsed = 0;
653+
this.introDuration = 0;
654+
this.introSourceX = null;
655+
this.introSourceY = null;
656+
}
657+
658+
private alignFacingForIntro(x: number, introType: PlayerIntroType): void {
659+
if (introType === "none") {
660+
return;
661+
}
662+
663+
const worldCenterX = (this.world.cols * WORLD.tile) * 0.5;
664+
this.facing = x > worldCenterX ? -1 : 1;
665+
this.lastAim = { x: this.facing, y: 0 };
666+
}
667+
668+
private introStartBegin(): void {
669+
this.vx = 0;
670+
this.vy = 0;
671+
}
672+
673+
private introRespawnBegin(): void {
674+
this.vx = 0;
675+
this.vy = 0;
676+
}
677+
678+
private introStartUpdate(): PlayerState {
679+
return this.advanceIntroState("intro_start");
680+
}
681+
682+
private introRespawnUpdate(): PlayerState {
683+
return this.advanceIntroState("intro_respawn");
684+
}
685+
686+
private advanceIntroState(state: "intro_start" | "intro_respawn"): PlayerState {
687+
this.vx = 0;
688+
this.vy = 0;
689+
this.introElapsed = Math.min(this.introDuration, this.introElapsed + this.frameDt);
690+
if (this.introElapsed < this.introDuration) {
691+
return state;
692+
}
693+
694+
if (state === "intro_respawn") {
695+
this.emit({ type: "respawn_pop" });
696+
}
697+
698+
return "normal";
699+
}
700+
701+
private introEnd(): void {
702+
this.clearIntroState();
703+
}
704+
502705
private normalBegin(): void {
503706
this.maxFall = toFloat(this.cfg.gravity.maxFall);
504707
}

0 commit comments

Comments
 (0)