feat: player entity with WASD movement, tile collision, camera follow (Phase 4.1)
Player spawns at walkable tile near map center. WASD controls movement (150px/s, normalized diagonal). Tile collision with wall-sliding prevents walking through acid pools, crystals, geysers. Camera follows player with smooth lerp. 39 new tests (134 total). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -65,6 +65,11 @@ export class PhaserBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the Phaser sprite for an entity (undefined if not yet rendered) */
|
||||
getSprite(eid: number): Phaser.GameObjects.Arc | undefined {
|
||||
return this.spriteMap.get(eid);
|
||||
}
|
||||
|
||||
/** Current number of rendered entities */
|
||||
get entityCount(): number {
|
||||
return this.spriteMap.size;
|
||||
|
||||
@@ -34,3 +34,8 @@ export const Health = {
|
||||
export const ChemicalComposition = {
|
||||
primaryElement: [] as number[], // atomic number (e.g. 11 for Na)
|
||||
};
|
||||
|
||||
/** Tag component — marks entity as the player (no data, identity only) */
|
||||
export const PlayerTag = {
|
||||
_tag: [] as number[],
|
||||
};
|
||||
|
||||
128
src/player/collision.ts
Normal file
128
src/player/collision.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tile Collision System
|
||||
*
|
||||
* Checks player position against tile grid after movement.
|
||||
* Supports wall-sliding: if full movement is blocked, tries
|
||||
* X-only or Y-only to let player slide along walls.
|
||||
*/
|
||||
|
||||
import { query } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import { Position, Velocity, PlayerTag } from '../ecs/components';
|
||||
import type { TileGrid } from '../world/types';
|
||||
import { PLAYER_COLLISION_RADIUS } from './types';
|
||||
|
||||
/**
|
||||
* Check if a pixel position falls on a walkable tile.
|
||||
* Returns false for out-of-bounds positions.
|
||||
*/
|
||||
export function isTileWalkable(
|
||||
px: number,
|
||||
py: number,
|
||||
grid: TileGrid,
|
||||
tileSize: number,
|
||||
walkable: Set<number>,
|
||||
): boolean {
|
||||
const tx = Math.floor(px / tileSize);
|
||||
const ty = Math.floor(py / tileSize);
|
||||
if (ty < 0 || ty >= grid.length || tx < 0 || tx >= (grid[0]?.length ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
return walkable.has(grid[ty][tx]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a circular area (4 corners of bounding box) is on walkable tiles.
|
||||
* Uses AABB corners, not true circle — good enough for small radii.
|
||||
*/
|
||||
export function isAreaWalkable(
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
grid: TileGrid,
|
||||
tileSize: number,
|
||||
walkable: Set<number>,
|
||||
): boolean {
|
||||
return (
|
||||
isTileWalkable(x - radius, y - radius, grid, tileSize, walkable) &&
|
||||
isTileWalkable(x + radius, y - radius, grid, tileSize, walkable) &&
|
||||
isTileWalkable(x - radius, y + radius, grid, tileSize, walkable) &&
|
||||
isTileWalkable(x + radius, y + radius, grid, tileSize, walkable)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve position after movement with wall-sliding.
|
||||
*
|
||||
* Priority: full move → X-slide → Y-slide → stay put.
|
||||
* This gives natural wall-sliding behavior.
|
||||
*/
|
||||
export function resolveCollision(
|
||||
newX: number,
|
||||
newY: number,
|
||||
prevX: number,
|
||||
prevY: number,
|
||||
radius: number,
|
||||
grid: TileGrid,
|
||||
tileSize: number,
|
||||
walkable: Set<number>,
|
||||
): { x: number; y: number } {
|
||||
// Full movement valid
|
||||
if (isAreaWalkable(newX, newY, radius, grid, tileSize, walkable)) {
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
// X-only: slide along Y wall
|
||||
if (isAreaWalkable(newX, prevY, radius, grid, tileSize, walkable)) {
|
||||
return { x: newX, y: prevY };
|
||||
}
|
||||
// Y-only: slide along X wall
|
||||
if (isAreaWalkable(prevX, newY, radius, grid, tileSize, walkable)) {
|
||||
return { x: prevX, y: newY };
|
||||
}
|
||||
// Can't move at all
|
||||
return { x: prevX, y: prevY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Set of walkable tile IDs from tile data.
|
||||
* Used to quickly check if a tile allows movement.
|
||||
*/
|
||||
export function buildWalkableSet(
|
||||
tiles: ReadonlyArray<{ id: number; walkable: boolean }>,
|
||||
): Set<number> {
|
||||
const set = new Set<number>();
|
||||
for (const tile of tiles) {
|
||||
if (tile.walkable) set.add(tile.id);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* ECS system: resolve tile collisions for player entities.
|
||||
*
|
||||
* Runs AFTER movementSystem. Reconstructs pre-movement position
|
||||
* from current position and velocity, then resolves collision.
|
||||
*/
|
||||
export function tileCollisionSystem(
|
||||
world: World,
|
||||
deltaMs: number,
|
||||
grid: TileGrid,
|
||||
tileSize: number,
|
||||
walkable: Set<number>,
|
||||
): void {
|
||||
const dt = deltaMs / 1000;
|
||||
for (const eid of query(world, [Position, Velocity, PlayerTag])) {
|
||||
const curX = Position.x[eid];
|
||||
const curY = Position.y[eid];
|
||||
// Reconstruct pre-movement position (movementSystem did: pos += vel * dt)
|
||||
const prevX = curX - Velocity.vx[eid] * dt;
|
||||
const prevY = curY - Velocity.vy[eid] * dt;
|
||||
|
||||
const resolved = resolveCollision(
|
||||
curX, curY, prevX, prevY,
|
||||
PLAYER_COLLISION_RADIUS, grid, tileSize, walkable,
|
||||
);
|
||||
Position.x[eid] = resolved.x;
|
||||
Position.y[eid] = resolved.y;
|
||||
}
|
||||
}
|
||||
31
src/player/factory.ts
Normal file
31
src/player/factory.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Player Entity Factory
|
||||
*
|
||||
* Creates the player entity with all required components:
|
||||
* Position, Velocity, SpriteRef, Health, PlayerTag.
|
||||
*/
|
||||
|
||||
import { addComponent } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import { PlayerTag } from '../ecs/components';
|
||||
import { createGameEntity } from '../ecs/factory';
|
||||
import { PLAYER_COLOR, PLAYER_RADIUS, PLAYER_HEALTH } from './types';
|
||||
|
||||
/**
|
||||
* Create the player entity at the given position.
|
||||
* @returns entity ID (eid)
|
||||
*/
|
||||
export function createPlayerEntity(
|
||||
world: World,
|
||||
x: number,
|
||||
y: number,
|
||||
): number {
|
||||
const eid = createGameEntity(world, {
|
||||
position: { x, y },
|
||||
velocity: { vx: 0, vy: 0 },
|
||||
health: { current: PLAYER_HEALTH, max: PLAYER_HEALTH },
|
||||
sprite: { color: PLAYER_COLOR, radius: PLAYER_RADIUS },
|
||||
});
|
||||
addComponent(world, eid, PlayerTag);
|
||||
return eid;
|
||||
}
|
||||
48
src/player/input.ts
Normal file
48
src/player/input.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Player Input System
|
||||
*
|
||||
* Reads InputState → sets player entity Velocity.
|
||||
* Diagonal movement is normalized to prevent faster diagonal speed.
|
||||
*/
|
||||
|
||||
import { query } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import { Velocity, PlayerTag } from '../ecs/components';
|
||||
import type { InputState } from './types';
|
||||
import { PLAYER_SPEED } from './types';
|
||||
|
||||
/**
|
||||
* Calculate velocity vector from input state (pure function).
|
||||
* Diagonal movement is normalized so magnitude = speed.
|
||||
*/
|
||||
export function calculatePlayerVelocity(
|
||||
input: InputState,
|
||||
speed: number,
|
||||
): { vx: number; vy: number } {
|
||||
const { moveX, moveY } = input;
|
||||
|
||||
if (moveX === 0 && moveY === 0) {
|
||||
return { vx: 0, vy: 0 };
|
||||
}
|
||||
|
||||
// Normalize diagonal so total speed stays constant
|
||||
const isDiagonal = moveX !== 0 && moveY !== 0;
|
||||
const factor = isDiagonal ? 1 / Math.SQRT2 : 1;
|
||||
|
||||
return {
|
||||
vx: moveX * speed * factor,
|
||||
vy: moveY * speed * factor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ECS system: set player entity velocity from input.
|
||||
* Only affects entities with PlayerTag + Velocity.
|
||||
*/
|
||||
export function playerInputSystem(world: World, input: InputState): void {
|
||||
const vel = calculatePlayerVelocity(input, PLAYER_SPEED);
|
||||
for (const eid of query(world, [Velocity, PlayerTag])) {
|
||||
Velocity.vx[eid] = vel.vx;
|
||||
Velocity.vy[eid] = vel.vy;
|
||||
}
|
||||
}
|
||||
51
src/player/spawn.ts
Normal file
51
src/player/spawn.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Player Spawn Position
|
||||
*
|
||||
* Finds a walkable tile near the center of the map.
|
||||
* Spirals outward from center to find the nearest valid spawn.
|
||||
*/
|
||||
|
||||
import type { TileGrid } from '../world/types';
|
||||
|
||||
/**
|
||||
* Find a walkable spawn position, starting from map center.
|
||||
* Returns pixel coordinates (center of tile) or null if no walkable tile exists.
|
||||
*/
|
||||
export function findSpawnPosition(
|
||||
grid: TileGrid,
|
||||
tileSize: number,
|
||||
walkable: Set<number>,
|
||||
): { x: number; y: number } | null {
|
||||
const height = grid.length;
|
||||
if (height === 0) return null;
|
||||
const width = grid[0].length;
|
||||
if (width === 0) return null;
|
||||
|
||||
const centerY = Math.floor(height / 2);
|
||||
const centerX = Math.floor(width / 2);
|
||||
|
||||
// Spiral outward from center
|
||||
const maxRadius = Math.max(width, height);
|
||||
for (let r = 0; r <= maxRadius; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
// Only check perimeter of ring (interior was checked in smaller radius)
|
||||
if (r > 0 && Math.abs(dx) < r && Math.abs(dy) < r) continue;
|
||||
|
||||
const ty = centerY + dy;
|
||||
const tx = centerX + dx;
|
||||
|
||||
if (ty >= 0 && ty < height && tx >= 0 && tx < width) {
|
||||
if (walkable.has(grid[ty][tx])) {
|
||||
return {
|
||||
x: tx * tileSize + tileSize / 2,
|
||||
y: ty * tileSize + tileSize / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
33
src/player/types.ts
Normal file
33
src/player/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Player Types & Constants
|
||||
*
|
||||
* InputState represents the current frame's player input.
|
||||
* Constants define player attributes (speed, size, health, color).
|
||||
*/
|
||||
|
||||
/** Keyboard/gamepad state for current frame */
|
||||
export interface InputState {
|
||||
/** Horizontal axis: -1 (left), 0 (none), +1 (right) */
|
||||
moveX: number;
|
||||
/** Vertical axis: -1 (up), 0 (none), +1 (down) */
|
||||
moveY: number;
|
||||
/** Interact key (E) pressed */
|
||||
interact: boolean;
|
||||
}
|
||||
|
||||
// === Player Constants ===
|
||||
|
||||
/** Movement speed in pixels per second */
|
||||
export const PLAYER_SPEED = 150;
|
||||
|
||||
/** Collision radius in pixels (smaller than visual for forgiving feel) */
|
||||
export const PLAYER_COLLISION_RADIUS = 6;
|
||||
|
||||
/** Starting and maximum health */
|
||||
export const PLAYER_HEALTH = 100;
|
||||
|
||||
/** Visual color (bright cyan — stands out on dark biome) */
|
||||
export const PLAYER_COLOR = 0x00e5ff;
|
||||
|
||||
/** Visual radius in pixels */
|
||||
export const PLAYER_RADIUS = 10;
|
||||
@@ -30,7 +30,7 @@ export class BootScene extends Phaser.Scene {
|
||||
|
||||
// Version
|
||||
this.add
|
||||
.text(cx, cy + 80, 'v0.2.0 — Phase 2: ECS Foundation', {
|
||||
.text(cx, cy + 80, 'v0.4.0 — Phase 4: Player Systems', {
|
||||
fontSize: '12px',
|
||||
color: '#333333',
|
||||
fontFamily: 'monospace',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Phaser from 'phaser';
|
||||
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
|
||||
import { Position } from '../ecs/components';
|
||||
import { movementSystem } from '../ecs/systems/movement';
|
||||
import { healthSystem } from '../ecs/systems/health';
|
||||
import { removeGameEntity } from '../ecs/factory';
|
||||
@@ -8,23 +9,40 @@ import biomeDataArray from '../data/biomes.json';
|
||||
import type { BiomeData } from '../world/types';
|
||||
import { generateWorld } from '../world/generator';
|
||||
import { createWorldTilemap } from '../world/tilemap';
|
||||
import { setupCamera, updateCamera, type CameraKeys } from '../world/camera';
|
||||
import { setupPlayerCamera } from '../world/camera';
|
||||
import { Minimap } from '../world/minimap';
|
||||
import { playerInputSystem } from '../player/input';
|
||||
import { tileCollisionSystem, buildWalkableSet } from '../player/collision';
|
||||
import { findSpawnPosition } from '../player/spawn';
|
||||
import { createPlayerEntity } from '../player/factory';
|
||||
import type { InputState } from '../player/types';
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private gameWorld!: GameWorld;
|
||||
private bridge!: PhaserBridge;
|
||||
private cameraKeys!: CameraKeys;
|
||||
private minimap!: Minimap;
|
||||
private statsText!: Phaser.GameObjects.Text;
|
||||
private worldSeed!: number;
|
||||
|
||||
// Player state
|
||||
private playerEid!: number;
|
||||
private walkableSet!: Set<number>;
|
||||
private worldGrid!: number[][];
|
||||
private tileSize!: number;
|
||||
private keys!: {
|
||||
W: Phaser.Input.Keyboard.Key;
|
||||
A: Phaser.Input.Keyboard.Key;
|
||||
S: Phaser.Input.Keyboard.Key;
|
||||
D: Phaser.Input.Keyboard.Key;
|
||||
E: Phaser.Input.Keyboard.Key;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super({ key: 'GameScene' });
|
||||
}
|
||||
|
||||
create(): void {
|
||||
// 1. Initialize ECS (needed for future entity systems)
|
||||
// 1. Initialize ECS
|
||||
this.gameWorld = createGameWorld();
|
||||
this.bridge = new PhaserBridge(this);
|
||||
|
||||
@@ -36,15 +54,45 @@ export class GameScene extends Phaser.Scene {
|
||||
// 3. Create tilemap
|
||||
createWorldTilemap(this, worldData);
|
||||
|
||||
// 4. Camera with bounds and WASD controls
|
||||
// 4. Build walkable set + store world data for collision
|
||||
this.walkableSet = buildWalkableSet(biome.tiles);
|
||||
this.worldGrid = worldData.grid;
|
||||
this.tileSize = biome.tileSize;
|
||||
|
||||
// 5. Create player at spawn position
|
||||
const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet);
|
||||
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
|
||||
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
|
||||
this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY);
|
||||
|
||||
// 6. Camera — follow player, zoom via scroll wheel
|
||||
const worldPixelW = biome.mapWidth * biome.tileSize;
|
||||
const worldPixelH = biome.mapHeight * biome.tileSize;
|
||||
this.cameraKeys = setupCamera(this, worldPixelW, worldPixelH);
|
||||
setupPlayerCamera(this, worldPixelW, worldPixelH);
|
||||
|
||||
// 5. Minimap
|
||||
// Sync bridge to create player sprite, then attach camera follow
|
||||
this.bridge.sync(this.gameWorld.world);
|
||||
const playerSprite = this.bridge.getSprite(this.playerEid);
|
||||
if (playerSprite) {
|
||||
playerSprite.setDepth(10);
|
||||
this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1);
|
||||
}
|
||||
|
||||
// 7. Keyboard input
|
||||
const keyboard = this.input.keyboard;
|
||||
if (!keyboard) throw new Error('Keyboard plugin not available');
|
||||
this.keys = {
|
||||
W: keyboard.addKey('W'),
|
||||
A: keyboard.addKey('A'),
|
||||
S: keyboard.addKey('S'),
|
||||
D: keyboard.addKey('D'),
|
||||
E: keyboard.addKey('E'),
|
||||
};
|
||||
|
||||
// 8. Minimap
|
||||
this.minimap = new Minimap(this, worldData);
|
||||
|
||||
// 6. UI overlay
|
||||
// 9. UI stats overlay
|
||||
this.statsText = this.add.text(10, 10, '', {
|
||||
fontSize: '12px',
|
||||
color: '#00ff88',
|
||||
@@ -60,24 +108,46 @@ export class GameScene extends Phaser.Scene {
|
||||
// 1. Update world time
|
||||
updateTime(this.gameWorld, delta);
|
||||
|
||||
// 2. Camera movement
|
||||
updateCamera(this, this.cameraKeys, delta);
|
||||
// 2. Read keyboard → InputState
|
||||
const input: InputState = {
|
||||
moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0),
|
||||
moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0),
|
||||
interact: this.keys.E.isDown,
|
||||
};
|
||||
|
||||
// 3. ECS systems (no entities yet — future phases will add player, creatures)
|
||||
// 3. Player input → velocity
|
||||
playerInputSystem(this.gameWorld.world, input);
|
||||
|
||||
// 4. Movement (all entities)
|
||||
movementSystem(this.gameWorld.world, delta);
|
||||
|
||||
// 5. Tile collision (player only)
|
||||
tileCollisionSystem(
|
||||
this.gameWorld.world,
|
||||
delta,
|
||||
this.worldGrid,
|
||||
this.tileSize,
|
||||
this.walkableSet,
|
||||
);
|
||||
|
||||
// 6. Health / death
|
||||
const dead = healthSystem(this.gameWorld.world);
|
||||
for (const eid of dead) {
|
||||
removeGameEntity(this.gameWorld.world, eid);
|
||||
}
|
||||
|
||||
// 7. Render sync
|
||||
this.bridge.sync(this.gameWorld.world);
|
||||
|
||||
// 4. Minimap viewport
|
||||
// 8. Minimap viewport
|
||||
this.minimap.update(this.cameras.main);
|
||||
|
||||
// 5. Stats
|
||||
// 9. Stats
|
||||
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
|
||||
const px = Math.round(Position.x[this.playerEid]);
|
||||
const py = Math.round(Position.y[this.playerEid]);
|
||||
this.statsText.setText(
|
||||
`seed: ${this.worldSeed} | ${fps} fps | WASD move, scroll zoom`,
|
||||
`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | WASD move, E interact, scroll zoom`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,3 +67,29 @@ export function updateCamera(
|
||||
if (keys.up.isDown) camera.scrollY -= speed * dt;
|
||||
if (keys.down.isDown) camera.scrollY += speed * dt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up camera for player-follow mode.
|
||||
* No WASD movement — camera follows the player entity.
|
||||
* Zoom via mouse wheel, bounds clamped to world size.
|
||||
*/
|
||||
export function setupPlayerCamera(
|
||||
scene: Phaser.Scene,
|
||||
worldPixelWidth: number,
|
||||
worldPixelHeight: number,
|
||||
): void {
|
||||
const camera = scene.cameras.main;
|
||||
camera.setBounds(0, 0, worldPixelWidth, worldPixelHeight);
|
||||
camera.setZoom(2); // closer view for gameplay
|
||||
|
||||
// Mouse wheel zoom (0.5x – 3x)
|
||||
scene.input.on('wheel', (
|
||||
_pointer: unknown,
|
||||
_gameObjects: unknown,
|
||||
_deltaX: number,
|
||||
deltaY: number,
|
||||
) => {
|
||||
const newZoom = Phaser.Math.Clamp(camera.zoom - deltaY * 0.001, 0.5, 3);
|
||||
camera.setZoom(newZoom);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user