refact round

This commit is contained in:
2026-05-15 21:45:18 +02:00
parent 5b0a989712
commit 737f16f38b
18 changed files with 168 additions and 232 deletions

View File

@@ -1,5 +1,20 @@
import Phaser from "phaser";
type PhysicsBodyHolder =
| Phaser.Types.Physics.Arcade.GameObjectWithBody
| Phaser.Physics.Arcade.Body
| Phaser.Physics.Arcade.StaticBody
| Phaser.Tilemaps.Tile;
function getBody(target: PhysicsBodyHolder): Phaser.Physics.Arcade.Body | Phaser.Physics.Arcade.StaticBody {
if (target instanceof Phaser.Physics.Arcade.Body || target instanceof Phaser.Physics.Arcade.StaticBody) {
return target;
}
return (target as Phaser.Types.Physics.Arcade.GameObjectWithBody).body as
| Phaser.Physics.Arcade.Body
| Phaser.Physics.Arcade.StaticBody;
}
export class Platform extends Phaser.GameObjects.TileSprite {
static readonly TEXTURE_KEY = "platform";
@@ -12,15 +27,12 @@ export class Platform extends Phaser.GameObjects.TileSprite {
}
// Phaser collider processCallback: only collide when the mover is descending onto the top.
static canLand(
mover: Phaser.Types.Physics.Arcade.GameObjectWithBody,
platform: Phaser.Types.Physics.Arcade.GameObjectWithBody,
): boolean {
const moverBody = mover.body as Phaser.Physics.Arcade.Body;
const platBody = platform.body as Phaser.Physics.Arcade.StaticBody;
static canLand: Phaser.Types.Physics.Arcade.ArcadePhysicsCallback = (mover, platform) => {
const moverBody = getBody(mover) as Phaser.Physics.Arcade.Body;
const platBody = getBody(platform) as Phaser.Physics.Arcade.StaticBody;
return (
moverBody.velocity.y >= 0 &&
moverBody.bottom - moverBody.deltaY() <= platBody.top
);
}
};
}

View File

@@ -0,0 +1,11 @@
import Phaser from "phaser";
export abstract class ObstacleSpikeBase extends Phaser.GameObjects.Image {
declare body: Phaser.Physics.Arcade.StaticBody;
constructor(scene: Phaser.Scene, x: number, y: number, textureKey: string) {
super(scene, x, y, textureKey);
scene.add.existing(this);
scene.physics.add.existing(this, true);
}
}

View File

@@ -1,15 +1,12 @@
import Phaser from "phaser";
import { ObstacleSpikeBase } from "./ObstacleSpikeBase";
export class ObstacleSpikeNormal extends Phaser.GameObjects.Image {
export class ObstacleSpikeNormal extends ObstacleSpikeBase {
static readonly TEXTURE_KEY = "spike-normal";
static readonly WIDTH = 40;
static readonly HEIGHT = 20;
declare body: Phaser.Physics.Arcade.StaticBody;
constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y, ObstacleSpikeNormal.TEXTURE_KEY);
scene.add.existing(this);
scene.physics.add.existing(this, true);
}
}

View File

@@ -1,23 +1,20 @@
import Phaser from "phaser";
import { ObstacleSpikeBase } from "./ObstacleSpikeBase";
export class ObstacleSpikeTricky extends Phaser.GameObjects.Image {
export class ObstacleSpikeTricky extends ObstacleSpikeBase {
static readonly TEXTURE_KEY = "spike-tricky";
static readonly WIDTH = 40;
static readonly HEIGHT = 20;
static readonly TRIGGER_DISTANCE = 55;
static readonly DISPLACEMENT = 100;
declare body: Phaser.Physics.Arcade.StaticBody;
private moved = false;
constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y, ObstacleSpikeTricky.TEXTURE_KEY);
scene.add.existing(this);
scene.physics.add.existing(this, true);
}
react(playerX: number): void {
react(playerX: number, _playerY: number): void {
if (this.moved) return;
const dx = this.x - playerX;
if (Math.abs(dx) < ObstacleSpikeTricky.TRIGGER_DISTANCE) {

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level01 = defineLevel("Spike Hop", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E........^........P",
"####################",
"####################",

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level02 = defineLevel("Trap Bridge", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E.................P",
"#######~~~##########",
"#######~~~##########",

View File

@@ -1,13 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level03 = defineLevel("Trickster Stairs", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E..................",
"#####...............",
"....#####...........",

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level04 = defineLevel("Spike Field", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E....^...^...^....P",
"####################",
"####################",

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level05 = defineLevel("Pit Crossing", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E.................P",
"####...######...####",
"####^^^######^^^####",

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level06 = defineLevel("Trickster", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E..........T......P",
"####################",
"####################",

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level07 = defineLevel("Bridge Hopping", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E.................P",
"####...#####...#####",
"####...#####...#####",

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level08 = defineLevel("Mixed Hazards", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E..^......T....^..P",
"####################",
"####################",

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level09 = defineLevel("Vanishing Bridges", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E.................P",
"####~~~####~~~######",
"####~~~####~~~######",

View File

@@ -1,18 +1,6 @@
import { defineLevel } from "../lib/levelTypes";
export const level10 = defineLevel("Final Gauntlet", [
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
"....................",
".E......T..........P",
"####...####~~~######",
"####^^^####...######",

View File

@@ -18,18 +18,29 @@ export interface LoadedLevel {
trickySpikes: ObstacleSpikeTricky[];
}
interface Cell {
row: number;
col: number;
}
const cellCenterX = (col: number): number => col * TILE_SIZE + TILE_SIZE / 2;
const cellBottomY = (row: number, entityHeight: number): number =>
(row + 1) * TILE_SIZE - entityHeight / 2;
export class LevelLoader {
constructor(private readonly scene: Phaser.Scene) {}
load(matrix: LevelMatrix): LoadedLevel {
return {
player: this.buildPlayer(matrix),
exit: this.buildExit(matrix),
player: this.buildSingleton(
matrix,
TileType.Player,
Player.SIZE,
(x, y) => new Player(this.scene, x, y),
"Player",
),
exit: this.buildSingleton(
matrix,
TileType.Exit,
Exit.HEIGHT,
(x, y) => new Exit(this.scene, x, y),
"Exit",
),
platforms: this.buildHorizontalRuns(
matrix,
TileType.Platform,
@@ -40,16 +51,18 @@ export class LevelLoader {
TileType.DisappearingPlatform,
(x, y, w, h) => new ObstaclePlatformDisappearing(this.scene, x, y, w, h),
),
spikes: this.buildSingleCells(matrix, TileType.Spike, (col, row) => {
const x = col * TILE_SIZE + TILE_SIZE / 2;
const y = (row + 1) * TILE_SIZE - ObstacleSpikeNormal.HEIGHT / 2;
return new ObstacleSpikeNormal(this.scene, x, y);
}),
trickySpikes: this.buildSingleCells(matrix, TileType.TrickySpike, (col, row) => {
const x = col * TILE_SIZE + TILE_SIZE / 2;
const y = (row + 1) * TILE_SIZE - ObstacleSpikeTricky.HEIGHT / 2;
return new ObstacleSpikeTricky(this.scene, x, y);
}),
spikes: this.buildSingleCells(
matrix,
TileType.Spike,
ObstacleSpikeNormal.HEIGHT,
(x, y) => new ObstacleSpikeNormal(this.scene, x, y),
),
trickySpikes: this.buildSingleCells(
matrix,
TileType.TrickySpike,
ObstacleSpikeTricky.HEIGHT,
(x, y) => new ObstacleSpikeTricky(this.scene, x, y),
),
};
}
@@ -81,39 +94,34 @@ export class LevelLoader {
private buildSingleCells<T>(
matrix: LevelMatrix,
tileType: TileType,
factory: (col: number, row: number) => T,
entityHeight: number,
factory: (x: number, y: number) => T,
): T[] {
const result: T[] = [];
for (let row = 0; row < matrix.length; row++) {
for (let col = 0; col < matrix[row].length; col++) {
if (matrix[row][col] === tileType) result.push(factory(col, row));
if (matrix[row][col] === tileType) {
result.push(factory(cellCenterX(col), cellBottomY(row, entityHeight)));
}
}
}
return result;
}
private buildPlayer(matrix: LevelMatrix): Player {
const cell = this.findTile(matrix, TileType.Player);
if (!cell) throw new Error("Level matrix has no Player tile");
const x = cell.col * TILE_SIZE + TILE_SIZE / 2;
const y = (cell.row + 1) * TILE_SIZE - Player.SIZE / 2;
return new Player(this.scene, x, y);
}
private buildExit(matrix: LevelMatrix): Exit {
const cell = this.findTile(matrix, TileType.Exit);
if (!cell) throw new Error("Level matrix has no Exit tile");
const x = cell.col * TILE_SIZE + TILE_SIZE / 2;
const y = (cell.row + 1) * TILE_SIZE - Exit.HEIGHT / 2;
return new Exit(this.scene, x, y);
}
private findTile(matrix: LevelMatrix, tile: TileType): Cell | null {
private buildSingleton<T>(
matrix: LevelMatrix,
tileType: TileType,
entityHeight: number,
factory: (x: number, y: number) => T,
label: string,
): T {
for (let row = 0; row < matrix.length; row++) {
for (let col = 0; col < matrix[row].length; col++) {
if (matrix[row][col] === tile) return { row, col };
if (matrix[row][col] === tileType) {
return factory(cellCenterX(col), cellBottomY(row, entityHeight));
}
}
return null;
}
throw new Error(`Level matrix has no ${label} tile`);
}
}

View File

@@ -29,6 +29,8 @@ const CHAR_TO_TILE: Record<string, TileType> = {
"T": TileType.TrickySpike,
};
const EMPTY_ROW = ".".repeat(LEVEL_COLS);
function parseLevel(rows: string[]): LevelMatrix {
return rows.map((row, rowIdx) =>
[...row].map((c, colIdx) => {
@@ -42,5 +44,12 @@ function parseLevel(rows: string[]): LevelMatrix {
}
export function defineLevel(name: string, rows: string[]): LevelDef {
return { name, matrix: parseLevel(rows) };
if (rows.length > LEVEL_ROWS) {
throw new Error(
`Level "${name}" has ${rows.length} rows, max is ${LEVEL_ROWS}`,
);
}
const padCount = LEVEL_ROWS - rows.length;
const padded = padCount > 0 ? [...Array(padCount).fill(EMPTY_ROW), ...rows] : rows;
return { name, matrix: parseLevel(padded) };
}

View File

@@ -7,26 +7,69 @@ import {
ObstacleSpikeTricky,
} from "../entities/obstacles";
interface SpriteAsset {
key: string;
path: string;
width: number;
height: number;
}
const SPRITE_ASSETS: SpriteAsset[] = [
{ key: Player.TEXTURE_KEY, path: "/sprites/player.svg", width: Player.SIZE, height: Player.SIZE },
{ key: Exit.TEXTURE_KEY, path: "/sprites/exit.svg", width: Exit.WIDTH, height: Exit.HEIGHT },
{ key: Platform.TEXTURE_KEY, path: "/sprites/platform.svg", width: 40, height: 40 },
{
key: ObstacleSpikeNormal.TEXTURE_KEY,
path: "/sprites/spike-normal.svg",
width: ObstacleSpikeNormal.WIDTH,
height: ObstacleSpikeNormal.HEIGHT,
},
{
key: ObstacleSpikeTricky.TEXTURE_KEY,
path: "/sprites/spike-tricky.svg",
width: ObstacleSpikeTricky.WIDTH,
height: ObstacleSpikeTricky.HEIGHT,
},
];
const TITLE_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
fontFamily: "Arial Black, Arial",
fontSize: "78px",
color: "#ffd700",
fontStyle: "bold",
stroke: "#7a5500",
strokeThickness: 6,
};
const SUBTITLE_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
fontFamily: "Arial",
fontSize: "18px",
color: "#aaaaaa",
fontStyle: "italic",
};
const PROMPT_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
fontFamily: "Arial",
fontSize: "26px",
color: "#ffffff",
fontStyle: "bold",
};
const HINT_STYLE: Phaser.Types.GameObjects.Text.TextStyle = {
fontFamily: "Arial",
fontSize: "14px",
color: "#888888",
};
export class BootScene extends Phaser.Scene {
constructor() {
super({ key: "BootScene" });
}
preload(): void {
this.load.svg(Player.TEXTURE_KEY, "/sprites/player.svg", { width: 32, height: 32 });
this.load.svg(Exit.TEXTURE_KEY, "/sprites/exit.svg", {
width: Exit.WIDTH,
height: Exit.HEIGHT,
});
this.load.svg(Platform.TEXTURE_KEY, "/sprites/platform.svg", { width: 40, height: 40 });
this.load.svg(ObstacleSpikeNormal.TEXTURE_KEY, "/sprites/spike-normal.svg", {
width: ObstacleSpikeNormal.WIDTH,
height: ObstacleSpikeNormal.HEIGHT,
});
this.load.svg(ObstacleSpikeTricky.TEXTURE_KEY, "/sprites/spike-tricky.svg", {
width: ObstacleSpikeTricky.WIDTH,
height: ObstacleSpikeTricky.HEIGHT,
});
for (const asset of SPRITE_ASSETS) {
this.load.svg(asset.key, asset.path, { width: asset.width, height: asset.height });
}
}
create(): void {
@@ -38,48 +81,16 @@ export class BootScene extends Phaser.Scene {
this.add.tileSprite(cx, h - 20, w, 40, Platform.TEXTURE_KEY);
this.add
.text(cx, 130, "TRICKSTER", {
fontFamily: "Arial Black, Arial",
fontSize: "78px",
color: "#ffd700",
fontStyle: "bold",
stroke: "#7a5500",
strokeThickness: 6,
})
.setOrigin(0.5);
this.add
.text(cx, 200, "TILES", {
fontFamily: "Arial Black, Arial",
fontSize: "78px",
color: "#ffd700",
fontStyle: "bold",
stroke: "#7a5500",
strokeThickness: 6,
})
.setOrigin(0.5);
this.add
.text(cx, 270, "a pixel-art platformer", {
fontFamily: "Arial",
fontSize: "18px",
color: "#aaaaaa",
fontStyle: "italic",
})
.setOrigin(0.5);
this.add.text(cx, 130, "TRICKSTER", TITLE_STYLE).setOrigin(0.5);
this.add.text(cx, 200, "TILES", TITLE_STYLE).setOrigin(0.5);
this.add.text(cx, 270, "a pixel-art platformer", SUBTITLE_STYLE).setOrigin(0.5);
this.add.image(cx - 80, 360, Player.TEXTURE_KEY);
this.add.image(cx, 374, ObstacleSpikeNormal.TEXTURE_KEY);
this.add.image(cx + 80, 374, ObstacleSpikeTricky.TEXTURE_KEY);
const startText = this.add
.text(cx, 450, "PRESS SPACE TO START", {
fontFamily: "Arial",
fontSize: "26px",
color: "#ffffff",
fontStyle: "bold",
})
.text(cx, 450, "PRESS SPACE TO START", PROMPT_STYLE)
.setOrigin(0.5);
this.tweens.add({
@@ -91,11 +102,7 @@ export class BootScene extends Phaser.Scene {
});
this.add
.text(cx, 510, "← → / A D to move SPACE / ↑ / W to jump", {
fontFamily: "Arial",
fontSize: "14px",
color: "#888888",
})
.text(cx, 510, "← → / A D to move SPACE / ↑ / W to jump", HINT_STYLE)
.setOrigin(0.5);
const start = () => this.scene.start("GameScene");

View File

@@ -14,9 +14,14 @@ interface SceneData {
levelIndex?: number;
}
interface Reactive {
react(playerX: number, playerY: number): void;
}
export class GameScene extends Phaser.Scene {
private static readonly TRANSITION_DELAY_MS = 1200;
private static readonly FALL_LIMIT_PADDING = 80;
private static readonly WORLD_BOUNDS_BOTTOM_PADDING = 200;
private levelIndex = 0;
private player!: Player;
@@ -25,6 +30,7 @@ export class GameScene extends Phaser.Scene {
private disappearing: ObstaclePlatformDisappearing[] = [];
private spikes: ObstacleSpikeNormal[] = [];
private trickySpikes: ObstacleSpikeTricky[] = [];
private reactives: Reactive[] = [];
private levelCompleted = false;
private dying = false;
private hudText!: Phaser.GameObjects.Text;
@@ -42,10 +48,16 @@ export class GameScene extends Phaser.Scene {
this.disappearing = [];
this.spikes = [];
this.trickySpikes = [];
this.reactives = [];
}
create(): void {
this.physics.world.setBounds(0, 0, this.scale.width, this.scale.height + 200);
this.physics.world.setBounds(
0,
0,
this.scale.width,
this.scale.height + GameScene.WORLD_BOUNDS_BOTTOM_PADDING,
);
const def = LEVELS[this.levelIndex];
const loader = new LevelLoader(this);
@@ -56,6 +68,7 @@ export class GameScene extends Phaser.Scene {
this.disappearing = loaded.disappearing;
this.spikes = loaded.spikes;
this.trickySpikes = loaded.trickySpikes;
this.reactives = [...loaded.disappearing, ...loaded.trickySpikes];
this.physics.add.collider(this.player, this.platforms, undefined, Platform.canLand);
this.physics.add.collider(this.player, this.disappearing, undefined, Platform.canLand);
@@ -85,11 +98,8 @@ export class GameScene extends Phaser.Scene {
if (this.levelCompleted || this.dying) return;
this.player.update();
for (const platform of this.disappearing) {
platform.react(this.player.x, this.player.y);
}
for (const spike of this.trickySpikes) {
spike.react(this.player.x);
for (const entity of this.reactives) {
entity.react(this.player.x, this.player.y);
}
if (this.player.y > this.scale.height + GameScene.FALL_LIMIT_PADDING) {