Added flamethrower with buring effects
This commit is contained in:
@@ -152,7 +152,15 @@ export const GAME_CONFIG = {
|
||||
|
||||
gameplay: {
|
||||
energyThreshold: 100,
|
||||
actionCost: 100
|
||||
actionCost: 100,
|
||||
flamethrower: {
|
||||
range: 4,
|
||||
initialDamage: 7,
|
||||
burnDamage: 3,
|
||||
burnDuration: 5,
|
||||
rechargeTurns: 20,
|
||||
maxCharges: 3
|
||||
}
|
||||
},
|
||||
|
||||
assets: {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type {
|
||||
ConsumableItem,
|
||||
MeleeWeaponItem,
|
||||
RangedWeaponItem,
|
||||
ArmourItem,
|
||||
AmmoItem
|
||||
import type {
|
||||
ConsumableItem,
|
||||
MeleeWeaponItem,
|
||||
RangedWeaponItem,
|
||||
ArmourItem,
|
||||
AmmoItem,
|
||||
FlamethrowerItem
|
||||
} from "../types";
|
||||
import { GAME_CONFIG } from "../config/GameConfig";
|
||||
|
||||
// =============================================================================
|
||||
// Per-Type Template Lists (Immutable)
|
||||
@@ -100,28 +102,28 @@ export type ItemTemplateId = keyof typeof ALL_TEMPLATES;
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
import {
|
||||
ALL_VARIANTS,
|
||||
import {
|
||||
ALL_VARIANTS,
|
||||
type ArmourVariantId,
|
||||
type WeaponVariantId,
|
||||
type ConsumableVariantId
|
||||
} from "./ItemVariants";
|
||||
|
||||
export function createConsumable(
|
||||
id: ConsumableId,
|
||||
quantity = 1,
|
||||
id: ConsumableId,
|
||||
quantity = 1,
|
||||
variant?: ConsumableVariantId
|
||||
): ConsumableItem {
|
||||
const t = CONSUMABLES[id];
|
||||
const v = variant ? ALL_VARIANTS[variant] : null;
|
||||
|
||||
|
||||
// Apply effect multiplier for consumables
|
||||
const effectMult = v?.statModifiers.effectMultiplier ?? 1;
|
||||
const baseHealAmount = "healAmount" in t ? t.healAmount : undefined;
|
||||
const finalHealAmount = baseHealAmount ? Math.floor(baseHealAmount * effectMult) : undefined;
|
||||
|
||||
|
||||
const name = v ? `${v.prefix} ${t.name}` : t.name;
|
||||
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
@@ -140,15 +142,15 @@ export function createConsumable(
|
||||
}
|
||||
|
||||
export function createRangedWeapon(
|
||||
id: RangedWeaponId,
|
||||
id: RangedWeaponId,
|
||||
variant?: WeaponVariantId
|
||||
): RangedWeaponItem {
|
||||
const t = RANGED_WEAPONS[id];
|
||||
const v = variant ? ALL_VARIANTS[variant] : null;
|
||||
|
||||
|
||||
const name = v ? `${v.prefix} ${t.name}` : t.name;
|
||||
const attackBonus = (v?.statModifiers as { attack?: number })?.attack ?? 0;
|
||||
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
@@ -176,10 +178,10 @@ export function createMeleeWeapon(
|
||||
): MeleeWeaponItem {
|
||||
const t = MELEE_WEAPONS[id];
|
||||
const v = variant ? ALL_VARIANTS[variant] : null;
|
||||
|
||||
|
||||
const name = v ? `${v.prefix} ${t.name}` : t.name;
|
||||
const attackBonus = (v?.statModifiers as { attack?: number })?.attack ?? 0;
|
||||
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
@@ -209,15 +211,15 @@ export function createAmmo(id: AmmoId, quantity = 10): AmmoItem {
|
||||
}
|
||||
|
||||
export function createArmour(
|
||||
id: ArmourId,
|
||||
id: ArmourId,
|
||||
variant?: ArmourVariantId
|
||||
): ArmourItem {
|
||||
const t = ARMOUR[id];
|
||||
const v = variant ? ALL_VARIANTS[variant] : null;
|
||||
|
||||
|
||||
const name = v ? `${v.prefix} ${t.name}` : t.name;
|
||||
const defenseBonus = v?.statModifiers.defense ?? 0;
|
||||
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
@@ -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
|
||||
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 {
|
||||
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Phaser from "phaser";
|
||||
import { type World, type EntityId, type Vec2, type ActorType } from "../core/types";
|
||||
import { TileType } from "../core/terrain";
|
||||
import { TILE_SIZE } from "../core/constants";
|
||||
import { idx, isWall } from "../engine/world/world-logic";
|
||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
@@ -167,6 +168,18 @@ export class DungeonRenderer {
|
||||
const seen = this.fovManager.seenArray;
|
||||
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
|
||||
this.layer.forEachTile(tile => {
|
||||
const i = idx(this.world, tile.x, tile.y);
|
||||
@@ -189,6 +202,13 @@ export class DungeonRenderer {
|
||||
if (isVis) {
|
||||
tile.alpha = 1.0;
|
||||
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 {
|
||||
tile.alpha = isWall(this.world, tile.x, tile.y) ? 0.4 : 0.2;
|
||||
tile.tint = 0x888888;
|
||||
@@ -219,8 +239,33 @@ export class DungeonRenderer {
|
||||
sprite.setAlpha(0.4);
|
||||
sprite.setTint(0x888888);
|
||||
} else {
|
||||
sprite.setAlpha(1);
|
||||
sprite.clearTint();
|
||||
// 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 {
|
||||
sprite.setAlpha(1);
|
||||
sprite.clearTint();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +298,14 @@ export class DungeonRenderer {
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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") {
|
||||
if (a.type === "exp_orb") {
|
||||
if (!isVis) continue;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/worl
|
||||
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
||||
import { generateWorld } from "../engine/world/generator";
|
||||
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||
import { Prefabs } from "../engine/ecs/Prefabs";
|
||||
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||
import { EntityAccessor } from "../engine/EntityAccessor";
|
||||
import { ProgressionManager } from "../engine/ProgressionManager";
|
||||
@@ -24,11 +25,13 @@ import { TargetingSystem } from "./systems/TargetingSystem";
|
||||
import { ECSWorld } from "../engine/ecs/World";
|
||||
import { SystemRegistry } from "../engine/ecs/System";
|
||||
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 { generateLoot } from "../engine/systems/LootSystem";
|
||||
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 { GameRenderer } from "./rendering/GameRenderer";
|
||||
import { PlayerInputHandler } from "./systems/PlayerInputHandler";
|
||||
@@ -254,6 +257,14 @@ export class GameScene extends Phaser.Scene {
|
||||
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
|
||||
this.ecsRegistry.updateAll();
|
||||
|
||||
@@ -299,11 +310,26 @@ export class GameScene extends Phaser.Scene {
|
||||
);
|
||||
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];
|
||||
|
||||
|
||||
this.gameRenderer.renderEvents(allEvents, this.playerId, this.entityAccessor);
|
||||
|
||||
// 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()) {
|
||||
this.syncRunStateFromPlayer();
|
||||
const uiScene = this.scene.get("GameUI") as GameUI;
|
||||
if (uiScene && 'showDeathScreen' in uiScene) {
|
||||
if (uiScene && "showDeathScreen" in uiScene) {
|
||||
uiScene.showDeathScreen({
|
||||
floor: this.floorIndex,
|
||||
gold: this.runState.inventory.gold,
|
||||
stats: this.runState.stats
|
||||
stats: this.runState.stats,
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -373,6 +398,7 @@ export class GameScene extends Phaser.Scene {
|
||||
this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus);
|
||||
this.ecsRegistry.register(new TriggerSystem());
|
||||
this.ecsRegistry.register(new StatusEffectSystem());
|
||||
this.ecsRegistry.register(new FireSystem(this.world));
|
||||
|
||||
// NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state.
|
||||
// No need to manually add player here anymore.
|
||||
@@ -462,18 +488,51 @@ export class GameScene extends Phaser.Scene {
|
||||
blockedPos,
|
||||
projectileId,
|
||||
() => {
|
||||
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon
|
||||
const shouldDrop = item.type !== "Weapon";
|
||||
// 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
|
||||
|
||||
if (shouldDrop) {
|
||||
// Drop a SINGLE item at the landing spot (not the whole stack)
|
||||
const singleItem = { ...item, quantity: 1 };
|
||||
this.itemManager.spawnItem(singleItem, blockedPos);
|
||||
}
|
||||
const config = GAME_CONFIG.gameplay.flamethrower;
|
||||
const targetTiles = getConeTiles(player.pos, blockedPos, config.range);
|
||||
|
||||
// Trigger destruction/interaction
|
||||
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
||||
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
|
||||
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
|
||||
const shouldDrop = item.type !== "Weapon";
|
||||
|
||||
if (shouldDrop) {
|
||||
// Drop a SINGLE item at the landing spot (not the whole stack)
|
||||
const singleItem = { ...item, quantity: 1 };
|
||||
this.itemManager.spawnItem(singleItem, blockedPos);
|
||||
}
|
||||
|
||||
// Trigger destruction/interaction
|
||||
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
|
||||
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
|
||||
}
|
||||
}
|
||||
|
||||
this.targetingSystem.cancel();
|
||||
|
||||
@@ -41,28 +41,28 @@ export class GameEventHandler {
|
||||
const player = this.scene.entityAccessor.getPlayer();
|
||||
if (player) {
|
||||
this.scene.progressionManager.allocateStat(player, statName);
|
||||
this.scene.emitUIUpdate();
|
||||
}
|
||||
this.scene.emitUIUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
events.on("allocate-passive", (nodeId: string) => {
|
||||
const player = this.scene.entityAccessor.getPlayer();
|
||||
if (player) {
|
||||
this.scene.progressionManager.allocatePassive(player, nodeId);
|
||||
this.scene.emitUIUpdate();
|
||||
}
|
||||
this.scene.emitUIUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
events.on("player-wait", () => {
|
||||
if (!this.scene.awaitingPlayer) return;
|
||||
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
|
||||
this.scene.commitPlayerAction({ type: "wait" });
|
||||
});
|
||||
|
||||
|
||||
events.on("player-search", () => {
|
||||
if (!this.scene.awaitingPlayer) return;
|
||||
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
|
||||
|
||||
|
||||
console.log("Player searching...");
|
||||
this.scene.commitPlayerAction({ type: "wait" });
|
||||
});
|
||||
@@ -86,7 +86,7 @@ export class GameEventHandler {
|
||||
|
||||
private handleUseItem(itemId: string) {
|
||||
if (!this.scene.awaitingPlayer) return;
|
||||
|
||||
|
||||
const player = this.scene.entityAccessor.getPlayer();
|
||||
if (!player || !player.inventory) return;
|
||||
|
||||
@@ -95,48 +95,56 @@ export class GameEventHandler {
|
||||
const item = player.inventory.items[itemIdx];
|
||||
|
||||
// Ranged Weapon Logic
|
||||
if (item.type === "Weapon" && item.weaponType === "ranged") {
|
||||
// Check Ammo
|
||||
if (item.currentAmmo <= 0) {
|
||||
if (item.type === "Weapon" && (item.weaponType === "ranged" || item.weaponType === "flamethrower")) {
|
||||
if (item.weaponType === "ranged") {
|
||||
// Check Ammo
|
||||
if (item.currentAmmo <= 0) {
|
||||
if (item.reloadingTurnsLeft > 0) {
|
||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
||||
return;
|
||||
}
|
||||
|
||||
// Try Reload
|
||||
this.scene.startReload(player, item as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Is it already reloading?
|
||||
if (item.reloadingTurnsLeft > 0) {
|
||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Try Reload
|
||||
this.scene.startReload(player, item as any);
|
||||
// Has ammo/charges, start targeting
|
||||
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
|
||||
// Already targeting - execute action
|
||||
if (this.scene.targetingSystem.cursorPos) {
|
||||
this.scene.executeThrow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Is it already reloading?
|
||||
if (item.reloadingTurnsLeft > 0) {
|
||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
|
||||
return;
|
||||
}
|
||||
|
||||
// Has ammo, start targeting
|
||||
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
|
||||
// Already targeting - execute shoot
|
||||
if (this.scene.targetingSystem.cursorPos) {
|
||||
this.scene.executeThrow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
|
||||
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
|
||||
|
||||
this.scene.targetingSystem.startTargeting(
|
||||
item.id,
|
||||
player.pos,
|
||||
this.scene.world,
|
||||
this.scene.entityAccessor,
|
||||
this.scene.playerId,
|
||||
this.scene.dungeonRenderer.seenArray,
|
||||
this.scene.world.width,
|
||||
{ x: tx, y: ty }
|
||||
);
|
||||
this.scene.emitUIUpdate();
|
||||
this.scene.targetingSystem.startTargeting(
|
||||
item.id,
|
||||
player.pos,
|
||||
this.scene.world,
|
||||
this.scene.entityAccessor,
|
||||
this.scene.playerId,
|
||||
this.scene.dungeonRenderer.seenArray,
|
||||
this.scene.world.width,
|
||||
{ x: tx, y: ty }
|
||||
);
|
||||
this.scene.emitUIUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,27 +152,27 @@ export class GameEventHandler {
|
||||
if (item.id === "upgrade_scroll") {
|
||||
const uiScene = this.scene.scene.get("GameUI") as GameUI;
|
||||
// Access the public inventory component
|
||||
const inventoryOverlay = uiScene.inventory;
|
||||
|
||||
const inventoryOverlay = uiScene.inventory;
|
||||
|
||||
if (inventoryOverlay && inventoryOverlay instanceof InventoryOverlay) {
|
||||
// Trigger upgrade mode
|
||||
inventoryOverlay.enterUpgradeMode((targetItem: any) => {
|
||||
const success = UpgradeManager.applyUpgrade(targetItem);
|
||||
if (success) {
|
||||
// Consume scroll logic handling stacking
|
||||
const scrollItem = player.inventory?.items.find(it => it.id === "upgrade_scroll");
|
||||
if (scrollItem) {
|
||||
if (scrollItem.stackable && scrollItem.quantity && scrollItem.quantity > 1) {
|
||||
scrollItem.quantity--;
|
||||
} else {
|
||||
this.scene.itemManager.removeFromInventory(player, "upgrade_scroll");
|
||||
}
|
||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
|
||||
}
|
||||
|
||||
inventoryOverlay.cancelUpgradeMode();
|
||||
this.scene.emitUIUpdate();
|
||||
this.scene.commitPlayerAction({ type: "wait" });
|
||||
const scrollItem = player.inventory?.items.find(it => it.id === "upgrade_scroll");
|
||||
if (scrollItem) {
|
||||
if (scrollItem.stackable && scrollItem.quantity && scrollItem.quantity > 1) {
|
||||
scrollItem.quantity--;
|
||||
} else {
|
||||
this.scene.itemManager.removeFromInventory(player, "upgrade_scroll");
|
||||
}
|
||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
|
||||
}
|
||||
|
||||
inventoryOverlay.cancelUpgradeMode();
|
||||
this.scene.emitUIUpdate();
|
||||
this.scene.commitPlayerAction({ type: "wait" });
|
||||
} else {
|
||||
// Should technically be prevented by UI highlights, but safety check
|
||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot upgrade!", "#ff0000");
|
||||
@@ -172,49 +180,49 @@ export class GameEventHandler {
|
||||
this.scene.emitUIUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Select Item to Upgrade", "#ffffff");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.scene.itemManager.handleUse(itemId, player);
|
||||
|
||||
|
||||
if (result.success && result.consumed) {
|
||||
const healAmount = player.stats.maxHp - player.stats.hp; // Already healed by manager
|
||||
const actualHeal = Math.min(healAmount, player.stats.hp);
|
||||
this.scene.dungeonRenderer.showHeal(player.pos.x, player.pos.y, actualHeal);
|
||||
this.scene.commitPlayerAction({ type: "wait" });
|
||||
this.scene.commitPlayerAction({ type: "wait" });
|
||||
this.scene.emitUIUpdate();
|
||||
} else if (result.success && !result.consumed) {
|
||||
// Throwable item - start targeting
|
||||
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
|
||||
// Already targeting - execute throw
|
||||
if (this.scene.targetingSystem.cursorPos) {
|
||||
this.scene.executeThrow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
|
||||
// Throwable item - start targeting
|
||||
if (this.scene.targetingSystem.isActive && this.scene.targetingSystem.itemId === item.id) {
|
||||
// Already targeting - execute throw
|
||||
if (this.scene.targetingSystem.cursorPos) {
|
||||
this.scene.executeThrow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.scene.targetingSystem.startTargeting(
|
||||
item.id,
|
||||
player.pos,
|
||||
this.scene.world,
|
||||
this.scene.entityAccessor,
|
||||
this.scene.playerId,
|
||||
this.scene.dungeonRenderer.seenArray,
|
||||
this.scene.world.width,
|
||||
{ x: tx, y: ty }
|
||||
);
|
||||
this.scene.emitUIUpdate();
|
||||
const { x: tx, y: ty } = this.scene.getPointerTilePos(this.scene.input.activePointer);
|
||||
|
||||
this.scene.targetingSystem.startTargeting(
|
||||
item.id,
|
||||
player.pos,
|
||||
this.scene.world,
|
||||
this.scene.entityAccessor,
|
||||
this.scene.playerId,
|
||||
this.scene.dungeonRenderer.seenArray,
|
||||
this.scene.world.width,
|
||||
{ x: tx, y: ty }
|
||||
);
|
||||
this.scene.emitUIUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private handleDropItem(data: { itemId: string, pointerX: number, pointerY: number }) {
|
||||
if (!this.scene.awaitingPlayer) return;
|
||||
|
||||
|
||||
const player = this.scene.entityAccessor.getPlayer();
|
||||
if (!player || !player.inventory) return;
|
||||
|
||||
@@ -225,7 +233,7 @@ export class GameEventHandler {
|
||||
let dropPos = { x: player.pos.x, y: player.pos.y };
|
||||
if (data.pointerX !== undefined && data.pointerY !== undefined) {
|
||||
const tilePos = this.scene.getPointerTilePos({ x: data.pointerX, y: data.pointerY } as Phaser.Input.Pointer);
|
||||
|
||||
|
||||
// Limit drop distance to 1 tile from player for balance/fairness
|
||||
const dx = Math.sign(tilePos.x - player.pos.x);
|
||||
const dy = Math.sign(tilePos.y - player.pos.y);
|
||||
@@ -240,10 +248,10 @@ export class GameEventHandler {
|
||||
// Remove from inventory and spawn in world
|
||||
if (this.scene.itemManager.removeFromInventory(player, data.itemId)) {
|
||||
this.scene.itemManager.spawnItem(item, dropPos);
|
||||
|
||||
|
||||
const quantityText = (item.quantity && item.quantity > 1) ? ` x${item.quantity}` : "";
|
||||
this.scene.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Dropped ${item.name}${quantityText}`, "#aaaaaa");
|
||||
|
||||
|
||||
this.scene.emitUIUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { World, Item, Vec2, EntityId } from "../../core/types";
|
||||
import { TILE_SIZE } from "../../core/constants";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -109,6 +109,18 @@ export class TargetingSystem {
|
||||
|
||||
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
|
||||
if (item.type === "Consumable" && item.throwable) {
|
||||
// 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 { blockedPos, hitActorId } = result;
|
||||
|
||||
@@ -196,6 +205,24 @@ export class TargetingSystem {
|
||||
|
||||
finalEndX = bPos.x * 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
|
||||
|
||||
@@ -8,7 +8,7 @@ export class QuickSlotComponent {
|
||||
private container!: Phaser.GameObjects.Container;
|
||||
private slots: Phaser.GameObjects.Container[] = [];
|
||||
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 dragIcon: Phaser.GameObjects.Sprite | null = null;
|
||||
private reloadSliderContainer!: Phaser.GameObjects.Container;
|
||||
@@ -247,6 +247,9 @@ export class QuickSlotComponent {
|
||||
} else if (foundItem.type === "Weapon" && foundItem.weaponType === "ranged" && foundItem.stats) {
|
||||
// Show ammo for non-stackable ranged weapons
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user