Auto reload last reloadble weapon when reload is triggered

This commit is contained in:
2026-01-28 18:32:52 +11:00
parent 90aebc892a
commit f01d8de15c
4 changed files with 436 additions and 277 deletions

View File

@@ -64,16 +64,16 @@ export type Stats = {
}; };
export type ItemType = export type ItemType =
| "Weapon" | "Weapon"
| "Offhand" | "Offhand"
| "BodyArmour" | "BodyArmour"
| "Helmet" | "Helmet"
| "Gloves" | "Gloves"
| "Boots" | "Boots"
| "Amulet" | "Amulet"
| "Ring" | "Ring"
| "Belt" | "Belt"
| "Currency" | "Currency"
| "Consumable" | "Consumable"
| "Ammo"; | "Ammo";
@@ -93,7 +93,7 @@ export interface MeleeWeaponItem extends BaseItem {
type: "Weapon"; type: "Weapon";
weaponType: "melee"; weaponType: "melee";
stats: { stats: {
attack: number; attack: number;
}; };
} }
@@ -103,12 +103,12 @@ export interface RangedWeaponItem extends BaseItem {
currentAmmo: number; // Runtime state - moved to top level for easier access currentAmmo: number; // Runtime state - moved to top level for easier access
reloadingTurnsLeft: number; reloadingTurnsLeft: number;
stats: { stats: {
attack: number; attack: number;
range: number; range: number;
magazineSize: number; magazineSize: number;
ammoType: string; ammoType: string;
projectileSpeed: number; projectileSpeed: number;
fireSound?: string; fireSound?: string;
}; };
} }
@@ -117,15 +117,15 @@ export type WeaponItem = MeleeWeaponItem | RangedWeaponItem;
export interface ArmourItem extends BaseItem { export interface ArmourItem extends BaseItem {
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots"; type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
stats: { stats: {
defense: number; defense: number;
}; };
} }
export interface ConsumableItem extends BaseItem { export interface ConsumableItem extends BaseItem {
type: "Consumable"; type: "Consumable";
stats?: { stats?: {
hp?: number; hp?: number;
attack?: number; attack?: number;
}; };
throwable?: boolean; throwable?: boolean;
} }
@@ -163,6 +163,7 @@ export type Inventory = {
export type RunState = { export type RunState = {
stats: Stats; stats: Stats;
inventory: Inventory; inventory: Inventory;
lastReloadableWeaponId?: string | null;
}; };
export interface BaseActor { export interface BaseActor {
@@ -179,12 +180,12 @@ export interface CombatantActor extends BaseActor {
stats: Stats; stats: Stats;
inventory?: Inventory; inventory?: Inventory;
equipment?: Equipment; equipment?: Equipment;
// Enemy AI state // Enemy AI state
aiState?: EnemyAIState; aiState?: EnemyAIState;
alertedAt?: number; alertedAt?: number;
lastKnownPlayerPos?: Vec2; lastKnownPlayerPos?: Vec2;
// Turn scheduling // Turn scheduling
energy: number; energy: number;
} }
@@ -196,9 +197,9 @@ export interface CollectibleActor extends BaseActor {
} }
export interface ItemDropActor extends BaseActor { export interface ItemDropActor extends BaseActor {
category: "item_drop"; category: "item_drop";
// type: string; // "health_potion", etc. or reuse Item // type: string; // "health_potion", etc. or reuse Item
item: Item; item: Item;
} }
export type Actor = CombatantActor | CollectibleActor | ItemDropActor; export type Actor = CombatantActor | CollectibleActor | ItemDropActor;
@@ -216,6 +217,6 @@ export interface UIUpdatePayload {
player: CombatantActor | null; // Added for ECS Access player: CombatantActor | null; // Added for ECS Access
floorIndex: number; floorIndex: number;
uiState: { uiState: {
targetingItemId: string | null; targetingItemId: string | null;
}; };
} }

View File

@@ -42,7 +42,8 @@ export class GameScene extends Phaser.Scene {
public runState: RunState = { public runState: RunState = {
stats: { ...GAME_CONFIG.player.initialStats }, stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] },
lastReloadableWeaponId: null
}; };
public gameInput!: GameInput; public gameInput!: GameInput;
@@ -88,13 +89,13 @@ export class GameScene extends Phaser.Scene {
this.cameras.main.fadeIn(1000, 0, 0, 0); this.cameras.main.fadeIn(1000, 0, 0, 0);
// Initialize Sub-systems // Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this); this.dungeonRenderer = new DungeonRenderer(this);
this.gameRenderer = new GameRenderer(this.dungeonRenderer); this.gameRenderer = new GameRenderer(this.dungeonRenderer);
this.cameraController = new CameraController(this.cameras.main); this.cameraController = new CameraController(this.cameras.main);
// Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor // Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor
this.itemManager = new ItemManager(this.world, this.entityAccessor); this.itemManager = new ItemManager(this.world, this.entityAccessor);
this.targetingSystem = new TargetingSystem(this); this.targetingSystem = new TargetingSystem(this);
// Initialize Input // Initialize Input
this.gameInput = new GameInput(this); this.gameInput = new GameInput(this);
@@ -132,7 +133,7 @@ export class GameScene extends Phaser.Scene {
if (this.playerPath.length >= 2) { if (this.playerPath.length >= 2) {
const player = this.entityAccessor.getPlayer(); const player = this.entityAccessor.getPlayer();
if (!player) return; if (!player) return;
const next = this.playerPath[1]; const next = this.playerPath[1];
const dx = next.x - player.pos.x; const dx = next.x - player.pos.x;
const dy = next.y - player.pos.y; const dy = next.y - player.pos.y;
@@ -146,15 +147,15 @@ export class GameScene extends Phaser.Scene {
const enemy = this.entityAccessor.findEnemyAt(next.x, next.y); const enemy = this.entityAccessor.findEnemyAt(next.x, next.y);
if (enemy) { if (enemy) {
this.commitPlayerAction({ type: "attack", targetId: enemy.id }); this.commitPlayerAction({ type: "attack", targetId: enemy.id });
this.playerPath = []; this.playerPath = [];
return; return;
} else { } else {
this.playerPath = []; this.playerPath = [];
return; return;
} }
} }
this.commitPlayerAction({ type: "move", dx, dy }); this.commitPlayerAction({ type: "move", dx, dy });
this.playerPath.shift(); this.playerPath.shift();
return; return;
@@ -169,106 +170,106 @@ export class GameScene extends Phaser.Scene {
} }
public emitUIUpdate() { public emitUIUpdate() {
const payload: UIUpdatePayload = { const payload: UIUpdatePayload = {
world: this.world, world: this.world,
playerId: this.playerId, playerId: this.playerId,
player: this.entityAccessor.getPlayer(), player: this.entityAccessor.getPlayer(),
floorIndex: this.floorIndex, floorIndex: this.floorIndex,
uiState: { uiState: {
targetingItemId: this.targetingSystem.itemId targetingItemId: this.targetingSystem.itemId
} }
}; };
this.events.emit("update-ui", payload); this.events.emit("update-ui", payload);
} }
public commitPlayerAction(action: Action) { public commitPlayerAction(action: Action) {
const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor); const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor);
if (playerEvents.some(ev => ev.type === "move-blocked")) { if (playerEvents.some(ev => ev.type === "move-blocked")) {
return; return;
} }
this.awaitingPlayer = false; this.awaitingPlayer = false;
this.cameraController.enableFollowMode(); this.cameraController.enableFollowMode();
// Process reloading progress // Process reloading progress
const player = this.entityAccessor.getPlayer(); const player = this.entityAccessor.getPlayer();
if (player && player.inventory) { if (player && player.inventory) {
// Check all items for reloading (usually only equipped or active) // Check all items for reloading (usually only equipped or active)
for (const item of player.inventory.items) { for (const item of player.inventory.items) {
if (item.type === "Weapon" && item.weaponType === "ranged" && item.reloadingTurnsLeft > 0) { if (item.type === "Weapon" && item.weaponType === "ranged" && item.reloadingTurnsLeft > 0) {
item.reloadingTurnsLeft--; item.reloadingTurnsLeft--;
if (item.reloadingTurnsLeft === 0) { if (item.reloadingTurnsLeft === 0) {
// Finalize Reload // Finalize Reload
const ammoId = `ammo_${item.stats.ammoType}`; const ammoId = `ammo_${item.stats.ammoType}`;
const ammoItem = player.inventory.items.find(it => it.id === ammoId); const ammoItem = player.inventory.items.find(it => it.id === ammoId);
if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) { if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) {
const needed = item.stats.magazineSize - item.currentAmmo; const needed = item.stats.magazineSize - item.currentAmmo;
const toTake = Math.min(needed, ammoItem.quantity); const toTake = Math.min(needed, ammoItem.quantity);
item.currentAmmo += toTake; item.currentAmmo += toTake;
ammoItem.quantity -= toTake; ammoItem.quantity -= toTake;
if (ammoItem.quantity <= 0) { if (ammoItem.quantity <= 0) {
player.inventory.items = player.inventory.items.filter(it => it !== ammoItem); player.inventory.items = player.inventory.items.filter(it => it !== ammoItem);
} }
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloaded!", "#00ff00"); this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloaded!", "#00ff00");
console.log(`Reloaded ${item.name}. Ammo:`, item.currentAmmo); console.log(`Reloaded ${item.name}. Ammo:`, item.currentAmmo);
} else { } else {
// Should be checked at startReload, but safe fallback // Should be checked at startReload, but safe fallback
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No Ammo!", "#ff0000"); this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No Ammo!", "#ff0000");
} }
} else { } else {
// Show reloading progress // Show reloading progress
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa"); this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
} }
}
} }
}
} }
// Check for pickups right after move (before enemy turn, so you get it efficiently) // Check for pickups right after move (before enemy turn, so you get it efficiently)
if (action.type === "move") { if (action.type === "move") {
const player = this.entityAccessor.getPlayer(); const player = this.entityAccessor.getPlayer();
if (!player) return; if (!player) return;
const pickedItem = this.itemManager.tryPickup(player);
if (pickedItem) {
this.emitUIUpdate();
}
// Process traps and status effects const pickedItem = this.itemManager.tryPickup(player);
this.ecsRegistry.updateAll(); if (pickedItem) {
this.emitUIUpdate();
}
// Handle trap events from ECS // Process traps and status effects
const trapEvents = this.ecsEventBus.drain(); this.ecsRegistry.updateAll();
if (trapEvents.length > 0) {
console.log(`[GameScene] Traps triggered: ${trapEvents.length} events`, trapEvents); // Handle trap events from ECS
} const trapEvents = this.ecsEventBus.drain();
for (const ev of trapEvents) { if (trapEvents.length > 0) {
if (ev.type === "trigger_activated") { console.log(`[GameScene] Traps triggered: ${trapEvents.length} events`, trapEvents);
const activator = this.entityAccessor.getActor(ev.activatorId); }
const trapTrigger = this.ecsWorld.getComponent(ev.triggerId, "trigger"); for (const ev of trapEvents) {
if (ev.type === "trigger_activated") {
if (trapTrigger?.effect && activator) { const activator = this.entityAccessor.getActor(ev.activatorId);
const color = getEffectColor(trapTrigger.effect); const trapTrigger = this.ecsWorld.getComponent(ev.triggerId, "trigger");
const text = getEffectName(trapTrigger.effect);
this.dungeonRenderer.showFloatingText(activator.pos.x, activator.pos.y, text, color); if (trapTrigger?.effect && activator) {
} const color = getEffectColor(trapTrigger.effect);
} else if (ev.type === "damage") { const text = getEffectName(trapTrigger.effect);
const victim = this.entityAccessor.getActor(ev.entityId); this.dungeonRenderer.showFloatingText(activator.pos.x, activator.pos.y, text, color);
if (victim) {
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, ev.amount);
}
} else if (ev.type === "status_applied") {
// Already handled above via trigger_activated
} else if (ev.type === "status_tick" && ev.entityId) {
// Show DOT damage tick
// Optional: could show small floating text here
} }
} else if (ev.type === "damage") {
const victim = this.entityAccessor.getActor(ev.entityId);
if (victim) {
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, ev.amount);
}
} else if (ev.type === "status_applied") {
// Already handled above via trigger_activated
} else if (ev.type === "status_tick" && ev.entityId) {
// Show DOT damage tick
// Optional: could show small floating text here
} }
}
} }
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor); const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor);
@@ -288,9 +289,9 @@ export class GameScene extends Phaser.Scene {
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)
for (const ev of allEvents) { for (const ev of allEvents) {
if (ev.type === "killed" && ev.victimType && ev.victimType !== "player") { if (ev.type === "killed" && ev.victimType && ev.victimType !== "player") {
@@ -304,7 +305,7 @@ 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({
@@ -341,14 +342,14 @@ export class GameScene extends Phaser.Scene {
const { world, playerId, ecsWorld } = generateWorld(floor, this.runState); const { world, playerId, ecsWorld } = generateWorld(floor, this.runState);
this.world = world; this.world = world;
this.playerId = playerId; this.playerId = playerId;
// Initialize or update entity accessor // Initialize or update entity accessor
if (!this.entityAccessor) { if (!this.entityAccessor) {
this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld); this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld);
} else { } else {
this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld); this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld);
} }
this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld); this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld);
// Initialize ECS for traps and status effects // Initialize ECS for traps and status effects
@@ -388,122 +389,127 @@ export class GameScene extends Phaser.Scene {
this.runState = { this.runState = {
stats: { ...p.stats }, stats: { ...p.stats },
inventory: { gold: p.inventory.gold, items: [...p.inventory.items] } inventory: { gold: p.inventory.gold, items: [...p.inventory.items] },
lastReloadableWeaponId: this.runState.lastReloadableWeaponId
}; };
} }
public restartGame() { public restartGame() {
this.runState = { this.runState = {
stats: { ...GAME_CONFIG.player.initialStats }, stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] },
lastReloadableWeaponId: null
}; };
this.floorIndex = 1; this.floorIndex = 1;
this.loadFloor(this.floorIndex); this.loadFloor(this.floorIndex);
} }
public executeThrow() { public executeThrow() {
const success = this.targetingSystem.executeThrow( const success = this.targetingSystem.executeThrow(
this.world, this.world,
this.playerId, this.playerId,
this.entityAccessor, this.entityAccessor,
(blockedPos, hitActorId, item) => { (blockedPos, hitActorId, item) => {
// Damage Logic // Damage Logic
if (hitActorId !== undefined) { if (hitActorId !== undefined) {
const victim = this.entityAccessor.getCombatant(hitActorId); const victim = this.entityAccessor.getCombatant(hitActorId);
const player = this.entityAccessor.getPlayer(); const player = this.entityAccessor.getPlayer();
if (victim && player) { if (victim && player) {
const damageResult = calculateDamage(player.stats, victim.stats, item); const damageResult = calculateDamage(player.stats, victim.stats, item);
if (damageResult.hit) {
victim.stats.hp -= damageResult.dmg;
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, damageResult.dmg, damageResult.isCrit, damageResult.isBlock);
this.dungeonRenderer.shakeCamera();
} else {
this.dungeonRenderer.showDodge(victim.pos.x, victim.pos.y);
}
}
}
const player = this.entityAccessor.getPlayer();
if (!player) return;
// Projectile Visuals
let projectileId = item.id;
if (item.type === "Weapon" && item.weaponType === "ranged") {
projectileId = `ammo_${item.stats.ammoType}`; // Show ammo sprite
// Consume Ammo
if (item.currentAmmo > 0) {
item.currentAmmo--;
}
}
this.dungeonRenderer.showProjectile( if (damageResult.hit) {
player.pos, victim.stats.hp -= damageResult.dmg;
blockedPos, this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, damageResult.dmg, damageResult.isCrit, damageResult.isBlock);
projectileId, this.dungeonRenderer.shakeCamera();
() => { } else {
// Only drop item if it acts as a thrown item (Consumable/Misc), NOT Weapon this.dungeonRenderer.showDodge(victim.pos.x, victim.pos.y);
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();
this.commitPlayerAction({ type: "throw" }); // Or 'attack' if shooting? 'throw' is fine for now
this.emitUIUpdate();
}
);
} }
); }
if (!success) { const player = this.entityAccessor.getPlayer();
this.emitUIUpdate(); if (!player) return;
// Projectile Visuals
let projectileId = item.id;
if (item.type === "Weapon" && item.weaponType === "ranged") {
projectileId = `ammo_${item.stats.ammoType}`; // Show ammo sprite
// Consume Ammo
if (item.currentAmmo > 0) {
item.currentAmmo--;
}
// Track as last used reloadable weapon
this.runState.lastReloadableWeaponId = item.id;
}
this.dungeonRenderer.showProjectile(
player.pos,
blockedPos,
projectileId,
() => {
// 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();
this.commitPlayerAction({ type: "throw" }); // Or 'attack' if shooting? 'throw' is fine for now
this.emitUIUpdate();
}
);
} }
);
if (!success) {
this.emitUIUpdate();
}
} }
public startReload(player: CombatantActor, item: RangedWeaponItem) { public startReload(player: CombatantActor, item: RangedWeaponItem) {
if (item.currentAmmo >= item.stats.magazineSize) { if (item.currentAmmo >= item.stats.magazineSize) {
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Full!", "#aaaaaa"); this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Full!", "#aaaaaa");
return; return;
}
if (item.reloadingTurnsLeft > 0) return;
const ammoId = `ammo_${item.stats.ammoType}`;
const ammoItem = player.inventory?.items.find(it => it.id === ammoId);
if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) {
item.reloadingTurnsLeft = GAME_CONFIG.player.reloadDuration;
this.runState.lastReloadableWeaponId = item.id;
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
console.log(`Started reloading ${item.name}. Duration: ${item.reloadingTurnsLeft}`);
if (this.targetingSystem.isActive && this.targetingSystem.itemId === item.id) {
this.targetingSystem.cancel();
} }
if (item.reloadingTurnsLeft > 0) return; this.commitPlayerAction({ type: "wait" });
this.emitUIUpdate();
const ammoId = `ammo_${item.stats.ammoType}`; } else {
const ammoItem = player.inventory?.items.find(it => it.id === ammoId); this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No Ammo!", "#ff0000");
console.log("No ammo found for", item.name);
if (ammoItem && ammoItem.quantity && ammoItem.quantity > 0) { }
item.reloadingTurnsLeft = GAME_CONFIG.player.reloadDuration;
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Reloading...", "#aaaaaa");
console.log(`Started reloading ${item.name}. Duration: ${item.reloadingTurnsLeft}`);
if (this.targetingSystem.isActive && this.targetingSystem.itemId === item.id) {
this.targetingSystem.cancel();
}
this.commitPlayerAction({ type: "wait" });
this.emitUIUpdate();
} else {
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "No Ammo!", "#ff0000");
console.log("No ammo found for", item.name);
}
} }
public getPointerTilePos(pointer: Phaser.Input.Pointer): { x: number, y: number } { public getPointerTilePos(pointer: Phaser.Input.Pointer): { x: number, y: number } {
const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y); const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y);
return { return {
x: Math.floor(worldPoint.x / TILE_SIZE), x: Math.floor(worldPoint.x / TILE_SIZE),
y: Math.floor(worldPoint.y / TILE_SIZE) y: Math.floor(worldPoint.y / TILE_SIZE)
}; };
} }
} }

View File

@@ -13,7 +13,7 @@ export class PlayerInputHandler {
public registerListeners() { public registerListeners() {
const input = this.scene.gameInput; const input = this.scene.gameInput;
input.on("toggle-menu", () => { input.on("toggle-menu", () => {
if (this.scene.dungeonRenderer.isMinimapVisible()) { if (this.scene.dungeonRenderer.isMinimapVisible()) {
this.scene.dungeonRenderer.toggleMinimap(); this.scene.dungeonRenderer.toggleMinimap();
@@ -21,7 +21,7 @@ export class PlayerInputHandler {
this.scene.events.emit("toggle-menu"); this.scene.events.emit("toggle-menu");
this.scene.emitUIUpdate(); this.scene.emitUIUpdate();
}); });
input.on("close-menu", () => { input.on("close-menu", () => {
this.scene.events.emit("close-menu"); this.scene.events.emit("close-menu");
if (this.scene.dungeonRenderer.isMinimapVisible()) { if (this.scene.dungeonRenderer.isMinimapVisible()) {
@@ -64,6 +64,13 @@ export class PlayerInputHandler {
} }
} }
if (!weaponToReload && this.scene.runState.lastReloadableWeaponId) {
const item = player.inventory.items.find(it => it.id === this.scene.runState.lastReloadableWeaponId);
if (item && item.type === "Weapon" && item.weaponType === "ranged") {
weaponToReload = item;
}
}
if (weaponToReload) { if (weaponToReload) {
this.scene.startReload(player, weaponToReload); this.scene.startReload(player, weaponToReload);
} }
@@ -95,12 +102,12 @@ export class PlayerInputHandler {
input.on("cursor-move", (worldX: number, worldY: number) => { input.on("cursor-move", (worldX: number, worldY: number) => {
if (this.scene.targetingSystem.isActive) { if (this.scene.targetingSystem.isActive) {
const tx = Math.floor(worldX / TILE_SIZE); const tx = Math.floor(worldX / TILE_SIZE);
const ty = Math.floor(worldY / TILE_SIZE); const ty = Math.floor(worldY / TILE_SIZE);
const player = this.scene.entityAccessor.getPlayer(); const player = this.scene.entityAccessor.getPlayer();
if (player) { if (player) {
this.scene.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos); this.scene.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos);
} }
} }
}); });
@@ -110,58 +117,58 @@ export class PlayerInputHandler {
} }
private handleTileClick(tx: number, ty: number, button: number) { private handleTileClick(tx: number, ty: number, button: number) {
// Targeting Click // Targeting Click
if (this.scene.targetingSystem.isActive) { if (this.scene.targetingSystem.isActive) {
// Only Left Click throws // Only Left Click throws
if (button === 0) { if (button === 0) {
if (this.scene.targetingSystem.cursorPos) { if (this.scene.targetingSystem.cursorPos) {
this.scene.executeThrow(); this.scene.executeThrow();
} }
} }
return; return;
} }
// Movement Click // Movement Click
if (button !== 0) return; if (button !== 0) return;
this.scene.cameraController.enableFollowMode(); this.scene.cameraController.enableFollowMode();
if (!this.scene.awaitingPlayer) return; if (!this.scene.awaitingPlayer) return;
if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return; if (this.scene.isMenuOpen || this.scene.isInventoryOpen || this.scene.dungeonRenderer.isMinimapVisible()) return;
if (!inBounds(this.scene.world, tx, ty)) return; if (!inBounds(this.scene.world, tx, ty)) return;
if (!this.scene.dungeonRenderer.isSeen(tx, ty)) return; if (!this.scene.dungeonRenderer.isSeen(tx, ty)) return;
const isEnemy = this.scene.entityAccessor.hasEnemyAt(tx, ty); const isEnemy = this.scene.entityAccessor.hasEnemyAt(tx, ty);
const player = this.scene.entityAccessor.getPlayer(); const player = this.scene.entityAccessor.getPlayer();
if (!player) return; if (!player) return;
const dx = tx - player.pos.x; const dx = tx - player.pos.x;
const dy = ty - player.pos.y; const dy = ty - player.pos.y;
const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1; const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1;
if (isEnemy && isDiagonalNeighbor) { if (isEnemy && isDiagonalNeighbor) {
const enemy = this.scene.entityAccessor.findEnemyAt(tx, ty); const enemy = this.scene.entityAccessor.findEnemyAt(tx, ty);
if (enemy) { if (enemy) {
this.scene.commitPlayerAction({ type: "attack", targetId: enemy.id }); this.scene.commitPlayerAction({ type: "attack", targetId: enemy.id });
return; return;
} }
} }
const path = findPathAStar( const path = findPathAStar(
this.scene.world, this.scene.world,
this.scene.dungeonRenderer.seenArray, this.scene.dungeonRenderer.seenArray,
{ ...player.pos }, { ...player.pos },
{ x: tx, y: ty }, { x: tx, y: ty },
{ ignoreBlockedTarget: isEnemy, accessor: this.scene.entityAccessor } { ignoreBlockedTarget: isEnemy, accessor: this.scene.entityAccessor }
); );
if (path.length >= 2) this.scene.playerPath = path; if (path.length >= 2) this.scene.playerPath = path;
this.scene.dungeonRenderer.render(this.scene.playerPath); this.scene.dungeonRenderer.render(this.scene.playerPath);
} }
public handleCursorMovement(): Action | null { public handleCursorMovement(): Action | null {
const { dx, dy, anyJustDown } = this.scene.gameInput.getCursorState(); const { dx, dy, anyJustDown } = this.scene.gameInput.getCursorState();
@@ -173,18 +180,18 @@ export class PlayerInputHandler {
} }
const player = this.scene.entityAccessor.getPlayer(); const player = this.scene.entityAccessor.getPlayer();
if (!player) return null; if (!player) return null;
const targetX = player.pos.x + dx; const targetX = player.pos.x + dx;
const targetY = player.pos.y + dy; const targetY = player.pos.y + dy;
const enemy = this.scene.entityAccessor.findEnemyAt(targetX, targetY); const enemy = this.scene.entityAccessor.findEnemyAt(targetX, targetY);
if (enemy) { if (enemy) {
return { type: "attack", targetId: enemy.id }; return { type: "attack", targetId: enemy.id };
} else { } else {
if (Math.abs(dx) + Math.abs(dy) === 1) { if (Math.abs(dx) + Math.abs(dy) === 1) {
return { type: "move", dx, dy }; return { type: "move", dx, dy };
} }
} }
} }
} }

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Phaser
vi.mock('phaser', () => {
class MockEventEmitter {
on = vi.fn().mockReturnThis();
once = vi.fn().mockReturnThis();
emit = vi.fn().mockReturnThis();
off = vi.fn().mockReturnThis();
removeAllListeners = vi.fn().mockReturnThis();
}
return {
default: {
Events: {
EventEmitter: MockEventEmitter
},
Scene: class {
events = new MockEventEmitter();
add = {
graphics: vi.fn(),
sprite: vi.fn(),
};
}
}
};
});
import { PlayerInputHandler } from '../PlayerInputHandler';
import { GameScene } from '../../GameScene';
import type { CombatantActor, RangedWeaponItem } from '../../../core/types';
// Minimal mock for GameScene
const createMockScene = () => {
const scene = {
gameInput: {
on: vi.fn(),
},
targetingSystem: {
itemId: null,
isActive: false,
},
entityAccessor: {
getPlayer: vi.fn(),
},
runState: {
lastReloadableWeaponId: null,
},
startReload: vi.fn(),
} as any;
return scene;
};
describe('Reload Last Used Weapon Logic', () => {
let scene: any;
let inputHandler: PlayerInputHandler;
let reloadCallback: Function;
beforeEach(() => {
scene = createMockScene();
inputHandler = new PlayerInputHandler(scene);
inputHandler.registerListeners();
// Find the reload listener
const reloadCall = scene.gameInput.on.mock.calls.find((call: any[]) => call[0] === 'reload');
reloadCallback = reloadCall[1];
});
it('should reload the last reloadable weapon if nothing else is targeted or equipped', () => {
const pistol: RangedWeaponItem = {
id: 'pistol-1',
name: 'Pistol',
type: 'Weapon',
weaponType: 'ranged',
currentAmmo: 0,
reloadingTurnsLeft: 0,
stats: { attack: 1, range: 5, magazineSize: 6, ammoType: '9mm', projectileSpeed: 10 },
textureKey: 'weapons',
spriteIndex: 0
};
const player: CombatantActor = {
id: 1,
pos: { x: 0, y: 0 },
category: 'combatant',
isPlayer: true,
type: 'player',
inventory: { items: [pistol], gold: 0 },
equipment: { mainHand: { type: 'Weapon', weaponType: 'melee', id: 'sword-1' } as any },
stats: {} as any,
energy: 100,
speed: 100
};
scene.entityAccessor.getPlayer.mockReturnValue(player);
scene.runState.lastReloadableWeaponId = 'pistol-1';
// Trigger reload (simulating 'R' press)
reloadCallback();
expect(scene.startReload).toHaveBeenCalledWith(player, pistol);
});
it('should prioritize targeted item over last used', () => {
const pistol1: RangedWeaponItem = { id: 'p1', name: 'P1', type: 'Weapon', weaponType: 'ranged' } as any;
const pistol2: RangedWeaponItem = { id: 'p2', name: 'P2', type: 'Weapon', weaponType: 'ranged' } as any;
const player: CombatantActor = {
id: 1, inventory: { items: [pistol1, pistol2] }, equipment: {}
} as any;
scene.entityAccessor.getPlayer.mockReturnValue(player);
scene.targetingSystem.itemId = 'p2';
scene.runState.lastReloadableWeaponId = 'p1';
reloadCallback();
expect(scene.startReload).toHaveBeenCalledWith(player, pistol2);
});
it('should prioritize equipped ranged weapon over last used', () => {
const pistol1: RangedWeaponItem = { id: 'p1', name: 'P1', type: 'Weapon', weaponType: 'ranged' } as any;
const pistol2: RangedWeaponItem = { id: 'p2', name: 'P2', type: 'Weapon', weaponType: 'ranged' } as any;
const player: CombatantActor = {
id: 1, inventory: { items: [pistol1, pistol2] }, equipment: { mainHand: pistol2 }
} as any;
scene.entityAccessor.getPlayer.mockReturnValue(player);
scene.runState.lastReloadableWeaponId = 'p1';
reloadCallback();
expect(scene.startReload).toHaveBeenCalledWith(player, pistol2);
});
it('should do nothing if no weapon is found', () => {
const player: CombatantActor = { id: 1, inventory: { items: [] }, equipment: {} } as any;
scene.entityAccessor.getPlayer.mockReturnValue(player);
reloadCallback();
expect(scene.startReload).not.toHaveBeenCalled();
});
});