Compare commits
2 Commits
3a656c46fc
...
58b3726d21
| Author | SHA1 | Date | |
|---|---|---|---|
| 58b3726d21 | |||
| 41909fd8e6 |
@@ -152,7 +152,15 @@ export const GAME_CONFIG = {
|
|||||||
|
|
||||||
gameplay: {
|
gameplay: {
|
||||||
energyThreshold: 100,
|
energyThreshold: 100,
|
||||||
actionCost: 100
|
actionCost: 100,
|
||||||
|
flamethrower: {
|
||||||
|
range: 4,
|
||||||
|
initialDamage: 7,
|
||||||
|
burnDamage: 3,
|
||||||
|
burnDuration: 5,
|
||||||
|
rechargeTurns: 20,
|
||||||
|
maxCharges: 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
assets: {
|
assets: {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import type {
|
|||||||
MeleeWeaponItem,
|
MeleeWeaponItem,
|
||||||
RangedWeaponItem,
|
RangedWeaponItem,
|
||||||
ArmourItem,
|
ArmourItem,
|
||||||
AmmoItem
|
AmmoItem,
|
||||||
|
FlamethrowerItem
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { GAME_CONFIG } from "../config/GameConfig";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Per-Type Template Lists (Immutable)
|
// Per-Type Template Lists (Immutable)
|
||||||
@@ -244,6 +246,24 @@ export function createUpgradeScroll(quantity = 1): ConsumableItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createFlamethrower(): FlamethrowerItem {
|
||||||
|
const config = GAME_CONFIG.gameplay.flamethrower;
|
||||||
|
return {
|
||||||
|
id: "flamethrower",
|
||||||
|
name: "Flamethrower",
|
||||||
|
type: "Weapon",
|
||||||
|
weaponType: "flamethrower",
|
||||||
|
textureKey: "weapons",
|
||||||
|
spriteIndex: 5,
|
||||||
|
charges: config.maxCharges,
|
||||||
|
maxCharges: config.maxCharges,
|
||||||
|
lastRechargeTurn: 0,
|
||||||
|
stats: {
|
||||||
|
attack: config.initialDamage,
|
||||||
|
range: config.range,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy export for backward compatibility during migration
|
// Legacy export for backward compatibility during migration
|
||||||
export const ITEMS = ALL_TEMPLATES;
|
export const ITEMS = ALL_TEMPLATES;
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,20 @@ export interface RangedWeaponItem extends BaseItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem;
|
export interface FlamethrowerItem extends BaseItem {
|
||||||
|
type: "Weapon";
|
||||||
|
weaponType: "flamethrower";
|
||||||
|
charges: number;
|
||||||
|
maxCharges: number;
|
||||||
|
lastRechargeTurn: number;
|
||||||
|
stats: {
|
||||||
|
attack: number;
|
||||||
|
range: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem | FlamethrowerItem;
|
||||||
|
|
||||||
|
|
||||||
export interface ArmourItem extends BaseItem {
|
export interface ArmourItem extends BaseItem {
|
||||||
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
|
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export type GameEvent =
|
|||||||
// Status effect events
|
// Status effect events
|
||||||
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
|
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
|
||||||
| { type: "status_expired"; entityId: EntityId; status: string }
|
| { type: "status_expired"; entityId: EntityId; status: string }
|
||||||
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number };
|
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number }
|
||||||
|
|
||||||
|
// World events
|
||||||
|
| { type: "tile_changed"; x: number; y: number };
|
||||||
|
|
||||||
export type GameEventType = GameEvent["type"];
|
export type GameEventType = GameEvent["type"];
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,29 @@ export const Prefabs = {
|
|||||||
.build();
|
.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.
|
* Create a player entity at the given position.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ 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 = {
|
export type ComponentMap = {
|
||||||
// Core components
|
// Core components
|
||||||
position: PositionComponent;
|
position: PositionComponent;
|
||||||
@@ -125,6 +132,7 @@ export type ComponentMap = {
|
|||||||
groundItem: GroundItemComponent;
|
groundItem: GroundItemComponent;
|
||||||
inventory: InventoryComponent;
|
inventory: InventoryComponent;
|
||||||
equipment: EquipmentComponent;
|
equipment: EquipmentComponent;
|
||||||
|
lifeSpan: LifeSpanComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ComponentType = keyof ComponentMap;
|
export type ComponentType = keyof ComponentMap;
|
||||||
|
|||||||
103
src/engine/ecs/systems/FireSystem.ts
Normal file
103
src/engine/ecs/systems/FireSystem.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
export function getClosestVisibleEnemy(
|
||||||
origin: Vec2,
|
origin: Vec2,
|
||||||
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
|
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
|
||||||
|
|||||||
@@ -185,11 +185,23 @@ function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, a
|
|||||||
|
|
||||||
accessor.removeActor(target.id);
|
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
|
// Spawn EXP Orb
|
||||||
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
||||||
const expAmount = enemyDef?.expValue || 0;
|
const expAmount = enemyDef?.expValue || 0;
|
||||||
|
|
||||||
const ecsWorld = accessor.context;
|
|
||||||
if (ecsWorld) {
|
if (ecsWorld) {
|
||||||
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
|
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 });
|
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
createRangedWeapon,
|
createRangedWeapon,
|
||||||
createArmour,
|
createArmour,
|
||||||
createUpgradeScroll,
|
createUpgradeScroll,
|
||||||
createAmmo
|
createAmmo,
|
||||||
|
createFlamethrower
|
||||||
} from "../../core/config/Items";
|
} from "../../core/config/Items";
|
||||||
import { seededRandom } from "../../core/math";
|
import { seededRandom } from "../../core/math";
|
||||||
import * as ROT from "rot-js";
|
import * as ROT from "rot-js";
|
||||||
@@ -62,6 +63,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
createConsumable("throwing_dagger", 3),
|
createConsumable("throwing_dagger", 3),
|
||||||
createRangedWeapon("pistol"),
|
createRangedWeapon("pistol"),
|
||||||
createAmmo("ammo_9mm", 10),
|
createAmmo("ammo_9mm", 10),
|
||||||
|
createFlamethrower(),
|
||||||
createArmour("leather_armor", "heavy"),
|
createArmour("leather_armor", "heavy"),
|
||||||
createUpgradeScroll(2)
|
createUpgradeScroll(2)
|
||||||
] : [])
|
] : [])
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
|
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
|
||||||
|
import { TileType } from "../core/terrain";
|
||||||
import { TILE_SIZE } from "../core/constants";
|
import { TILE_SIZE } from "../core/constants";
|
||||||
import { idx, isWall } from "../engine/world/world-logic";
|
import { idx, isWall } from "../engine/world/world-logic";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
@@ -167,6 +168,18 @@ export class DungeonRenderer {
|
|||||||
const seen = this.fovManager.seenArray;
|
const seen = this.fovManager.seenArray;
|
||||||
const visible = this.fovManager.visibleArray;
|
const visible = this.fovManager.visibleArray;
|
||||||
|
|
||||||
|
// Pre-collect fire positions for efficient tile tinting
|
||||||
|
const firePositions = new Set<string>();
|
||||||
|
if (this.ecsWorld) {
|
||||||
|
const fires = this.ecsWorld.getEntitiesWith("position", "name");
|
||||||
|
for (const fid of fires) {
|
||||||
|
if (this.ecsWorld.getComponent(fid, "name")?.name === "Fire") {
|
||||||
|
const pos = this.ecsWorld.getComponent(fid, "position")!;
|
||||||
|
firePositions.add(`${pos.x},${pos.y}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update Tiles
|
// Update Tiles
|
||||||
this.layer.forEachTile(tile => {
|
this.layer.forEachTile(tile => {
|
||||||
const i = idx(this.world, tile.x, tile.y);
|
const i = idx(this.world, tile.x, tile.y);
|
||||||
@@ -189,6 +202,13 @@ export class DungeonRenderer {
|
|||||||
if (isVis) {
|
if (isVis) {
|
||||||
tile.alpha = 1.0;
|
tile.alpha = 1.0;
|
||||||
tile.tint = 0xffffff;
|
tile.tint = 0xffffff;
|
||||||
|
|
||||||
|
// Special effect for burning grass
|
||||||
|
if (firePositions.has(`${tile.x},${tile.y}`) && worldTile === TileType.GRASS) {
|
||||||
|
const flicker = 0.8 + Math.sin(this.scene.time.now / 120) * 0.2;
|
||||||
|
tile.tint = 0xff3333; // Bright red
|
||||||
|
tile.alpha = flicker;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
|
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
|
||||||
tile.tint = 0x888888;
|
tile.tint = 0x888888;
|
||||||
@@ -218,6 +238,30 @@ export class DungeonRenderer {
|
|||||||
if (isSeen && !isVis) {
|
if (isSeen && !isVis) {
|
||||||
sprite.setAlpha(0.4);
|
sprite.setAlpha(0.4);
|
||||||
sprite.setTint(0x888888);
|
sprite.setTint(0x888888);
|
||||||
|
} else {
|
||||||
|
// Flickering effect for Fire
|
||||||
|
const name = this.ecsWorld.getComponent(trapId, "name");
|
||||||
|
if (name?.name === "Fire") {
|
||||||
|
const flicker = 0.8 + Math.sin(this.scene.time.now / 100) * 0.2;
|
||||||
|
sprite.setAlpha(flicker);
|
||||||
|
sprite.setScale(0.9 + Math.sin(this.scene.time.now / 150) * 0.1);
|
||||||
|
|
||||||
|
// Tint based on underlying tile
|
||||||
|
const tileIdx = idx(this.world, pos.x, pos.y);
|
||||||
|
const worldTile = this.world.tiles[tileIdx];
|
||||||
|
|
||||||
|
if (worldTile === TileType.GRASS) {
|
||||||
|
sprite.setTint(0xff3300); // Bright red-orange for burning grass
|
||||||
|
} else if (worldTile === TileType.DOOR_CLOSED || worldTile === TileType.DOOR_OPEN) {
|
||||||
|
// Pulse between yellow and red for doors
|
||||||
|
const pulse = (Math.sin(this.scene.time.now / 150) + 1) / 2;
|
||||||
|
const r = 255;
|
||||||
|
const g = Math.floor(200 * (1 - pulse));
|
||||||
|
const b = 0;
|
||||||
|
sprite.setTint((r << 16) | (g << 8) | b);
|
||||||
|
} else {
|
||||||
|
sprite.setTint(0xffaa44); // Default orange
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
sprite.setAlpha(1);
|
sprite.setAlpha(1);
|
||||||
sprite.clearTint();
|
sprite.clearTint();
|
||||||
@@ -225,6 +269,7 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Actors (Combatants)
|
// Actors (Combatants)
|
||||||
const activeEnemyIds = new Set<EntityId>();
|
const activeEnemyIds = new Set<EntityId>();
|
||||||
@@ -253,6 +298,14 @@ export class DungeonRenderer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.playerSprite.setVisible(true);
|
this.playerSprite.setVisible(true);
|
||||||
|
|
||||||
|
// Burning status effect
|
||||||
|
const statusEffects = this.ecsWorld.getComponent(this.entityAccessor.playerId, "statusEffects");
|
||||||
|
if (statusEffects?.effects.some(e => e.type === "burning")) {
|
||||||
|
this.playerSprite.setTint(0xff6600);
|
||||||
|
} else {
|
||||||
|
this.playerSprite.clearTint();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -292,6 +345,14 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Burning status effect
|
||||||
|
const statusEffects = this.ecsWorld.getComponent(a.id, "statusEffects");
|
||||||
|
if (statusEffects?.effects.some(e => e.type === "burning")) {
|
||||||
|
sprite.setTint(0xff6600);
|
||||||
|
} else if (sprite) {
|
||||||
|
sprite.clearTint();
|
||||||
|
}
|
||||||
|
|
||||||
} else if (a.category === "collectible") {
|
} else if (a.category === "collectible") {
|
||||||
if (a.type === "exp_orb") {
|
if (a.type === "exp_orb") {
|
||||||
if (!isVis) continue;
|
if (!isVis) continue;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/worl
|
|||||||
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
||||||
import { generateWorld } from "../engine/world/generator";
|
import { generateWorld } from "../engine/world/generator";
|
||||||
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||||
|
import { Prefabs } from "../engine/ecs/Prefabs";
|
||||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
import { EntityAccessor } from "../engine/EntityAccessor";
|
import { EntityAccessor } from "../engine/EntityAccessor";
|
||||||
import { ProgressionManager } from "../engine/ProgressionManager";
|
import { ProgressionManager } from "../engine/ProgressionManager";
|
||||||
@@ -24,11 +25,13 @@ import { TargetingSystem } from "./systems/TargetingSystem";
|
|||||||
import { ECSWorld } from "../engine/ecs/World";
|
import { ECSWorld } from "../engine/ecs/World";
|
||||||
import { SystemRegistry } from "../engine/ecs/System";
|
import { SystemRegistry } from "../engine/ecs/System";
|
||||||
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
||||||
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
import { StatusEffectSystem, applyStatusEffect } from "../engine/ecs/systems/StatusEffectSystem";
|
||||||
|
import { TileType } from "../core/terrain";
|
||||||
|
import { FireSystem } from "../engine/ecs/systems/FireSystem";
|
||||||
import { EventBus } from "../engine/ecs/EventBus";
|
import { EventBus } from "../engine/ecs/EventBus";
|
||||||
import { generateLoot } from "../engine/systems/LootSystem";
|
import { generateLoot } from "../engine/systems/LootSystem";
|
||||||
import { getEffectColor, getEffectName } from "./systems/EventRenderer";
|
import { getEffectColor, getEffectName } from "./systems/EventRenderer";
|
||||||
import { calculateDamage } from "../engine/gameplay/CombatLogic";
|
import { calculateDamage, getConeTiles } from "../engine/gameplay/CombatLogic";
|
||||||
import { GameInput } from "../engine/input/GameInput";
|
import { GameInput } from "../engine/input/GameInput";
|
||||||
import { GameRenderer } from "./rendering/GameRenderer";
|
import { GameRenderer } from "./rendering/GameRenderer";
|
||||||
import { PlayerInputHandler } from "./systems/PlayerInputHandler";
|
import { PlayerInputHandler } from "./systems/PlayerInputHandler";
|
||||||
@@ -254,6 +257,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle tile changes from ECS/FireSystem
|
||||||
|
const ecsEvents = this.ecsEventBus.drain();
|
||||||
|
for (const ev of ecsEvents) {
|
||||||
|
if (ev.type === "tile_changed") {
|
||||||
|
this.dungeonRenderer.updateTile(ev.x, ev.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process traps and status effects
|
// Process traps and status effects
|
||||||
this.ecsRegistry.updateAll();
|
this.ecsRegistry.updateAll();
|
||||||
|
|
||||||
@@ -299,11 +310,26 @@ export class GameScene extends Phaser.Scene {
|
|||||||
);
|
);
|
||||||
player.stats.mana += regenAmount;
|
player.stats.mana += regenAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flamethrower Recharge logic
|
||||||
|
if (player && player.inventory) {
|
||||||
|
for (const item of player.inventory.items) {
|
||||||
|
if (item.type === "Weapon" && item.weaponType === "flamethrower") {
|
||||||
|
if (item.charges < item.maxCharges) {
|
||||||
|
const turnsSinceLast = this.turnCount - item.lastRechargeTurn;
|
||||||
|
if (turnsSinceLast >= GAME_CONFIG.gameplay.flamethrower.rechargeTurns) {
|
||||||
|
item.charges++;
|
||||||
|
item.lastRechargeTurn = this.turnCount;
|
||||||
|
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "+Charge", "#ff6600");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allEvents = [...playerEvents, ...enemyStep.events];
|
const allEvents = [...playerEvents, ...enemyStep.events];
|
||||||
|
|
||||||
|
|
||||||
this.gameRenderer.renderEvents(allEvents, this.playerId, this.entityAccessor);
|
this.gameRenderer.renderEvents(allEvents, this.playerId, this.entityAccessor);
|
||||||
|
|
||||||
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
|
// Handle loot drops from kills (not part of renderSimEvents since it modifies world state)
|
||||||
@@ -317,15 +343,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!this.entityAccessor.isPlayerAlive()) {
|
if (!this.entityAccessor.isPlayerAlive()) {
|
||||||
this.syncRunStateFromPlayer();
|
this.syncRunStateFromPlayer();
|
||||||
const uiScene = this.scene.get("GameUI") as GameUI;
|
const uiScene = this.scene.get("GameUI") as GameUI;
|
||||||
if (uiScene && 'showDeathScreen' in uiScene) {
|
if (uiScene && "showDeathScreen" in uiScene) {
|
||||||
uiScene.showDeathScreen({
|
uiScene.showDeathScreen({
|
||||||
floor: this.floorIndex,
|
floor: this.floorIndex,
|
||||||
gold: this.runState.inventory.gold,
|
gold: this.runState.inventory.gold,
|
||||||
stats: this.runState.stats
|
stats: this.runState.stats,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -373,6 +398,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
|
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
|
||||||
this.ecsRegistry.register(new TriggerSystem());
|
this.ecsRegistry.register(new TriggerSystem());
|
||||||
this.ecsRegistry.register(new StatusEffectSystem());
|
this.ecsRegistry.register(new StatusEffectSystem());
|
||||||
|
this.ecsRegistry.register(new FireSystem(this.world));
|
||||||
|
|
||||||
// NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state.
|
// NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state.
|
||||||
// No need to manually add player here anymore.
|
// No need to manually add player here anymore.
|
||||||
@@ -462,6 +488,38 @@ export class GameScene extends Phaser.Scene {
|
|||||||
blockedPos,
|
blockedPos,
|
||||||
projectileId,
|
projectileId,
|
||||||
() => {
|
() => {
|
||||||
|
// Handle Flamethrower specific impact
|
||||||
|
if (item.type === "Weapon" && item.weaponType === "flamethrower") {
|
||||||
|
item.charges--;
|
||||||
|
item.lastRechargeTurn = this.turnCount; // Prevent immediate recharge if turn logic is before/after
|
||||||
|
|
||||||
|
const config = GAME_CONFIG.gameplay.flamethrower;
|
||||||
|
const targetTiles = getConeTiles(player.pos, blockedPos, config.range);
|
||||||
|
|
||||||
|
for (const tile of targetTiles) {
|
||||||
|
// 1. Initial Damage to Enemies
|
||||||
|
const enemy = this.entityAccessor.findEnemyAt(tile.x, tile.y);
|
||||||
|
if (enemy) {
|
||||||
|
enemy.stats.hp -= config.initialDamage;
|
||||||
|
this.dungeonRenderer.showDamage(tile.x, tile.y, config.initialDamage);
|
||||||
|
|
||||||
|
// 2. Burning Status
|
||||||
|
applyStatusEffect(this.ecsWorld, enemy.id, {
|
||||||
|
type: "burning",
|
||||||
|
duration: config.burnDuration,
|
||||||
|
magnitude: config.burnDamage,
|
||||||
|
source: this.playerId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Set Tile on Fire (ONLY if flammable)
|
||||||
|
const tileIdx = tile.y * this.world.width + tile.x;
|
||||||
|
const worldTile = this.world.tiles[tileIdx];
|
||||||
|
if (worldTile === TileType.GRASS || worldTile === TileType.DOOR_CLOSED || worldTile === TileType.DOOR_OPEN) {
|
||||||
|
Prefabs.fire(this.ecsWorld, tile.x, tile.y, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
|
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
|
||||||
const shouldDrop = item.type !== "Weapon";
|
const shouldDrop = item.type !== "Weapon";
|
||||||
|
|
||||||
@@ -475,6 +533,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
||||||
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
|
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.targetingSystem.cancel();
|
this.targetingSystem.cancel();
|
||||||
this.commitPlayerAction({ type: "throw" }); // Or 'attack' if shooting? 'throw' is fine for now
|
this.commitPlayerAction({ type: "throw" }); // Or 'attack' if shooting? 'throw' is fine for now
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export class GameEventHandler {
|
|||||||
const item = player.inventory.items[itemIdx];
|
const item = player.inventory.items[itemIdx];
|
||||||
|
|
||||||
// Ranged Weapon Logic
|
// Ranged Weapon Logic
|
||||||
if (item.type === "Weapon" && item.weaponType === "ranged") {
|
if (item.type === "Weapon" && (item.weaponType === "ranged" || item.weaponType === "flamethrower")) {
|
||||||
|
if (item.weaponType === "ranged") {
|
||||||
// Check Ammo
|
// Check Ammo
|
||||||
if (item.currentAmmo <= 0) {
|
if (item.currentAmmo <= 0) {
|
||||||
if (item.reloadingTurnsLeft > 0) {
|
if (item.reloadingTurnsLeft > 0) {
|
||||||
@@ -113,10 +114,17 @@ export class GameEventHandler {
|
|||||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if (item.weaponType === "flamethrower") {
|
||||||
|
// Check Charges
|
||||||
|
if (item.charges <= 0) {
|
||||||
|
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No charges!", "#ff6600");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Has ammo, start targeting
|
// Has ammo/charges, start targeting
|
||||||
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
|
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
|
||||||
// Already targeting - execute shoot
|
// Already targeting - execute action
|
||||||
if (this.scene.targetingSystem.cursorPos) {
|
if (this.scene.targetingSystem.cursorPos) {
|
||||||
this.scene.executeThrow();
|
this.scene.executeThrow();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { World, Item, Vec2, EntityId } from "../../core/types";
|
|||||||
import { TILE_SIZE } from "../../core/constants";
|
import { TILE_SIZE } from "../../core/constants";
|
||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { UI_CONFIG } from "../../core/config/ui";
|
import { UI_CONFIG } from "../../core/config/ui";
|
||||||
import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic";
|
import { traceProjectile, getClosestVisibleEnemy, getConeTiles } from "../../engine/gameplay/CombatLogic";
|
||||||
import { type EntityAccessor } from "../../engine/EntityAccessor";
|
import { type EntityAccessor } from "../../engine/EntityAccessor";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,6 +109,18 @@ export class TargetingSystem {
|
|||||||
|
|
||||||
const item = player.inventory.items[itemIdx];
|
const item = player.inventory.items[itemIdx];
|
||||||
|
|
||||||
|
const start = player.pos;
|
||||||
|
const end = { x: this.cursor.x, y: this.cursor.y };
|
||||||
|
|
||||||
|
if (item.type === "Weapon" && item.weaponType === "flamethrower") {
|
||||||
|
if (item.charges <= 0) {
|
||||||
|
console.log("No charges left!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
onProjectileComplete(end, undefined, item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Only remove if it's a consumable throwable
|
// Only remove if it's a consumable throwable
|
||||||
if (item.type === "Consumable" && item.throwable) {
|
if (item.type === "Consumable" && item.throwable) {
|
||||||
// Handle stack decrement if applicable, or remove
|
// Handle stack decrement if applicable, or remove
|
||||||
@@ -119,9 +131,6 @@ export class TargetingSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = player.pos;
|
|
||||||
const end = { x: this.cursor.x, y: this.cursor.y };
|
|
||||||
|
|
||||||
const result = traceProjectile(world, start, end, accessor, playerId);
|
const result = traceProjectile(world, start, end, accessor, playerId);
|
||||||
const { blockedPos, hitActorId } = result;
|
const { blockedPos, hitActorId } = result;
|
||||||
|
|
||||||
@@ -196,6 +205,24 @@ export class TargetingSystem {
|
|||||||
|
|
||||||
finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2;
|
finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
finalEndY = bPos.y * TILE_SIZE + TILE_SIZE / 2;
|
finalEndY = bPos.y * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
|
||||||
|
// Draw Cone if it's a flamethrower
|
||||||
|
const player = this.accessor.getCombatant(this.playerId);
|
||||||
|
const item = player?.inventory?.items.find(it => it.id === this.targetingItemId);
|
||||||
|
if (item?.type === "Weapon" && item.weaponType === "flamethrower") {
|
||||||
|
const range = item.stats.range;
|
||||||
|
const tiles = getConeTiles(playerPos, this.cursor, range);
|
||||||
|
|
||||||
|
this.graphics.fillStyle(GAME_CONFIG.ui.targetingLineColor, 0.2);
|
||||||
|
for (const tile of tiles) {
|
||||||
|
this.graphics.fillRect(
|
||||||
|
tile.x * TILE_SIZE,
|
||||||
|
tile.y * TILE_SIZE,
|
||||||
|
TILE_SIZE,
|
||||||
|
TILE_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update crosshair position to ACTUAL impact point
|
// Update crosshair position to ACTUAL impact point
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class QuickSlotComponent {
|
|||||||
private container!: Phaser.GameObjects.Container;
|
private container!: Phaser.GameObjects.Container;
|
||||||
private slots: Phaser.GameObjects.Container[] = [];
|
private slots: Phaser.GameObjects.Container[] = [];
|
||||||
private itemMap: (Item | null)[] = new Array(10).fill(null);
|
private itemMap: (Item | null)[] = new Array(10).fill(null);
|
||||||
private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", ...new Array(7).fill("")];
|
private assignedIds: string[] = ["health_potion", "pistol", "throwing_dagger", "flamethrower", ...new Array(6).fill("")];
|
||||||
private draggedSlotIndex: number | null = null;
|
private draggedSlotIndex: number | null = null;
|
||||||
private dragIcon: Phaser.GameObjects.Sprite | null = null;
|
private dragIcon: Phaser.GameObjects.Sprite | null = null;
|
||||||
private reloadSliderContainer!: Phaser.GameObjects.Container;
|
private reloadSliderContainer!: Phaser.GameObjects.Container;
|
||||||
@@ -247,6 +247,9 @@ export class QuickSlotComponent {
|
|||||||
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
|
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
|
||||||
// Show ammo for non-stackable ranged weapons
|
// Show ammo for non-stackable ranged weapons
|
||||||
labelText = `${foundItem.currentAmmo}/${foundItem.stats.magazineSize}`;
|
labelText = `${foundItem.currentAmmo}/${foundItem.stats.magazineSize}`;
|
||||||
|
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "flamethrower") {
|
||||||
|
// Show charges for flamethrower
|
||||||
|
labelText = `${foundItem.charges}/${foundItem.maxCharges}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelText) {
|
if (labelText) {
|
||||||
|
|||||||
Reference in New Issue
Block a user