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

View File

@@ -1,6 +1,6 @@
import { type ECSWorld } from "./World";
import { type ComponentMap } from "./components";
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item } from "../../core/types";
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item, type Vec2 } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
/**
@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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;
}