feat: create item variants

This commit is contained in:
Peter Stockings
2026-01-23 08:29:39 +11:00
parent d2039df8c8
commit e130e6d174
8 changed files with 384 additions and 39 deletions

View File

@@ -0,0 +1,177 @@
import type { ItemType } from "../types";
// =============================================================================
// Variant Stat Modifiers
// =============================================================================
export type VariantStatModifiers = Partial<{
defense: number;
attack: number;
speed: number;
maxHp: number;
critChance: number;
critMultiplier: number;
accuracy: number;
evasion: number;
blockChance: number;
lifesteal: number;
luck: number;
// Consumable-specific multiplier
effectMultiplier: number;
}>;
export interface ItemVariant {
prefix: string;
glowColor: number;
statModifiers: VariantStatModifiers;
applicableTo: ItemType[];
}
// =============================================================================
// Armour Variants
// =============================================================================
export const ARMOUR_VARIANTS = {
heavy: {
prefix: "Heavy",
glowColor: 0x4488ff, // Blue
statModifiers: { defense: 2, speed: -1 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
light: {
prefix: "Light",
glowColor: 0x44ff88, // Green
statModifiers: { speed: 1, evasion: 5, defense: -1 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
reinforced: {
prefix: "Reinforced",
glowColor: 0xcccccc, // Silver
statModifiers: { defense: 1, blockChance: 5 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
blessed: {
prefix: "Blessed",
glowColor: 0xffd700, // Gold
statModifiers: { maxHp: 5, defense: 1 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
cursed: {
prefix: "Cursed",
glowColor: 0x8844ff, // Purple
statModifiers: { defense: 3, luck: -10 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
spiked: {
prefix: "Spiked",
glowColor: 0xff4444, // Red
statModifiers: { attack: 1, defense: 1 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
} as const;
// =============================================================================
// Weapon Variants
// =============================================================================
export const WEAPON_VARIANTS = {
sharp: {
prefix: "Sharp",
glowColor: 0xffffff, // White
statModifiers: { attack: 2, critChance: 5 },
applicableTo: ["Weapon"] as ItemType[],
},
heavy_weapon: {
prefix: "Heavy",
glowColor: 0x4488ff, // Blue
statModifiers: { attack: 3, speed: -1 },
applicableTo: ["Weapon"] as ItemType[],
},
balanced: {
prefix: "Balanced",
glowColor: 0x44ffff, // Cyan
statModifiers: { attack: 1, accuracy: 10 },
applicableTo: ["Weapon"] as ItemType[],
},
venomous: {
prefix: "Venomous",
glowColor: 0x88ff44, // Toxic green
statModifiers: { attack: 1 },
applicableTo: ["Weapon"] as ItemType[],
},
vampiric: {
prefix: "Vampiric",
glowColor: 0xcc2222, // Crimson
statModifiers: { lifesteal: 5 },
applicableTo: ["Weapon"] as ItemType[],
},
brutal: {
prefix: "Brutal",
glowColor: 0xff8844, // Orange
statModifiers: { critMultiplier: 0.5 },
applicableTo: ["Weapon"] as ItemType[],
},
} as const;
// =============================================================================
// Consumable Variants
// =============================================================================
export const CONSUMABLE_VARIANTS = {
potent: {
prefix: "Potent",
glowColor: 0xff6644, // Red-orange
statModifiers: { effectMultiplier: 1.5 },
applicableTo: ["Consumable"] as ItemType[],
},
diluted: {
prefix: "Diluted",
glowColor: 0xaaaaaa, // Pale gray
statModifiers: { effectMultiplier: 0.5 },
applicableTo: ["Consumable"] as ItemType[],
},
enchanted: {
prefix: "Enchanted",
glowColor: 0xff44ff, // Magenta
statModifiers: { effectMultiplier: 2 },
applicableTo: ["Consumable"] as ItemType[],
},
} as const;
// =============================================================================
// Combined Variant Lookup
// =============================================================================
export const ALL_VARIANTS = {
...ARMOUR_VARIANTS,
...WEAPON_VARIANTS,
...CONSUMABLE_VARIANTS,
} as const;
export type ArmourVariantId = keyof typeof ARMOUR_VARIANTS;
export type WeaponVariantId = keyof typeof WEAPON_VARIANTS;
export type ConsumableVariantId = keyof typeof CONSUMABLE_VARIANTS;
export type ItemVariantId = keyof typeof ALL_VARIANTS;
// =============================================================================
// Helper Functions
// =============================================================================
export function getVariant(variantId: ItemVariantId): ItemVariant {
return ALL_VARIANTS[variantId];
}
export function getVariantGlowColor(variantId: ItemVariantId): number {
return ALL_VARIANTS[variantId].glowColor;
}
export function isVariantApplicable(variantId: ItemVariantId, itemType: ItemType): boolean {
const variant = ALL_VARIANTS[variantId];
return variant.applicableTo.includes(itemType);
}
export function getApplicableVariants(itemType: ItemType): ItemVariantId[] {
return (Object.keys(ALL_VARIANTS) as ItemVariantId[]).filter(
(id) => isVariantApplicable(id, itemType)
);
}

View File

@@ -94,36 +94,66 @@ export type ItemTemplateId = keyof typeof ALL_TEMPLATES;
// Factory Functions // Factory Functions
// ============================================================================= // =============================================================================
export function createConsumable(id: ConsumableId, quantity = 1): ConsumableItem { import {
ALL_VARIANTS,
type ArmourVariantId,
type WeaponVariantId,
type ConsumableVariantId
} from "./ItemVariants";
export function createConsumable(
id: ConsumableId,
quantity = 1,
variant?: ConsumableVariantId
): ConsumableItem {
const t = CONSUMABLES[id]; 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 { return {
id, id,
name: t.name, name,
type: "Consumable", type: "Consumable",
textureKey: t.textureKey, textureKey: t.textureKey,
spriteIndex: t.spriteIndex, spriteIndex: t.spriteIndex,
stackable: t.stackable ?? false, stackable: t.stackable ?? false,
quantity, quantity,
variant,
stats: { stats: {
hp: "healAmount" in t ? t.healAmount : undefined, hp: finalHealAmount,
attack: "attack" in t ? t.attack : undefined, attack: "attack" in t ? t.attack : undefined,
}, },
throwable: "throwable" in t ? t.throwable : undefined, throwable: "throwable" in t ? t.throwable : undefined,
}; };
} }
export function createRangedWeapon(id: RangedWeaponId): RangedWeaponItem { export function createRangedWeapon(
id: RangedWeaponId,
variant?: WeaponVariantId
): RangedWeaponItem {
const t = RANGED_WEAPONS[id]; 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 { return {
id, id,
name: t.name, name,
type: "Weapon", type: "Weapon",
weaponType: "ranged", weaponType: "ranged",
textureKey: t.textureKey, textureKey: t.textureKey,
spriteIndex: t.spriteIndex, spriteIndex: t.spriteIndex,
currentAmmo: t.magazineSize, currentAmmo: t.magazineSize,
variant,
stats: { stats: {
attack: t.attack, attack: t.attack + attackBonus,
range: t.range, range: t.range,
magazineSize: t.magazineSize, magazineSize: t.magazineSize,
ammoType: t.ammoType, ammoType: t.ammoType,
@@ -133,17 +163,26 @@ export function createRangedWeapon(id: RangedWeaponId): RangedWeaponItem {
}; };
} }
export function createMeleeWeapon(id: MeleeWeaponId): MeleeWeaponItem { export function createMeleeWeapon(
id: MeleeWeaponId,
variant?: WeaponVariantId
): MeleeWeaponItem {
const t = MELEE_WEAPONS[id]; 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 { return {
id, id,
name: t.name, name,
type: "Weapon", type: "Weapon",
weaponType: "melee", weaponType: "melee",
textureKey: t.textureKey, textureKey: t.textureKey,
spriteIndex: t.spriteIndex, spriteIndex: t.spriteIndex,
variant,
stats: { stats: {
attack: t.attack, attack: t.attack + attackBonus,
}, },
}; };
} }
@@ -162,19 +201,29 @@ export function createAmmo(id: AmmoId, quantity = 10): AmmoItem {
}; };
} }
export function createArmour(id: ArmourId): ArmourItem { export function createArmour(
id: ArmourId,
variant?: ArmourVariantId
): ArmourItem {
const t = ARMOUR[id]; 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 { return {
id, id,
name: t.name, name,
type: "BodyArmour", type: "BodyArmour",
textureKey: t.textureKey, textureKey: t.textureKey,
spriteIndex: t.spriteIndex, spriteIndex: t.spriteIndex,
variant,
stats: { stats: {
defense: t.defense, defense: t.defense + defenseBonus,
}, },
}; };
} }
// Legacy export for backward compatibility during migration // Legacy export for backward compatibility during migration
export const ITEMS = ALL_TEMPLATES; export const ITEMS = ALL_TEMPLATES;

View File

@@ -85,6 +85,7 @@ export interface BaseItem {
spriteIndex: number; spriteIndex: number;
quantity?: number; quantity?: number;
stackable?: boolean; stackable?: boolean;
variant?: string; // ItemVariantId - stored as string to avoid circular imports
} }
export interface MeleeWeaponItem extends BaseItem { export interface MeleeWeaponItem extends BaseItem {

View File

@@ -59,10 +59,10 @@ export function generateWorld(floor: number, runState: RunState): { world: World
// Add starting items for testing if empty // Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [ ...(runState.inventory.items.length === 0 ? [
createConsumable("health_potion", 2), createConsumable("health_potion", 2),
createMeleeWeapon("iron_sword"), createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant
createConsumable("throwing_dagger", 3), createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"), createRangedWeapon("pistol"),
createArmour("leather_armor") createArmour("leather_armor", "heavy") // Heavy armour variant
] : []) ] : [])
] ]
}, },

View File

@@ -7,6 +7,7 @@ import { ALL_TEMPLATES } from "../core/config/Items";
import { FovManager } from "./FovManager"; import { FovManager } from "./FovManager";
import { MinimapRenderer } from "./MinimapRenderer"; import { MinimapRenderer } from "./MinimapRenderer";
import { FxRenderer } from "./FxRenderer"; import { FxRenderer } from "./FxRenderer";
import { ItemSpriteFactory } from "./ItemSpriteFactory";
export class DungeonRenderer { export class DungeonRenderer {
private scene: Phaser.Scene; private scene: Phaser.Scene;
@@ -16,7 +17,7 @@ export class DungeonRenderer {
private playerSprite?: Phaser.GameObjects.Sprite; private playerSprite?: Phaser.GameObjects.Sprite;
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map(); private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map(); private orbSprites: Map<EntityId, Phaser.GameObjects.Arc> = new Map();
private itemSprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map(); private itemSprites: Map<EntityId, Phaser.GameObjects.Container> = new Map();
private fovManager: FovManager; private fovManager: FovManager;
private minimapRenderer: MinimapRenderer; private minimapRenderer: MinimapRenderer;
@@ -228,19 +229,20 @@ export class DungeonRenderer {
if (!isVis) continue; if (!isVis) continue;
activeItemIds.add(a.id); activeItemIds.add(a.id);
let itemSprite = this.itemSprites.get(a.id); let itemContainer = this.itemSprites.get(a.id);
if (!itemSprite) { if (!itemContainer) {
itemSprite = this.scene.add.sprite(0, 0, a.item.textureKey, a.item.spriteIndex); // Use ItemSpriteFactory to create sprite with optional glow
itemSprite.setDepth(40); itemContainer = ItemSpriteFactory.createItemSprite(this.scene, a.item, 0, 0, 1);
this.itemSprites.set(a.id, itemSprite); itemContainer.setDepth(40);
this.itemSprites.set(a.id, itemContainer);
} }
const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2; const tx = a.pos.x * TILE_SIZE + TILE_SIZE / 2;
const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2; const ty = a.pos.y * TILE_SIZE + TILE_SIZE / 2;
itemSprite.setPosition(tx, ty); itemContainer.setPosition(tx, ty);
itemSprite.setVisible(true); itemContainer.setVisible(true);
// bobbing effect? // bobbing effect on the container
itemSprite.y += Math.sin(this.scene.time.now / 300) * 2; itemContainer.y += Math.sin(this.scene.time.now / 300) * 2;
} }
} }

View File

@@ -0,0 +1,118 @@
import Phaser from "phaser";
import type { Item } from "../core/types";
import { ALL_VARIANTS, type ItemVariantId } from "../core/config/ItemVariants";
/**
* Factory for creating item sprites with optional variant glow effects.
* Centralizes item rendering logic to ensure consistent glow styling across
* inventory, quick slots, and world drops.
*/
export class ItemSpriteFactory {
/**
* Creates an item sprite with optional glow effect for variants.
* Returns a container with the glow (if applicable) and main sprite.
*/
static createItemSprite(
scene: Phaser.Scene,
item: Item,
x: number,
y: number,
scale: number = 1
): Phaser.GameObjects.Container {
const container = scene.add.container(x, y);
// Create glow effect if item has a variant
if (item.variant) {
const glowColor = this.getGlowColor(item.variant as ItemVariantId);
if (glowColor !== null) {
const glow = this.createGlow(scene, item, scale, glowColor);
container.add(glow);
}
}
// Create main item sprite
const sprite = scene.add.sprite(0, 0, item.textureKey, item.spriteIndex);
sprite.setScale(scale);
container.add(sprite);
return container;
}
/**
* Creates just a sprite (no container) for simpler use cases like drag icons.
* Does not include glow - use createItemSprite for full effect.
*/
static createSimpleSprite(
scene: Phaser.Scene,
item: Item,
x: number,
y: number,
scale: number = 1
): Phaser.GameObjects.Sprite {
const sprite = scene.add.sprite(x, y, item.textureKey, item.spriteIndex);
sprite.setScale(scale);
return sprite;
}
/**
* Creates a soft glow effect behind the item using graphics.
* Uses a radial gradient-like effect with multiple circles.
*/
private static createGlow(
scene: Phaser.Scene,
_item: Item,
scale: number,
color: number
): Phaser.GameObjects.Graphics {
const glow = scene.add.graphics();
// Base size for the glow (16x16 sprite scaled)
const baseSize = 16 * scale;
const glowRadius = baseSize * 0.8;
// Extract RGB from hex color
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
// Draw multiple circles with decreasing alpha for soft glow effect
const layers = 5;
for (let i = layers; i >= 1; i--) {
const layerRadius = glowRadius * (i / layers) * 1.2;
const layerAlpha = 0.15 * (1 - (i - 1) / layers);
glow.fillStyle(Phaser.Display.Color.GetColor(r, g, b), layerAlpha);
glow.fillCircle(0, 0, layerRadius);
}
// Add pulsing animation to the glow
scene.tweens.add({
targets: glow,
alpha: { from: 0.7, to: 1.0 },
scaleX: { from: 0.9, to: 1.1 },
scaleY: { from: 0.9, to: 1.1 },
duration: 800,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
return glow;
}
/**
* Gets the glow color for a variant.
*/
private static getGlowColor(variantId: ItemVariantId): number | null {
const variant = ALL_VARIANTS[variantId];
return variant?.glowColor ?? null;
}
/**
* Checks if an item has a variant with a glow.
*/
static hasGlow(item: Item): boolean {
return !!item.variant && !!ALL_VARIANTS[item.variant as ItemVariantId];
}
}

View File

@@ -1,6 +1,7 @@
import Phaser from "phaser"; import Phaser from "phaser";
import { OverlayComponent } from "./OverlayComponent"; import { OverlayComponent } from "./OverlayComponent";
import { type CombatantActor } from "../../core/types"; import { type CombatantActor } from "../../core/types";
import { ItemSpriteFactory } from "../../rendering/ItemSpriteFactory";
export class InventoryOverlay extends OverlayComponent { export class InventoryOverlay extends OverlayComponent {
private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map(); private equipmentSlots: Map<string, Phaser.GameObjects.Container> = new Map();
@@ -330,13 +331,9 @@ export class InventoryOverlay extends OverlayComponent {
const slot = this.backpackSlots[index]; const slot = this.backpackSlots[index];
const texture = item.textureKey; // Use ItemSpriteFactory for glow effect on variants
const frame = item.spriteIndex; const itemContainer = ItemSpriteFactory.createItemSprite(this.scene, item, 0, 0, 2.2);
slot.add(itemContainer);
const sprite = this.scene.add.sprite(0, 0, texture, frame);
sprite.setScale(2.2); // Scale to fit nicely in 44px slots
slot.add(sprite);
// Add Count Label (Bottom-Right) // Add Count Label (Bottom-Right)
let labelText = ""; let labelText = "";
@@ -384,9 +381,9 @@ export class InventoryOverlay extends OverlayComponent {
const slot = this.equipmentSlots.get(key); const slot = this.equipmentSlots.get(key);
if (!slot) return; if (!slot) return;
const sprite = this.scene.add.sprite(0, 0, item.textureKey, item.spriteIndex); // Use ItemSpriteFactory for glow effect on variants
sprite.setScale(2.2); const itemContainer = ItemSpriteFactory.createItemSprite(this.scene, item, 0, 0, 2.2);
slot.add(sprite); slot.add(itemContainer);
// Add interactivity // Add interactivity
const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46; const size = (key === "bodyArmour") ? 58 : (key === "belt") ? 32 : (key === "boots") ? 46 : (key.startsWith("ring")) ? 38 : 46;

View File

@@ -1,5 +1,6 @@
import Phaser from "phaser"; import Phaser from "phaser";
import type { CombatantActor, Item } from "../../core/types"; import type { CombatantActor, Item } from "../../core/types";
import { ItemSpriteFactory } from "../../rendering/ItemSpriteFactory";
export class QuickSlotComponent { export class QuickSlotComponent {
private scene: Phaser.Scene; private scene: Phaser.Scene;
@@ -220,11 +221,11 @@ export class QuickSlotComponent {
bgGraphics.strokeRect(0, 0, slotSize, slotSize); bgGraphics.strokeRect(0, 0, slotSize, slotSize);
if (foundItem) { if (foundItem) {
const texture = foundItem.textureKey ?? "items"; // Use ItemSpriteFactory for glow effect on variants
const sprite = this.scene.add.sprite(slotSize / 2, slotSize / 2, texture, foundItem.spriteIndex); const itemContainer = ItemSpriteFactory.createItemSprite(
// PD items are 16x16, slot is 48x48. Scale up slightly this.scene, foundItem, slotSize / 2, slotSize / 2, 2.5
sprite.setScale(2.5); );
slot.add(sprite); slot.add(itemContainer);
// Unified Label (Bottom-Right) // Unified Label (Bottom-Right)
let labelText = ""; let labelText = "";