feat: add upgrade scrolls

This commit is contained in:
Peter Stockings
2026-01-23 23:26:55 +11:00
parent e130e6d174
commit c415becc38
8 changed files with 412 additions and 4 deletions

View File

@@ -26,6 +26,12 @@ export const CONSUMABLES = {
throwable: true,
stackable: true,
},
upgrade_scroll: {
name: "Upgrade Scroll",
textureKey: "items",
spriteIndex: 79,
stackable: true,
},
} as const;
export const RANGED_WEAPONS = {
@@ -224,6 +230,19 @@ export function createArmour(
};
}
export function createUpgradeScroll(quantity = 1): ConsumableItem {
const t = CONSUMABLES["upgrade_scroll"];
return {
id: "upgrade_scroll",
name: t.name,
type: "Consumable",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
stackable: true,
quantity,
};
}
// Legacy export for backward compatibility during migration
export const ITEMS = ALL_TEMPLATES;

View File

@@ -86,6 +86,7 @@ export interface BaseItem {
quantity?: number;
stackable?: boolean;
variant?: string; // ItemVariantId - stored as string to avoid circular imports
upgradeLevel?: number; // Enhancement level (+1, +2, etc.)
}
export interface MeleeWeaponItem extends BaseItem {

View File

@@ -0,0 +1,64 @@
import type { Item, WeaponItem, ArmourItem } from "../../core/types";
/**
* Manages item upgrade logic for applying upgrade scrolls.
*/
export class UpgradeManager {
/**
* Checks if an item can be upgraded (weapons and armour only).
*/
static canUpgrade(item: Item): boolean {
return item.type === "Weapon" ||
item.type === "BodyArmour" ||
item.type === "Helmet" ||
item.type === "Gloves" ||
item.type === "Boots";
}
/**
* Applies an upgrade to an item, increasing all stats by +1.
* Returns true if successful.
*/
static applyUpgrade(item: Item): boolean {
if (!this.canUpgrade(item)) {
return false;
}
// Increment upgrade level
const currentLevel = item.upgradeLevel ?? 0;
item.upgradeLevel = currentLevel + 1;
// Update item name with level suffix
// Remove any existing upgrade suffix first
const baseName = item.name.replace(/\s*\+\d+$/, "");
item.name = `${baseName} +${item.upgradeLevel}`;
// Increase all numeric stats by +1
if (item.type === "Weapon") {
const weaponItem = item as WeaponItem;
if (weaponItem.stats.attack !== undefined) {
weaponItem.stats.attack += 1;
}
} else if (item.type === "BodyArmour" || item.type === "Helmet" ||
item.type === "Gloves" || item.type === "Boots") {
const armourItem = item as ArmourItem;
if (armourItem.stats.defense !== undefined) {
armourItem.stats.defense += 1;
}
}
return true;
}
/**
* Gets the display name for an item including upgrade level.
*/
static getDisplayName(item: Item): string {
if (item.upgradeLevel && item.upgradeLevel > 0) {
const baseName = item.name.replace(/\s*\+\d+$/, "");
return `${baseName} +${item.upgradeLevel}`;
}
return item.name;
}
}

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { UpgradeManager } from '../UpgradeManager';
import { createMeleeWeapon, createArmour, createConsumable } from '../../../core/config/Items';
import type { WeaponItem, ArmourItem } from '../../../core/types';
describe('UpgradeManager', () => {
it('should correctly identify upgradeable items', () => {
const sword = createMeleeWeapon("iron_sword");
const armor = createArmour("leather_armor");
const potion = createConsumable("health_potion");
expect(UpgradeManager.canUpgrade(sword)).toBe(true);
expect(UpgradeManager.canUpgrade(armor)).toBe(true);
expect(UpgradeManager.canUpgrade(potion)).toBe(false);
});
it('should upgrade weapon stats and name', () => {
const sword = createMeleeWeapon("iron_sword") as WeaponItem;
const initialAttack = sword.stats.attack!;
const initialName = sword.name;
const success = UpgradeManager.applyUpgrade(sword);
expect(success).toBe(true);
expect(sword.stats.attack).toBe(initialAttack + 1);
expect(sword.upgradeLevel).toBe(1);
expect(sword.name).toBe(`${initialName} +1`);
});
it('should upgrade armour stats and name', () => {
const armor = createArmour("leather_armor") as ArmourItem;
const initialDefense = armor.stats.defense!;
const initialName = armor.name;
const success = UpgradeManager.applyUpgrade(armor);
expect(success).toBe(true);
expect(armor.stats.defense).toBe(initialDefense + 1);
expect(armor.upgradeLevel).toBe(1);
expect(armor.name).toBe(`${initialName} +1`);
});
it('should handle sequential upgrades', () => {
const sword = createMeleeWeapon("iron_sword") as WeaponItem;
const initialAttack = sword.stats.attack!;
const initialName = sword.name;
UpgradeManager.applyUpgrade(sword); // +1
UpgradeManager.applyUpgrade(sword); // +2
expect(sword.stats.attack).toBe(initialAttack + 2);
expect(sword.upgradeLevel).toBe(2);
expect(sword.name).toBe(`${initialName} +2`);
});
it('should not upgrade non-upgradeable items', () => {
const potion = createConsumable("health_potion");
const initialName = potion.name;
const success = UpgradeManager.applyUpgrade(potion);
expect(success).toBe(false);
expect(potion.upgradeLevel).toBeUndefined();
expect(potion.name).toBe(initialName);
});
});

View File

@@ -6,7 +6,8 @@ import {
createConsumable,
createMeleeWeapon,
createRangedWeapon,
createArmour
createArmour,
createUpgradeScroll
} from "../../core/config/Items";
import { seededRandom } from "../../core/math";
import * as ROT from "rot-js";
@@ -62,7 +63,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World
createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createArmour("leather_armor", "heavy") // Heavy armour variant
createArmour("leather_armor", "heavy"), // Heavy armour variant
createUpgradeScroll(2) // 2 Upgrade scrolls
] : [])
]
},

View File

@@ -36,6 +36,12 @@ export class ItemSpriteFactory {
sprite.setScale(scale);
container.add(sprite);
// Add upgrade level badge if item has been upgraded
if (item.upgradeLevel && item.upgradeLevel > 0) {
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
container.add(badge);
}
return container;
}
@@ -109,6 +115,31 @@ export class ItemSpriteFactory {
return variant?.glowColor ?? null;
}
/**
* Creates a badge displaying the upgrade level (e.g., "+1").
*/
private static createUpgradeBadge(
scene: Phaser.Scene,
level: number,
scale: number
): Phaser.GameObjects.Text {
// Position at top-right corner, slightly inset
const offset = 5 * scale;
// Level text with strong outline for readability without background
const text = scene.add.text(offset, -offset, `+${level}`, {
fontSize: `${9 * scale}px`,
color: "#ffd700",
fontStyle: "bold",
fontFamily: "monospace",
stroke: "#000000",
strokeThickness: 3
});
text.setOrigin(0.5);
return text;
}
/**
* Checks if an item has a variant with a glow.
*/

View File

@@ -22,6 +22,8 @@ import GameUI from "../ui/GameUI";
import { CameraController } from "./systems/CameraController";
import { ItemManager } from "./systems/ItemManager";
import { TargetingSystem } from "./systems/TargetingSystem";
import { UpgradeManager } from "../engine/systems/UpgradeManager";
import { InventoryOverlay } from "../ui/components/InventoryOverlay";
export class GameScene extends Phaser.Scene {
private world!: World;
@@ -229,6 +231,42 @@ export class GameScene extends Phaser.Scene {
return;
}
// Upgrade Scroll Logic
if (item.id === "upgrade_scroll") {
const uiScene = this.scene.get("GameUI") as GameUI;
// Access the public inventory component
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.itemManager.removeFromInventory(player, "upgrade_scroll");
}
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Upgraded!", "#ffd700");
}
inventoryOverlay.cancelUpgradeMode();
this.emitUIUpdate();
this.commitPlayerAction({ type: "wait" });
} else {
// Should technically be prevented by UI highlights, but safety check
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot upgrade!", "#ff0000");
}
});
this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Select Item to Upgrade", "#ffffff");
}
return;
}
const result = this.itemManager.handleUse(data.itemId, player);
if (result.success && result.consumed) {

View File

@@ -9,6 +9,14 @@ export class InventoryOverlay extends OverlayComponent {
private dragIcon: Phaser.GameObjects.Sprite | null = null;
private draggedItemIndex: number | null = null;
private draggedEquipmentKey: string | null = null;
private isDragging = false;
private dragPayload: any = null;
private cachedPlayer: CombatantActor | null = null; // Cache player for local methods
// Upgrade Mode
public isUpgradeMode = false;
private onUpgradeSelect?: (item: any) => void;
private tooltip: Phaser.GameObjects.Container | null = null;
private tooltipName: Phaser.GameObjects.Text | null = null;
private tooltipStats: Phaser.GameObjects.Text | null = null;
@@ -35,6 +43,23 @@ export class InventoryOverlay extends OverlayComponent {
this.createEquipmentPanel();
this.createBackpackPanel();
this.createTooltip();
// Global input listener to cancel upgrade mode on click outside
this.scene.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
// Only check if visible and in upgrade mode
if (this.container.visible && this.isUpgradeMode) {
this.handleUpgradeClick(pointer);
// If clicking outside both panels, cancel
const overBackpack = this.getBackpackSlotAt(pointer.x, pointer.y) !== null;
const overEquip = this.getEquipmentSlotAt(pointer.x, pointer.y) !== null;
if (!overBackpack && !overEquip) {
console.log("Clicked outside - cancelling (DEBUG: DISABLED to fix interaction)");
// this.cancelUpgradeMode();
}
}
});
}
private createTooltip() {
@@ -309,6 +334,7 @@ export class InventoryOverlay extends OverlayComponent {
}
update(player: CombatantActor) {
this.cachedPlayer = player;
if (!player.inventory) return;
// Clear existing items from backpack slots
@@ -361,8 +387,25 @@ export class InventoryOverlay extends OverlayComponent {
slot.setData("equipmentKey", undefined); // Explicitly clear to avoid confusion
this.scene.input.setDraggable(slot);
slot.on("pointerdown", () => {
console.log("Clicked item:", item);
// Clear previous listeners to avoid accumulation
slot.removeAllListeners("pointerdown");
slot.removeAllListeners("pointerover");
slot.removeAllListeners("pointerout");
slot.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
if (this.isUpgradeMode && this.onUpgradeSelect) {
if (item && (item.type === "Weapon" || item.type === "BodyArmour" || item.type === "Helmet" || item.type === "Gloves" || item.type === "Boots")) {
this.onUpgradeSelect(item);
}
return;
}
// Right click to use item
if (pointer.rightButtonDown()) {
const gameScene = this.scene.scene.get("GameScene");
gameScene.events.emit("use-item", { itemId: item.id });
return;
}
});
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
@@ -391,6 +434,19 @@ export class InventoryOverlay extends OverlayComponent {
slot.setData("equipmentKey", key);
this.scene.input.setDraggable(slot);
// Clear previous listeners
slot.removeAllListeners("pointerdown");
slot.removeAllListeners("pointerover");
slot.removeAllListeners("pointerout");
slot.on("pointerdown", () => {
if (this.isUpgradeMode && this.onUpgradeSelect) {
// All equipped items in valid slots are upgradeable by definition
this.onUpgradeSelect(item);
return;
}
});
slot.on("pointerover", (pointer: Phaser.Input.Pointer) => {
this.showTooltip(item, pointer.x, pointer.y);
});
@@ -440,6 +496,7 @@ export class InventoryOverlay extends OverlayComponent {
}
private clearHighlights() {
// Reset Equipment Slots
this.equipmentSlots.forEach((container, key) => {
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
if (graphics) {
@@ -456,12 +513,142 @@ export class InventoryOverlay extends OverlayComponent {
graphics.fillStyle(slotBg, 1);
graphics.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
// Allow interactions again if they were disabled (though we don't disable them currently)
container.setAlpha(1);
}
});
// Reset Backpack Slots
this.backpackSlots.forEach(container => {
const graphics = container.list[0] as Phaser.GameObjects.Graphics;
if (graphics) {
graphics.clear();
const slotSize = 44;
const slotBorder = 0xd4af37;
const slotBg = 0x1a0f1a; // Darker bg for backpack
graphics.lineStyle(2, slotBorder, 1);
graphics.strokeRect(-slotSize / 2, -slotSize / 2, slotSize, slotSize);
graphics.lineStyle(1, 0x8b7355, 1);
graphics.strokeRect(-slotSize / 2 + 2, -slotSize / 2 + 2, slotSize - 4, slotSize - 4);
graphics.fillStyle(slotBg, 1);
graphics.fillRect(-slotSize / 2 + 3, -slotSize / 2 + 3, slotSize - 6, slotSize - 6);
container.setAlpha(1);
}
});
}
/**
* Enters upgrade mode, highlighting upgradeable items.
*/
enterUpgradeMode(onSelect: (item: any) => void) {
this.isUpgradeMode = true;
this.onUpgradeSelect = onSelect;
// Highlight all upgradeable items
this.highlightUpgradeableItems();
}
cancelUpgradeMode() {
this.isUpgradeMode = false;
this.onUpgradeSelect = undefined;
this.clearHighlights();
}
private highlightUpgradeableItems() {
if (!this.cachedPlayer || !this.cachedPlayer.inventory) return;
// Green highlight for upgradeable items
this.backpackSlots.forEach((slot, index) => {
const item = this.cachedPlayer!.inventory!.items[index];
let bg = slot.list.find(c => c instanceof Phaser.GameObjects.Graphics) as Phaser.GameObjects.Graphics;
// In this complex container setup, the bg is the first child of the slot container's first child
// Actually looking at createBackpackPanel, the slot IS a container with [Graphics] in it.
// The Graphics draws border and bg.
// We should clear and redraw highlighted
if (item && (item.type === "Weapon" || item.type === "BodyArmour" || item.type === "Helmet" || item.type === "Gloves" || item.type === "Boots")) {
this.drawSlotHighlight(slot, true, 44);
} else {
this.drawSlotDim(slot, 44);
}
});
this.equipmentSlots.forEach((slot, key) => {
const item = (this.cachedPlayer!.equipment as any)?.[key];
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;
if (item) {
this.drawSlotHighlight(slot, true, size);
} else {
this.drawSlotDim(slot, size);
}
});
}
private drawSlotHighlight(slot: Phaser.GameObjects.Container, active: boolean, size: number) {
const g = slot.list[0] as Phaser.GameObjects.Graphics;
if (g) {
g.clear();
// Highlight border
g.lineStyle(2, 0x00ff00, 1);
g.strokeRect(-size / 2, -size / 2, size, size);
g.lineStyle(1, 0x00aa00, 1);
g.strokeRect(-size / 2 + 2, -size / 2 + 2, size - 4, size - 4);
g.fillStyle(0x1a2f1a, 1);
g.fillRect(-size / 2 + 3, -size / 2 + 3, size - 6, size - 6);
}
}
private drawSlotDim(slot: Phaser.GameObjects.Container, size: number) {
const g = slot.list[0] as Phaser.GameObjects.Graphics;
if (g) {
g.setAlpha(0.3);
}
}
// Handle clicks for upgrade selection
private handleUpgradeClick(pointer: Phaser.Input.Pointer) {
if (!this.isUpgradeMode || !this.onUpgradeSelect) {
return;
}
// Check backpack
const backpackIndex = this.getBackpackSlotAt(pointer.x, pointer.y);
if (backpackIndex !== null && this.cachedPlayer && this.cachedPlayer.inventory) {
const item = this.cachedPlayer.inventory.items[backpackIndex];
// Reuse eligibility check
if (item && (item.type === "Weapon" || item.type === "BodyArmour" || item.type === "Helmet" || item.type === "Gloves" || item.type === "Boots")) {
this.onUpgradeSelect(item);
}
return;
}
// Check equipment
const equipSlot = this.getEquipmentSlotAt(pointer.x, pointer.y);
if (equipSlot !== null && this.cachedPlayer && this.cachedPlayer.equipment) {
const item = (this.cachedPlayer.equipment as any)[equipSlot];
if (item) { // All equipped items are upgradeable types
this.onUpgradeSelect(item);
}
return;
}
}
private setupDragEvents() {
this.scene.input.on("dragstart", (pointer: Phaser.Input.Pointer, gameObject: any) => {
// Handle Upgrade Mode clicks (prevent drag)
if (this.isUpgradeMode) {
return;
}
const gameScene = this.scene.scene.get("GameScene") as any;
const player = gameScene.world.actors.get(gameScene.playerId);
if (!player) return;