Added rail tracks, cart and camera movement with arrow keys, removed enemies... 4 now

This commit is contained in:
2026-01-31 13:47:34 +11:00
parent b18e2d08ba
commit 43b33733e9
22 changed files with 712 additions and 395 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -73,10 +73,16 @@ export const GAME_CONFIG = {
},
enemyScaling: {
baseCount: 3,
baseCountPerFloor: 3,
baseCount: 0,
baseCountPerFloor: 0,
hpPerFloor: 5,
attackPerTwoFloors: 1,
expMultiplier: 1.2
},
trapScaling: {
baseCount: 0,
baseCountPerFloor: 0.5
},
leveling: {
@@ -180,9 +186,14 @@ export const GAME_CONFIG = {
{ key: "PriestessNorth", path: "assets/sprites/priestess/PriestessNorth.png" },
{ key: "PriestessSouth", path: "assets/sprites/priestess/PriestessSouth.png" },
{ key: "PriestessEast", path: "assets/sprites/priestess/PriestessEast.png" },
{ key: "PriestessWest", path: "assets/sprites/priestess/PriestessWest.png" }
{ key: "PriestessWest", path: "assets/sprites/priestess/PriestessWest.png" },
{ key: "mine_cart", path: "assets/sprites/items/mine_cart.png" },
{ key: "track_straight", path: "assets/sprites/items/track_straight.png" },
{ key: "track_corner", path: "assets/sprites/items/track_corner.png" },
{ key: "track_vertical", path: "assets/sprites/items/track_vertical.png" }
]
},
animations: [

View File

@@ -8,9 +8,13 @@ export const TileType = {
EXIT: 8,
WATER: 63, // Unused but kept for safety/legacy
DOOR_CLOSED: 5,
DOOR_OPEN: 6
DOOR_OPEN: 6,
TRACK: 30, // Restored to 30 to fix duplicate key error
SWITCH_OFF: 31,
SWITCH_ON: 32
} as const;
export type TileType = typeof TileType[keyof typeof TileType];
export interface TileBehavior {
@@ -32,9 +36,13 @@ export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED }
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED },
[TileType.TRACK]: { id: TileType.TRACK, isBlocking: false, isDestructible: false },
[TileType.SWITCH_OFF]: { id: TileType.SWITCH_OFF, isBlocking: true, isDestructible: false },
[TileType.SWITCH_ON]: { id: TileType.SWITCH_ON, isBlocking: true, isDestructible: false }
};
export function isBlocking(tile: number): boolean {
const def = TILE_DEFINITIONS[tile];
return def ? def.isBlocking : false;

View File

@@ -27,7 +27,8 @@ export type SimEvent =
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number }
| { type: "move-blocked"; actorId: EntityId; x: number; y: number };
| { type: "move-blocked"; actorId: EntityId; x: number; y: number }
| { type: "mission-complete" };
export type Stats = {
@@ -222,6 +223,7 @@ export type World = {
height: number;
tiles: Tile[];
exit: Vec2;
trackPath: Vec2[];
};
export interface UIUpdatePayload {

View File

@@ -1,6 +1,6 @@
import { type ECSWorld } from "./World";
import { type ComponentMap } from "./components";
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item } from "../../core/types";
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item, type Vec2 } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
/**
@@ -167,17 +167,22 @@ export class EntityBuilder {
asTrigger(options: {
onEnter?: boolean;
onExit?: boolean;
onInteract?: boolean;
oneShot?: boolean;
targetId?: EntityId;
effect?: string;
effectDuration?: number;
}): this {
this.components.trigger = {
onEnter: options.onEnter ?? true,
onExit: options.onExit,
onInteract: options.onInteract,
oneShot: options.oneShot,
targetId: options.targetId,
effect: options.effect,
effectDuration: options.effectDuration
};
return this;
}
@@ -237,11 +242,26 @@ export class EntityBuilder {
return this;
}
/**
* Configure as a mine cart.
*/
asMineCart(path: Vec2[]): this {
this.components.mineCart = {
isMoving: false,
path,
pathIndex: 0
};
this.withSprite("mine_cart", 0);
this.withName("Mine Cart");
return this;
}
/**
* Finalize and register all components with the ECS world.
* @returns The created entity ID
*/
build(): EntityId {
for (const [type, data] of Object.entries(this.components)) {
if (data !== undefined) {
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);

View File

@@ -19,15 +19,18 @@ export type GameEvent =
// Movement & trigger events
| { type: "stepped_on"; entityId: EntityId; x: number; y: number }
| { type: "entity_moved"; entityId: EntityId; from: { x: number; y: number }; to: { x: number; y: number } }
| { type: "trigger_activated"; triggerId: EntityId; activatorId: EntityId }
// Status effect events
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
| { type: "status_expired"; entityId: EntityId; status: string }
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number }
// World events
| { type: "tile_changed"; x: number; y: number };
| { type: "tile_changed"; x: number; y: number }
| { type: "mission_complete" };
export type GameEventType = GameEvent["type"];

View File

@@ -1,6 +1,7 @@
import { type ECSWorld } from "./World";
import { EntityBuilder } from "./EntityBuilder";
import { type EntityId, type Item } from "../../core/types";
import { type EntityId, type Item, type Vec2 } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
/**
@@ -222,9 +223,36 @@ export const Prefabs = {
.withEnergy(config.speed)
.withCombat()
.build();
},
/**
* Create a mine cart at the start of a path.
*/
mineCart(world: ECSWorld, path: Vec2[]): EntityId {
const start = path[0];
return EntityBuilder.create(world)
.withPosition(start.x, start.y)
.asMineCart(path)
.build();
},
/**
* Create a switch that triggers the mine cart.
*/
trackSwitch(world: ECSWorld, x: number, y: number, cartId: EntityId): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Track Switch")
.withSprite("dungeon", 31) // TileType.SWITCH_OFF
.asTrigger({
onInteract: true,
targetId: cartId
})
.build();
}
};
/**
* Type for prefab factory functions.
* Useful for creating maps of spawnable entities.

View File

@@ -46,13 +46,25 @@ export interface ActorTypeComponent {
export interface TriggerComponent {
onEnter?: boolean; // Trigger when entity steps on this tile
onExit?: boolean; // Trigger when entity leaves this tile
onInteract?: boolean; // Trigger when entity interacts with this
oneShot?: boolean; // Destroy/disable after triggering once
triggered?: boolean; // Has already triggered (for oneShot triggers)
targetId?: EntityId; // Target entity for this trigger (e.g., mine cart for a switch)
damage?: number; // Damage to deal on trigger (for traps)
effect?: string; // Status effect to apply (e.g., "poison", "slow")
effectDuration?: number; // Duration of applied effect
}
/**
* For the Mine Cart.
*/
export interface MineCartComponent {
isMoving: boolean;
path: Vec2[];
pathIndex: number;
}
/**
* Status effect instance applied to an entity.
*/
@@ -133,6 +145,7 @@ export type ComponentMap = {
inventory: InventoryComponent;
equipment: EquipmentComponent;
lifeSpan: LifeSpanComponent;
mineCart: MineCartComponent;
};
export type ComponentType = keyof ComponentMap;

View File

@@ -0,0 +1,47 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type EntityId } from "../../../core/types";
/**
* System that moves the mine cart along its fixed path.
* Moves 1 tile per update (tick).
*/
export class MineCartSystem extends System {
readonly name = "MineCart";
readonly requiredComponents = ["mineCart", "position", "sprite"] as const;
update(entities: EntityId[], world: ECSWorld) {
for (const id of entities) {
const mineCart = world.getComponent(id, "mineCart");
const pos = world.getComponent(id, "position");
if (!mineCart || !pos || !mineCart.isMoving) continue;
// Move to next path node if available
if (mineCart.pathIndex < mineCart.path.length - 1) {
mineCart.pathIndex++;
const nextPos = mineCart.path[mineCart.pathIndex];
// Update position component
pos.x = nextPos.x;
pos.y = nextPos.y;
// Emit event for visual feedback
this.eventBus?.emit({
type: "entity_moved",
entityId: id,
from: { x: pos.x, y: pos.y },
to: nextPos
});
} else {
// Reached the end
if (mineCart.isMoving) {
mineCart.isMoving = false;
this.eventBus?.emit({ type: "mission_complete" });
}
}
}
}
}

View File

@@ -49,9 +49,14 @@ export class TriggerSystem extends System {
const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
// Handle enter
if (trigger.onEnter && isOnTrigger && !wasOnTrigger) {
// Handle enter or manual trigger
if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || (trigger.triggered && !trigger.oneShot)) {
this.activateTrigger(triggerId, entityId, trigger, world);
// If it was manually triggered, we should probably reset the flag if its not oneShot
if (trigger.triggered && !trigger.oneShot) {
trigger.triggered = false;
}
}
// Handle exit
@@ -81,6 +86,9 @@ export class TriggerSystem extends System {
effectDuration?: number;
oneShot?: boolean;
triggered?: boolean;
targetId?: EntityId;
onInteract?: boolean;
},
world: ECSWorld
): void {
@@ -91,7 +99,22 @@ export class TriggerSystem extends System {
activatorId
});
// Handle Mine Cart activation
if (trigger.targetId) {
const mineCart = world.getComponent(trigger.targetId, "mineCart");
if (mineCart) {
mineCart.isMoving = true;
// Change switch sprite to "on" (using dungeon sprite 32)
const sprite = world.getComponent(triggerId, "sprite");
if (sprite) {
sprite.index = 32;
}
}
}
// Apply damage if trap
if (trigger.damage && trigger.damage > 0) {
const stats = world.getComponent(activatorId, "stats");
if (stats) {

View File

@@ -99,25 +99,21 @@ export class GameInput extends Phaser.Events.EventEmitter {
}
public getCursorState() {
// Return simplified cursor state for movement
// Return simplified WASD state for movement
let dx = 0;
let dy = 0;
const left = this.cursors.left?.isDown || this.wasd.A.isDown;
const right = this.cursors.right?.isDown || this.wasd.D.isDown;
const up = this.cursors.up?.isDown || this.wasd.W.isDown;
const down = this.cursors.down?.isDown || this.wasd.S.isDown;
const left = this.wasd.A.isDown;
const right = this.wasd.D.isDown;
const up = this.wasd.W.isDown;
const down = this.wasd.S.isDown;
if (left) dx -= 1;
if (right) dx += 1;
if (up) dy -= 1;
if (down) dy += 1;
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.right!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.up!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.down!) ||
Phaser.Input.Keyboard.JustDown(this.wasd.W) ||
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.wasd.W) ||
Phaser.Input.Keyboard.JustDown(this.wasd.A) ||
Phaser.Input.Keyboard.JustDown(this.wasd.S) ||
Phaser.Input.Keyboard.JustDown(this.wasd.D);
@@ -131,6 +127,19 @@ export class GameInput extends Phaser.Events.EventEmitter {
};
}
public getCameraPanState() {
// Return Arrow key state for camera panning
let dx = 0;
let dy = 0;
if (this.cursors.left?.isDown) dx -= 1;
if (this.cursors.right?.isDown) dx += 1;
if (this.cursors.up?.isDown) dy -= 1;
if (this.cursors.down?.isDown) dy += 1;
return { dx, dy };
}
public cleanup() {
this.removeAllListeners();
// Determine is scene specific cleanup is needed for inputs

View File

@@ -108,6 +108,23 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
handleExpCollection(actor, events, accessor);
}
return events;
} else {
// If blocked, check if we can interact with an entity at the target position
if (actor.category === "combatant" && actor.isPlayer && accessor?.context) {
const ecsWorld = accessor.context;
const interactables = ecsWorld.getEntitiesWith("position", "trigger").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const t = ecsWorld.getComponent(id, "trigger");
return p?.x === nx && p?.y === ny && t?.onInteract;
});
if (interactables.length > 0) {
// Trigger interaction by marking it as triggered
// The TriggerSystem will pick this up on the next update
ecsWorld.getComponent(interactables[0], "trigger")!.triggered = true;
}
}
}
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
@@ -115,6 +132,7 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
const target = accessor.getActor(action.targetId);
if (target && target.category === "combatant" && actor.category === "combatant") {

View File

@@ -18,6 +18,8 @@ import { Prefabs } from "../ecs/Prefabs";
import { EntityBuilder } from "../ecs/EntityBuilder";
interface Room {
x: number;
y: number;
@@ -44,12 +46,25 @@ export function generateWorld(floor: number, runState: RunState): { world: World
// Set ROT's RNG seed for consistent dungeon generation
ROT.RNG.setSeed(floor * 12345);
const rooms = generateRooms(width, height, tiles, floor, random);
// Replace generateRooms call with track-first logic for mine cart mechanic
const { rooms, trackPath } = generateTrackLevel(width, height, tiles, floor, random);
console.log(`[generator] Track generated with ${trackPath.length} nodes.`);
console.log(`[generator] Rooms generated: ${rooms.length}`);
if (!trackPath || trackPath.length === 0) {
throw new Error("Failed to generate track path");
}
// Place player at start of track
const playerX = trackPath[0].x;
const playerY = trackPath[0].y;
// Clear track path
for (const pos of trackPath) {
tiles[pos.y * width + pos.x] = TileType.TRACK;
}
// Place player in first room
const firstRoom = rooms[0];
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
// Create Player Entity in ECS
const runInventory = {
@@ -79,248 +94,218 @@ export function generateWorld(floor: number, runState: RunState): { world: World
.withEnergy(GAME_CONFIG.player.speed)
.build();
// No more legacy Actors Map
// Create Mine Cart at start of track
const cartId = Prefabs.mineCart(ecsWorld, trackPath);
// Place exit in last room
const lastRoom = rooms[rooms.length - 1];
const exit: Vec2 = {
x: lastRoom.x + Math.floor(lastRoom.width / 2),
y: lastRoom.y + Math.floor(lastRoom.height / 2)
};
const exit = { ...trackPath[trackPath.length - 1] };
placeEnemies(floor, rooms, ecsWorld, random);
// Place traps (using same ecsWorld)
// Place Switch at the end of the track
Prefabs.trackSwitch(ecsWorld, exit.x, exit.y, cartId);
// Mark all track and room tiles as occupied for objects
const occupiedPositions = new Set<string>();
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Place doors for dungeon levels (Uniform/Digger)
// Caves (Floors 10+) shouldn't have manufactured doors
if (floor <= 9) {
placeDoors(width, height, tiles, rooms, random);
occupiedPositions.add(`${playerX},${playerY}`);
occupiedPositions.add(`${exit.x},${exit.y}`);
for (const pos of trackPath) {
occupiedPositions.add(`${pos.x},${pos.y}`);
}
// Place enemies
placeEnemies(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Place traps
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Decorate and finalize tiles
decorate(width, height, tiles, random, exit);
// CRITICAL FIX: Ensure player start position is always clear!
// Otherwise spawning in Grass (which blocks vision) makes the player blind.
// Ensure start and end are walkable and marked
tiles[playerY * width + playerX] = TileType.EMPTY;
tiles[exit.y * width + exit.x] = TileType.EXIT;
return {
world: { width, height, tiles, exit },
world: { width, height, tiles, exit, trackPath },
playerId,
ecsWorld
};
}
// Update generateRooms signature to accept random
function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] {
/**
* Generates a level with a central rail track from start to end.
*/
function generateTrackLevel(width: number, height: number, tiles: Tile[], _floor: number, random: () => number): { rooms: Room[], trackPath: Vec2[] } {
const rooms: Room[] = [];
// Choose dungeon algorithm based on floor depth
let dungeon: any;
// 1. Generate Start and End points (further apart)
const start: Vec2 = { x: 3, y: 5 + Math.floor(random() * (height - 10)) };
const end: Vec2 = { x: width - 4, y: 5 + Math.floor(random() * (height - 10)) };
if (floor <= 4) {
// Floors 1-4: Uniform (organic, irregular rooms)
dungeon = new ROT.Map.Uniform(width, height, {
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
roomDugPercentage: 0.3,
});
} else if (floor <= 9) {
// Floors 5-9: Digger (traditional rectangular rooms + corridors)
dungeon = new ROT.Map.Digger(width, height, {
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth],
roomHeight: [GAME_CONFIG.map.roomMinHeight, GAME_CONFIG.map.roomMaxHeight],
corridorLength: [2, 6],
});
} else {
// Floors 10+: Cellular (natural cave systems)
dungeon = new ROT.Map.Cellular(width, height, {
born: [4, 5, 6, 7, 8],
survive: [2, 3, 4, 5],
});
// 2. Generate Track Path (Winding random walk)
const trackPath: Vec2[] = [];
let curr = { ...start };
trackPath.push(curr);
// Cellular needs randomization and smoothing
dungeon.randomize(0.5);
for (let i = 0; i < 4; i++) {
dungeon.create();
}
}
// Bias weights
const targetBias = 0.6;
const straightBias = 0.2;
// Generate the dungeon
dungeon.create((x: number, y: number, value: number) => {
if (value === 0) {
// 0 = floor, 1 = wall
tiles[y * width + x] = TileType.EMPTY;
}
});
let iter = 0;
const maxIter = width * height;
// Extract room information from the generated dungeon
const roomData = (dungeon as any).getRooms?.();
let lastDir = { dx: 1, dy: 0 };
if (roomData && roomData.length > 0) {
// Traditional dungeons (Uniform/Digger) have explicit rooms
for (const room of roomData) {
rooms.push({
x: room.getLeft(),
y: room.getTop(),
width: room.getRight() - room.getLeft() + 1,
height: room.getBottom() - room.getTop() + 1
});
}
} else {
// Cellular caves don't have explicit rooms, so find connected floor areas
rooms.push(...extractRoomsFromCave(width, height, tiles));
// Connect the isolated cave rooms
connectRooms(width, tiles, rooms, random);
}
// Ensure we have at least 2 rooms for player/exit placement
if (rooms.length < 2) {
// Fallback: create two basic rooms
rooms.push(
{ x: 5, y: 5, width: 5, height: 5 },
{ x: width - 10, y: height - 10, width: 5, height: 5 }
);
// Connect the fallback rooms
connectRooms(width, tiles, rooms, random);
}
return rooms;
}
function connectRooms(width: number, tiles: Tile[], rooms: Room[], random: () => number) {
for (let i = 0; i < rooms.length - 1; i++) {
const r1 = rooms[i];
const r2 = rooms[i + 1];
const c1x = r1.x + Math.floor(r1.width / 2);
const c1y = r1.y + Math.floor(r1.height / 2);
const c2x = r2.x + Math.floor(r2.width / 2);
const c2y = r2.y + Math.floor(r2.height / 2);
if (random() < 0.5) {
digH(width, tiles, c1x, c2x, c1y);
digV(width, tiles, c1y, c2y, c2x);
} else {
digV(width, tiles, c1y, c2y, c1x);
digH(width, tiles, c1x, c2x, c2y);
}
}
}
function digH(width: number, tiles: Tile[], x1: number, x2: number, y: number) {
const start = Math.min(x1, x2);
const end = Math.max(x1, x2);
for (let x = start; x <= end; x++) {
const idx = y * width + x;
if (tiles[idx] === TileType.WALL) {
tiles[idx] = TileType.EMPTY;
}
}
}
function digV(width: number, tiles: Tile[], y1: number, y2: number, x: number) {
const start = Math.min(y1, y2);
const end = Math.max(y1, y2);
for (let y = start; y <= end; y++) {
const idx = y * width + x;
if (tiles[idx] === TileType.WALL) {
tiles[idx] = TileType.EMPTY;
}
}
}
/**
* For cellular/cave maps, find clusters of floor tiles to use as "rooms"
*/
function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Room[] {
const rooms: Room[] = [];
const visited = new Set<number>();
// Find large connected floor areas
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
if (tiles[idx] === TileType.EMPTY && !visited.has(idx)) {
const cluster = floodFill(width, height, tiles, x, y, visited);
// Only consider clusters larger than 20 tiles
if (cluster.length > 20) {
// Create bounding box for this cluster
let minX = width, maxX = 0, minY = height, maxY = 0;
for (const pos of cluster) {
const cx = pos % width;
const cy = Math.floor(pos / width);
minX = Math.min(minX, cx);
maxX = Math.max(maxX, cx);
minY = Math.min(minY, cy);
maxY = Math.max(maxY, cy);
}
rooms.push({
x: minX,
y: minY,
width: maxX - minX + 1,
height: maxY - minY + 1
});
}
}
}
}
return rooms;
}
/**
* Flood fill to find connected floor tiles
*/
function floodFill(width: number, height: number, tiles: Tile[], startX: number, startY: number, visited: Set<number>): number[] {
const cluster: number[] = [];
const queue: number[] = [startY * width + startX];
while (queue.length > 0) {
const idx = queue.shift()!;
if (visited.has(idx)) continue;
visited.add(idx);
cluster.push(idx);
const x = idx % width;
const y = Math.floor(idx / width);
// Check 4 directions
const neighbors = [
{ nx: x + 1, ny: y },
{ nx: x - 1, ny: y },
{ nx: x, ny: y + 1 },
{ nx: x, ny: y - 1 },
while ((curr.x !== end.x || curr.y !== end.y) && iter < maxIter) {
iter++;
// Determine possible directions
const dirs = [
{ dx: 1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 },
{ dx: -1, dy: 0 }
];
for (const { nx, ny } of neighbors) {
// Score directions
const scores = dirs.map(d => {
let score = 0;
// Target bias (distance reduction)
const distCurr = Math.abs(curr.x - end.x) + Math.abs(curr.y - end.y);
const distNext = Math.abs((curr.x + d.dx) - end.x) + Math.abs((curr.y + d.dy) - end.y);
if (distNext < distCurr) score += targetBias;
// Straight bias
if (d.dx === lastDir.dx && d.dy === lastDir.dy) score += straightBias;
// Randomness
score += random() * 0.3;
// Boundary check
const nx = curr.x + d.dx;
const ny = curr.y + d.dy;
if (nx < 2 || nx >= width - 2 || ny < 2 || ny >= height - 2) score = -100;
return { d, score };
});
// scores already sorted
scores.sort((a, b) => b.score - a.score);
const best = scores[0];
const nextX = curr.x + best.d.dx;
const nextY = curr.y + best.d.dy;
// Create NEW position object to avoid mutation bugs
curr = { x: nextX, y: nextY };
lastDir = best.d;
// Avoid double-back if possible
const existing = trackPath.find(p => p.x === curr.x && p.y === curr.y);
if (!existing) {
trackPath.push({ ...curr });
}
}
console.log(`[generator] Track walker finished at (${curr.x}, ${curr.y}) after ${iter} iterations. Path length: ${trackPath.length}`);
// 3. Dig out the track path (Narrower 2x2 tunnel)
for (const pos of trackPath) {
for (let dy = 0; dy <= 1; dy++) {
for (let dx = 0; dx <= 1; dx++) {
const nx = pos.x + dx;
const ny = pos.y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = ny * width + nx;
if (tiles[nIdx] === TileType.EMPTY && !visited.has(nIdx)) {
queue.push(nIdx);
tiles[ny * width + nx] = TileType.EMPTY;
}
}
}
}
return cluster;
// 4. Generate rooms branching off the track
const numRooms = 12 + Math.floor(random() * 6);
for (let i = 0; i < numRooms; i++) {
const pathIdx = Math.floor(random() * trackPath.length);
const pathNode = trackPath[pathIdx];
const rw = 6 + Math.floor(random() * 6);
const rh = 5 + Math.floor(random() * 6);
// Random side offset
const side = random() < 0.5 ? -1 : 1;
let rx, ry;
if (random() < 0.5) { // Horizontal branch
rx = pathNode.x + (side * Math.floor(rw / 2 + 2));
ry = pathNode.y - Math.floor(rh / 2);
} else { // Vertical branch
rx = pathNode.x - Math.floor(rw / 2);
ry = pathNode.y + (side * Math.floor(rh / 2 + 2));
}
function decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void {
rx = Math.max(1, Math.min(width - rw - 1, rx));
ry = Math.max(1, Math.min(height - rh - 1, ry));
const room = { x: rx, y: ry, width: rw, height: rh };
// Overlap check
const overlap = rooms.some(r => {
return !(room.x + room.width < r.x - 1 ||
room.x > r.x + r.width + 1 ||
room.y + room.height < r.y - 1 ||
room.y > r.y + r.height + 1);
});
if (overlap) continue;
// Dig room interior
for (let y = ry + 1; y < ry + rh - 1; y++) {
for (let x = rx + 1; x < rx + rw - 1; x++) {
tiles[y * width + x] = TileType.EMPTY;
}
}
// Connect room to path node
digCorridor(width, tiles, pathNode.x, pathNode.y, rx + Math.floor(rw / 2), ry + Math.floor(rh / 2));
// Door at entrance
let ex = rx + Math.floor(rw / 2);
let ey = ry + (pathNode.y <= ry ? 0 : rh - 1);
if (Math.abs(pathNode.x - (rx + rw / 2)) > Math.abs(pathNode.y - (ry + rh / 2))) {
ex = (pathNode.x <= rx ? 0 : rw - 1) + rx;
ey = ry + Math.floor(rh / 2);
}
tiles[ey * width + ex] = TileType.DOOR_CLOSED;
rooms.push(room);
}
// Place visual exit at track end
const lastNode = trackPath[trackPath.length - 1];
tiles[lastNode.y * width + lastNode.x] = TileType.EXIT;
return { rooms, trackPath };
}
function digCorridor(width: number, tiles: Tile[], x1: number, y1: number, x2: number, y2: number) {
let currX = x1;
let currY = y1;
while (currX !== x2 || currY !== y2) {
if (currX !== x2) {
currX += x2 > currX ? 1 : -1;
} else if (currY !== y2) {
currY += y2 > currY ? 1 : -1;
}
tiles[currY * width + currX] = TileType.EMPTY;
}
}
function decorate(width: number, height: number, tiles: Tile[], random: () => number, _exit: Vec2): void {
const world = { width, height };
// Set exit tile
tiles[idx(world as any, exit.x, exit.y)] = TileType.EXIT;
// Stairs removed as per user request
// Use Simplex noise for natural-looking grass distribution
const grassNoise = new ROT.Noise.Simplex();
@@ -352,7 +337,6 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
if (decoValue > 0.5) {
tiles[i] = TileType.EMPTY_DECO;
} else if (decoValue > 0.3 && random() < 0.3) {
// Sparse decorations at medium noise levels
tiles[i] = TileType.EMPTY_DECO;
}
}
@@ -375,11 +359,20 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
}
}
function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random: () => number): void {
function placeEnemies(
floor: number,
rooms: Room[],
ecsWorld: ECSWorld,
tiles: Tile[],
width: number,
random: () => number,
occupiedPositions: Set<string>
): void {
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
const occupiedPositions = new Set<string>();
if (rooms.length < 2) return;
for (let i = 0; i < numEnemies; i++) {
// Pick a random room (not the starting room 0)
@@ -392,8 +385,12 @@ function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random:
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
const k = `${ex},${ey}`;
const tileIdx = ey * width + ex;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
if (!occupiedPositions.has(k)) {
if (isFloor && !occupiedPositions.has(k)) {
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
const enemyDef = GAME_CONFIG.enemies[type];
@@ -444,6 +441,8 @@ function placeTraps(
const maxTraps = minTraps + 2;
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
if (rooms.length < 2) return;
for (let i = 0; i < numTraps; i++) {
// Pick a random room (not the starting room)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
@@ -491,39 +490,3 @@ function placeTraps(
export const makeTestWorld = generateWorld;
function placeDoors(width: number, height: number, tiles: Tile[], rooms: Room[], random: () => number): void {
const checkAndPlaceDoor = (x: number, y: number) => {
const i = idx({ width, height } as any, x, y);
if (tiles[i] === TileType.EMPTY) {
// Found a connection (floor tile on perimeter)
// 50% chance to place a door
if (random() < 0.5) {
// 90% chance for closed door, 10% for open
tiles[i] = random() < 0.9 ? TileType.DOOR_CLOSED : TileType.DOOR_OPEN;
}
}
};
for (const room of rooms) {
// Scan top and bottom walls
const topY = room.y - 1;
const bottomY = room.y + room.height;
// Scan horizontal perimeters (iterate x from left-1 to right+1 to cover corners too if needed,
// but usually doors are in the middle segments. Let's cover the full range adjacent to room.)
for (let x = room.x; x < room.x + room.width; x++) {
if (topY >= 0) checkAndPlaceDoor(x, topY);
if (bottomY < height) checkAndPlaceDoor(x, bottomY);
}
// Scan left and right walls
const leftX = room.x - 1;
const rightX = room.x + room.width;
for (let y = room.y; y < room.y + room.height; y++) {
if (leftX >= 0) checkAndPlaceDoor(leftX, y);
if (rightX < width) checkAndPlaceDoor(rightX, y);
}
}
}

View File

@@ -48,8 +48,3 @@ export function isBlocked(w: World, x: number, y: number, accessor: EntityAccess
export function isPlayerOnExit(w: World, accessor: EntityAccessor): boolean {
const p = accessor.getPlayer();
if (!p) return false;
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
}

View File

@@ -30,6 +30,7 @@ export class DungeonRenderer {
private entityAccessor!: EntityAccessor;
private ecsWorld!: ECSWorld;
private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map();
private trackSprites: Phaser.GameObjects.Sprite[] = [];
constructor(scene: Phaser.Scene) {
this.scene = scene;
@@ -50,6 +51,12 @@ export class DungeonRenderer {
}
this.trapSprites.clear();
for (const sprite of this.trackSprites) {
sprite.destroy();
}
this.trackSprites = [];
this.trapSprites.clear();
for (const [, sprite] of this.enemySprites) {
sprite.destroy();
}
@@ -68,22 +75,39 @@ export class DungeonRenderer {
// Setup Tilemap
if (this.map) this.map.destroy();
this.map = this.scene.make.tilemap({
data: Array.from({ length: world.height }, (_, y) =>
Array.from({ length: world.width }, (_, x) => this.world.tiles[idx(this.world, x, y)])
),
tileWidth: 16,
tileHeight: 16
tileWidth: TILE_SIZE,
tileHeight: TILE_SIZE,
width: world.width,
height: world.height
});
const tileset = this.map.addTilesetImage("dungeon", "dungeon", 16, 16, 0, 0)!;
this.layer = this.map.createLayer(0, tileset, 0, 0)!;
const tileset = this.map.addTilesetImage("dungeon", "dungeon");
if (!tileset) {
console.error("[DungeonRenderer] FAILED to load tileset 'dungeon'!");
// Fallback or throw?
}
this.layer = this.map.createBlankLayer("floor", tileset || "dungeon")!;
if (this.layer) {
this.layer.setDepth(0);
this.layer.setVisible(true);
console.log(`[DungeonRenderer] Layer created. Size: ${world.width}x${world.height}`);
} else {
console.error("[DungeonRenderer] FAILED to create tilemap layer!");
}
// Initial tile states (hidden)
this.layer.forEachTile(tile => {
tile.setVisible(false);
});
let tilesPlaced = 0;
for (let y = 0; y < world.height; y++) {
for (let x = 0; x < world.width; x++) {
const i = y * world.width + x;
const tile = world.tiles[i];
if (tile !== undefined && this.layer) {
this.layer.putTileAt(tile, x, y);
tilesPlaced++;
}
}
}
console.log(`[DungeonRenderer] Placed ${tilesPlaced} tiles.`);
this.fxRenderer.clearCorpses();
// Ensure player sprite exists
@@ -111,27 +135,43 @@ export class DungeonRenderer {
}
}
// Create sprites for ECS trap entities
// Create sprites for ECS entities with sprites (traps, mine carts, etc.)
if (this.ecsWorld) {
const traps = this.ecsWorld.getEntitiesWith("trigger", "position", "sprite");
for (const trapId of traps) {
const pos = this.ecsWorld.getComponent(trapId, "position");
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
console.log(`[DungeonRenderer] Creating ECS sprites...`);
const spriteEntities = this.ecsWorld.getEntitiesWith("position", "sprite");
for (const entId of spriteEntities) {
// Skip player as it's handled separately
const player = this.ecsWorld.getComponent(entId, "player");
if (player) continue;
const pos = this.ecsWorld.getComponent(entId, "position");
const spriteData = this.ecsWorld.getComponent(entId, "sprite");
if (pos && spriteData) {
try {
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head";
const sprite = this.scene.add.sprite(
pos.x * TILE_SIZE + TILE_SIZE / 2,
pos.y * TILE_SIZE + TILE_SIZE / 2,
spriteData.texture,
spriteData.index
isStandalone ? undefined : (spriteData.index ?? 0)
);
sprite.setDepth(5); // Below actors, above floor
sprite.setVisible(false); // Hidden until FOV reveals
this.trapSprites.set(trapId, sprite);
sprite.setDepth(5);
sprite.setVisible(true); // Force visible for diagnostics
sprite.setAlpha(1.0); // Force opaque for diagnostics
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
console.log(`[DungeonRenderer] Created sprite for ${spriteData.texture} at ${pos.x},${pos.y}`);
this.trapSprites.set(entId, sprite);
} catch (e) {
console.error(`[DungeonRenderer] Failed to create sprite for entity ${entId}:`, e);
}
}
}
}
// Render static tracks
this.renderTracks();
}
toggleMinimap() {
@@ -163,9 +203,16 @@ export class DungeonRenderer {
return this.fovManager.seenArray;
}
private firstRender = true;
render(_playerPath: Vec2[]) {
if (!this.world || !this.layer) return;
if (this.firstRender) {
console.log(`[DungeonRenderer] First render call... World: ${this.world.width}x${this.world.height}`);
this.firstRender = false;
}
const seen = this.fovManager.seenArray;
const visible = this.fovManager.visibleArray;
@@ -217,6 +264,18 @@ export class DungeonRenderer {
}
});
// Update track sprites visibility
for (const sprite of this.trackSprites) {
const tx = Math.floor(sprite.x / TILE_SIZE);
const ty = Math.floor(sprite.y / TILE_SIZE);
const i = idx(this.world, tx, ty);
const isSeen = seen[i] === 1;
const isVis = visible[i] === 1;
sprite.setVisible(isSeen);
sprite.alpha = isVis ? 1.0 : 0.3;
}
// Update trap sprites visibility and appearance
if (this.ecsWorld) {
for (const [trapId, sprite] of this.trapSprites) {
@@ -224,14 +283,43 @@ export class DungeonRenderer {
const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
if (pos && spriteData) {
// Bounds check
if (pos.x < 0 || pos.x >= this.world.width || pos.y < 0 || pos.y >= this.world.height) {
sprite.setVisible(false);
continue;
}
const i = idx(this.world, pos.x, pos.y);
const isSeen = seen[i] === 1;
const isVis = visible[i] === 1;
sprite.setVisible(isSeen);
// Update position (with simple smoothing)
const targetX = pos.x * TILE_SIZE + TILE_SIZE / 2;
const targetY = pos.y * TILE_SIZE + TILE_SIZE / 2;
if (sprite.x !== targetX || sprite.y !== targetY) {
// Check if it's far away (teleport) or nearby (tween)
const dist = Phaser.Math.Distance.Between(sprite.x, sprite.y, targetX, targetY);
if (dist > TILE_SIZE * 2) {
this.scene.tweens.killTweensOf(sprite);
sprite.setPosition(targetX, targetY);
} else if (!this.scene.tweens.isTweening(sprite)) {
this.scene.tweens.add({
targets: sprite,
x: targetX,
y: targetY,
duration: GAME_CONFIG.rendering.moveDuration,
ease: 'Power1'
});
}
}
// Update sprite frame in case trap was triggered
if (sprite.frame.name !== String(spriteData.index)) {
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head";
if (!isStandalone && sprite.frame.name !== String(spriteData.index)) {
sprite.setFrame(spriteData.index);
}
@@ -491,13 +579,8 @@ export class DungeonRenderer {
? this.scene.add.sprite(startX, startY, texture)
: this.scene.add.sprite(startX, startY, texture, frame);
// Scale for standalone 24x24 image should be 1.0 (or matching world scale)
// Other sprites are 16x16.
if (isStandalone) {
sprite.setDisplaySize(16, 16);
} else {
sprite.setScale(1.0);
}
// Ensure all sprites fit in a single 16x16 tile.
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
sprite.setDepth(2000);
@@ -526,4 +609,59 @@ export class DungeonRenderer {
shakeCamera() {
this.scene.cameras.main.shake(100, 0.01);
}
private renderTracks() {
if (!this.world.trackPath || this.world.trackPath.length === 0) return;
const path = this.world.trackPath;
for (let i = 0; i < path.length; i++) {
const curr = path[i];
const prev = i > 0 ? path[i - 1] : null;
const next = i < path.length - 1 ? path[i + 1] : null;
let spriteKey = "track_straight";
let angle = 0;
if (prev && next) {
const dx1 = curr.x - prev.x;
const dy1 = curr.y - prev.y;
const dx2 = next.x - curr.x;
const dy2 = next.y - curr.y;
if (dx1 === dx2 && dy1 === dy2) {
// Straight
spriteKey = "track_straight";
angle = dx1 === 0 ? 0 : 90; // Asset is vertical (0 deg), rotate to 90 for horizontal
} else {
// Corner
spriteKey = "track_corner";
const p = { dx: prev.x - curr.x, dy: prev.y - curr.y };
const n = { dx: next.x - curr.x, dy: next.y - curr.y };
// Top-Right: 180, Right-Bottom: 270, Bottom-Left: 0, Left-Top: 90
if ((p.dy === -1 && n.dx === 1) || (n.dy === -1 && p.dx === 1)) angle = 180;
else if ((p.dx === 1 && n.dy === 1) || (n.dx === 1 && p.dy === 1)) angle = 270;
else if ((p.dy === 1 && n.dx === -1) || (n.dy === 1 && p.dx === -1)) angle = 0;
else if ((p.dx === -1 && n.dy === -1) || (n.dx === -1 && p.dy === -1)) angle = 90;
}
} else if (next) {
spriteKey = "track_straight";
angle = (next.x === curr.x) ? 0 : 90;
} else if (prev) {
spriteKey = "track_straight";
angle = (prev.x === curr.x) ? 0 : 90;
}
const sprite = this.scene.add.sprite(
curr.x * TILE_SIZE + TILE_SIZE / 2,
curr.y * TILE_SIZE + TILE_SIZE / 2,
spriteKey
);
sprite.setAngle(angle);
sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
sprite.setDepth(2);
sprite.setVisible(false);
this.trackSprites.push(sprite);
}
}
}

View File

@@ -141,12 +141,7 @@ export class FxRenderer {
0
);
corpse.setDepth(50);
// Use display size for Priestess sprites to match 1 tile
if (textureKey.startsWith("Priestess")) {
corpse.setDisplaySize(TILE_SIZE, TILE_SIZE);
} else {
corpse.setScale(1.0); // Reset to standard scale for spritesheet assets
}
corpse.setDisplaySize(TILE_SIZE, TILE_SIZE); // All corpses should be tile-sized

View File

@@ -10,7 +10,7 @@ import {
type RangedWeaponItem,
} from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
import { isBlocked, tryDestructTile } from "../engine/world/world-logic";
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
import { generateWorld } from "../engine/world/generator";
import { DungeonRenderer } from "../rendering/DungeonRenderer";
@@ -28,7 +28,9 @@ import { ECSWorld } from "../engine/ecs/World";
import { SystemRegistry } from "../engine/ecs/System";
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
import { StatusEffectSystem, applyStatusEffect } from "../engine/ecs/systems/StatusEffectSystem";
import { MineCartSystem } from "../engine/ecs/systems/MineCartSystem";
import { TileType } from "../core/terrain";
import { FireSystem } from "../engine/ecs/systems/FireSystem";
import { EventBus } from "../engine/ecs/EventBus";
import { generateLoot } from "../engine/systems/LootSystem";
@@ -87,18 +89,14 @@ export class GameScene extends Phaser.Scene {
}
create() {
// this.cursors initialized in GameInput
// Camera
this.cameras.main.setBackgroundColor(0x1a1a1a);
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
this.cameras.main.fadeIn(1000, 0, 0, 0);
this.cameras.main.fadeIn(500, 0, 0, 0);
// Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this);
this.gameRenderer = new GameRenderer(this.dungeonRenderer);
this.cameraController = new CameraController(this.cameras.main);
// Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor
this.itemManager = new ItemManager(this.world, this.entityAccessor);
this.targetingSystem = new TargetingSystem(this);
@@ -126,12 +124,21 @@ export class GameScene extends Phaser.Scene {
// Load initial floor
this.loadFloor(1);
// Register Handlers
this.playerInputHandler.registerListeners();
this.gameEventHandler.registerListeners();
}
update() {
update(_time: number, _delta: number) {
// Handle camera panning via arrow keys
const cameraPan = this.gameInput.getCameraPanState();
if (cameraPan.dx !== 0 || cameraPan.dy !== 0) {
// Sensitivity factor for smooth panning
// Note: we invert cameraPan dx/dy because handlePan subtracts from scroll (standard for drag)
// but for keys we want to move "in the direction" of the key.
const panSpeed = 12;
this.cameraController.handlePan(-cameraPan.dx * panSpeed, -cameraPan.dy * panSpeed);
}
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.isInventoryOpen || this.isCharacterOpen || this.dungeonRenderer.isMinimapVisible()) return;
@@ -189,6 +196,10 @@ export class GameScene extends Phaser.Scene {
}
public emitUIUpdate() {
if (!this.entityAccessor) {
console.warn("[GameScene] emitUIUpdate called before entityAccessor was initialized.");
return;
}
const payload: UIUpdatePayload = {
world: this.world,
playerId: this.playerId,
@@ -209,7 +220,6 @@ export class GameScene extends Phaser.Scene {
}
this.awaitingPlayer = false;
this.cameraController.enableFollowMode();
// Process reloading progress
const player = this.entityAccessor.getPlayer();
@@ -358,13 +368,6 @@ export class GameScene extends Phaser.Scene {
return;
}
if (isPlayerOnExit(this.world, this.entityAccessor)) {
this.syncRunStateFromPlayer();
this.floorIndex++;
this.loadFloor(this.floorIndex);
return;
}
this.dungeonRenderer.computeFov();
if (this.cameraController.isFollowing) {
const player = this.entityAccessor.getPlayer();
@@ -377,53 +380,96 @@ export class GameScene extends Phaser.Scene {
}
private loadFloor(floor: number) {
try {
console.log(`[GameScene] loadFloor started for floor ${floor}`);
this.floorIndex = floor;
this.cameraController.enableFollowMode();
console.log(`[GameScene] Calling generateWorld...`);
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
console.log(`[GameScene] World generated. Width: ${world.width}, Height: ${world.height}`);
// DIAGNOSTIC: Count tiles
const counts: Record<number, number> = {};
world.tiles.forEach(t => counts[t] = (counts[t] || 0) + 1);
console.log(`[GameScene] Tile counts:`, counts);
console.log(`[GameScene] Exit position:`, world.exit);
this.world = world;
this.playerId = playerId;
// Initialize or update entity accessor
console.log(`[GameScene] Setting up EntityAccessor...`);
if (!this.entityAccessor) {
this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld);
} else {
this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld);
}
console.log(`[GameScene] Updating ItemManager...`);
this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld);
// Initialize ECS for traps and status effects
this.ecsWorld = ecsWorld;
this.ecsEventBus = new EventBus();
// Handle level completion
this.ecsEventBus.on("mission_complete", () => {
console.log("[GameScene] Mission Complete! Loading next floor...");
this.syncRunStateFromPlayer();
this.loadFloor(this.floorIndex + 1);
});
// Register systems
console.log(`[GameScene] Registering ECS systems...`);
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
this.ecsRegistry.register(new TriggerSystem());
this.ecsRegistry.register(new StatusEffectSystem());
this.ecsRegistry.register(new MineCartSystem());
this.ecsRegistry.register(new FireSystem(this.world));
// NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state.
// No need to manually add player here anymore.
console.log(`[GameScene] ECS systems registered.`);
this.playerPath = [];
this.awaitingPlayer = false;
this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
const paddingX = this.world.width * TILE_SIZE;
const paddingY = this.world.height * TILE_SIZE;
this.cameraController.setBounds(
-paddingX,
-paddingY,
this.world.width * TILE_SIZE + paddingX * 2,
this.world.height * TILE_SIZE + paddingY * 2
);
console.log(`[GameScene] Initializing floor renderer...`);
this.dungeonRenderer.initializeFloor(this.world, this.ecsWorld, this.entityAccessor);
this.cameras.main.fadeIn(500, 0, 0, 0); // Force fade in
console.log(`[GameScene] Stepping simulation until player turn...`);
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
console.log(`[GameScene] Computing FOV...`);
this.dungeonRenderer.computeFov();
const p = this.entityAccessor.getPlayer();
if (p) {
console.log(`[GameScene] Centering camera on player at ${p.pos.x},${p.pos.y}`);
this.cameraController.centerOnTile(p.pos.x, p.pos.y);
} else {
console.error(`[GameScene] Player not found for camera centering!`);
}
console.log(`[GameScene] Triggering first render...`);
this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate();
console.log(`[GameScene] loadFloor complete.`);
} catch (e) {
console.error(`[GameScene] CRITICAL ERROR in loadFloor:`, e);
// Fallback: reveal everything if possible?
this.cameras.main.setBackgroundColor(0xff0000); // Red screen of death
}
}
public syncRunStateFromPlayer() {
const p = this.entityAccessor.getPlayer();

View File

@@ -8,7 +8,7 @@ import { GAME_CONFIG } from "../../core/config/GameConfig";
*/
export class CameraController {
private camera: Phaser.Cameras.Scene2D.Camera;
private followMode: boolean = true;
private followMode: boolean = false;
constructor(camera: Phaser.Cameras.Scene2D.Camera) {
this.camera = camera;

View File

@@ -137,8 +137,6 @@ export class PlayerInputHandler {
// Movement Click
if (button !== 0) return;
this.scene.cameraController.enableFollowMode();
if (!this.scene.awaitingPlayer) return;
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;