@@ -17,6 +17,14 @@ import {
1717 toFloat ,
1818} from "./math" ;
1919import { 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" ;
2028import { StateMachine } from "./StateMachine" ;
2129import { InputState , PlayerEffect , PlayerSnapshot , PlayerState } from "./types" ;
2230
@@ -29,6 +37,7 @@ const USED_HAIR_LERP_RATE = 6;
2937const BOUNCE_AUTO_JUMP_TIME = 0.1 ;
3038const BOUNCE_VAR_JUMP_TIME = 0.2 ;
3139const BOUNCE_SPEED = - 140 ;
40+ const ZERO_DIRECTION = { x : 0 , y : 0 } ;
3241const 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