diff --git a/public/assets/sprites/items/mine_cart.png b/public/assets/sprites/items/mine_cart.png new file mode 100644 index 0000000..b1aa523 Binary files /dev/null and b/public/assets/sprites/items/mine_cart.png differ diff --git a/public/assets/sprites/items/track_corner.png b/public/assets/sprites/items/track_corner.png new file mode 100644 index 0000000..473febd Binary files /dev/null and b/public/assets/sprites/items/track_corner.png differ diff --git a/public/assets/sprites/items/track_straight.png b/public/assets/sprites/items/track_straight.png new file mode 100644 index 0000000..a182ef7 Binary files /dev/null and b/public/assets/sprites/items/track_straight.png differ diff --git a/public/assets/sprites/items/track_vertical.png b/public/assets/sprites/items/track_vertical.png new file mode 100644 index 0000000..a182ef7 Binary files /dev/null and b/public/assets/sprites/items/track_vertical.png differ diff --git a/src/core/config/GameConfig.ts b/src/core/config/GameConfig.ts index 8f20527..215b653 100644 --- a/src/core/config/GameConfig.ts +++ b/src/core/config/GameConfig.ts @@ -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: [ diff --git a/src/core/terrain.ts b/src/core/terrain.ts index 383f3c2..0720837 100644 --- a/src/core/terrain.ts +++ b/src/core/terrain.ts @@ -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 = { [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; diff --git a/src/core/types.ts b/src/core/types.ts index c59c426..c3bc8fc 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 { diff --git a/src/engine/ecs/EntityBuilder.ts b/src/engine/ecs/EntityBuilder.ts index 4b461dd..d22ef8c 100644 --- a/src/engine/ecs/EntityBuilder.ts +++ b/src/engine/ecs/EntityBuilder.ts @@ -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"; /** @@ -129,10 +129,10 @@ export class EntityBuilder { if (type === "player") { throw new Error("Use asPlayer() for player entities"); } - + this.components.actorType = { type }; this.withAI("wandering"); - + // Apply enemy stats from config const config = GAME_CONFIG.enemies[type as keyof typeof GAME_CONFIG.enemies]; if (config) { @@ -145,7 +145,7 @@ export class EntityBuilder { }); this.withEnergy(speed); } - + return this; } @@ -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); diff --git a/src/engine/ecs/EventBus.ts b/src/engine/ecs/EventBus.ts index 42e1ac2..1f9fd30 100644 --- a/src/engine/ecs/EventBus.ts +++ b/src/engine/ecs/EventBus.ts @@ -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"]; diff --git a/src/engine/ecs/Prefabs.ts b/src/engine/ecs/Prefabs.ts index 16dbd03..35c5a2e 100644 --- a/src/engine/ecs/Prefabs.ts +++ b/src/engine/ecs/Prefabs.ts @@ -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. diff --git a/src/engine/ecs/components.ts b/src/engine/ecs/components.ts index ae90703..6eace08 100644 --- a/src/engine/ecs/components.ts +++ b/src/engine/ecs/components.ts @@ -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; diff --git a/src/engine/ecs/systems/MineCartSystem.ts b/src/engine/ecs/systems/MineCartSystem.ts new file mode 100644 index 0000000..97b25cc --- /dev/null +++ b/src/engine/ecs/systems/MineCartSystem.ts @@ -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" }); + } + } + } + } +} diff --git a/src/engine/ecs/systems/TriggerSystem.ts b/src/engine/ecs/systems/TriggerSystem.ts index 21d4a27..395eb0a 100644 --- a/src/engine/ecs/systems/TriggerSystem.ts +++ b/src/engine/ecs/systems/TriggerSystem.ts @@ -31,7 +31,7 @@ export class TriggerSystem extends System { update(entities: EntityId[], world: ECSWorld, _dt?: number): void { // Get all entities with positions (potential activators) const allWithPosition = world.getEntitiesWith("position"); - + for (const triggerId of entities) { const trigger = world.getComponent(triggerId, "trigger"); const triggerPos = world.getComponent(triggerId, "position"); @@ -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 @@ -75,12 +80,15 @@ export class TriggerSystem extends System { private activateTrigger( triggerId: EntityId, activatorId: EntityId, - trigger: { - damage?: number; - effect?: string; - effectDuration?: number; - oneShot?: boolean; + trigger: { + damage?: number; + effect?: string; + effectDuration?: number; + oneShot?: boolean; triggered?: boolean; + targetId?: EntityId; + onInteract?: boolean; + }, world: ECSWorld ): void { @@ -91,12 +99,27 @@ 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) { stats.hp = Math.max(0, stats.hp - trigger.damage); - + this.eventBus?.emit({ type: "damage", entityId: activatorId, @@ -125,7 +148,7 @@ export class TriggerSystem extends System { // Mark as triggered for one-shot triggers and update sprite if (trigger.oneShot) { trigger.triggered = true; - + // Change sprite to triggered appearance (dungeon sprite 23) const sprite = world.getComponent(triggerId, "sprite"); if (sprite) { diff --git a/src/engine/input/GameInput.ts b/src/engine/input/GameInput.ts index 4461287..9d7d90c 100644 --- a/src/engine/input/GameInput.ts +++ b/src/engine/input/GameInput.ts @@ -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 diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 08ce3fc..39b529d 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -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") { diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index 4b05ae5..e36a0dd 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -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(); - 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(); - - // 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[] { - 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) { - 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); + // 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) { + 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)); + } + + 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 decorate(width: number, height: number, tiles: Tile[], random: () => number, exit: Vec2): void { - const world = { width, height }; +function digCorridor(width: number, tiles: Tile[], x1: number, y1: number, x2: number, y2: number) { + let currX = x1; + let currY = y1; - // Set exit tile - tiles[idx(world as any, exit.x, exit.y)] = TileType.EXIT; + 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 }; + // 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 +): void { const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor; const enemyTypes = Object.keys(GAME_CONFIG.enemies); - const occupiedPositions = new Set(); + + 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); - } - } -} diff --git a/src/engine/world/world-logic.ts b/src/engine/world/world-logic.ts index 7e15651..a5dd33d 100644 --- a/src/engine/world/world-logic.ts +++ b/src/engine/world/world-logic.ts @@ -23,7 +23,7 @@ export function isBlockingTile(w: World, x: number, y: number): boolean { export function tryDestructTile(w: World, x: number, y: number): boolean { if (!inBounds(w, x, y)) return false; - + const i = idx(w, x, y); const tile = w.tiles[i]; @@ -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; -} diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index af00e92..6222f01 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -30,6 +30,7 @@ export class DungeonRenderer { private entityAccessor!: EntityAccessor; private ecsWorld!: ECSWorld; private trapSprites: Map = 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)!; - this.layer.setDepth(0); + const tileset = this.map.addTilesetImage("dungeon", "dungeon"); + if (!tileset) { + console.error("[DungeonRenderer] FAILED to load tileset 'dungeon'!"); + // Fallback or throw? + } - // Initial tile states (hidden) - this.layer.forEachTile(tile => { - tile.setVisible(false); - }); + 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!"); + } + 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,25 +135,41 @@ 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) { - const sprite = this.scene.add.sprite( - pos.x * TILE_SIZE + TILE_SIZE / 2, - pos.y * TILE_SIZE + TILE_SIZE / 2, - spriteData.texture, - spriteData.index - ); - sprite.setDepth(5); // Below actors, above floor - sprite.setVisible(false); // Hidden until FOV reveals - this.trapSprites.set(trapId, sprite); + 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, + isStandalone ? undefined : (spriteData.index ?? 0) + ); + 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(); } @@ -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); + } + } } diff --git a/src/rendering/FxRenderer.ts b/src/rendering/FxRenderer.ts index d46a3bf..c08f440 100644 --- a/src/rendering/FxRenderer.ts +++ b/src/rendering/FxRenderer.ts @@ -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 diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index f9e0604..d636b5f 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -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,54 +380,97 @@ export class GameScene extends Phaser.Scene { } private loadFloor(floor: number) { - this.floorIndex = floor; - this.cameraController.enableFollowMode(); + try { + console.log(`[GameScene] loadFloor started for floor ${floor}`); + this.floorIndex = floor; - const { world, playerId, ecsWorld } = generateWorld(floor, this.runState); - this.world = world; - this.playerId = playerId; + console.log(`[GameScene] Calling generateWorld...`); + const { world, playerId, ecsWorld } = generateWorld(floor, this.runState); + console.log(`[GameScene] World generated. Width: ${world.width}, Height: ${world.height}`); - // Initialize or update entity accessor - if (!this.entityAccessor) { - this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld); - } else { - this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld); + // DIAGNOSTIC: Count tiles + const counts: Record = {}; + 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)); + + console.log(`[GameScene] ECS systems registered.`); + + this.playerPath = []; + this.awaitingPlayer = false; + + 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 } - - this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld); - - // Initialize ECS for traps and status effects - this.ecsWorld = ecsWorld; - this.ecsEventBus = new EventBus(); - // Register systems - this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus); - this.ecsRegistry.register(new TriggerSystem()); - this.ecsRegistry.register(new StatusEffectSystem()); - 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. - - this.playerPath = []; - this.awaitingPlayer = false; - - this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE); - - this.dungeonRenderer.initializeFloor(this.world, this.ecsWorld, this.entityAccessor); - - const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor); - this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; - - - this.dungeonRenderer.computeFov(); - const p = this.entityAccessor.getPlayer(); - if (p) { - this.cameraController.centerOnTile(p.pos.x, p.pos.y); - } - this.dungeonRenderer.render(this.playerPath); - this.emitUIUpdate(); } + public syncRunStateFromPlayer() { const p = this.entityAccessor.getPlayer(); if (!p || !p.stats || !p.inventory) return; diff --git a/src/scenes/systems/CameraController.ts b/src/scenes/systems/CameraController.ts index aece70c..85b8a91 100644 --- a/src/scenes/systems/CameraController.ts +++ b/src/scenes/systems/CameraController.ts @@ -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; diff --git a/src/scenes/systems/PlayerInputHandler.ts b/src/scenes/systems/PlayerInputHandler.ts index d3956d2..d064fb0 100644 --- a/src/scenes/systems/PlayerInputHandler.ts +++ b/src/scenes/systems/PlayerInputHandler.ts @@ -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;