Added flamethrower with buring effects

This commit is contained in:
2026-01-30 17:49:23 +11:00
parent c06823e08b
commit 41909fd8e6
15 changed files with 706 additions and 327 deletions

View File

@@ -24,7 +24,10 @@ export type GameEvent =
// 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 };
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number }
// World events
| { type: "tile_changed"; x: number; y: number };
export type GameEventType = GameEvent["type"];

View File

@@ -185,6 +185,29 @@ export const Prefabs = {
.build();
},
/**
* Create a fire entity on a tile.
*/
fire(world: ECSWorld, x: number, y: number, duration: number = 4): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Fire")
.withSprite("dungeon", 19) // Reuse fire trap sprite index for fire
.with("lifeSpan", { remainingTurns: duration })
.asTrigger({
onEnter: true,
effect: "burning",
effectDuration: 5
})
.with("trigger", {
onEnter: true,
effect: "burning",
effectDuration: 5,
damage: 3
})
.build();
},
/**
* Create a player entity at the given position.
*/

View File

@@ -1,8 +1,8 @@
import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId, type Inventory, type Equipment, type Item } from "../../core/types";
export interface PositionComponent extends Vec2 {}
export interface PositionComponent extends Vec2 { }
export interface StatsComponent extends Stats {}
export interface StatsComponent extends Stats { }
export interface EnergyComponent {
current: number;
@@ -15,7 +15,7 @@ export interface AIComponent {
lastKnownPlayerPos?: Vec2;
}
export interface PlayerTagComponent {}
export interface PlayerTagComponent { }
export interface CollectibleComponent {
type: "exp_orb";
@@ -101,9 +101,16 @@ export interface GroundItemComponent {
item: Item;
}
export interface InventoryComponent extends Inventory {}
export interface InventoryComponent extends Inventory { }
export interface EquipmentComponent extends Equipment {}
export interface EquipmentComponent extends Equipment { }
/**
* For entities that should be destroyed after a certain amount of time/turns.
*/
export interface LifeSpanComponent {
remainingTurns: number;
}
export type ComponentMap = {
// Core components
@@ -116,7 +123,7 @@ export type ComponentMap = {
sprite: SpriteComponent;
name: NameComponent;
actorType: ActorTypeComponent;
// Extended gameplay components
trigger: TriggerComponent;
statusEffects: StatusEffectsComponent;
@@ -125,6 +132,7 @@ export type ComponentMap = {
groundItem: GroundItemComponent;
inventory: InventoryComponent;
equipment: EquipmentComponent;
lifeSpan: LifeSpanComponent;
};
export type ComponentType = keyof ComponentMap;

View File

@@ -0,0 +1,103 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type ComponentType } from "../components";
import { type EntityId, type World } from "../../../core/types";
import { TileType, getDestructionResult } from "../../../core/terrain";
import { idx, inBounds } from "../../world/world-logic";
import { Prefabs } from "../Prefabs";
export class FireSystem extends System {
readonly name = "Fire";
readonly requiredComponents: readonly ComponentType[] = ["position"];
readonly priority = 15; // Run after status effects
private world: World;
constructor(world: World) {
super();
this.world = world;
}
update(entities: EntityId[], ecsWorld: ECSWorld, _dt?: number): void {
const fireEntities = entities.filter(id => ecsWorld.getComponent(id, "name")?.name === "Fire");
const spreadTargets: { x: number; y: number; duration: number }[] = [];
const entitiesToRemove: EntityId[] = [];
// Get all combatant positions to avoid spreading onto them
const combatantEntities = ecsWorld.getEntitiesWith("position").filter(id =>
ecsWorld.hasComponent(id, "player") || ecsWorld.hasComponent(id, "stats")
);
const combatantPosSet = new Set(combatantEntities.map(id => {
const p = ecsWorld.getComponent(id, "position")!;
return `${p.x},${p.y}`;
}));
// 1. Process existing fire entities
for (const fireId of fireEntities) {
const pos = ecsWorld.getComponent(fireId, "position");
const lifeSpan = ecsWorld.getComponent(fireId, "lifeSpan");
if (!pos) continue;
// Decrement lifespan
if (lifeSpan) {
lifeSpan.remainingTurns--;
// If fire expires, destroy it and the tile below it
if (lifeSpan.remainingTurns <= 0) {
entitiesToRemove.push(fireId);
const tileIdx = idx(this.world, pos.x, pos.y);
const tile = this.world.tiles[tileIdx];
const nextTile = getDestructionResult(tile);
if (nextTile !== undefined) {
this.world.tiles[tileIdx] = nextTile;
this.eventBus?.emit({ type: "tile_changed", x: pos.x, y: pos.y });
}
continue; // Fire is gone, don't spread from it anymore
}
}
// 2. Spreading logic (only if fire is still active)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = pos.x + dx;
const ny = pos.y + dy;
if (!inBounds(this.world, nx, ny)) continue;
// Skip tiles occupied by any combatant
if (combatantPosSet.has(`${nx},${ny}`)) continue;
const tileIdx = idx(this.world, nx, ny);
const tile = this.world.tiles[tileIdx];
// Fire ONLY spreads to GRASS
if (tile === TileType.GRASS) {
spreadTargets.push({ x: nx, y: ny, duration: 2 });
}
}
}
}
// Cleanup expired fires
for (const id of entitiesToRemove) {
ecsWorld.destroyEntity(id);
}
// 3. Apply spreading
for (const target of spreadTargets) {
// Check if fire already there
const existing = ecsWorld.getEntitiesWith("position").find(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.x && p?.y === target.y && n?.name === "Fire";
});
if (!existing) {
Prefabs.fire(ecsWorld, target.x, target.y, target.duration);
}
}
}
}

View File

@@ -30,7 +30,7 @@ export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?:
// 1. Accuracy vs Evasion Check
const hitChance = attackerStats.accuracy - targetStats.evasion;
const hitRoll = Math.random() * 100;
if (hitRoll > hitChance) {
return result; // Miss
}
@@ -47,7 +47,7 @@ export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?:
// Actually, equipmentService adds item.stats.attack to player.stats.attack.
// So baseAttack is already "player + weapon".
// BUT for projectiles/thrown, we might want to ensure we're using the right value.
// If it's a weapon item, it's likely already factored in.
// If it's a CONSUMABLE (thrown), it might NOT be.
if (item.type === "Consumable") {
@@ -56,7 +56,7 @@ export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?:
}
let dmg = Math.max(1, baseAttack - targetStats.defense);
// 3. Critical Strike Check
const critRoll = Math.random() * 100;
const isCrit = critRoll < attackerStats.critChance;
@@ -68,7 +68,7 @@ export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?:
// 4. Block Chance Check
const blockRoll = Math.random() * 100;
if (blockRoll < targetStats.blockChance) {
dmg = Math.floor(dmg * 0.5);
dmg = Math.floor(dmg * 0.5);
result.isBlock = true;
}
@@ -101,7 +101,7 @@ export function traceProjectile(
if (accessor) {
actors = accessor.getActorsAt(p.x, p.y);
}
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
if (enemy) {
@@ -123,9 +123,38 @@ export function traceProjectile(
};
}
/**
* Finds the closest visible enemy to a given position.
* Calculates tiles within a cone for area of effect attacks.
*/
export function getConeTiles(origin: Vec2, target: Vec2, range: number): Vec2[] {
const tiles: Vec2[] = [];
const angle = Math.atan2(target.y - origin.y, target.x - origin.x);
const halfSpread = Math.PI / 4; // 90 degree cone
for (let dy = -range; dy <= range; dy++) {
for (let dx = -range; dx <= range; dx++) {
if (dx === 0 && dy === 0) continue;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > range + 0.5) continue;
const tilePos = { x: origin.x + dx, y: origin.y + dy };
const tileAngle = Math.atan2(dy, dx);
// Normalize angle difference to [-PI, PI]
let angleDiff = tileAngle - angle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
if (Math.abs(angleDiff) <= halfSpread) {
tiles.push(tilePos);
}
}
}
return tiles;
}
export function getClosestVisibleEnemy(
origin: Vec2,
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures

View File

@@ -36,22 +36,22 @@ function handleExpCollection(player: Actor, events: SimEvent[], accessor: Entity
if (player.category !== "combatant") return;
const actorsAtPos = accessor.getActorsAt(player.pos.x, player.pos.y);
const orbs = actorsAtPos.filter(a =>
a.category === "collectible" &&
const orbs = actorsAtPos.filter(a =>
a.category === "collectible" &&
a.type === "exp_orb"
) as CollectibleActor[];
for (const orb of orbs) {
const amount = orb.expAmount || 0;
player.stats.exp += amount;
events.push({
type: "exp-collected",
actorId: player.id,
amount,
x: player.pos.x,
y: player.pos.y
events.push({
type: "exp-collected",
actorId: player.id,
amount,
x: player.pos.x,
y: player.pos.y
});
checkLevelUp(player, events);
accessor.removeActor(orb.id);
}
@@ -59,11 +59,11 @@ function handleExpCollection(player: Actor, events: SimEvent[], accessor: Entity
function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
const s = player.stats;
while (s.exp >= s.expToNextLevel) {
s.level++;
s.exp -= s.expToNextLevel;
// Growth
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
s.hp = s.maxHp; // Heal on level up
@@ -73,16 +73,16 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
s.skillPoints += GAME_CONFIG.leveling.skillPointsPerLevel;
// Scale requirement
s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier);
events.push({
type: "leveled-up",
actorId: player.id,
level: s.level,
x: player.pos.x,
y: player.pos.y
events.push({
type: "leveled-up",
actorId: player.id,
level: s.level,
x: player.pos.x,
y: player.pos.y
});
}
}
@@ -98,7 +98,7 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
actor.pos.y = ny;
const to = { ...actor.pos };
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
const tileIdx = ny * w.width + nx;
if (isDestructibleByWalk(w.tiles[tileIdx])) {
tryDestructTile(w, nx, ny);
@@ -109,7 +109,7 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number },
}
return events;
}
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
}
@@ -119,14 +119,14 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
const target = accessor.getActor(action.targetId);
if (target && target.category === "combatant" && actor.category === "combatant") {
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
// 1. Calculate Damage
const result = calculateDamage(actor.stats, target.stats);
if (!result.hit) {
events.push({
type: "dodged",
targetId: action.targetId,
events.push({
type: "dodged",
targetId: action.targetId,
x: target.pos.x,
y: target.pos.y
});
@@ -138,13 +138,13 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
const isBlock = result.isBlock;
target.stats.hp -= dmg;
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
target.aiState = "pursuing";
target.alertedAt = Date.now();
if (actor.pos) {
target.lastKnownPlayerPos = { ...actor.pos };
}
target.aiState = "pursuing";
target.alertedAt = Date.now();
if (actor.pos) {
target.lastKnownPlayerPos = { ...actor.pos };
}
}
// 5. Lifesteal Logic
@@ -153,19 +153,19 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
if (healAmount > 0) {
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
events.push({
type: "healed",
actorId: actor.id,
amount: healAmount,
x: actor.pos.x,
y: actor.pos.y
type: "healed",
actorId: actor.id,
amount: healAmount,
x: actor.pos.x,
y: actor.pos.y
});
}
}
events.push({
type: "damaged",
targetId: action.targetId,
amount: dmg,
events.push({
type: "damaged",
targetId: action.targetId,
amount: dmg,
hp: target.stats.hp,
x: target.pos.x,
y: target.pos.y,
@@ -174,25 +174,37 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
});
if (target.stats.hp <= 0) {
events.push({
type: "killed",
targetId: target.id,
killerId: actor.id,
x: target.pos.x,
y: target.pos.y,
events.push({
type: "killed",
targetId: target.id,
killerId: actor.id,
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
accessor.removeActor(target.id);
// Extinguish fire at the death position
const ecsWorld = accessor.context;
if (ecsWorld) {
const firesAtPos = ecsWorld.getEntitiesWith("position", "name").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.pos.x && p?.y === target.pos.y && n?.name === "Fire";
});
for (const fireId of firesAtPos) {
ecsWorld.destroyEntity(fireId);
}
}
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
const ecsWorld = accessor.context;
if (ecsWorld) {
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
}
return events;
@@ -211,17 +223,17 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, accessor: EntityAccessor): { action: Action; justAlerted: boolean } {
const ecsWorld = accessor.context;
if (ecsWorld) {
const aiSystem = new AISystem(ecsWorld, w, accessor);
const result = aiSystem.update(enemy.id, player.id);
const aiComp = ecsWorld.getComponent(enemy.id, "ai");
if (aiComp) {
enemy.aiState = aiComp.state;
enemy.alertedAt = aiComp.alertedAt;
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
}
return result;
const aiSystem = new AISystem(ecsWorld, w, accessor);
const result = aiSystem.update(enemy.id, player.id);
const aiComp = ecsWorld.getComponent(enemy.id, "ai");
if (aiComp) {
enemy.aiState = aiComp.state;
enemy.alertedAt = aiComp.alertedAt;
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
}
return result;
}
return { action: { type: "wait" }, justAlerted: false };
@@ -240,51 +252,51 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: Enti
const events: SimEvent[] = [];
if (player.energy >= THRESHOLD) {
player.energy -= THRESHOLD;
player.energy -= THRESHOLD;
}
while (true) {
if (player.energy >= THRESHOLD) {
return { awaitingPlayerId: playerId, events };
return { awaitingPlayerId: playerId, events };
}
const actors = [...accessor.getAllActors()];
for (const actor of actors) {
if (actor.category === "combatant") {
actor.energy += actor.speed;
}
if (actor.category === "combatant") {
actor.energy += actor.speed;
}
}
let actionsTaken = 0;
while (true) {
const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD);
if (eligibleActors.length === 0) break;
eligibleActors.sort((a, b) => b.energy - a.energy);
const actor = eligibleActors[0];
actor.energy -= THRESHOLD;
const decision = decideEnemyAction(w, actor, player, accessor);
if (decision.justAlerted) {
events.push({
type: "enemy-alerted",
enemyId: actor.id,
x: actor.pos.x,
y: actor.pos.y
});
}
events.push(...applyAction(w, actor.id, decision.action, accessor));
const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD);
if (!accessor.isPlayerAlive()) {
return { awaitingPlayerId: null as any, events };
}
actionsTaken++;
if (actionsTaken > 1000) break;
if (eligibleActors.length === 0) break;
eligibleActors.sort((a, b) => b.energy - a.energy);
const actor = eligibleActors[0];
actor.energy -= THRESHOLD;
const decision = decideEnemyAction(w, actor, player, accessor);
if (decision.justAlerted) {
events.push({
type: "enemy-alerted",
enemyId: actor.id,
x: actor.pos.x,
y: actor.pos.y
});
}
events.push(...applyAction(w, actor.id, decision.action, accessor));
if (!accessor.isPlayerAlive()) {
return { awaitingPlayerId: null as any, events };
}
actionsTaken++;
if (actionsTaken > 1000) break;
}
}
}

View File

@@ -2,13 +2,14 @@ import { type World, type EntityId, type RunState, type Tile, type Vec2 } from "
import { TileType } from "../../core/terrain";
import { idx } from "./world-logic";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import {
createConsumable,
createMeleeWeapon,
createRangedWeapon,
import {
createConsumable,
createMeleeWeapon,
createRangedWeapon,
createArmour,
createUpgradeScroll,
createAmmo
createAmmo,
createFlamethrower
} from "../../core/config/Items";
import { seededRandom } from "../../core/math";
import * as ROT from "rot-js";
@@ -36,7 +37,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
const tiles: Tile[] = new Array(width * height).fill(TileType.WALL);
const random = seededRandom(floor * 12345);
// Create ECS World first
const ecsWorld = new ECSWorld(); // Starts at ID 1 by default
@@ -44,28 +45,29 @@ export function generateWorld(floor: number, runState: RunState): { world: World
ROT.RNG.setSeed(floor * 12345);
const rooms = generateRooms(width, height, tiles, floor, random);
// 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 = {
gold: runState.inventory.gold,
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [
createConsumable("health_potion", 2),
createMeleeWeapon("iron_sword", "sharp"),
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createAmmo("ammo_9mm", 10),
createArmour("leather_armor", "heavy"),
createUpgradeScroll(2)
] : [])
]
const runInventory = {
gold: runState.inventory.gold,
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [
createConsumable("health_potion", 2),
createMeleeWeapon("iron_sword", "sharp"),
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createAmmo("ammo_9mm", 10),
createFlamethrower(),
createArmour("leather_armor", "heavy"),
createUpgradeScroll(2)
] : [])
]
};
const playerId = EntityBuilder.create(ecsWorld)
@@ -78,7 +80,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
.build();
// No more legacy Actors Map
// Place exit in last room
const lastRoom = rooms[rooms.length - 1];
const exit: Vec2 = {
@@ -87,14 +89,14 @@ export function generateWorld(floor: number, runState: RunState): { world: World
};
placeEnemies(floor, rooms, ecsWorld, random);
// Place traps (using same ecsWorld)
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) {
@@ -102,13 +104,13 @@ export function generateWorld(floor: number, runState: RunState): { world: World
}
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.
tiles[playerY * width + playerX] = TileType.EMPTY;
return {
world: { width, height, tiles, exit },
return {
world: { width, height, tiles, exit },
playerId,
ecsWorld
};
@@ -118,10 +120,10 @@ export function generateWorld(floor: number, runState: RunState): { world: World
// Update generateRooms signature to accept random
function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] {
const rooms: Room[] = [];
// Choose dungeon algorithm based on floor depth
let dungeon: any;
if (floor <= 4) {
// Floors 1-4: Uniform (organic, irregular rooms)
dungeon = new ROT.Map.Uniform(width, height, {
@@ -142,7 +144,7 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb
born: [4, 5, 6, 7, 8],
survive: [2, 3, 4, 5],
});
// Cellular needs randomization and smoothing
dungeon.randomize(0.5);
for (let i = 0; i < 4; i++) {
@@ -160,7 +162,7 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb
// Extract room information from the generated dungeon
const roomData = (dungeon as any).getRooms?.();
if (roomData && roomData.length > 0) {
// Traditional dungeons (Uniform/Digger) have explicit rooms
for (const room of roomData) {
@@ -174,7 +176,7 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb
} 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);
}
@@ -196,13 +198,13 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb
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 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);
@@ -241,14 +243,14 @@ function digV(width: number, tiles: Tile[], y1: number, y2: number, x: number) {
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
@@ -261,7 +263,7 @@ function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Roo
minY = Math.min(minY, cy);
maxY = Math.max(maxY, cy);
}
rooms.push({
x: minX,
y: minY,
@@ -272,7 +274,7 @@ function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Roo
}
}
}
return rooms;
}
@@ -282,17 +284,17 @@ function extractRoomsFromCave(width: number, height: number, tiles: Tile[]): Roo
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 },
@@ -300,7 +302,7 @@ function floodFill(width: number, height: number, tiles: Tile[], startX: number,
{ nx: x, ny: y + 1 },
{ nx: x, ny: y - 1 },
];
for (const { nx, ny } of neighbors) {
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = ny * width + nx;
@@ -310,7 +312,7 @@ function floodFill(width: number, height: number, tiles: Tile[], startX: number,
}
}
}
return cluster;
}
@@ -323,19 +325,19 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
// Use Simplex noise for natural-looking grass distribution
const grassNoise = new ROT.Noise.Simplex();
const decorationNoise = new ROT.Noise.Simplex();
// Offset noise to get different patterns for grass vs decorations
const grassOffset = random() * 1000;
const decorOffset = random() * 1000;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = idx(world as any, x, y);
if (tiles[i] === TileType.EMPTY) {
// Grass patches: use noise to create organic shapes
const grassValue = grassNoise.get((x + grassOffset) / 15, (y + grassOffset) / 15);
// Create grass patches where noise is above threshold
if (grassValue > 0.35) {
tiles[i] = TileType.GRASS;
@@ -345,7 +347,7 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
} else {
// Floor decorations (moss/rocks): clustered distribution
const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8);
// Dense clusters where noise is high
if (decoValue > 0.5) {
tiles[i] = TileType.EMPTY_DECO;
@@ -364,9 +366,9 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
const i = idx(world as any, x, y);
const nextY = idx(world as any, x, y + 1);
if (tiles[i] === TileType.WALL &&
tiles[nextY] === TileType.GRASS &&
random() < 0.25) {
if (tiles[i] === TileType.WALL &&
tiles[nextY] === TileType.GRASS &&
random() < 0.25) {
tiles[i] = TileType.WALL_DECO;
}
}
@@ -374,8 +376,8 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu
}
function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random: () => number): 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 occupiedPositions = new Set<string>();
@@ -383,7 +385,7 @@ function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random:
// Pick a random room (not the starting room 0)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
const room = rooms[roomIdx];
// Try to find an empty spot in the room
for (let attempts = 0; attempts < 5; attempts++) {
@@ -397,23 +399,23 @@ function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random:
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
const speed = enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed));
// Create Enemy in ECS
EntityBuilder.create(ecsWorld)
.asEnemy(type)
.withPosition(ex, ey)
.withStats({
maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4),
attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense,
})
.withEnergy(speed) // Configured speed
// Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats
.build();
.asEnemy(type)
.withPosition(ex, ey)
.withStats({
maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4),
attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense,
})
.withEnergy(speed) // Configured speed
// Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats
.build();
occupiedPositions.add(k);
break;
}
@@ -436,37 +438,37 @@ function placeTraps(
): void {
// Trap configuration
const trapTypes = ["poison", "fire", "paralysis"] as const;
// Number of traps scales with floor (1-2 on floor 1, up to 5-6 on floor 10)
const minTraps = 1 + Math.floor(floor / 3);
const maxTraps = minTraps + 2;
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
for (let i = 0; i < numTraps; i++) {
// Pick a random room (not the starting room)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
const room = rooms[roomIdx];
// Try to find a valid position
for (let attempts = 0; attempts < 10; attempts++) {
const tx = room.x + 1 + Math.floor(random() * (room.width - 2));
const ty = room.y + 1 + Math.floor(random() * (room.height - 2));
const key = `${tx},${ty}`;
// Check if position is valid (floor tile, not occupied)
const tileIdx = ty * width + tx;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
if (isFloor && !occupiedPositions.has(key)) {
// Pick a random trap type
const trapType = trapTypes[Math.floor(random() * trapTypes.length)];
// Scale effect duration/magnitude with floor
const duration = 3 + Math.floor(floor / 3);
const magnitude = 2 + Math.floor(floor / 2);
switch (trapType) {
case "poison":
Prefabs.poisonTrap(ecsWorld, tx, ty, duration, magnitude);
@@ -478,7 +480,7 @@ function placeTraps(
Prefabs.paralysisTrap(ecsWorld, tx, ty, Math.max(2, Math.ceil(duration / 2)));
break;
}
occupiedPositions.add(key);
break;
}
@@ -494,7 +496,7 @@ function placeDoors(width: number, height: number, tiles: Tile[], rooms: Room[],
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
@@ -507,7 +509,7 @@ function placeDoors(width: number, height: number, tiles: Tile[], rooms: Room[],
// 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++) {
@@ -518,7 +520,7 @@ function placeDoors(width: number, height: number, tiles: Tile[], rooms: Room[],
// 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);