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: { enemyScaling: {
baseCount: 3, baseCount: 0,
baseCountPerFloor: 3, baseCountPerFloor: 0,
hpPerFloor: 5, hpPerFloor: 5,
attackPerTwoFloors: 1, attackPerTwoFloors: 1,
expMultiplier: 1.2
},
trapScaling: {
baseCount: 0,
baseCountPerFloor: 0.5
}, },
leveling: { leveling: {
@@ -180,9 +186,14 @@ export const GAME_CONFIG = {
{ key: "PriestessNorth", path: "assets/sprites/priestess/PriestessNorth.png" }, { key: "PriestessNorth", path: "assets/sprites/priestess/PriestessNorth.png" },
{ key: "PriestessSouth", path: "assets/sprites/priestess/PriestessSouth.png" }, { key: "PriestessSouth", path: "assets/sprites/priestess/PriestessSouth.png" },
{ key: "PriestessEast", path: "assets/sprites/priestess/PriestessEast.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: [ animations: [

View File

@@ -8,9 +8,13 @@ export const TileType = {
EXIT: 8, EXIT: 8,
WATER: 63, // Unused but kept for safety/legacy WATER: 63, // Unused but kept for safety/legacy
DOOR_CLOSED: 5, 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; } as const;
export type TileType = typeof TileType[keyof typeof TileType]; export type TileType = typeof TileType[keyof typeof TileType];
export interface TileBehavior { export interface TileBehavior {
@@ -32,9 +36,13 @@ export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false }, [TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, 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_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 { export function isBlocking(tile: number): boolean {
const def = TILE_DEFINITIONS[tile]; const def = TILE_DEFINITIONS[tile];
return def ? def.isBlocking : false; return def ? def.isBlocking : false;

View File

@@ -27,7 +27,8 @@ export type SimEvent =
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number } | { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
| { type: "leveled-up"; actorId: EntityId; level: number; 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: "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 = { export type Stats = {
@@ -222,6 +223,7 @@ export type World = {
height: number; height: number;
tiles: Tile[]; tiles: Tile[];
exit: Vec2; exit: Vec2;
trackPath: Vec2[];
}; };
export interface UIUpdatePayload { export interface UIUpdatePayload {

View File

@@ -1,6 +1,6 @@
import { type ECSWorld } from "./World"; import { type ECSWorld } from "./World";
import { type ComponentMap } from "./components"; 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"; import { GAME_CONFIG } from "../../core/config/GameConfig";
/** /**
@@ -167,17 +167,22 @@ export class EntityBuilder {
asTrigger(options: { asTrigger(options: {
onEnter?: boolean; onEnter?: boolean;
onExit?: boolean; onExit?: boolean;
onInteract?: boolean;
oneShot?: boolean; oneShot?: boolean;
targetId?: EntityId;
effect?: string; effect?: string;
effectDuration?: number; effectDuration?: number;
}): this { }): this {
this.components.trigger = { this.components.trigger = {
onEnter: options.onEnter ?? true, onEnter: options.onEnter ?? true,
onExit: options.onExit, onExit: options.onExit,
onInteract: options.onInteract,
oneShot: options.oneShot, oneShot: options.oneShot,
targetId: options.targetId,
effect: options.effect, effect: options.effect,
effectDuration: options.effectDuration effectDuration: options.effectDuration
}; };
return this; return this;
} }
@@ -237,11 +242,26 @@ export class EntityBuilder {
return this; 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. * Finalize and register all components with the ECS world.
* @returns The created entity ID * @returns The created entity ID
*/ */
build(): EntityId { build(): EntityId {
for (const [type, data] of Object.entries(this.components)) { for (const [type, data] of Object.entries(this.components)) {
if (data !== undefined) { if (data !== undefined) {
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any); this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);

View File

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

View File

@@ -1,6 +1,7 @@
import { type ECSWorld } from "./World"; import { type ECSWorld } from "./World";
import { EntityBuilder } from "./EntityBuilder"; 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"; import { GAME_CONFIG } from "../../core/config/GameConfig";
/** /**
@@ -222,9 +223,36 @@ export const Prefabs = {
.withEnergy(config.speed) .withEnergy(config.speed)
.withCombat() .withCombat()
.build(); .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. * Type for prefab factory functions.
* Useful for creating maps of spawnable entities. * Useful for creating maps of spawnable entities.

View File

@@ -46,13 +46,25 @@ export interface ActorTypeComponent {
export interface TriggerComponent { export interface TriggerComponent {
onEnter?: boolean; // Trigger when entity steps on this tile onEnter?: boolean; // Trigger when entity steps on this tile
onExit?: boolean; // Trigger when entity leaves 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 oneShot?: boolean; // Destroy/disable after triggering once
triggered?: boolean; // Has already triggered (for oneShot triggers) 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) damage?: number; // Damage to deal on trigger (for traps)
effect?: string; // Status effect to apply (e.g., "poison", "slow") effect?: string; // Status effect to apply (e.g., "poison", "slow")
effectDuration?: number; // Duration of applied effect 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. * Status effect instance applied to an entity.
*/ */
@@ -133,6 +145,7 @@ export type ComponentMap = {
inventory: InventoryComponent; inventory: InventoryComponent;
equipment: EquipmentComponent; equipment: EquipmentComponent;
lifeSpan: LifeSpanComponent; lifeSpan: LifeSpanComponent;
mineCart: MineCartComponent;
}; };
export type ComponentType = keyof ComponentMap; 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 isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos); const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
// Handle enter // Handle enter or manual trigger
if (trigger.onEnter && isOnTrigger && !wasOnTrigger) { if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || (trigger.triggered && !trigger.oneShot)) {
this.activateTrigger(triggerId, entityId, trigger, world); 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 // Handle exit
@@ -81,6 +86,9 @@ export class TriggerSystem extends System {
effectDuration?: number; effectDuration?: number;
oneShot?: boolean; oneShot?: boolean;
triggered?: boolean; triggered?: boolean;
targetId?: EntityId;
onInteract?: boolean;
}, },
world: ECSWorld world: ECSWorld
): void { ): void {
@@ -91,7 +99,22 @@ export class TriggerSystem extends System {
activatorId 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 // Apply damage if trap
if (trigger.damage && trigger.damage > 0) { if (trigger.damage && trigger.damage > 0) {
const stats = world.getComponent(activatorId, "stats"); const stats = world.getComponent(activatorId, "stats");
if (stats) { if (stats) {

View File

@@ -99,25 +99,21 @@ export class GameInput extends Phaser.Events.EventEmitter {
} }
public getCursorState() { public getCursorState() {
// Return simplified cursor state for movement // Return simplified WASD state for movement
let dx = 0; let dx = 0;
let dy = 0; let dy = 0;
const left = this.cursors.left?.isDown || this.wasd.A.isDown; const left = this.wasd.A.isDown;
const right = this.cursors.right?.isDown || this.wasd.D.isDown; const right = this.wasd.D.isDown;
const up = this.cursors.up?.isDown || this.wasd.W.isDown; const up = this.wasd.W.isDown;
const down = this.cursors.down?.isDown || this.wasd.S.isDown; const down = this.wasd.S.isDown;
if (left) dx -= 1; if (left) dx -= 1;
if (right) dx += 1; if (right) dx += 1;
if (up) dy -= 1; if (up) dy -= 1;
if (down) dy += 1; if (down) dy += 1;
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) || const anyJustDown = Phaser.Input.Keyboard.JustDown(this.wasd.W) ||
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) ||
Phaser.Input.Keyboard.JustDown(this.wasd.A) || Phaser.Input.Keyboard.JustDown(this.wasd.A) ||
Phaser.Input.Keyboard.JustDown(this.wasd.S) || Phaser.Input.Keyboard.JustDown(this.wasd.S) ||
Phaser.Input.Keyboard.JustDown(this.wasd.D); 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() { public cleanup() {
this.removeAllListeners(); this.removeAllListeners();
// Determine is scene specific cleanup is needed for inputs // 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); handleExpCollection(actor, events, accessor);
} }
return events; 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 }]; 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[] { function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
const target = accessor.getActor(action.targetId); const target = accessor.getActor(action.targetId);
if (target && target.category === "combatant" && actor.category === "combatant") { if (target && target.category === "combatant" && actor.category === "combatant") {

View File

@@ -18,6 +18,8 @@ import { Prefabs } from "../ecs/Prefabs";
import { EntityBuilder } from "../ecs/EntityBuilder"; import { EntityBuilder } from "../ecs/EntityBuilder";
interface Room { interface Room {
x: number; x: number;
y: 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 // Set ROT's RNG seed for consistent dungeon generation
ROT.RNG.setSeed(floor * 12345); 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 // Create Player Entity in ECS
const runInventory = { const runInventory = {
@@ -79,248 +94,218 @@ export function generateWorld(floor: number, runState: RunState): { world: World
.withEnergy(GAME_CONFIG.player.speed) .withEnergy(GAME_CONFIG.player.speed)
.build(); .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 exit = { ...trackPath[trackPath.length - 1] };
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)
};
placeEnemies(floor, rooms, ecsWorld, random); // Place Switch at the end of the track
Prefabs.trackSwitch(ecsWorld, exit.x, exit.y, cartId);
// Place traps (using same ecsWorld)
// Mark all track and room tiles as occupied for objects
const occupiedPositions = new Set<string>(); const occupiedPositions = new Set<string>();
occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start occupiedPositions.add(`${playerX},${playerY}`);
occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit occupiedPositions.add(`${exit.x},${exit.y}`);
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions); for (const pos of trackPath) {
occupiedPositions.add(`${pos.x},${pos.y}`);
// Place doors for dungeon levels (Uniform/Digger)
// Caves (Floors 10+) shouldn't have manufactured doors
if (floor <= 9) {
placeDoors(width, height, tiles, rooms, random);
} }
// 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); decorate(width, height, tiles, random, exit);
// CRITICAL FIX: Ensure player start position is always clear! // Ensure start and end are walkable and marked
// Otherwise spawning in Grass (which blocks vision) makes the player blind.
tiles[playerY * width + playerX] = TileType.EMPTY; tiles[playerY * width + playerX] = TileType.EMPTY;
tiles[exit.y * width + exit.x] = TileType.EXIT;
return { return {
world: { width, height, tiles, exit }, world: { width, height, tiles, exit, trackPath },
playerId, playerId,
ecsWorld ecsWorld
}; };
} }
/**
// Update generateRooms signature to accept random * Generates a level with a central rail track from start to end.
function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] { */
function generateTrackLevel(width: number, height: number, tiles: Tile[], _floor: number, random: () => number): { rooms: Room[], trackPath: Vec2[] } {
const rooms: Room[] = []; const rooms: Room[] = [];
// Choose dungeon algorithm based on floor depth // 1. Generate Start and End points (further apart)
let dungeon: any; 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) { // 2. Generate Track Path (Winding random walk)
// Floors 1-4: Uniform (organic, irregular rooms) const trackPath: Vec2[] = [];
dungeon = new ROT.Map.Uniform(width, height, { let curr = { ...start };
roomWidth: [GAME_CONFIG.map.roomMinWidth, GAME_CONFIG.map.roomMaxWidth], trackPath.push(curr);
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],
});
// Cellular needs randomization and smoothing // Bias weights
dungeon.randomize(0.5); const targetBias = 0.6;
for (let i = 0; i < 4; i++) { const straightBias = 0.2;
dungeon.create();
}
}
// Generate the dungeon let iter = 0;
dungeon.create((x: number, y: number, value: number) => { const maxIter = width * height;
if (value === 0) {
// 0 = floor, 1 = wall
tiles[y * width + x] = TileType.EMPTY;
}
});
// Extract room information from the generated dungeon let lastDir = { dx: 1, dy: 0 };
const roomData = (dungeon as any).getRooms?.();
if (roomData && roomData.length > 0) { while ((curr.x !== end.x || curr.y !== end.y) && iter < maxIter) {
// Traditional dungeons (Uniform/Digger) have explicit rooms iter++;
for (const room of roomData) { // Determine possible directions
rooms.push({ const dirs = [
x: room.getLeft(), { dx: 1, dy: 0 },
y: room.getTop(), { dx: 0, dy: 1 },
width: room.getRight() - room.getLeft() + 1, { dx: 0, dy: -1 },
height: room.getBottom() - room.getTop() + 1 { dx: -1, dy: 0 }
});
}
} 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 },
]; ];
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) { if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = ny * width + nx; tiles[ny * width + nx] = TileType.EMPTY;
if (tiles[nIdx] === TileType.EMPTY && !visited.has(nIdx)) {
queue.push(nIdx);
} }
} }
} }
} }
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 }; const world = { width, height };
// Stairs removed as per user request
// Set exit tile
tiles[idx(world as any, exit.x, exit.y)] = TileType.EXIT;
// Use Simplex noise for natural-looking grass distribution // Use Simplex noise for natural-looking grass distribution
const grassNoise = new ROT.Noise.Simplex(); const grassNoise = new ROT.Noise.Simplex();
@@ -352,7 +337,6 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
if (decoValue > 0.5) { if (decoValue > 0.5) {
tiles[i] = TileType.EMPTY_DECO; tiles[i] = TileType.EMPTY_DECO;
} else if (decoValue > 0.3 && random() < 0.3) { } else if (decoValue > 0.3 && random() < 0.3) {
// Sparse decorations at medium noise levels
tiles[i] = TileType.EMPTY_DECO; 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 numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
const enemyTypes = Object.keys(GAME_CONFIG.enemies); const enemyTypes = Object.keys(GAME_CONFIG.enemies);
const occupiedPositions = new Set<string>();
if (rooms.length < 2) return;
for (let i = 0; i < numEnemies; i++) { for (let i = 0; i < numEnemies; i++) {
// Pick a random room (not the starting room 0) // 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 ex = room.x + 1 + Math.floor(random() * (room.width - 2));
const ey = room.y + 1 + Math.floor(random() * (room.height - 2)); const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
const k = `${ex},${ey}`; 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 type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
const enemyDef = GAME_CONFIG.enemies[type]; const enemyDef = GAME_CONFIG.enemies[type];
@@ -444,6 +441,8 @@ function placeTraps(
const maxTraps = minTraps + 2; const maxTraps = minTraps + 2;
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1)); const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
if (rooms.length < 2) return;
for (let i = 0; i < numTraps; i++) { for (let i = 0; i < numTraps; i++) {
// Pick a random room (not the starting room) // Pick a random room (not the starting room)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1)); const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
@@ -491,39 +490,3 @@ function placeTraps(
export const makeTestWorld = generateWorld; 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 entityAccessor!: EntityAccessor;
private ecsWorld!: ECSWorld; private ecsWorld!: ECSWorld;
private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map(); private trapSprites: Map<number, Phaser.GameObjects.Sprite> = new Map();
private trackSprites: Phaser.GameObjects.Sprite[] = [];
constructor(scene: Phaser.Scene) { constructor(scene: Phaser.Scene) {
this.scene = scene; this.scene = scene;
@@ -50,6 +51,12 @@ export class DungeonRenderer {
} }
this.trapSprites.clear(); this.trapSprites.clear();
for (const sprite of this.trackSprites) {
sprite.destroy();
}
this.trackSprites = [];
this.trapSprites.clear();
for (const [, sprite] of this.enemySprites) { for (const [, sprite] of this.enemySprites) {
sprite.destroy(); sprite.destroy();
} }
@@ -68,22 +75,39 @@ export class DungeonRenderer {
// Setup Tilemap // Setup Tilemap
if (this.map) this.map.destroy(); if (this.map) this.map.destroy();
this.map = this.scene.make.tilemap({ this.map = this.scene.make.tilemap({
data: Array.from({ length: world.height }, (_, y) => tileWidth: TILE_SIZE,
Array.from({ length: world.width }, (_, x) => this.world.tiles[idx(this.world, x, y)]) tileHeight: TILE_SIZE,
), width: world.width,
tileWidth: 16, height: world.height
tileHeight: 16
}); });
const tileset = this.map.addTilesetImage("dungeon", "dungeon", 16, 16, 0, 0)!; const tileset = this.map.addTilesetImage("dungeon", "dungeon");
this.layer = this.map.createLayer(0, tileset, 0, 0)!; 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.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) let tilesPlaced = 0;
this.layer.forEachTile(tile => { for (let y = 0; y < world.height; y++) {
tile.setVisible(false); 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(); this.fxRenderer.clearCorpses();
// Ensure player sprite exists // 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) { if (this.ecsWorld) {
const traps = this.ecsWorld.getEntitiesWith("trigger", "position", "sprite"); console.log(`[DungeonRenderer] Creating ECS sprites...`);
for (const trapId of traps) { const spriteEntities = this.ecsWorld.getEntitiesWith("position", "sprite");
const pos = this.ecsWorld.getComponent(trapId, "position"); for (const entId of spriteEntities) {
const spriteData = this.ecsWorld.getComponent(trapId, "sprite"); // 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) { if (pos && spriteData) {
try {
const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head";
const sprite = this.scene.add.sprite( const sprite = this.scene.add.sprite(
pos.x * TILE_SIZE + TILE_SIZE / 2, pos.x * TILE_SIZE + TILE_SIZE / 2,
pos.y * TILE_SIZE + TILE_SIZE / 2, pos.y * TILE_SIZE + TILE_SIZE / 2,
spriteData.texture, spriteData.texture,
spriteData.index isStandalone ? undefined : (spriteData.index ?? 0)
); );
sprite.setDepth(5); // Below actors, above floor sprite.setDepth(5);
sprite.setVisible(false); // Hidden until FOV reveals sprite.setVisible(true); // Force visible for diagnostics
this.trapSprites.set(trapId, sprite); 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() { toggleMinimap() {
@@ -163,9 +203,16 @@ export class DungeonRenderer {
return this.fovManager.seenArray; return this.fovManager.seenArray;
} }
private firstRender = true;
render(_playerPath: Vec2[]) { render(_playerPath: Vec2[]) {
if (!this.world || !this.layer) return; 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 seen = this.fovManager.seenArray;
const visible = this.fovManager.visibleArray; 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 // Update trap sprites visibility and appearance
if (this.ecsWorld) { if (this.ecsWorld) {
for (const [trapId, sprite] of this.trapSprites) { for (const [trapId, sprite] of this.trapSprites) {
@@ -224,14 +283,43 @@ export class DungeonRenderer {
const spriteData = this.ecsWorld.getComponent(trapId, "sprite"); const spriteData = this.ecsWorld.getComponent(trapId, "sprite");
if (pos && spriteData) { 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 i = idx(this.world, pos.x, pos.y);
const isSeen = seen[i] === 1; const isSeen = seen[i] === 1;
const isVis = visible[i] === 1; const isVis = visible[i] === 1;
sprite.setVisible(isSeen); 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 // 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); 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)
: this.scene.add.sprite(startX, startY, texture, frame); : this.scene.add.sprite(startX, startY, texture, frame);
// Scale for standalone 24x24 image should be 1.0 (or matching world scale) // Ensure all sprites fit in a single 16x16 tile.
// Other sprites are 16x16. sprite.setDisplaySize(TILE_SIZE, TILE_SIZE);
if (isStandalone) {
sprite.setDisplaySize(16, 16);
} else {
sprite.setScale(1.0);
}
sprite.setDepth(2000); sprite.setDepth(2000);
@@ -526,4 +609,59 @@ export class DungeonRenderer {
shakeCamera() { shakeCamera() {
this.scene.cameras.main.shake(100, 0.01); 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 0
); );
corpse.setDepth(50); corpse.setDepth(50);
// Use display size for Priestess sprites to match 1 tile corpse.setDisplaySize(TILE_SIZE, TILE_SIZE); // All corpses should be tile-sized
if (textureKey.startsWith("Priestess")) {
corpse.setDisplaySize(TILE_SIZE, TILE_SIZE);
} else {
corpse.setScale(1.0); // Reset to standard scale for spritesheet assets
}

View File

@@ -10,7 +10,7 @@ import {
type RangedWeaponItem, type RangedWeaponItem,
} from "../core/types"; } from "../core/types";
import { TILE_SIZE } from "../core/constants"; 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 { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
import { generateWorld } from "../engine/world/generator"; import { generateWorld } from "../engine/world/generator";
import { DungeonRenderer } from "../rendering/DungeonRenderer"; import { DungeonRenderer } from "../rendering/DungeonRenderer";
@@ -28,7 +28,9 @@ import { ECSWorld } from "../engine/ecs/World";
import { SystemRegistry } from "../engine/ecs/System"; import { SystemRegistry } from "../engine/ecs/System";
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem"; import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
import { StatusEffectSystem, applyStatusEffect } from "../engine/ecs/systems/StatusEffectSystem"; import { StatusEffectSystem, applyStatusEffect } from "../engine/ecs/systems/StatusEffectSystem";
import { MineCartSystem } from "../engine/ecs/systems/MineCartSystem";
import { TileType } from "../core/terrain"; import { TileType } from "../core/terrain";
import { FireSystem } from "../engine/ecs/systems/FireSystem"; import { FireSystem } from "../engine/ecs/systems/FireSystem";
import { EventBus } from "../engine/ecs/EventBus"; import { EventBus } from "../engine/ecs/EventBus";
import { generateLoot } from "../engine/systems/LootSystem"; import { generateLoot } from "../engine/systems/LootSystem";
@@ -87,18 +89,14 @@ export class GameScene extends Phaser.Scene {
} }
create() { create() {
// this.cursors initialized in GameInput this.cameras.main.setBackgroundColor(0x1a1a1a);
// Camera
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom); 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 // Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this); this.dungeonRenderer = new DungeonRenderer(this);
this.gameRenderer = new GameRenderer(this.dungeonRenderer); this.gameRenderer = new GameRenderer(this.dungeonRenderer);
this.cameraController = new CameraController(this.cameras.main); 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.itemManager = new ItemManager(this.world, this.entityAccessor);
this.targetingSystem = new TargetingSystem(this); this.targetingSystem = new TargetingSystem(this);
@@ -126,12 +124,21 @@ export class GameScene extends Phaser.Scene {
// Load initial floor // Load initial floor
this.loadFloor(1); this.loadFloor(1);
// Register Handlers
this.playerInputHandler.registerListeners(); this.playerInputHandler.registerListeners();
this.gameEventHandler.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.awaitingPlayer) return;
if (this.isMenuOpen || this.isInventoryOpen || this.isCharacterOpen || this.dungeonRenderer.isMinimapVisible()) return; if (this.isMenuOpen || this.isInventoryOpen || this.isCharacterOpen || this.dungeonRenderer.isMinimapVisible()) return;
@@ -189,6 +196,10 @@ export class GameScene extends Phaser.Scene {
} }
public emitUIUpdate() { public emitUIUpdate() {
if (!this.entityAccessor) {
console.warn("[GameScene] emitUIUpdate called before entityAccessor was initialized.");
return;
}
const payload: UIUpdatePayload = { const payload: UIUpdatePayload = {
world: this.world, world: this.world,
playerId: this.playerId, playerId: this.playerId,
@@ -209,7 +220,6 @@ export class GameScene extends Phaser.Scene {
} }
this.awaitingPlayer = false; this.awaitingPlayer = false;
this.cameraController.enableFollowMode();
// Process reloading progress // Process reloading progress
const player = this.entityAccessor.getPlayer(); const player = this.entityAccessor.getPlayer();
@@ -358,13 +368,6 @@ export class GameScene extends Phaser.Scene {
return; return;
} }
if (isPlayerOnExit(this.world, this.entityAccessor)) {
this.syncRunStateFromPlayer();
this.floorIndex++;
this.loadFloor(this.floorIndex);
return;
}
this.dungeonRenderer.computeFov(); this.dungeonRenderer.computeFov();
if (this.cameraController.isFollowing) { if (this.cameraController.isFollowing) {
const player = this.entityAccessor.getPlayer(); const player = this.entityAccessor.getPlayer();
@@ -377,53 +380,96 @@ export class GameScene extends Phaser.Scene {
} }
private loadFloor(floor: number) { private loadFloor(floor: number) {
try {
console.log(`[GameScene] loadFloor started for floor ${floor}`);
this.floorIndex = floor; this.floorIndex = floor;
this.cameraController.enableFollowMode();
console.log(`[GameScene] Calling generateWorld...`);
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState); 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.world = world;
this.playerId = playerId; this.playerId = playerId;
// Initialize or update entity accessor // Initialize or update entity accessor
console.log(`[GameScene] Setting up EntityAccessor...`);
if (!this.entityAccessor) { if (!this.entityAccessor) {
this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld); this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld);
} else { } else {
this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld); this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld);
} }
console.log(`[GameScene] Updating ItemManager...`);
this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld); this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld);
// Initialize ECS for traps and status effects // Initialize ECS for traps and status effects
this.ecsWorld = ecsWorld; this.ecsWorld = ecsWorld;
this.ecsEventBus = new EventBus(); 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 // Register systems
console.log(`[GameScene] Registering ECS systems...`);
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus); this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
this.ecsRegistry.register(new TriggerSystem()); this.ecsRegistry.register(new TriggerSystem());
this.ecsRegistry.register(new StatusEffectSystem()); this.ecsRegistry.register(new StatusEffectSystem());
this.ecsRegistry.register(new MineCartSystem());
this.ecsRegistry.register(new FireSystem(this.world)); this.ecsRegistry.register(new FireSystem(this.world));
// NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state. console.log(`[GameScene] ECS systems registered.`);
// No need to manually add player here anymore.
this.playerPath = []; this.playerPath = [];
this.awaitingPlayer = false; 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.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); const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
console.log(`[GameScene] Computing FOV...`);
this.dungeonRenderer.computeFov(); this.dungeonRenderer.computeFov();
const p = this.entityAccessor.getPlayer(); const p = this.entityAccessor.getPlayer();
if (p) { 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); 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.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate(); 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() { public syncRunStateFromPlayer() {
const p = this.entityAccessor.getPlayer(); const p = this.entityAccessor.getPlayer();

View File

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

View File

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