Skip to content

Commit cd2a799

Browse files
committed
feat: implement dynamic spawn transition sequences and grounded spawn point resolution
1 parent 523d0be commit cd2a799

8 files changed

Lines changed: 657 additions & 240 deletions

File tree

src/GameScene.ts

Lines changed: 306 additions & 56 deletions
Large diffs are not rendered by default.

src/level.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { PLAYER_GEOMETRY, WORLD } from "./constants";
22
import { EntityWorld } from "./entities/EntityWorld";
33
import { type Aabb, LevelEntitySpec, SpikeDirection } from "./entities/types";
4+
import { resolveGroundedSpawnPoint } from "./spawn";
45

56
export type RoomDirection = "left" | "right" | "up" | "down";
67

@@ -48,8 +49,8 @@ const ROOM_BLUEPRINTS: readonly LevelRoomBlueprint[] = [
4849
"XXXXX...................................",
4950
"XXXXX...................................",
5051
"XXXXX.....................XXXX..........",
51-
"XXS.....................................",
5252
"XX......................................",
53+
"XXS.....................................",
5354
"XXXXXXXX................................",
5455
"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
5556
"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
@@ -84,9 +85,9 @@ const ROOM_BLUEPRINTS: readonly LevelRoomBlueprint[] = [
8485
"...................................XXXXX",
8586
"...................................XXXXX",
8687
"...................................XXXXX",
87-
"S..................................XXXXX",
8888
"...................................XXXXX",
89-
"......................XXXXXXX......XXXXX",
89+
"...................................XXXXX",
90+
"S.....................XXXXXXX......XXXXX",
9091
"XXXXXXXXXXXXXXXXXX...XXXXXXXXXXXXXXXXXXX",
9192
"XXXXXXXXXXXXXXXXXX...XXXXXXXXXXXXXXXXXXX",
9293
"XXXXXXXXXXXXXXXXXX...XXXXXXXXXXXXXXXXXXX",
@@ -121,8 +122,8 @@ const ROOM_BLUEPRINTS: readonly LevelRoomBlueprint[] = [
121122
"XXXX...........XXXXXXXX...XXXXXXX..................XXXX",
122123
"XXXXX..........XXXXXXXX......XXXX.............=====XXXX",
123124
"XXXXX..........XXXXXXXX.....XXXXX..................XXXX",
124-
"XXXXXS.........XXXXXXXX............................XXXX",
125-
"XXXXX..........XXXXXXXX...XXXXXXX..................XXXX",
125+
"XXXXX..........XXXXXXXX............................XXXX",
126+
"XXXXXS.........XXXXXXXX...XXXXXXX..................XXXX",
126127
"XXXXXXXXX^^^^^^XXXXXXXX^^^XXXXXXX.......XXXX.......XXXX",
127128
"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.......XXXX",
128129
"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX^^^^^^^XXXX",
@@ -142,8 +143,8 @@ const ROOM_BLUEPRINTS: readonly LevelRoomBlueprint[] = [
142143
"X.......................................XXXXX",
143144
".......^^^^^^^^^^^^^^^^^^...............XXXXX",
144145
".......XXXXXXXXXXXXXXXXXX...............XXXXX",
145-
"S.......................................XXXXX",
146146
"........................................XXXXX",
147+
"S.......................................XXXXX",
147148
"XX....................................XXXXXXX",
148149
"XX....................................XXXXXXX",
149150
"XX........XXXXXXX...................XXXXXXXXX",
@@ -186,7 +187,7 @@ export function buildLevelFromBlueprints(
186187
const spawnInsetX = Math.floor((WORLD.tile - PLAYER_GEOMETRY.hitboxW) * 0.5);
187188
let worldCols = 0;
188189
let worldRows = 0;
189-
let startCheckpoint: { x: number; y: number } | null = null;
190+
let startRoomId: string | null = null;
190191
let hasExplicitInitialSpawn = false;
191192

192193
for (const blueprint of blueprints) {
@@ -214,10 +215,10 @@ export function buildLevelFromBlueprints(
214215
if (ch === "S") {
215216
checkpoint ??= {
216217
x: col * WORLD.tile + spawnInsetX + PLAYER_GEOMETRY.hitboxW * 0.5,
217-
y: row * WORLD.tile + PLAYER_GEOMETRY.hitboxH,
218+
y: row * WORLD.tile + WORLD.tile,
218219
};
219-
if (!hasExplicitInitialSpawn) {
220-
startCheckpoint ??= checkpoint;
220+
if (!hasExplicitInitialSpawn && startRoomId === null) {
221+
startRoomId = blueprint.id;
221222
}
222223
continue;
223224
}
@@ -258,7 +259,7 @@ export function buildLevelFromBlueprints(
258259
if (checkpoint === null) {
259260
throw new Error(`Room "${blueprint.id}" is marked as the initial spawn room but has no checkpoint`);
260261
}
261-
startCheckpoint = checkpoint;
262+
startRoomId = blueprint.id;
262263
hasExplicitInitialSpawn = true;
263264
}
264265

@@ -273,15 +274,27 @@ export function buildLevelFromBlueprints(
273274
});
274275
}
275276

276-
if (startCheckpoint === null) {
277+
if (startRoomId === null) {
277278
throw new Error("Level requires at least one room checkpoint marked with 'S'");
278279
}
279280

281+
const world = EntityWorld.fromSpecs(worldCols, worldRows, entities);
282+
const resolvedRooms = rooms.map((room) => ({
283+
...room,
284+
checkpoint: room.checkpoint === null
285+
? null
286+
: resolveGroundedSpawnPoint(world, room.bounds, room.checkpoint),
287+
}));
288+
const startRoom = resolvedRooms.find((room) => room.id === startRoomId);
289+
if (startRoom?.checkpoint === null || startRoom === undefined) {
290+
throw new Error(`Resolved initial spawn room "${startRoomId}" has no checkpoint`);
291+
}
292+
280293
return {
281-
world: EntityWorld.fromSpecs(worldCols, worldRows, entities),
282-
rooms,
283-
spawnX: startCheckpoint.x,
284-
spawnY: startCheckpoint.y,
294+
world,
295+
rooms: resolvedRooms,
296+
spawnX: startRoom.checkpoint.x,
297+
spawnY: startRoom.checkpoint.y,
285298
};
286299
}
287300

src/player/Player.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export class Player extends Actor {
113113
private liftTimer = 0;
114114

115115
private wasOnGround = false;
116+
private suppressGroundedLandingEffects = false;
116117
private effects: PlayerEffect[] = [];
117118

118119
private cfg: PlayerConfig;
@@ -163,6 +164,9 @@ export class Player extends Actor {
163164
this.frameDt = dt;
164165
this.input = input;
165166
this.refreshEnvironment();
167+
if (this.suppressGroundedLandingEffects && !this.onGround) {
168+
this.suppressGroundedLandingEffects = false;
169+
}
166170
this.wallDustDir = 0;
167171

168172
if (this.forceMoveXTimer > 0) {
@@ -280,6 +284,9 @@ export class Player extends Actor {
280284
this.moveV(mulFloat(this.vy, dt));
281285

282286
this.refreshEnvironment();
287+
if (this.suppressGroundedLandingEffects && !this.onGround) {
288+
this.suppressGroundedLandingEffects = false;
289+
}
283290
this.updateHairState(dt);
284291

285292
this.wasOnGround = this.onGround;
@@ -381,6 +388,7 @@ export class Player extends Actor {
381388
this.wallDir = 0;
382389
this.wallDustDir = 0;
383390
this.wasOnGround = false;
391+
this.suppressGroundedLandingEffects = false;
384392

385393
this.moveXInput = 0;
386394
this.jumpGraceTimer = 0;
@@ -433,6 +441,15 @@ export class Player extends Actor {
433441
this.stateMachine.forceState("normal");
434442
}
435443

444+
finalizeRespawnState(): void {
445+
this.refreshEnvironment();
446+
this.wasOnGround = this.onGround;
447+
this.suppressGroundedLandingEffects = this.onGround;
448+
if (this.onGround) {
449+
this.jumpGraceTimer = toFloat(this.cfg.jump.graceTime);
450+
}
451+
}
452+
436453
get state(): PlayerState {
437454
return this.stateMachine.state;
438455
}
@@ -1593,7 +1610,12 @@ export class Player extends Actor {
15931610
this.applyDashSlide(!this.dashStartedOnGround);
15941611
}
15951612

1596-
if (step > 0 && this.vy > 0 && this.stateMachine.state !== "climb") {
1613+
if (
1614+
step > 0 &&
1615+
this.vy > 0 &&
1616+
this.stateMachine.state !== "climb" &&
1617+
!this.suppressGroundedLandingEffects
1618+
) {
15971619
const impact = clamp01Float(this.vy / this.cfg.gravity.fastMaxFall);
15981620
this.emit({ type: "land", impact });
15991621
}

src/spawn.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { PLAYER_GEOMETRY, WORLD } from "./constants";
2+
import type { CollisionWorld } from "./entities/CollisionWorld";
3+
import type { Aabb } from "./entities/types";
4+
5+
export interface SpawnPoint {
6+
x: number;
7+
y: number;
8+
}
9+
10+
export function resolveGroundedSpawnPoint(
11+
world: CollisionWorld,
12+
roomBounds: Aabb,
13+
authored: SpawnPoint,
14+
): SpawnPoint {
15+
const maxSearch = Math.max(roomBounds.h + WORLD.tile, PLAYER_GEOMETRY.hitboxH + WORLD.tile);
16+
let fallback: SpawnPoint | null = null;
17+
18+
for (let delta = 0; delta <= maxSearch; delta++) {
19+
const candidates = delta === 0
20+
? [authored.y]
21+
: [authored.y + delta, authored.y - delta];
22+
23+
for (const y of candidates) {
24+
const candidate = { x: authored.x, y };
25+
if (!spawnFits(world, candidate)) {
26+
continue;
27+
}
28+
29+
if (isGroundedSpawn(world, candidate)) {
30+
return candidate;
31+
}
32+
33+
fallback ??= candidate;
34+
}
35+
}
36+
37+
return fallback ?? authored;
38+
}
39+
40+
function isGroundedSpawn(world: CollisionWorld, point: SpawnPoint): boolean {
41+
const bounds = spawnBounds(point);
42+
return world.probeGround(bounds.x, bounds.y, bounds.w, bounds.h).onGround;
43+
}
44+
45+
function spawnFits(world: CollisionWorld, point: SpawnPoint): boolean {
46+
const bounds = spawnBounds(point);
47+
return !world.collideSolidAt(bounds.x, bounds.y, bounds.w, bounds.h) &&
48+
!world.collidesWithSpikeAt(bounds.x, bounds.y, bounds.w, bounds.h, 0, 0);
49+
}
50+
51+
function spawnBounds(point: SpawnPoint): Aabb {
52+
return {
53+
x: point.x - PLAYER_GEOMETRY.hitboxW * 0.5,
54+
y: point.y - PLAYER_GEOMETRY.hitboxH,
55+
w: PLAYER_GEOMETRY.hitboxW,
56+
h: PLAYER_GEOMETRY.hitboxH,
57+
};
58+
}

0 commit comments

Comments
 (0)