Add in throwable items (dagger) from pixel dungeon

This commit is contained in:
Peter Stockings
2026-01-06 20:58:53 +11:00
parent 3b29180a00
commit 9b1fc78409
18 changed files with 659 additions and 155 deletions

View File

@@ -1,3 +1,4 @@
// Reading types.ts to verify actor structure before next step
import Phaser from "phaser";
import {
type EntityId,
@@ -5,13 +6,18 @@ import {
type Action,
type RunState,
type World,
type CombatantActor
type CombatantActor,
type Item,
type ItemDropActor
} from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic";
import { inBounds, isBlocked, isPlayerOnExit, tryDestructTile } from "../engine/world/world-logic";
import { findPathAStar } from "../engine/world/pathfinding";
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
import { generateWorld } from "../engine/world/generator";
import { traceProjectile } from "../engine/gameplay/CombatLogic";
import { DungeonRenderer } from "../rendering/DungeonRenderer";
import { GAME_CONFIG } from "../core/config/GameConfig";
@@ -44,6 +50,11 @@ export class GameScene extends Phaser.Scene {
private entityManager!: EntityManager;
private progressionManager: ProgressionManager = new ProgressionManager();
// Targeting Mode
private isTargeting = false;
private targetingItem: string | null = null;
private targetingGraphics!: Phaser.GameObjects.Graphics;
private turnCount = 0; // Track turns for mana regen
constructor() {
@@ -58,7 +69,8 @@ export class GameScene extends Phaser.Scene {
this.cameras.main.fadeIn(1000, 0, 0, 0);
// Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this);
this.dungeonRenderer = new DungeonRenderer(this);
this.targetingGraphics = this.add.graphics().setDepth(2000);
// Launch UI Scene
this.scene.launch("GameUI");
@@ -79,28 +91,23 @@ export class GameScene extends Phaser.Scene {
// Menu Inputs
this.input.keyboard?.on("keydown-I", () => {
// Close minimap if it's open
if (this.dungeonRenderer.isMinimapVisible()) {
this.dungeonRenderer.toggleMinimap();
}
this.events.emit("toggle-menu");
// Force update UI in case it opened
this.emitUIUpdate();
});
this.input.keyboard?.on("keydown-ESC", () => {
this.events.emit("close-menu");
// Also close minimap
if (this.dungeonRenderer.isMinimapVisible()) {
this.dungeonRenderer.toggleMinimap();
}
});
this.input.keyboard?.on("keydown-M", () => {
// Close menu if it's open
this.events.emit("close-menu");
this.dungeonRenderer.toggleMinimap();
});
this.input.keyboard?.on("keydown-B", () => {
// Toggle inventory
this.events.emit("toggle-inventory");
});
this.input.keyboard?.on("keydown-C", () => {
@@ -132,16 +139,16 @@ export class GameScene extends Phaser.Scene {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) {
this.progressionManager.allocateStat(player, statName);
this.emitUIUpdate();
}
this.emitUIUpdate();
}
});
this.events.on("allocate-passive", (nodeId: string) => {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) {
this.progressionManager.allocatePassive(player, nodeId);
this.emitUIUpdate();
}
this.emitUIUpdate();
}
});
this.events.on("player-wait", () => {
@@ -155,50 +162,44 @@ export class GameScene extends Phaser.Scene {
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
console.log("Player searching...");
// Search takes a turn (functionally same as wait for now, but semantically distinct)
this.commitPlayerAction({ type: "wait" });
});
this.events.on("use-item", (data: { itemId: string }) => {
if (!this.awaitingPlayer) return;
// Don't block item usage if inventory is open, as we might use it from there or hotbar.
// But if we use it from inventory, we might want to close inventory or update it.
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (!player || !player.inventory) return;
if (data.itemId === "health_potion") {
// Heal logic
const healAmount = 5;
const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId);
if (itemIdx === -1) return;
const item = player.inventory.items[itemIdx];
if (item.stats && item.stats.hp && item.stats.hp > 0) {
const healAmount = item.stats.hp;
if (player.stats.hp < player.stats.maxHp) {
player.stats.hp = Math.min(player.stats.hp + healAmount, player.stats.maxHp);
// Visuals handled by diff in stats usually? No, we need explicit heal event or simple floating text
// commitPlayerAction triggers simulation which might generate events.
// But healing from item is instant effect before turn passes?
// Or we treat it as an action.
// Remove item after use
player.inventory.items.splice(itemIdx, 1);
// Let's remove item first
const idx = player.inventory.items.findIndex(it => it.id === "health_potion");
if (idx !== -1) {
player.inventory.items.splice(idx, 1);
// Show visual
this.dungeonRenderer.showHeal(player.pos.x, player.pos.y, healAmount);
// Pass turn
this.commitPlayerAction({ type: "wait" });
this.emitUIUpdate();
}
} else {
console.log("Already at full health");
this.dungeonRenderer.showHeal(player.pos.x, player.pos.y, healAmount);
this.commitPlayerAction({ type: "wait" });
this.emitUIUpdate();
}
} else {
console.log("Used item:", data.itemId);
} else if (item.throwable) {
this.targetingItem = item.id;
this.isTargeting = true;
console.log("Targeting Mode: ON");
}
});
// Right Clicks to cancel targeting
this.input.on('pointerdown', (p: Phaser.Input.Pointer) => {
if (p.rightButtonDown() && this.isTargeting) {
this.cancelTargeting();
}
});
// Zoom Control
this.input.on(
@@ -227,12 +228,15 @@ export class GameScene extends Phaser.Scene {
// Camera Panning
this.input.on("pointermove", (p: Phaser.Input.Pointer) => {
if (!p.isDown) return;
if (!p.isDown) { // Even if not down, we might need to update targeting line
if (this.isTargeting) {
this.updateTargetingLine(p);
}
return;
}
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
// Pan with Middle Click or Right Click
// Note: p.button is not always reliable in move events for holding,
// so we use specific button down checks or the shift key modifier.
const isRightDrag = p.rightButtonDown();
const isMiddleDrag = p.middleButtonDown();
const isShiftDrag = p.isDown && p.event.shiftKey;
@@ -249,16 +253,30 @@ export class GameScene extends Phaser.Scene {
this.followPlayer = false;
}
if (this.isTargeting) {
this.updateTargetingLine(p);
}
});
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
// Mouse click ->
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
// Only allow Left Click (0) for movement
// Targeting Click
if (this.isTargeting) {
// Only Left Click throws
if (p.button === 0) {
const tx = Math.floor(p.worldX / TILE_SIZE);
const ty = Math.floor(p.worldY / TILE_SIZE);
this.executeThrow(tx, ty);
}
return;
}
// Movement Click
if (p.button !== 0) return;
this.followPlayer = true;
if (!this.awaitingPlayer) return;
if (this.isMenuOpen || this.isInventoryOpen || this.dungeonRenderer.isMinimapVisible()) return;
@@ -267,22 +285,18 @@ export class GameScene extends Phaser.Scene {
if (!inBounds(this.world, tx, ty)) return;
// Exploration rule: cannot click-to-move into unseen tiles
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
// Check if clicking on an enemy
const isEnemy = [...this.world.actors.values()].some(a =>
a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
);
// Check for diagonal adjacency for immediate attack
const player = this.world.actors.get(this.playerId) as CombatantActor;
const dx = tx - player.pos.x;
const dy = ty - player.pos.y;
const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1;
if (isEnemy && isDiagonalNeighbor) {
// Check targetId again to get the ID... technically we just did .some() above.
const targetId = [...this.world.actors.values()].find(
a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer
)?.id;
@@ -300,7 +314,6 @@ export class GameScene extends Phaser.Scene {
{ ignoreBlockedTarget: isEnemy }
);
if (path.length >= 2) this.playerPath = path;
this.dungeonRenderer.render(this.playerPath);
});
@@ -323,17 +336,15 @@ export class GameScene extends Phaser.Scene {
}
if (isBlocked(this.world, next.x, next.y, this.entityManager)) {
// Check if it's an enemy at 'next'
const targetId = [...this.world.actors.values()].find(
a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer
)?.id;
if (targetId !== undefined) {
this.commitPlayerAction({ type: "attack", targetId });
this.playerPath = []; // Stop after attack
this.playerPath = [];
return;
} else {
// Blocked by something else (friendly?)
this.playerPath = [];
return;
}
@@ -344,39 +355,16 @@ export class GameScene extends Phaser.Scene {
return;
}
// Arrow keys - Support diagonals for attacking only
let action: Action | null = null;
let dx = 0;
let dy = 0;
// Check all keys to allow simultaneous presses
if (this.cursors.left!.isDown) dx -= 1;
if (this.cursors.right!.isDown) dx += 1;
if (this.cursors.up!.isDown) dy -= 1;
if (this.cursors.down!.isDown) dy += 1;
// Force single step input "just now" check to avoid super speed,
// OR we rely on `awaitingPlayer` to throttle us.
// `update` runs every frame. `awaitingPlayer` is set to false in `commitPlayerAction`.
// It remains false until `stepUntilPlayerTurn` returns true.
// So as long as we only act when `awaitingPlayer` is true, simple `isDown` works for direction combination.
// BUT we need to ensure we don't accidentally move if we just want to tap.
// However, common roguelike Input: if you hold, you repeat.
// We already have `awaitingPlayer` logic.
// One nuance: mixing JustDown and isDown.
// If we use isDown, we might act immediately.
// If we want to support "turn based", usually we wait for "JustDown" of *any* key.
// But if we want diagonal, we need 2 keys.
// Simpler approach:
// If any direction key is JustDown, capture the state of ALL direction keys.
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.cursors.left!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.right!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.up!) ||
Phaser.Input.Keyboard.JustDown(this.cursors.down!);
if (anyJustDown) {
// Recalculate dx/dy based on currently held keys to catch the combo
dx = 0; dy = 0;
if (this.cursors.left!.isDown) dx -= 1;
if (this.cursors.right!.isDown) dx += 1;
@@ -388,7 +376,6 @@ export class GameScene extends Phaser.Scene {
const targetX = player.pos.x + dx;
const targetY = player.pos.y + dy;
// Check for enemy at target position
const targetId = [...this.world.actors.values()].find(
a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
)?.id;
@@ -396,7 +383,6 @@ export class GameScene extends Phaser.Scene {
if (targetId !== undefined) {
action = { type: "attack", targetId };
} else {
// Only move if strictly cardinal (no diagonals)
if (Math.abs(dx) + Math.abs(dy) === 1) {
action = { type: "move", dx, dy };
}
@@ -423,10 +409,15 @@ export class GameScene extends Phaser.Scene {
this.followPlayer = true;
const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager);
// Check for pickups right after move (before enemy turn, so you get it efficiently)
if (action.type === "move") {
this.tryPickupItem();
}
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
// Increment turn counter and handle mana regeneration
this.turnCount++;
if (this.turnCount % GAME_CONFIG.mana.regenInterval === 0) {
const player = this.world.actors.get(this.playerId) as CombatantActor;
@@ -440,7 +431,6 @@ export class GameScene extends Phaser.Scene {
}
// Process events for visual fx
const allEvents = [...playerEvents, ...enemyStep.events];
for (const ev of allEvents) {
if (ev.type === "damaged") {
@@ -468,9 +458,8 @@ export class GameScene extends Phaser.Scene {
}
// Check if player died
if (!this.world.actors.has(this.playerId)) {
this.syncRunStateFromPlayer(); // Save final stats for death screen
this.syncRunStateFromPlayer();
const uiScene = this.scene.get("GameUI") as any;
if (uiScene) {
uiScene.showDeathScreen({
@@ -482,7 +471,6 @@ export class GameScene extends Phaser.Scene {
return;
}
// Level transition
if (isPlayerOnExit(this.world, this.playerId)) {
this.syncRunStateFromPlayer();
this.floorIndex++;
@@ -508,17 +496,13 @@ export class GameScene extends Phaser.Scene {
this.entityManager = new EntityManager(this.world);
// Reset transient state
this.playerPath = [];
this.awaitingPlayer = false;
// Camera bounds for this level
this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
// Initialize Renderer for new floor
this.dungeonRenderer.initializeFloor(this.world, this.playerId);
// Step until player turn
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
@@ -527,6 +511,9 @@ export class GameScene extends Phaser.Scene {
this.centerCameraOnPlayer();
this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate();
// Create daggers for testing if none exist (redundant if generator does it, but good for safety)
// Removed to rely on generator.ts
}
private syncRunStateFromPlayer() {
@@ -548,7 +535,6 @@ export class GameScene extends Phaser.Scene {
this.loadFloor(this.floorIndex);
}
private centerCameraOnPlayer() {
const player = this.world.actors.get(this.playerId) as CombatantActor;
this.cameras.main.centerOn(
@@ -557,5 +543,129 @@ export class GameScene extends Phaser.Scene {
);
}
}
private updateTargetingLine(p: Phaser.Input.Pointer) {
if (!this.world) return;
this.targetingGraphics.clear();
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (!player) return;
const startX = player.pos.x * TILE_SIZE + TILE_SIZE / 2;
const startY = player.pos.y * TILE_SIZE + TILE_SIZE / 2;
const endX = p.worldX;
const endY = p.worldY;
this.targetingGraphics.lineStyle(2, 0xff0000, 0.7);
this.targetingGraphics.lineBetween(startX, startY, endX, endY);
const tx = Math.floor(endX / TILE_SIZE);
const ty = Math.floor(endY / TILE_SIZE);
this.targetingGraphics.strokeRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
private cancelTargeting() {
this.isTargeting = false;
this.targetingItem = null;
this.targetingGraphics.clear();
console.log("Targeting cancelled");
}
private executeThrow(targetX: number, targetY: number) {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (!player) return;
const itemArg = this.targetingItem;
if (!itemArg) return;
const itemIdx = player.inventory!.items.findIndex(it => it.id === itemArg);
if (itemIdx === -1) {
console.log("Item not found!");
this.cancelTargeting();
return;
}
const item = player.inventory!.items[itemIdx];
player.inventory!.items.splice(itemIdx, 1);
const start = player.pos;
const end = { x: targetX, y: targetY };
const result = traceProjectile(this.world, start, end, this.entityManager, this.playerId);
const { blockedPos, hitActorId } = result;
this.dungeonRenderer.showProjectile(
start,
blockedPos,
item.id,
() => {
if (hitActorId !== undefined) {
const victim = this.world.actors.get(hitActorId) as CombatantActor;
if (victim) {
const dmg = item.stats?.attack ?? 1; // Use item stats
victim.stats.hp -= dmg;
this.dungeonRenderer.showDamage(victim.pos.x, victim.pos.y, dmg);
this.dungeonRenderer.shakeCamera();
if (victim.stats.hp <= 0) {
// Force kill handled by simulation
}
}
}
// Drop the actual item at the landing spot
this.spawnItem(item, blockedPos.x, blockedPos.y);
// "Count as walking over the tile" -> Trigger destruction/interaction
// e.g. breaking grass, opening items
if (tryDestructTile(this.world, blockedPos.x, blockedPos.y)) {
this.dungeonRenderer.updateTile(blockedPos.x, blockedPos.y);
}
this.cancelTargeting();
this.commitPlayerAction({ type: "wait" });
this.emitUIUpdate();
}
);
}
private spawnItem(item: Item, x: number, y: number) {
if (!this.world || !this.entityManager) return;
const id = this.entityManager.getNextId();
const drop: ItemDropActor = {
id,
pos: { x, y },
category: "item_drop",
item: { ...item } // Clone item
};
this.entityManager.addActor(drop);
// Ensure renderer knows? Renderer iterates world.actors, so it should pick it up if we handle "item_drop"
}
private tryPickupItem() {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (!player) return;
const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y);
const itemActor = actors.find(a => (a as any).category === "item_drop"); // Safe check
if (itemActor) {
const drop = itemActor as any; // Cast to ItemDropActor
const item = drop.item;
// Add to inventory
player.inventory!.items.push(item);
// Remove from world
this.entityManager.removeActor(drop.id);
console.log("Picked up:", item.name);
// Show FX?
// this.dungeonRenderer.showPickup(player.pos.x, player.pos.y); -> need to implement
this.emitUIUpdate();
}
}
}