From 7f810e40e3af8059e36a9c84801c29a2d38daa38 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 22 Sep 2026 10:45:48 +0000 Subject: [PATCH] feat(class): complete 7-class switching with signature sets and stash treasures (Fixes #379) --- src/game/class-starter-profiles.ts | 1811 ++++++++++++++++++++++++++++ src/scene/act-scene.ts | 22 +- src/ui/hotkeys.ts | 11 + src/ui/hud-manager.ts | 86 ++ src/ui/skill-tree-panel.ts | 170 ++- tests/attack-skill.test.ts | 2 +- tests/class-switching.test.ts | 328 +++++ 7 files changed, 2384 insertions(+), 46 deletions(-) create mode 100644 src/game/class-starter-profiles.ts create mode 100644 tests/class-switching.test.ts diff --git a/src/game/class-starter-profiles.ts b/src/game/class-starter-profiles.ts new file mode 100644 index 0000000..07dfe8a --- /dev/null +++ b/src/game/class-starter-profiles.ts @@ -0,0 +1,1811 @@ +/** + * Diablo II v1.13c 7-Class Starter Profiles, Exclusive Signature Sets, and Stash Treasures. + * + * Implements Issue #379 specifications: + * - 7 Classes: Amazon (ama), Sorceress (sor), Necromancer (nec), Paladin (pal), Barbarian (bar), Druid (dru), Assassin (ass) + * - Exclusive Set on Body ONLY: + * - Amazon: M'avina's Battle Hymn (Diadem, Kraken Shell, Grand Matron Bow, Battle Gauntlets, Sharkskin Belt) + * - Sorceress: Tal Rasha's Wrappings (Death Mask, Lacquered Plate, Swirling Crystal, Mesh Belt, Amulet) + * - Necromancer: Trang-Oul's Avatar (Bone Visage, Chaos Armor, Cantor Trophy, Heavy Bracers, Troll Belt) + * - Paladin: Griswold's Legacy (Corona, Ornate Plate, Caduceus, Vortex Shield) + * - Barbarian: Immortal King (Avenger Guard, Sacred Armor, Ogre Maul, War Gauntlets, War Belt, War Boots) + * - Druid: Aldur's Watchtower (Hunter's Guise, Shadow Plate, Jagged Star, Battle Boots) + * - Assassin: Natalya's Odium (Grim Helm, Loricated Mail, Scissors Suwayyah, Mesh Boots) + * - Stash contains authentic Runes (Jah, Ber, Lo, etc.), Perfect & Flawless Gems/Skulls, and unique/magic/rare Jewels. + * - Complete isolation and persistence across class switching. + */ + +import type { CharacterClassCode } from './classes.ts' +import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../ui/inventory.ts' +import type { CharacterAttributes } from '../ui/character-sheet.ts' +import type { HotkeySkillEntry } from '../ui/hotkeys.ts' + +export interface ClassProfileState { + readonly classCode: CharacterClassCode + equipped: Partial> + bagItems: GridPlacement[] + stashItems: GridPlacement[] + stashGold: number + attrs: CharacterAttributes + hardPoints: Map + unspentSkillPoints: number + activeSkillTab: 0 | 1 | 2 + defaultLeftSkill: number + defaultRightSkill: number + availableSkills: readonly HotkeySkillEntry[] + hp: number + mana: number +} + +// ------------------------------------------------------------------------------------------------- +// 1. CLASS EXCLUSIVE SET ITEMS (Body Only) +// ------------------------------------------------------------------------------------------------- + +const MAVINA_PIECES: readonly string[] = [ + "马维娜的真实之眼 (M'avina's True Sight)", + "马维娜的拥抱 (M'avina's Embrace)", + "马维娜的施展 (M'avina's Caster)", + "马维娜的冰冷之握 (M'avina's Icy Clutch)", + "马维娜的原则 (M'avina's Tenet)", +] +const MAVINA_BONUSES = [ + { text: '+3 亚马逊技能等级 (完整套装)', color: 'green' as const }, + { text: '+100 防御 (完整套装)', color: 'green' as const }, + { text: '+100 攻击准确率 (完整套装)', color: 'green' as const }, + { text: '所有抗性 +50 (完整套装)', color: 'green' as const }, + { text: '100% 更佳机会取得魔法装备 (完整套装)', color: 'green' as const }, +] + +const TAL_PIECES: readonly string[] = [ + "塔·拉夏的守护 (Tal Rasha's Guardianship)", + "塔·拉夏的赫拉迪克纹章 (Tal Rasha's Horadric Crest)", + "塔·拉夏的判决 (Tal Rasha's Adjudication)", + "塔·拉夏的无睑之眼 (Tal Rasha's Lidless Eye)", + "塔·拉夏的织细衣服 (Tal Rasha's Fine-Spun Cloth)", +] +const TAL_BONUSES = [ + { text: '+3 法师技能等级 (完整套装)', color: 'green' as const }, + { text: '+150 防御 (完整套装)', color: 'green' as const }, + { text: '+50 对远距离攻击的防御 (完整套装)', color: 'green' as const }, + { text: '+150 生命 (完整套装)', color: 'green' as const }, + { text: '所有抗性 +50 (完整套装)', color: 'green' as const }, +] + +const TRANG_PIECES: readonly string[] = [ + "塔格奥之容 (Trang-Oul's Guise)", + "塔格奥之鳞 (Trang-Oul's Scales)", + "塔格奥之翼 (Trang-Oul's Wing)", + "塔格奥之爪 (Trang-Oul's Claws)", + "塔格奥之束带 (Trang-Oul's Girth)", +] +const TRANG_BONUSES = [ + { text: '+3 死灵法师技能等级 (完整套装)', color: 'green' as const }, + { text: '+200 防御 (完整套装)', color: 'green' as const }, + { text: '+100 法力 (完整套装)', color: 'green' as const }, + { text: '所有抗性 +50 (完整套装)', color: 'green' as const }, + { text: '化身为吸血鬼巫妖形态 (完整套装)', color: 'green' as const }, + { text: '+18 火球 / +13 火墙 / +10 陨石 / +3 支配火焰 (完整套装)', color: 'green' as const }, +] + +const GRIS_PIECES: readonly string[] = [ + "格里斯瓦德的勇气 (Griswold's Valor)", + "格里斯瓦德之心 (Griswold's Heart)", + "格里斯瓦德的救赎 (Griswold's Redemption)", + "格里斯瓦德的荣耀 (Griswold's Honor)", +] +const GRIS_BONUSES = [ + { text: '+3 圣骑士技能等级 (完整套装)', color: 'green' as const }, + { text: '+200 攻击准确率 (完整套装)', color: 'green' as const }, + { text: '+150 生命 (完整套装)', color: 'green' as const }, + { text: '所有抗性 +50 (完整套装)', color: 'green' as const }, + { text: '+30% 快速再度攻击 (完整套装)', color: 'green' as const }, +] + +const IK_PIECES: readonly string[] = [ + "不朽之王的意志 (Immortal King's Will)", + "不朽之王的灵魂牢笼 (Immortal King's Soul Cage)", + "不朽之王的点魂碎石者 (Immortal King's Stone Crusher)", + "不朽之王的熔炉 (Immortal King's Forge)", + "不朽之王的心腹 (Immortal King's Detail)", + "不朽之王之柱 (Immortal King's Pillar)", +] +const IK_BONUSES = [ + { text: '+3 野蛮人技能等级 (完整套装)', color: 'green' as const }, + { text: '+450 攻击准确率 (完整套装)', color: 'green' as const }, + { text: '+150 生命 (完整套装)', color: 'green' as const }, + { text: '所有抗性 +50 (完整套装)', color: 'green' as const }, + { text: '魔法伤害减少 10 (完整套装)', color: 'green' as const }, +] + +const ALDUR_PIECES: readonly string[] = [ + "艾尔多的冷酷目光 (Aldur's Stony Gaze)", + "艾尔多的欺瞒 (Aldur's Deception)", + "艾尔多的律动 (Aldur's Rhythm)", + "艾尔多的成长 (Aldur's Advance)", +] +const ALDUR_BONUSES = [ + { text: '+3 德鲁伊技能等级 (完整套装)', color: 'green' as const }, + { text: '+150% 额外攻击准确率 (完整套装)', color: 'green' as const }, + { text: '+150 防御 (完整套装)', color: 'green' as const }, + { text: '+150 法力 (完整套装)', color: 'green' as const }, + { text: '所有抗性 +50 (完整套装)', color: 'green' as const }, +] + +const NAT_PIECES: readonly string[] = [ + "娜塔亚的图腾 (Natalya's Totem)", + "娜塔亚的影子 (Natalya's Shadow)", + "娜塔亚的印记 (Natalya's Mark)", + "娜塔亚的灵魂 (Natalya's Soul)", +] +const NAT_BONUSES = [ + { text: '+3 刺客技能等级 (完整套装)', color: 'green' as const }, + { text: '+200 防御 (完整套装)', color: 'green' as const }, + { text: '所有抗性 +50 (完整套装)', color: 'green' as const }, + { text: '物理伤害减少 30% (完整套装)', color: 'green' as const }, + { text: '14% 每次命中偷取生命 / 14% 偷取法力 (完整套装)', color: 'green' as const }, +] + +export const CLASS_EXCLUSIVE_SETS: Record>>> = { + ama: { + helm: { + id: 'set-mavina-helm', + code: 'ci3', + invFile: 'invci3', + invtransform: 'whit', + name: "M'avina's True Sight", + nameZh: "马维娜的真实之眼 (M'avina's True Sight)", + baseNameZh: '权冠 (Diadem)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + defense: 210, + durability: { current: 20, max: 20 }, + reqLevel: 64, + stats: [ + { text: '+150 防御', color: 'blue' }, + { text: '+30% 快速再度攻击 (FHR)', color: 'blue' }, + { text: '法力回复速度提高 10%', color: 'blue' }, + { text: '+25 法力', color: 'blue' }, + { text: '+1 所有技能 (完整套装加成)', color: 'green' }, + { text: '+50% 额外攻击准确率 (完整套装加成)', color: 'green' }, + { text: '所有抗性 +25 (完整套装加成)', color: 'green' }, + ], + setPieces: MAVINA_PIECES, + setBonuses: MAVINA_BONUSES, + }, + armor: { + id: 'set-mavina-armor', + code: 'uld', + invFile: 'invfld', + invtransform: 'whit', + name: "M'avina's Embrace", + nameZh: "马维娜的拥抱 (M'avina's Embrace)", + baseNameZh: '海妖壳甲 (Kraken Shell)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + defense: 1220, + durability: { current: 48, max: 48 }, + reqLevel: 70, + reqStr: 122, + stats: [ + { text: '+350 防御', color: 'blue' }, + { text: '+2 亚马逊被动与魔法技能', color: 'blue' }, + { text: '需求 -30%', color: 'blue' }, + { text: '魔法伤害减少 12', color: 'blue' }, + { text: '+30% 快速再度攻击 (完整套装加成)', color: 'green' }, + ], + setPieces: MAVINA_PIECES, + setBonuses: MAVINA_BONUSES, + }, + weapon1: { + id: 'set-mavina-bow', + code: 'amc', + invFile: 'invam2', + invtransform: 'whit', + name: "M'avina's Caster", + nameZh: "马维娜的施展 (M'avina's Caster)", + baseNameZh: '大院长的弓 (Grand Matron Bow)', + quality: 'set', + invWidth: 2, + invHeight: 4, + allowedSlots: ['weapon1'], + damage: '40 - 207', + speedText: '弓类 - 极快攻击速度 [40]', + reqLevel: 70, + reqStr: 108, + stats: [ + { text: '+188% 增强伤害 (ED)', color: 'blue' }, + { text: '+40% 提升攻击速度 (IAS)', color: 'blue' }, + { text: '发射魔法箭 (等级 1)', color: 'blue' }, + { text: '+50 攻击准确率', color: 'blue' }, + { text: '增加 114-377 魔法伤害 (完整套装加成)', color: 'green' }, + { text: '+2 亚马逊弓与十字弓技能 (完整套装加成)', color: 'green' }, + ], + setPieces: MAVINA_PIECES, + setBonuses: MAVINA_BONUSES, + }, + gloves: { + id: 'set-mavina-gloves', + code: 'xtg', + invFile: 'invtgl', + invtransform: 'whit', + name: "M'avina's Icy Clutch", + nameZh: "马维娜的冰冷之握 (M'avina's Icy Clutch)", + baseNameZh: '战场手套 (Battle Gauntlets)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['gloves'], + defense: 98, + durability: { current: 18, max: 18 }, + reqLevel: 32, + reqStr: 88, + stats: [ + { text: '+50 防御', color: 'blue' }, + { text: '+10 力量 / +15 敏捷', color: 'blue' }, + { text: '增加 6-18 冰冷伤害 (持续 4 秒)', color: 'blue' }, + { text: '冻结目标时间减半', color: 'blue' }, + { text: '增加 131-252 冰冷伤害 (完整套装加成)', color: 'green' }, + { text: '+20% 冰冷技能伤害 (完整套装加成)', color: 'green' }, + ], + setPieces: MAVINA_PIECES, + setBonuses: MAVINA_BONUSES, + }, + belt: { + id: 'set-mavina-belt', + code: 'zvb', + invFile: 'invvbl', + invtransform: 'whit', + name: "M'avina's Tenet", + nameZh: "马维娜的原则 (M'avina's Tenet)", + baseNameZh: '鲨皮腰带 (Sharkskin Belt)', + quality: 'set', + invWidth: 2, + invHeight: 1, + allowedSlots: ['belt'], + defense: 86, + durability: { current: 14, max: 14 }, + reqLevel: 45, + reqStr: 20, + stats: [ + { text: '+50 防御', color: 'blue' }, + { text: '+20% 高速跑步/行走 (FRW)', color: 'blue' }, + { text: '5% 每次命中偷取法力', color: 'blue' }, + { text: '+5 照亮范围', color: 'blue' }, + { text: '所有抗性 +25 (完整套装加成)', color: 'green' }, + ], + setPieces: MAVINA_PIECES, + setBonuses: MAVINA_BONUSES, + }, + }, + sor: { + helm: { + id: 'set-tal-helm', + code: 'xsk', + invFile: 'invmsk', + invtransform: 'dpur', + name: "Tal Rasha's Horadric Crest", + nameZh: "塔·拉夏的赫拉迪克纹章 (Tal Rasha's Horadric Crest)", + baseNameZh: '死亡面具 (Death Mask)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + defense: 131, + durability: { current: 20, max: 20 }, + reqLevel: 66, + reqStr: 55, + stats: [ + { text: '+45 防御', color: 'blue' }, + { text: '10% 每次命中偷取生命', color: 'blue' }, + { text: '10% 每次命中偷取法力', color: 'blue' }, + { text: '+60 生命 / +30 法力', color: 'blue' }, + { text: '所有抗性 +15', color: 'blue' }, + ], + setPieces: TAL_PIECES, + setBonuses: TAL_BONUSES, + }, + armor: { + id: 'set-tal-armor', + code: 'uth', + invFile: 'invgth', + invtransform: 'dpur', + name: "Tal Rasha's Guardianship", + nameZh: "塔·拉夏的守护 (Tal Rasha's Guardianship)", + baseNameZh: '漆甲 (Lacquered Plate)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + defense: 941, + durability: { current: 55, max: 55 }, + reqLevel: 71, + reqStr: 84, + stats: [ + { text: '+400 防御', color: 'blue' }, + { text: '抗寒 +40% / 抗电 +40% / 抗火 +40%', color: 'blue' }, + { text: '魔法伤害减少 15', color: 'blue' }, + { text: '88% 更佳机会取得魔法装备', color: 'blue' }, + ], + setPieces: TAL_PIECES, + setBonuses: TAL_BONUSES, + }, + weapon1: { + id: 'set-tal-orb', + code: 'oba', + invFile: 'invob5', + invtransform: 'dpur', + name: "Tal Rasha's Lidless Eye", + nameZh: "塔·拉夏的无睑之眼 (Tal Rasha's Lidless Eye)", + baseNameZh: '涡流水晶 (Swirling Crystal)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['weapon1'], + damage: '18 - 42', + durability: { current: 50, max: 50 }, + speedText: '法球类 - 普通攻击速度', + reqLevel: 65, + stats: [ + { text: '+20% 快速施法速度 (FCR)', color: 'blue' }, + { text: '+1-2 支配火焰 / +1-2 支配闪电 / +1-2 支配冰冷', color: 'blue' }, + { text: '+57 生命 / +77 法力 / +10 精力', color: 'blue' }, + ], + setPieces: TAL_PIECES, + setBonuses: TAL_BONUSES, + }, + belt: { + id: 'set-tal-belt', + code: 'zmb', + invFile: 'invmbl', + invtransform: 'dpur', + name: "Tal Rasha's Fine-Spun Cloth", + nameZh: "塔·拉夏的织细衣服 (Tal Rasha's Fine-Spun Cloth)", + baseNameZh: '织网腰带 (Mesh Belt)', + quality: 'set', + invWidth: 2, + invHeight: 1, + allowedSlots: ['belt'], + defense: 40, + durability: { current: 16, max: 16 }, + reqLevel: 53, + reqStr: 47, + stats: [ + { text: '+20 敏捷 / +30 法力', color: 'blue' }, + { text: '受损生命转化为法力 37%', color: 'blue' }, + { text: '15% 更佳机会取得魔法装备', color: 'blue' }, + { text: '需求 -20%', color: 'blue' }, + ], + setPieces: TAL_PIECES, + setBonuses: TAL_BONUSES, + }, + amulet: { + id: 'set-tal-amulet', + code: 'amu', + invFile: 'invamu', + invtransform: 'dpur', + name: "Tal Rasha's Adjudication", + nameZh: "塔·拉夏的判决 (Tal Rasha's Adjudication)", + baseNameZh: '项链 (Amulet)', + quality: 'set', + invWidth: 1, + invHeight: 1, + allowedSlots: ['amulet'], + reqLevel: 67, + stats: [ + { text: '+2 法师技能等级', color: 'blue' }, + { text: '增加 3-32 闪电伤害', color: 'blue' }, + { text: '+50 生命 / +42 法力', color: 'blue' }, + { text: '抗电 +33%', color: 'blue' }, + ], + setPieces: TAL_PIECES, + setBonuses: TAL_BONUSES, + }, + }, + nec: { + helm: { + id: 'set-trang-helm', + code: 'uh9', + invFile: 'invbhm', + invtransform: 'dgld', + name: "Trang-Oul's Guise", + nameZh: "塔格奥之容 (Trang-Oul's Guise)", + baseNameZh: '骸骨面罩 (Bone Visage)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + defense: 257, + durability: { current: 40, max: 40 }, + reqLevel: 65, + reqStr: 106, + stats: [ + { text: '+100 防御', color: 'blue' }, + { text: '+25% 快速再度攻击 (FHR)', color: 'blue' }, + { text: '+150 法力', color: 'blue' }, + { text: '生命回复 +5', color: 'blue' }, + { text: '攻击者受到伤害 20', color: 'blue' }, + ], + setPieces: TRANG_PIECES, + setBonuses: TRANG_BONUSES, + }, + armor: { + id: 'set-trang-armor', + code: 'xul', + invFile: 'invful', + invtransform: 'dyel', + name: "Trang-Oul's Scales", + nameZh: "塔格奥之鳞 (Trang-Oul's Scales)", + baseNameZh: '混沌战甲 (Chaos Armor)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + defense: 857, + durability: { current: 70, max: 70 }, + reqLevel: 49, + reqStr: 84, + stats: [ + { text: '+150% 增强防御', color: 'blue' }, + { text: '+40% 高速跑步/行走 (FRW)', color: 'blue' }, + { text: '+2 召唤技能 (限死灵法师)', color: 'blue' }, + { text: '+100 对远距离攻击的防御', color: 'blue' }, + { text: '需求 -40%', color: 'blue' }, + { text: '抗毒 +40%', color: 'blue' }, + ], + setPieces: TRANG_PIECES, + setBonuses: TRANG_BONUSES, + }, + weapon2: { + id: 'set-trang-shield', + code: 'ne9', + invFile: 'invne4', + invtransform: 'dyel', + name: "Trang-Oul's Wing", + nameZh: "塔格奥之翼 (Trang-Oul's Wing)", + baseNameZh: '领唱者印记 (Cantor Trophy)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['weapon2'], + defense: 189, + durability: { current: 20, max: 20 }, + reqLevel: 54, + reqStr: 50, + stats: [ + { text: '+125 防御', color: 'blue' }, + { text: '+30% 增加格挡几率', color: 'blue' }, + { text: '+2 毒素与白骨技能 (限死灵法师)', color: 'blue' }, + { text: '+25 力量 / +15 敏捷', color: 'blue' }, + { text: '抗火 +45% / 抗毒 +40%', color: 'blue' }, + ], + setPieces: TRANG_PIECES, + setBonuses: TRANG_BONUSES, + }, + gloves: { + id: 'set-trang-gloves', + code: 'xmg', + invFile: 'invmgl', + invtransform: 'dyel', + name: "Trang-Oul's Claws", + nameZh: "塔格奥之爪 (Trang-Oul's Claws)", + baseNameZh: '重型手镯 (Heavy Bracers)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['gloves'], + defense: 74, + durability: { current: 16, max: 16 }, + reqLevel: 45, + reqStr: 58, + stats: [ + { text: '+20% 快速施法速度 (FCR)', color: 'blue' }, + { text: '+25% 毒素技能伤害', color: 'blue' }, + { text: '+30 防御', color: 'blue' }, + { text: '抗寒 +30%', color: 'blue' }, + { text: '+2 诅咒技能 (限死灵法师)', color: 'blue' }, + ], + setPieces: TRANG_PIECES, + setBonuses: TRANG_BONUSES, + }, + belt: { + id: 'set-trang-belt', + code: 'utc', + invFile: 'invtbl', + invtransform: 'dyel', + name: "Trang-Oul's Girth", + nameZh: "塔格奥之束带 (Trang-Oul's Girth)", + baseNameZh: '巨魔腰带 (Troll Belt)', + quality: 'set', + invWidth: 2, + invHeight: 1, + allowedSlots: ['belt'], + defense: 166, + durability: { current: 18, max: 18 }, + reqLevel: 62, + reqStr: 91, + stats: [ + { text: '+100 防御', color: 'blue' }, + { text: '无法冰冻', color: 'blue' }, + { text: '需求 -40%', color: 'blue' }, + { text: '+66 生命 / +50 法力 / +30 耐力', color: 'blue' }, + { text: '生命回复 +5', color: 'blue' }, + ], + setPieces: TRANG_PIECES, + setBonuses: TRANG_BONUSES, + }, + }, + pal: { + helm: { + id: 'set-gris-helm', + code: 'urn', + invFile: 'invcrn', + invtransform: 'dgld', + name: "Griswold's Valor", + nameZh: "格里斯瓦德的勇气 (Griswold's Valor)", + baseNameZh: '头冠 (Corona)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + defense: 290, + durability: { current: 50, max: 50 }, + reqLevel: 69, + reqStr: 105, + sockets: 2, + stats: [ + { text: '+75% 增强防御', color: 'blue' }, + { text: '所有抗性 +5', color: 'blue' }, + { text: '30% 更佳机会取得魔法装备', color: 'blue' }, + { text: '需求 -40%', color: 'blue' }, + { text: '带插槽 (2)', color: 'blue' }, + ], + setPieces: GRIS_PIECES, + setBonuses: GRIS_BONUSES, + }, + armor: { + id: 'set-gris-armor', + code: 'xar', + invFile: 'invaar', + invtransform: 'dgld', + name: "Griswold's Heart", + nameZh: "格里斯瓦德之心 (Griswold's Heart)", + baseNameZh: '华丽战甲 (Ornate Plate)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + defense: 950, + durability: { current: 60, max: 60 }, + reqLevel: 45, + reqStr: 102, + sockets: 3, + stats: [ + { text: '+500 防御', color: 'blue' }, + { text: '+2 防御灵气 (限圣骑士)', color: 'blue' }, + { text: '+20 力量', color: 'blue' }, + { text: '需求 -40%', color: 'blue' }, + { text: '带插槽 (3)', color: 'blue' }, + ], + setPieces: GRIS_PIECES, + setBonuses: GRIS_BONUSES, + }, + weapon1: { + id: 'set-gris-scepter', + code: '7ws', + invFile: 'invwsp', + invtransform: 'dgld', + name: "Griswold's Redemption", + nameZh: "格里斯瓦德的救赎 (Griswold's Redemption)", + baseNameZh: '神使之杖 (Caduceus)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['weapon1'], + damage: '125 - 162', + durability: { current: 70, max: 70 }, + speedText: '权杖类 - 极快攻击速度 [40]', + reqLevel: 66, + reqStr: 78, + stats: [ + { text: '+240% 增强伤害 (ED)', color: 'blue' }, + { text: '+40% 提升攻击速度 (IAS)', color: 'blue' }, + { text: '+200% 对不死生物的伤害', color: 'blue' }, + { text: '需求 -20%', color: 'blue' }, + { text: '带插槽 (4)', color: 'blue' }, + ], + setPieces: GRIS_PIECES, + setBonuses: GRIS_BONUSES, + }, + weapon2: { + id: 'set-gris-shield', + code: 'paf', + invFile: 'invpa5', + invtransform: 'dgld', + name: "Griswold's Honor", + nameZh: "格里斯瓦德的荣耀 (Griswold's Honor)", + baseNameZh: '旋风盾 (Vortex Shield)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['weapon2'], + defense: 333, + durability: { current: 90, max: 90 }, + reqLevel: 68, + reqStr: 148, + sockets: 3, + stats: [ + { text: '+108 防御', color: 'blue' }, + { text: '+65% 快速格挡率', color: 'blue' }, + { text: '+20% 增加格挡几率', color: 'blue' }, + { text: '所有抗性 +45', color: 'blue' }, + { text: '带插槽 (3)', color: 'blue' }, + ], + setPieces: GRIS_PIECES, + setBonuses: GRIS_BONUSES, + }, + }, + bar: { + helm: { + id: 'set-ik-helm', + code: 'ba5', + invFile: 'invba5', + invtransform: 'lgry', + name: "Immortal King's Will", + nameZh: "不朽之王的意志 (Immortal King's Will)", + baseNameZh: '复仇者之盔 (Avenger Guard)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + defense: 175, + durability: { current: 55, max: 55 }, + reqLevel: 47, + reqStr: 65, + sockets: 2, + stats: [ + { text: '+2 战吼技能 (限野蛮人)', color: 'blue' }, + { text: '+4 生命补给', color: 'blue' }, + { text: '40% 更佳机会取得魔法装备', color: 'blue' }, + { text: '带插槽 (2)', color: 'blue' }, + ], + setPieces: IK_PIECES, + setBonuses: IK_BONUSES, + }, + armor: { + id: 'set-ik-armor', + code: 'uar', + invFile: 'invaar', + invtransform: 'lgry', + name: "Immortal King's Soul Cage", + nameZh: "不朽之王的灵魂牢笼 (Immortal King's Soul Cage)", + baseNameZh: '神圣盔甲 (Sacred Armor)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + defense: 1001, + durability: { current: 60, max: 60 }, + reqLevel: 76, + reqStr: 232, + stats: [ + { text: '+400 防御', color: 'blue' }, + { text: '+2 战斗技能 (限野蛮人)', color: 'blue' }, + { text: '抗毒 +50%', color: 'blue' }, + ], + setPieces: IK_PIECES, + setBonuses: IK_BONUSES, + }, + weapon1: { + id: 'set-ik-maul', + code: '7m7', + invFile: 'invmau', + invtransform: 'lgry', + name: "Immortal King's Stone Crusher", + nameZh: "不朽之王的点魂碎石者 (Immortal King's Stone Crusher)", + baseNameZh: '食人魔之槌 (Ogre Maul)', + quality: 'set', + invWidth: 2, + invHeight: 4, + allowedSlots: ['weapon1'], + damage: '231 - 318', + durability: { current: 60, max: 60 }, + speedText: '锤类 - 极快攻击速度 [40]', + reqLevel: 76, + reqStr: 225, + sockets: 2, + stats: [ + { text: '+200% 增强伤害 (ED)', color: 'blue' }, + { text: '+40% 提升攻击速度 (IAS)', color: 'blue' }, + { text: '40% 压碎性打击 (CB)', color: 'blue' }, + { text: '永不磨损 (Indestructible)', color: 'blue' }, + { text: '带插槽 (2)', color: 'blue' }, + ], + setPieces: IK_PIECES, + setBonuses: IK_BONUSES, + }, + gloves: { + id: 'set-ik-gloves', + code: 'xhg', + invFile: 'invhgl', + invtransform: 'lgry', + name: "Immortal King's Forge", + nameZh: "不朽之王的熔炉 (Immortal King's Forge)", + baseNameZh: '战场手套 (War Gauntlets)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['gloves'], + defense: 118, + durability: { current: 24, max: 24 }, + reqLevel: 30, + reqStr: 110, + stats: [ + { text: '+65 防御', color: 'blue' }, + { text: '+20 力量 / +20 敏捷', color: 'blue' }, + ], + setPieces: IK_PIECES, + setBonuses: IK_BONUSES, + }, + belt: { + id: 'set-ik-belt', + code: 'zhb', + invFile: 'invhbl', + invtransform: 'lgry', + name: "Immortal King's Detail", + nameZh: "不朽之王的心腹 (Immortal King's Detail)", + baseNameZh: '战场腰带 (War Belt)', + quality: 'set', + invWidth: 2, + invHeight: 1, + allowedSlots: ['belt'], + defense: 89, + durability: { current: 24, max: 24 }, + reqLevel: 29, + reqStr: 110, + stats: [ + { text: '+36 防御', color: 'blue' }, + { text: '+25 力量', color: 'blue' }, + { text: '抗火 +28% / 抗电 +31%', color: 'blue' }, + ], + setPieces: IK_PIECES, + setBonuses: IK_BONUSES, + }, + boots: { + id: 'set-ik-boots', + code: 'xhb', + invFile: 'invhbt', + invtransform: 'lgry', + name: "Immortal King's Pillar", + nameZh: "不朽之王之柱 (Immortal King's Pillar)", + baseNameZh: '战场之靴 (War Boots)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['boots'], + defense: 128, + durability: { current: 24, max: 24 }, + reqLevel: 31, + reqStr: 125, + stats: [ + { text: '+75 防御', color: 'blue' }, + { text: '+40% 高速跑步/行走 (FRW)', color: 'blue' }, + { text: '+110 攻击准确率', color: 'blue' }, + { text: '+44 生命', color: 'blue' }, + ], + setPieces: IK_PIECES, + setBonuses: IK_BONUSES, + }, + }, + dru: { + helm: { + id: 'set-aldur-helm', + code: 'dr8', + invFile: 'invdr3', + invtransform: 'oran', + name: "Aldur's Stony Gaze", + nameZh: "艾尔多的冷酷目光 (Aldur's Stony Gaze)", + baseNameZh: "猎人之容 (Hunter's Guise)", + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + defense: 171, + durability: { current: 20, max: 20 }, + reqLevel: 36, + reqStr: 56, + sockets: 2, + stats: [ + { text: '+90 防御', color: 'blue' }, + { text: '+25% 快速再度攻击 (FHR)', color: 'blue' }, + { text: '抗寒 +50%', color: 'blue' }, + { text: '法力回复速度提高 17%', color: 'blue' }, + { text: '带插槽 (2)', color: 'blue' }, + ], + setPieces: ALDUR_PIECES, + setBonuses: ALDUR_BONUSES, + }, + armor: { + id: 'set-aldur-armor', + code: 'uul', + invFile: 'invful', + invtransform: 'oran', + name: "Aldur's Deception", + nameZh: "艾尔多的欺瞒 (Aldur's Deception)", + baseNameZh: '阴影铠甲 (Shadow Plate)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + defense: 850, + durability: { current: 70, max: 70 }, + reqLevel: 76, + reqStr: 115, + stats: [ + { text: '+300 防御', color: 'blue' }, + { text: '+1 元素技能 / +1 变形技能 (限德鲁伊)', color: 'blue' }, + { text: '+20 力量 / +15 敏捷', color: 'blue' }, + { text: '抗电 +50%', color: 'blue' }, + { text: '需求 -50%', color: 'blue' }, + ], + setPieces: ALDUR_PIECES, + setBonuses: ALDUR_BONUSES, + }, + weapon1: { + id: 'set-aldur-mace', + code: '9mt', + invFile: 'invmst', + invtransform: 'oran', + name: "Aldur's Rhythm", + nameZh: "艾尔多的律动 (Aldur's Rhythm)", + baseNameZh: '锯齿流星锤 (Jagged Star)', + quality: 'set', + invWidth: 1, + invHeight: 3, + allowedSlots: ['weapon1'], + damage: '60 - 93', + durability: { current: 72, max: 72 }, + speedText: '钉头槌类 - 极快攻击速度 [30]', + reqLevel: 42, + reqStr: 74, + sockets: 3, + stats: [ + { text: '+200% 增强伤害 (ED)', color: 'blue' }, + { text: '+30% 提升攻击速度 (IAS)', color: 'blue' }, + { text: '10% 每次命中偷取生命 / 5% 偷取法力', color: 'blue' }, + { text: '增加 40-62 闪电伤害', color: 'blue' }, + { text: '带插槽 (3)', color: 'blue' }, + ], + setPieces: ALDUR_PIECES, + setBonuses: ALDUR_BONUSES, + }, + boots: { + id: 'set-aldur-boots', + code: 'xtb', + invFile: 'invtbt', + invtransform: 'oran', + name: "Aldur's Advance", + nameZh: "艾尔多的成长 (Aldur's Advance)", + baseNameZh: '战场之靴 (Battle Boots)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['boots'], + defense: 47, + durability: { current: 48, max: 48 }, + reqLevel: 45, + reqStr: 95, + stats: [ + { text: '+40% 高速跑步/行走 (FRW)', color: 'blue' }, + { text: '+50 生命 / +180 耐力', color: 'blue' }, + { text: '抗火 +50%', color: 'blue' }, + { text: '受损生命转化为法力 10%', color: 'blue' }, + ], + setPieces: ALDUR_PIECES, + setBonuses: ALDUR_BONUSES, + }, + }, + ass: { + helm: { + id: 'set-nat-helm', + code: 'xh9', + invFile: 'invbhm', + invtransform: 'dgry', + name: "Natalya's Totem", + nameZh: "娜塔亚的图腾 (Natalya's Totem)", + baseNameZh: '残酷头盔 (Grim Helm)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['helm'], + defense: 300, + durability: { current: 40, max: 40 }, + reqLevel: 59, + reqStr: 58, + stats: [ + { text: '+175 防御', color: 'blue' }, + { text: '所有抗性 +20', color: 'blue' }, + { text: '+20 力量 / +30 敏捷', color: 'blue' }, + { text: '魔法伤害减少 3', color: 'blue' }, + ], + setPieces: NAT_PIECES, + setBonuses: NAT_BONUSES, + }, + armor: { + id: 'set-nat-armor', + code: 'ucl', + invFile: 'invscl', + invtransform: 'dgry', + name: "Natalya's Shadow", + nameZh: "娜塔亚的影子 (Natalya's Shadow)", + baseNameZh: '罗格战甲 (Loricated Mail)', + quality: 'set', + invWidth: 2, + invHeight: 3, + allowedSlots: ['armor'], + defense: 721, + durability: { current: 36, max: 36 }, + reqLevel: 73, + reqStr: 149, + sockets: 3, + stats: [ + { text: '+225 防御', color: 'blue' }, + { text: '+2 影子训练技能 (限刺客)', color: 'blue' }, + { text: '抗毒 +25%', color: 'blue' }, + { text: '生命回复 +10', color: 'blue' }, + { text: '带插槽 (3)', color: 'blue' }, + ], + setPieces: NAT_PIECES, + setBonuses: NAT_BONUSES, + }, + weapon1: { + id: 'set-nat-claw', + code: '7qr', + invFile: 'invskr', + invtransform: 'dgry', + name: "Natalya's Mark", + nameZh: "娜塔亚的印记 (Natalya's Mark)", + baseNameZh: '近身刃 (Scissors Suwayyah)', + quality: 'set', + invWidth: 1, + invHeight: 3, + allowedSlots: ['weapon1'], + damage: '153 - 170', + durability: { current: 68, max: 68 }, + speedText: '爪类 - 极快攻击速度 [40]', + reqLevel: 79, + reqStr: 118, + stats: [ + { text: '+200% 增强伤害 (ED)', color: 'blue' }, + { text: '+40% 提升攻击速度 (IAS)', color: 'blue' }, + { text: '增加 12-17 伤害', color: 'blue' }, + { text: '忽略目标防御', color: 'blue' }, + { text: '+200% 对恶魔的伤害', color: 'blue' }, + { text: '+200% 对不死生物的伤害', color: 'blue' }, + { text: '增加 50 冰冷伤害 (持续 4 秒)', color: 'blue' }, + ], + setPieces: NAT_PIECES, + setBonuses: NAT_BONUSES, + }, + boots: { + id: 'set-nat-boots', + code: 'xmb', + invFile: 'invmbt', + invtransform: 'dgry', + name: "Natalya's Soul", + nameZh: "娜塔亚的灵魂 (Natalya's Soul)", + baseNameZh: '织网之靴 (Mesh Boots)', + quality: 'set', + invWidth: 2, + invHeight: 2, + allowedSlots: ['boots'], + defense: 169, + durability: { current: 16, max: 16 }, + reqLevel: 25, + reqStr: 65, + stats: [ + { text: '+125 防御', color: 'blue' }, + { text: '+40% 高速跑步/行走 (FRW)', color: 'blue' }, + { text: '抗寒 +25% / 抗电 +25%', color: 'blue' }, + { text: '生命回复 +10', color: 'blue' }, + { text: '受损生命转化为法力 10%', color: 'blue' }, + ], + setPieces: NAT_PIECES, + setBonuses: NAT_BONUSES, + }, + }, +} + +// ------------------------------------------------------------------------------------------------- +// 2. STASH ITEMS: JEWELS, GEMS, AND RUNES (6x8 grid) +// ------------------------------------------------------------------------------------------------- + +export const STARTER_STASH_ITEMS: readonly GridPlacement[] = [ + // Row 0: High Runes (Jah, Ber, Lo, Ohm, Vex, Cham) + { + col: 0, row: 0, + item: { + id: 'stash-r-jah', code: 'r31', invFile: 'invrjo', name: 'Jah Rune', nameZh: '乔 (Jah 符文)', baseNameZh: '符文 (#31)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 65, + stats: [{ text: '武器: 忽略目标防御', color: 'white' }, { text: '防具: 最大生命值提升 5%', color: 'white' }], + }, + }, + { + col: 1, row: 0, + item: { + id: 'stash-r-ber', code: 'r30', invFile: 'invrber', name: 'Ber Rune', nameZh: '贝 (Ber 符文)', baseNameZh: '符文 (#30)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 63, + stats: [{ text: '武器: 20% 压碎性打击 (CB)', color: 'white' }, { text: '防具: 伤害减少 8%', color: 'white' }], + }, + }, + { + col: 2, row: 0, + item: { + id: 'stash-r-lo', code: 'r28', invFile: 'invrlo', name: 'Lo Rune', nameZh: '罗 (Lo 符文)', baseNameZh: '符文 (#28)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 59, + stats: [{ text: '武器: 20% 致命攻击 (DS)', color: 'white' }, { text: '防具: +5% 闪电抗性上限', color: 'white' }], + }, + }, + { + col: 3, row: 0, + item: { + id: 'stash-r-ohm', code: 'r27', invFile: 'invrohm', name: 'Ohm Rune', nameZh: '欧姆 (Ohm 符文)', baseNameZh: '符文 (#27)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 57, + stats: [{ text: '武器: +50% 增强伤害 (ED)', color: 'white' }, { text: '防具: +5% 冰冷抗性上限', color: 'white' }], + }, + }, + { + col: 4, row: 0, + item: { + id: 'stash-r-vex', code: 'r26', invFile: 'invrvex', name: 'Vex Rune', nameZh: '伐克斯 (Vex 符文)', baseNameZh: '符文 (#26)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 55, + stats: [{ text: '武器: 7% 每次命中偷取法力', color: 'white' }, { text: '防具: +5% 火焰抗性上限', color: 'white' }], + }, + }, + { + col: 5, row: 0, + item: { + id: 'stash-r-cham', code: 'r32', invFile: 'invrcham', name: 'Cham Rune', nameZh: '查姆 (Cham 符文)', baseNameZh: '符文 (#32)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 67, + stats: [{ text: '武器: 冰冻目标 +3', color: 'white' }, { text: '防具: 无法冰冻', color: 'white' }], + }, + }, + + // Row 1: High & Mid Runes (Zod, Sur, Gul, Ist, Mal, Um) + { + col: 0, row: 1, + item: { + id: 'stash-r-zod', code: 'r33', invFile: 'invrzod', name: 'Zod Rune', nameZh: '佐德 (Zod 符文)', baseNameZh: '符文 (#33)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 69, + stats: [{ text: '永不磨损 (Indestructible)', color: 'white' }], + }, + }, + { + col: 1, row: 1, + item: { + id: 'stash-r-sur', code: 'r29', invFile: 'invrsur', name: 'Sur Rune', nameZh: '苏尔 (Sur 符文)', baseNameZh: '符文 (#29)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 61, + stats: [{ text: '武器: 致盲目标', color: 'white' }, { text: '防具: 最大法力值提升 5%', color: 'white' }], + }, + }, + { + col: 2, row: 1, + item: { + id: 'stash-r-gul', code: 'r25', invFile: 'invrgul', name: 'Gul Rune', nameZh: '古尔 (Gul 符文)', baseNameZh: '符文 (#25)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 53, + stats: [{ text: '武器: 20% 额外攻击准确率', color: 'white' }, { text: '防具: +5% 毒素抗性上限', color: 'white' }], + }, + }, + { + col: 3, row: 1, + item: { + id: 'stash-r-ist', code: 'r24', invFile: 'invrist', name: 'Ist Rune', nameZh: '伊斯特 (Ist 符文)', baseNameZh: '符文 (#24)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 51, + stats: [{ text: '武器: 30% 更佳机会取得魔法装备', color: 'white' }, { text: '防具: 25% 更佳机会取得魔法装备', color: 'white' }], + }, + }, + { + col: 4, row: 1, + item: { + id: 'stash-r-mal', code: 'r23', invFile: 'invrmal', name: 'Mal Rune', nameZh: '马尔 (Mal 符文)', baseNameZh: '符文 (#23)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 49, + stats: [{ text: '武器: 阻止怪物治疗', color: 'white' }, { text: '防具: 魔法伤害减少 7', color: 'white' }], + }, + }, + { + col: 5, row: 1, + item: { + id: 'stash-r-um', code: 'r22', invFile: 'invrum', name: 'Um Rune', nameZh: '乌姆 (Um 符文)', baseNameZh: '符文 (#22)', quality: 'rune', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 47, + stats: [{ text: '武器: 25% 造成撕裂伤口', color: 'white' }, { text: '防具: 所有抗性 +15 (盾牌 +22)', color: 'white' }], + }, + }, + + // Row 2: Perfect Gems (Amethyst, Ruby, Sapphire, Topaz, Emerald, Diamond) + { + col: 0, row: 2, + item: { + id: 'stash-gem-p-ame', code: 'gpv', invFile: 'invgsve', name: 'Perfect Amethyst', nameZh: '完美紫宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18, + stats: [{ text: '武器: +150 攻击准确率', color: 'white' }, { text: '防具: +10 力量', color: 'white' }], + }, + }, + { + col: 1, row: 2, + item: { + id: 'stash-gem-p-ruby', code: 'gpr', invFile: 'invgsre', name: 'Perfect Ruby', nameZh: '完美红宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18, + stats: [{ text: '武器: 增加 15-20 火焰伤害', color: 'white' }, { text: '防具: +38 生命', color: 'white' }], + }, + }, + { + col: 2, row: 2, + item: { + id: 'stash-gem-p-sap', code: 'gpb', invFile: 'invgsbe', name: 'Perfect Sapphire', nameZh: '完美蓝宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18, + stats: [{ text: '武器: 增加 10-14 冰冷伤害 (3秒)', color: 'white' }, { text: '防具: +38 法力', color: 'white' }], + }, + }, + { + col: 3, row: 2, + item: { + id: 'stash-gem-p-top', code: 'gpy', invFile: 'invgsye', name: 'Perfect Topaz', nameZh: '完美黄宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18, + stats: [{ text: '武器: 增加 1-40 闪电伤害', color: 'white' }, { text: '防具: 24% 更佳机会取得魔法装备', color: 'white' }], + }, + }, + { + col: 4, row: 2, + item: { + id: 'stash-gem-p-eme', code: 'gpg', invFile: 'invgsge', name: 'Perfect Emerald', nameZh: '完美绿宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18, + stats: [{ text: '武器: 增加 100 毒素伤害 (7秒)', color: 'white' }, { text: '防具: +10 敏捷', color: 'white' }], + }, + }, + { + col: 5, row: 2, + item: { + id: 'stash-gem-p-dia', code: 'gpw', invFile: 'invgswe', name: 'Perfect Diamond', nameZh: '完美白宝石', baseNameZh: '完美宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 18, + stats: [{ text: '武器: +68% 对不死生物的伤害', color: 'white' }, { text: '防具: 所有抗性 +19 (盾牌)', color: 'white' }], + }, + }, + + // Row 3: Flawless Gems & Skulls + { + col: 0, row: 3, + item: { + id: 'stash-gem-fl-ame', code: 'gzv', invFile: 'invgsvd', name: 'Flawless Amethyst', nameZh: '无瑕紫宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15, + stats: [{ text: '防具: +8 力量', color: 'white' }], + }, + }, + { + col: 1, row: 3, + item: { + id: 'stash-gem-fl-ruby', code: 'glr', invFile: 'invgsrd', name: 'Flawless Ruby', nameZh: '无瑕红宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15, + stats: [{ text: '防具: +31 生命', color: 'white' }], + }, + }, + { + col: 2, row: 3, + item: { + id: 'stash-gem-fl-sap', code: 'glb', invFile: 'invgsbd', name: 'Flawless Sapphire', nameZh: '无瑕蓝宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15, + stats: [{ text: '防具: +31 法力', color: 'white' }], + }, + }, + { + col: 3, row: 3, + item: { + id: 'stash-gem-fl-top', code: 'gly', invFile: 'invgsyd', name: 'Flawless Topaz', nameZh: '无瑕黄宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15, + stats: [{ text: '防具: 20% 更佳机会取得魔法装备', color: 'white' }], + }, + }, + { + col: 4, row: 3, + item: { + id: 'stash-gem-fl-eme', code: 'glg', invFile: 'invgsgd', name: 'Flawless Emerald', nameZh: '无瑕绿宝石', baseNameZh: '宝石', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15, + stats: [{ text: '防具: +8 敏捷', color: 'white' }], + }, + }, + { + col: 5, row: 3, + item: { + id: 'stash-gem-fl-skull', code: 'skz', invFile: 'invskz', name: 'Flawless Skull', nameZh: '无瑕骷髅', baseNameZh: '骷髅', quality: 'normal', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 15, + stats: [{ text: '武器: 3% 偷取生命 / 2% 偷取法力', color: 'white' }, { text: '防具: 生命回复 +4 / 法力回复 +12%', color: 'white' }], + }, + }, + + // Row 4: Jewels (Unique Rainbow Facets, Magic, Rare) + { + col: 0, row: 4, + item: { + id: 'stash-jew-facet-fire', code: 'jew', invFile: 'invjw1', name: 'Rainbow Facet', nameZh: '彩虹刻面 (Rainbow Facet - 火焰)', baseNameZh: '暗金珠宝 (Jewel)', quality: 'unique', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 49, + stats: [ + { text: '升级时有 100% 几率施放等级 29 炽烈之径', color: 'blue' }, + { text: '+5% 火焰技能伤害', color: 'blue' }, + { text: '-5% 敌人火焰抗性', color: 'blue' }, + { text: '增加 17-45 火焰伤害', color: 'blue' }, + ], + }, + }, + { + col: 1, row: 4, + item: { + id: 'stash-jew-ruby-fervor', code: 'jew', invFile: 'invjw2', name: 'Ruby Jewel of Fervor', nameZh: '红宝石之狂热珠宝 (40/15)', baseNameZh: '魔法珠宝 (Jewel)', quality: 'magic', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 58, + stats: [ + { text: '+40% 增强伤害 (ED)', color: 'blue' }, + { text: '+15% 提升攻击速度 (IAS)', color: 'blue' }, + ], + }, + }, + { + col: 2, row: 4, + item: { + id: 'stash-jew-res-ias', code: 'jew', invFile: 'invjw3', name: 'Scintillating Jewel of Fervor', nameZh: '闪耀之狂热珠宝 (15/15)', baseNameZh: '魔法珠宝 (Jewel)', quality: 'magic', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 31, + stats: [ + { text: '+15% 提升攻击速度 (IAS)', color: 'blue' }, + { text: '所有抗性 +15 (All Resistances +15)', color: 'blue' }, + ], + }, + }, + { + col: 3, row: 4, + item: { + id: 'stash-jew-rare', code: 'jew', invFile: 'invjw4', name: 'Doom Gyre', nameZh: '末日环绕 (稀有珠宝)', baseNameZh: '稀有珠宝 (Rare Jewel)', quality: 'rare', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 42, + stats: [ + { text: '+30% 增强伤害 (ED)', color: 'blue' }, + { text: '+9 力量 / +9 敏捷', color: 'blue' }, + { text: '所有抗性 +10', color: 'blue' }, + { text: '+7% 快速再度攻击 (FHR)', color: 'blue' }, + ], + }, + }, + { + col: 4, row: 4, + item: { + id: 'stash-jew-facet-cold', code: 'jew', invFile: 'invjw5', name: 'Rainbow Facet (Cold)', nameZh: '彩虹刻面 (Rainbow Facet - 冰冷)', baseNameZh: '暗金珠宝 (Jewel)', quality: 'unique', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 49, + stats: [ + { text: '阵亡时有 100% 几率施放等级 37 暴风雪', color: 'blue' }, + { text: '+5% 冰冷技能伤害', color: 'blue' }, + { text: '-5% 敌人冰冷抗性', color: 'blue' }, + { text: '增加 24-38 冰冷伤害 (持续 3 秒)', color: 'blue' }, + ], + }, + }, + { + col: 5, row: 4, + item: { + id: 'stash-jew-facet-ltng', code: 'jew', invFile: 'invjw6', name: 'Rainbow Facet (Lightning)', nameZh: '彩虹刻面 (Rainbow Facet - 闪电)', baseNameZh: '暗金珠宝 (Jewel)', quality: 'unique', invWidth: 1, invHeight: 1, allowedSlots: [], reqLevel: 49, + stats: [ + { text: '升级时有 100% 几率施放等级 41 新星', color: 'blue' }, + { text: '+5% 闪电技能伤害', color: 'blue' }, + { text: '-5% 敌人闪电抗性', color: 'blue' }, + { text: '增加 1-74 闪电伤害', color: 'blue' }, + ], + }, + }, +] + +// ------------------------------------------------------------------------------------------------- +// 3. INVENTORY BAG GENERATION PER CLASS +// ------------------------------------------------------------------------------------------------- + +export function createStarterBagForClass(classCode: CharacterClassCode): GridPlacement[] { + const torchSkillText: Record = { + ama: '+3 亚马逊技能等级', + sor: '+3 法师技能等级', + nec: '+3 死灵法师技能等级', + pal: '+3 圣骑士技能等级', + bar: '+3 野蛮人技能等级', + dru: '+3 德鲁伊技能等级', + ass: '+3 刺客技能等级', + } + + const skillerNameZh: Record = { + ama: '弓与十字弓超级护身符', + sor: '冰冷法术超级护身符', + nec: '毒素与白骨超级护身符', + pal: '圣骑士战斗技能超级护身符', + bar: '战斗专家超级护身符', + dru: '元素法术超级护身符', + ass: '陷阱技能超级护身符', + } + + return [ + { + col: 0, + row: 0, + item: { + id: `bag-cube-${classCode}`, + code: 'box', + invFile: 'invbox', + name: 'Horadric Cube', + nameZh: '赫拉迪克方块 (Horadric Cube)', + baseNameZh: '任务宝盒', + quality: 'unique', + invWidth: 2, + invHeight: 2, + allowedSlots: [], + stats: [{ text: '右键点击打开合成界面 (3×4 网格)', color: 'gold' }], + }, + }, + { + col: 2, + row: 0, + item: { + id: `bag-tbk-${classCode}`, + code: 'tbk', + invFile: 'invbbk', + name: 'Tome of Town Portal', + nameZh: '回城之书 (Tome of Town Portal)', + baseNameZh: '法术书 (数量: 20)', + quality: 'normal', + invWidth: 1, + invHeight: 2, + allowedSlots: [], + stats: [{ text: '右键施放城镇传送门', color: 'white' }], + }, + }, + { + col: 2, + row: 2, + item: { + id: `bag-ibk-${classCode}`, + code: 'ibk', + invFile: 'invrbk', + name: 'Tome of Identify', + nameZh: '鉴定之书 (Tome of Identify)', + baseNameZh: '法术书 (数量: 20)', + quality: 'normal', + invWidth: 1, + invHeight: 2, + allowedSlots: [], + stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }], + }, + }, + { + col: 3, + row: 0, + item: { + id: `bag-torch-${classCode}`, + code: 'cm2', + invFile: 'invtrch', + name: 'Hellfire Torch', + nameZh: '地狱火炬 (Hellfire Torch)', + baseNameZh: '大型护身符 (Large Charm)', + quality: 'unique', + invWidth: 1, + invHeight: 2, + allowedSlots: [], + reqLevel: 75, + stats: [ + { text: '击中时有 5% 几率施放等级 10 火风暴', color: 'blue' }, + { text: torchSkillText[classCode] ?? '+3 角色技能等级', color: 'blue' }, + { text: '+20 所有属性', color: 'blue' }, + { text: '所有抗性 +20', color: 'blue' }, + { text: '+8 照亮范围', color: 'blue' }, + ], + }, + }, + { + col: 3, + row: 2, + item: { + id: `bag-anni-${classCode}`, + code: 'cm1', + invFile: 'invmss', + name: 'Annihilus', + nameZh: '毁灭 (Annihilus)', + baseNameZh: '小型护身符 (Small Charm)', + quality: 'unique', + invWidth: 1, + invHeight: 1, + allowedSlots: [], + reqLevel: 70, + stats: [ + { text: '+1 所有技能 (+1 To All Skills)', color: 'blue' }, + { text: '+20 所有属性', color: 'blue' }, + { text: '所有抗性 +20', color: 'blue' }, + { text: '+10% 获得经验值', color: 'blue' }, + ], + }, + }, + { + col: 4, + row: 0, + item: { + id: `bag-gheeds-${classCode}`, + code: 'cm3', + invFile: 'invch3', + name: "Gheed's Fortune", + nameZh: "基德的运气 (Gheed's Fortune)", + baseNameZh: '超大型护身符 (Grand Charm)', + quality: 'unique', + invWidth: 1, + invHeight: 3, + allowedSlots: [], + reqLevel: 62, + stats: [ + { text: '160% 额外金币取得', color: 'blue' }, + { text: '40% 更佳机会取得魔法装备 (MF)', color: 'blue' }, + { text: '降低所有商人价格 15%', color: 'blue' }, + ], + }, + }, + { + col: 5, + row: 0, + item: { + id: `bag-skiller-${classCode}`, + code: 'cm3', + invFile: 'invch3', + name: 'Grand Charm of Vita', + nameZh: `${skillerNameZh[classCode]} (Skiller)`, + baseNameZh: '超大型护身符 (Grand Charm)', + quality: 'magic', + invWidth: 1, + invHeight: 3, + allowedSlots: [], + reqLevel: 42, + stats: [ + { text: `${torchSkillText[classCode]?.replace('+3', '+1')}`, color: 'blue' }, + { text: '+45 最大生命', color: 'blue' }, + ], + }, + }, + ] +} + +// ------------------------------------------------------------------------------------------------- +// 4. CHARACTER SHEET ATTRIBUTES PER CLASS +// ------------------------------------------------------------------------------------------------- + +export const CLASS_STARTER_ATTRS: Record = { + ama: { + name: 'Cassia', + classNameZh: '亚马逊', + classNameEn: 'Amazon', + level: 85, + xp: 1_068_078_030, + nextLevelXp: 1_163_635_493, + str: 135, + dex: 210, + vit: 250, + ene: 50, + statPoints: 15, + baseMaxHp: 1250, + baseMaxMana: 450, + baseMaxStamina: 480, + defense: 1680, + attackRating: 2450, + fireRes: 75, + coldRes: 75, + lightRes: 75, + poisRes: 75, + }, + sor: { + name: 'Isendra', + classNameZh: '女法师', + classNameEn: 'Sorceress', + level: 85, + xp: 1_068_078_030, + nextLevelXp: 1_163_635_493, + str: 156, + dex: 75, + vit: 315, + ene: 95, + statPoints: 15, + baseMaxHp: 1180, + baseMaxMana: 940, + baseMaxStamina: 465, + defense: 1845, + attackRating: 1240, + fireRes: 75, + coldRes: 75, + lightRes: 75, + poisRes: 75, + }, + nec: { + name: 'Xul', + classNameZh: '死灵法师', + classNameEn: 'Necromancer', + level: 85, + xp: 1_068_078_030, + nextLevelXp: 1_163_635_493, + str: 120, + dex: 80, + vit: 290, + ene: 120, + statPoints: 15, + baseMaxHp: 1080, + baseMaxMana: 820, + baseMaxStamina: 450, + defense: 1580, + attackRating: 1120, + fireRes: 75, + coldRes: 75, + lightRes: 75, + poisRes: 75, + }, + pal: { + name: 'Cartographus', + classNameZh: '圣骑士', + classNameEn: 'Paladin', + level: 85, + xp: 1_068_078_030, + nextLevelXp: 1_163_635_493, + str: 175, + dex: 120, + vit: 340, + ene: 45, + statPoints: 15, + baseMaxHp: 1520, + baseMaxMana: 520, + baseMaxStamina: 520, + defense: 2680, + attackRating: 2150, + fireRes: 75, + coldRes: 75, + lightRes: 75, + poisRes: 75, + }, + bar: { + name: 'Sonya', + classNameZh: '野蛮人', + classNameEn: 'Barbarian', + level: 85, + xp: 1_068_078_030, + nextLevelXp: 1_163_635_493, + str: 220, + dex: 110, + vit: 380, + ene: 30, + statPoints: 15, + baseMaxHp: 1950, + baseMaxMana: 350, + baseMaxStamina: 550, + defense: 2450, + attackRating: 2850, + fireRes: 75, + coldRes: 75, + lightRes: 75, + poisRes: 75, + }, + dru: { + name: 'Loreov', + classNameZh: '德鲁伊', + classNameEn: 'Druid', + level: 85, + xp: 1_068_078_030, + nextLevelXp: 1_163_635_493, + str: 165, + dex: 90, + vit: 330, + ene: 70, + statPoints: 15, + baseMaxHp: 1420, + baseMaxMana: 580, + baseMaxStamina: 490, + defense: 1720, + attackRating: 1840, + fireRes: 75, + coldRes: 75, + lightRes: 75, + poisRes: 75, + }, + ass: { + name: 'Natalya', + classNameZh: '刺客', + classNameEn: 'Assassin', + level: 85, + xp: 1_068_078_030, + nextLevelXp: 1_163_635_493, + str: 150, + dex: 180, + vit: 280, + ene: 60, + statPoints: 15, + baseMaxHp: 1320, + baseMaxMana: 490, + baseMaxStamina: 510, + defense: 1920, + attackRating: 2320, + fireRes: 75, + coldRes: 75, + lightRes: 75, + poisRes: 75, + }, +} + +// ------------------------------------------------------------------------------------------------- +// 5. STARTER HARD POINTS ALLOCATIONS PER CLASS (Level 85) +// ------------------------------------------------------------------------------------------------- + +export const CLASS_STARTER_HARD_POINTS: Record>> = { + ama: { + 6: 1, 7: 1, 11: 1, 12: 10, 22: 20, 26: 10, 31: 10, // Bow + 8: 1, 9: 10, 13: 5, 17: 1, 18: 5, 23: 10, 29: 5, 32: 10, 33: 5, // Passive + 10: 1, 14: 1, 20: 1, 24: 5, 35: 10, // Javelin + }, + sor: { + 39: 1, 40: 1, 44: 1, 45: 5, 55: 10, 59: 20, 64: 20, 65: 10, // Cold + 38: 1, 42: 1, 43: 1, 49: 5, 54: 1, // Lightning + 36: 1, 37: 1, 47: 15, 56: 10, 61: 5, // Fire + }, + nec: { + 70: 20, 69: 20, 75: 1, 79: 1, 80: 5, 89: 5, 95: 5, // Summoning + 67: 1, 68: 1, 74: 10, 78: 1, 84: 15, 92: 5, // Poison & Bone + 66: 1, 71: 1, 72: 1, 76: 1, 77: 1, 82: 1, 87: 1, // Curses + }, + pal: { + 96: 1, 97: 1, 101: 1, 106: 5, 107: 1, 112: 20, 117: 15, // Combat + 98: 1, 108: 20, 113: 20, 122: 10, // Offensive + 99: 1, 100: 1, 104: 1, 105: 1, 109: 1, 110: 1, 115: 15, 120: 1, 124: 1, // Defensive + }, + bar: { + 126: 1, 132: 1, 133: 5, 140: 1, 143: 1, 144: 5, 147: 10, 151: 20, 152: 1, // Combat + 129: 20, 145: 10, 148: 10, 153: 5, // Masteries + 130: 1, 137: 1, 138: 15, 149: 20, 154: 1, 155: 1, // Warcries + }, + dru: { + 221: 1, 225: 1, 227: 15, 229: 1, 235: 15, 240: 20, 245: 20, // Elemental + 223: 1, 224: 5, // Shape Shifting + 226: 15, 231: 1, 237: 5, 247: 5, // Summoning + }, + ass: { + 251: 15, 256: 15, 261: 15, 271: 20, 276: 20, // Traps + 252: 1, 253: 1, 258: 5, 263: 5, 264: 1, 267: 5, 268: 1, 273: 1, 279: 5, // Shadow + 254: 1, 255: 5, 260: 1, 270: 1, // Martial Arts + }, +} + +// ------------------------------------------------------------------------------------------------- +// 6. DEFAULT SKILLS & HOTKEYS PER CLASS +// ------------------------------------------------------------------------------------------------- + +export const CLASS_DEFAULT_SKILLS: Record = { + ama: [ + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 6, name: 'Magic Arrow', nameZh: '魔法箭', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 7, name: 'Fire Arrow', nameZh: '火箭', level: 1, manaCost: 3, leftUsable: true, rightUsable: true }, + { skillId: 11, name: 'Cold Arrow', nameZh: '冰箭', level: 1, manaCost: 3.5, leftUsable: true, rightUsable: true }, + { skillId: 12, name: 'Multiple Shot', nameZh: '多重箭', level: 10, manaCost: 13, leftUsable: true, rightUsable: true }, + { skillId: 22, name: 'Guided Arrow', nameZh: '导引箭', level: 20, manaCost: 8, leftUsable: true, rightUsable: true }, + { skillId: 26, name: 'Strafe', nameZh: '炮轰', level: 10, manaCost: 11, leftUsable: true, rightUsable: true }, + { skillId: 31, name: 'Freezing Arrow', nameZh: '冻结箭', level: 10, manaCost: 17, leftUsable: true, rightUsable: true }, + { skillId: 10, name: 'Jab', nameZh: '戳刺', level: 1, manaCost: 2, leftUsable: true, rightUsable: true }, + { skillId: 24, name: 'Charged Strike', nameZh: '充能一击', level: 5, manaCost: 5, leftUsable: true, rightUsable: true }, + { skillId: 35, name: 'Lightning Fury', nameZh: '闪电之怒', level: 10, manaCost: 14, leftUsable: true, rightUsable: true }, + { skillId: 8, name: 'Inner Sight', nameZh: '内视', level: 1, manaCost: 5, leftUsable: false, rightUsable: true }, + { skillId: 17, name: 'Slow Missiles', nameZh: '减速箭', level: 1, manaCost: 5, leftUsable: false, rightUsable: true }, + { skillId: 28, name: 'Decoy', nameZh: '诱饵', level: 1, manaCost: 19, leftUsable: false, rightUsable: true }, + { skillId: 32, name: 'Valkyrie', nameZh: '女武神', level: 10, manaCost: 34, leftUsable: false, rightUsable: true }, + ], + sor: [ + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true }, + { skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true }, + { skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, rightUsable: true }, + { skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true }, + { skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true }, + { skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true }, + { skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true }, + { skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: true, rightUsable: true }, + { skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true }, + { skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true }, + { skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true }, + ], + nec: [ + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 66, name: 'Amplify Damage', nameZh: '伤害加深', level: 1, manaCost: 4, leftUsable: false, rightUsable: true }, + { skillId: 67, name: 'Teeth', nameZh: '牙', level: 5, manaCost: 5, leftUsable: true, rightUsable: true }, + { skillId: 68, name: 'Bone Armor', nameZh: '白骨装甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true }, + { skillId: 70, name: 'Raise Skeleton', nameZh: '复生骷髅', level: 20, manaCost: 15, leftUsable: false, rightUsable: true }, + { skillId: 74, name: 'Corpse Explosion', nameZh: '尸体爆炸', level: 10, manaCost: 15, leftUsable: true, rightUsable: true }, + { skillId: 75, name: 'Clay Golem', nameZh: '粘土石魔', level: 1, manaCost: 15, leftUsable: false, rightUsable: true }, + { skillId: 80, name: 'Raise Skeletal Mage', nameZh: '复生骷髅法师', level: 5, manaCost: 12, leftUsable: false, rightUsable: true }, + { skillId: 84, name: 'Bone Spear', nameZh: '骨矛', level: 15, manaCost: 12, leftUsable: true, rightUsable: true }, + { skillId: 87, name: 'Decrepify', nameZh: '衰老', level: 1, manaCost: 11, leftUsable: false, rightUsable: true }, + { skillId: 92, name: 'Poison Nova', nameZh: '剧毒新星', level: 5, manaCost: 20, leftUsable: false, rightUsable: true }, + { skillId: 95, name: 'Revive', nameZh: '重生', level: 5, manaCost: 45, leftUsable: false, rightUsable: true }, + ], + pal: [ + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 96, name: 'Sacrifice', nameZh: '牺牲', level: 5, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 97, name: 'Smite', nameZh: '重击', level: 5, manaCost: 2, leftUsable: true, rightUsable: true }, + { skillId: 101, name: 'Holy Bolt', nameZh: '圣光弹', level: 5, manaCost: 6, leftUsable: true, rightUsable: true }, + { skillId: 106, name: 'Zeal', nameZh: '热忱', level: 5, manaCost: 2, leftUsable: true, rightUsable: true }, + { skillId: 107, name: 'Charge', nameZh: '突击', level: 1, manaCost: 9, leftUsable: true, rightUsable: true }, + { skillId: 112, name: 'Blessed Hammer', nameZh: '祝福之槌', level: 20, manaCost: 10, leftUsable: true, rightUsable: true }, + { skillId: 117, name: 'Holy Shield', nameZh: '圣盾', level: 15, manaCost: 35, leftUsable: false, rightUsable: true }, + { skillId: 98, name: 'Might', nameZh: '力量灵气', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 113, name: 'Concentration', nameZh: '专注灵气', level: 20, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 115, name: 'Vigor', nameZh: '活力灵气', level: 15, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 122, name: 'Fanaticism', nameZh: '狂热灵气', level: 10, manaCost: 0, leftUsable: false, rightUsable: true }, + ], + bar: [ + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 126, name: 'Bash', nameZh: '重击', level: 5, manaCost: 2, leftUsable: true, rightUsable: true }, + { skillId: 130, name: 'Howl', nameZh: '狂嚎', level: 1, manaCost: 4, leftUsable: false, rightUsable: true }, + { skillId: 132, name: 'Leap', nameZh: '跳跃', level: 1, manaCost: 2, leftUsable: false, rightUsable: true }, + { skillId: 133, name: 'Double Swing', nameZh: '双手挥击', level: 5, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 138, name: 'Shout', nameZh: '大叫', level: 15, manaCost: 6, leftUsable: false, rightUsable: true }, + { skillId: 143, name: 'Leap Attack', nameZh: '跳跃攻击', level: 1, manaCost: 9, leftUsable: true, rightUsable: true }, + { skillId: 144, name: 'Concentrate', nameZh: '专心', level: 5, manaCost: 2, leftUsable: true, rightUsable: true }, + { skillId: 147, name: 'Frenzy', nameZh: '狂乱', level: 10, manaCost: 3, leftUsable: true, rightUsable: true }, + { skillId: 149, name: 'Battle Orders', nameZh: '战斗体制', level: 20, manaCost: 7, leftUsable: false, rightUsable: true }, + { skillId: 151, name: 'Whirlwind', nameZh: '旋风', level: 20, manaCost: 25, leftUsable: true, rightUsable: true }, + { skillId: 152, name: 'Berserk', nameZh: '狂战士', level: 1, manaCost: 4, leftUsable: true, rightUsable: true }, + { skillId: 155, name: 'Battle Command', nameZh: '战斗指挥', level: 1, manaCost: 11, leftUsable: false, rightUsable: true }, + ], + dru: [ + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 221, name: 'Arctic Blast', nameZh: '极地风暴', level: 1, manaCost: 4, leftUsable: true, rightUsable: true }, + { skillId: 223, name: 'Werewolf', nameZh: '狼人变化', level: 5, manaCost: 15, leftUsable: false, rightUsable: true }, + { skillId: 225, name: 'Firestorm', nameZh: '火风暴', level: 5, manaCost: 4, leftUsable: true, rightUsable: true }, + { skillId: 226, name: 'Oak Sage', nameZh: '橡木智者', level: 15, manaCost: 20, leftUsable: false, rightUsable: true }, + { skillId: 227, name: 'Cyclone Armor', nameZh: '飓风装甲', level: 15, manaCost: 15, leftUsable: false, rightUsable: true }, + { skillId: 229, name: 'Molten Boulder', nameZh: '熔火巨石', level: 5, manaCost: 10, leftUsable: true, rightUsable: true }, + { skillId: 235, name: 'Twister', nameZh: '龙卷风', level: 15, manaCost: 7, leftUsable: true, rightUsable: true }, + { skillId: 237, name: 'Summon Dire Wolf', nameZh: '召唤狂狼', level: 5, manaCost: 20, leftUsable: false, rightUsable: true }, + { skillId: 240, name: 'Tornado', nameZh: '暴风', level: 20, manaCost: 10, leftUsable: true, rightUsable: true }, + { skillId: 245, name: 'Hurricane', nameZh: '毁灭风暴', level: 20, manaCost: 30, leftUsable: false, rightUsable: true }, + { skillId: 247, name: 'Summon Grizzly', nameZh: '召唤灰熊', level: 5, manaCost: 40, leftUsable: false, rightUsable: true }, + ], + ass: [ + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, + { skillId: 251, name: 'Fire Blast', nameZh: '火焰爆震', level: 15, manaCost: 3, leftUsable: true, rightUsable: true }, + { skillId: 254, name: 'Tiger Strike', nameZh: '虎击', level: 5, manaCost: 1, leftUsable: true, rightUsable: true }, + { skillId: 255, name: 'Dragon Talon', nameZh: '龙爪', level: 5, manaCost: 6, leftUsable: true, rightUsable: true }, + { skillId: 256, name: 'Shock Web', nameZh: '电能网', level: 15, manaCost: 6, leftUsable: true, rightUsable: true }, + { skillId: 258, name: 'Burst of Speed', nameZh: '速度爆发', level: 5, manaCost: 10, leftUsable: false, rightUsable: true }, + { skillId: 260, name: 'Dragon Claw', nameZh: '双龙爪', level: 5, manaCost: 2, leftUsable: true, rightUsable: true }, + { skillId: 261, name: 'Charged Bolt Sentry', nameZh: '电能守卫', level: 15, manaCost: 13, leftUsable: false, rightUsable: true }, + { skillId: 264, name: 'Cloak of Shadows', nameZh: '魔影斗篷', level: 1, manaCost: 13, leftUsable: false, rightUsable: true }, + { skillId: 267, name: 'Fade', nameZh: '能量消解', level: 5, manaCost: 10, leftUsable: false, rightUsable: true }, + { skillId: 271, name: 'Lightning Sentry', nameZh: '雷电守卫', level: 20, manaCost: 20, leftUsable: false, rightUsable: true }, + { skillId: 276, name: 'Death Sentry', nameZh: '亡者守卫', level: 20, manaCost: 20, leftUsable: false, rightUsable: true }, + { skillId: 279, name: 'Shadow Master', nameZh: '暗影大师', level: 5, manaCost: 35, leftUsable: false, rightUsable: true }, + ], +} + +// ------------------------------------------------------------------------------------------------- +// 7. STARTER PROFILE FACTORY +// ------------------------------------------------------------------------------------------------- + +export function createStarterProfileForClass(classCode: CharacterClassCode): ClassProfileState { + const equipped = { ...(CLASS_EXCLUSIVE_SETS[classCode] ?? CLASS_EXCLUSIVE_SETS.sor) } + const bagItems = createStarterBagForClass(classCode) + const stashItems = STARTER_STASH_ITEMS.map((gp) => ({ ...gp, item: { ...gp.item } })) + const attrs = { ...(CLASS_STARTER_ATTRS[classCode] ?? CLASS_STARTER_ATTRS.sor) } + + const hardPointsEntries = Object.entries(CLASS_STARTER_HARD_POINTS[classCode] ?? CLASS_STARTER_HARD_POINTS.sor) + const hardPoints = new Map(hardPointsEntries.map(([id, pts]) => [Number(id), pts])) + + const defaultSkills: Record = { + ama: { left: 6, right: 7, tab: 0 }, + sor: { left: 47, right: 64, tab: 2 }, + nec: { left: 67, right: 70, tab: 2 }, + pal: { left: 96, right: 112, tab: 0 }, + bar: { left: 126, right: 133, tab: 0 }, + dru: { left: 225, right: 229, tab: 2 }, + ass: { left: 254, right: 251, tab: 0 }, + } + const defaults = defaultSkills[classCode] ?? defaultSkills.sor + + return { + classCode, + equipped, + bagItems, + stashItems, + stashGold: 2_500_000, + attrs, + hardPoints, + unspentSkillPoints: 12, + activeSkillTab: defaults.tab, + defaultLeftSkill: defaults.left, + defaultRightSkill: defaults.right, + availableSkills: CLASS_DEFAULT_SKILLS[classCode] ?? CLASS_DEFAULT_SKILLS.sor, + hp: attrs.baseMaxHp, + mana: attrs.baseMaxMana, + } +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 8baab26..c541cd8 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -21,6 +21,7 @@ import { ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed, } from '../game/d2map.ts' +import type { CharacterClassCode } from '../game/classes.ts' import type { CollisionGrid, IsoDraw } from '../game/d2map.ts' import { updateAnimatableTiles, @@ -2981,24 +2982,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st state.character = true state.characterMembers = nextChar.members if (hudManager !== null) { - if (hudManager.charSheet) { - hudManager.charSheet.attrs.name = nextInfo.charIdentity.name - hudManager.charSheet.attrs.classNameZh = nextInfo.charIdentity.classNameZh - hudManager.charSheet.attrs.classNameEn = nextInfo.charIdentity.classNameEn - hudManager.charSheet.attrs.str = nextInfo.charIdentity.str - hudManager.charSheet.attrs.dex = nextInfo.charIdentity.dex - hudManager.charSheet.attrs.vit = nextInfo.charIdentity.vit - hudManager.charSheet.attrs.ene = nextInfo.charIdentity.ene - hudManager.charSheet.attrs.baseMaxHp = nextInfo.charIdentity.baseMaxHp - hudManager.charSheet.attrs.baseMaxMana = nextInfo.charIdentity.baseMaxMana - } - hudManager.maxHp = nextInfo.charIdentity.baseMaxHp - hudManager.hp = nextInfo.charIdentity.baseMaxHp - hudManager.maxMana = nextInfo.charIdentity.baseMaxMana - hudManager.mana = nextInfo.charIdentity.baseMaxMana - hudManager.hotkeys.leftSkillId = nextInfo.defaultLeftSkill - hudManager.hotkeys.rightSkillId = nextInfo.defaultRightSkill - hudManager.syncPublishedState() + hudManager.switchClass(nextInfo.code as CharacterClassCode) } if (heroSelect !== null && heroSelect.value !== nextToken) { heroSelect.value = nextToken @@ -3199,6 +3183,8 @@ if (typeof window !== 'undefined') { if (hudManager !== null) { try { await hudManager.init('/ui') + const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so + hudManager.switchClass(initialHeroInfo.code as CharacterClassCode) } catch (err) { console.error('Failed to initialize HudManager:', err) } diff --git a/src/ui/hotkeys.ts b/src/ui/hotkeys.ts index 01edaab..295a91c 100644 --- a/src/ui/hotkeys.ts +++ b/src/ui/hotkeys.ts @@ -169,6 +169,17 @@ export class SkillHotkeysHud { } } + setAvailableSkills( + skills: readonly HotkeySkillEntry[], + defaultLeft?: number, + defaultRight?: number, + ): void { + this.availableSkills = [...skills] + if (defaultLeft !== undefined) this.leftSkillId = defaultLeft + if (defaultRight !== undefined) this.rightSkillId = defaultRight + this.preloadSkillIcons() + } + getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null { for (const [key, binding] of this.bindings.entries()) { if (binding.side === side && binding.skillId === skillId) return key diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 8c9637f..8c83a21 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -24,6 +24,11 @@ import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts' import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts' import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts' import { CursorController, type CursorState } from './cursor.ts' +import type { CharacterClassCode } from '../game/classes.ts' +import { + createStarterProfileForClass, + type ClassProfileState, +} from '../game/class-starter-profiles.ts' export interface PublishedHudState { ready: boolean @@ -274,6 +279,9 @@ export class HudManager { docking: computeDockingLayout(800, 600, 1), } + currentClass: CharacterClassCode | null = null + private classProfiles = new Map() + constructor( private readonly hudCanvas: HTMLCanvasElement, private readonly callbacks: { @@ -292,6 +300,84 @@ export class HudManager { } } + /** + * Switches active character class with complete isolation and persistence across + * inventory, exclusive signature sets on body ONLY, stash treasures (runes/gems/jewels/gold), + * attributes, 30-skill trees, and hotkeys (Parity with Issue #379). + */ + switchClass(classCode: CharacterClassCode): void { + // 1. Snapshot current class state if currentClass is already initialized + if (this.currentClass && this.classProfiles.has(this.currentClass)) { + const currentProfile: ClassProfileState = { + classCode: this.currentClass, + equipped: { ...this.inventory.equipped }, + bagItems: this.inventory.gridItems.map(gp => ({ ...gp, item: { ...gp.item } })), + stashItems: this.worldPanels.stashItems.map(gp => ({ ...gp, item: { ...gp.item } })), + stashGold: this.worldPanels.stashGold, + attrs: { ...this.charSheet.attrs }, + hardPoints: new Map(this.skillTree.hardPoints), + unspentSkillPoints: this.skillTree.unspentSkillPoints, + activeSkillTab: this.skillTree.activeTab, + defaultLeftSkill: this.hotkeys.leftSkillId, + defaultRightSkill: this.hotkeys.rightSkillId, + availableSkills: [...this.hotkeys.availableSkills], + hp: this.hp, + mana: this.mana, + } + this.classProfiles.set(this.currentClass, currentProfile) + } + + // 2. Fetch target profile or create fresh starter profile + let targetProfile = this.classProfiles.get(classCode) + if (!targetProfile) { + targetProfile = createStarterProfileForClass(classCode) + this.classProfiles.set(classCode, targetProfile) + } + + // 3. Apply profile to current systems + this.currentClass = classCode + + // 3.1 Inventory & Equipment: Body has ONLY exclusive set items + for (const key of Object.keys(this.inventory.equipped) as EquipSlotId[]) { + delete this.inventory.equipped[key] + } + Object.assign(this.inventory.equipped, targetProfile.equipped) + this.inventory.gridItems = targetProfile.bagItems.map(gp => ({ ...gp, item: { ...gp.item } })) + + // 3.2 Stash: gems, runes, jewels, and gold + this.worldPanels.stashItems = targetProfile.stashItems.map(gp => ({ ...gp, item: { ...gp.item } })) + this.worldPanels.stashGold = targetProfile.stashGold + + // 3.3 Character Sheet + this.charSheet.attrs = { ...targetProfile.attrs } + + // 3.4 Skill Tree + this.skillTree.loadProfile( + targetProfile.classCode, + targetProfile.hardPoints, + targetProfile.unspentSkillPoints, + targetProfile.activeSkillTab, + ) + + // 3.5 Hotkeys + this.hotkeys.setAvailableSkills( + targetProfile.availableSkills, + targetProfile.defaultLeftSkill, + targetProfile.defaultRightSkill, + ) + this.hotkeys.hasEquippedWeapon = Boolean(this.inventory.equipped.weapon1 || this.inventory.equipped.weapon2) + + // 3.6 Published vitals + this.maxHp = targetProfile.attrs.baseMaxHp + this.hp = Math.min(targetProfile.hp, this.maxHp) + this.maxMana = targetProfile.attrs.baseMaxMana + this.mana = Math.min(targetProfile.mana, this.maxMana) + this.stamina = targetProfile.attrs.baseMaxStamina + this.maxStamina = targetProfile.attrs.baseMaxStamina + + this.syncPublishedState() + } + async init(baseUrl = '/ui'): Promise { this.hotkeys.preloadSkillIcons() this.skillTree.preloadAssets() diff --git a/src/ui/skill-tree-panel.ts b/src/ui/skill-tree-panel.ts index 9666cc4..1b52be6 100644 --- a/src/ui/skill-tree-panel.ts +++ b/src/ui/skill-tree-panel.ts @@ -10,6 +10,7 @@ import type { D2FontRenderer, D2ColorCode } from './font.ts' import type { CharacterClassCode } from '../game/classes.ts' +import { SKILLS_CATALOG } from '../data/skills-catalog.ts' import { buildTooltipViewModel, type TooltipViewModel, @@ -81,9 +82,88 @@ export const SORCERESS_SKILL_TREE: readonly SkillNodeDef[] = [ { skillId: 62, tab: 0, row: 6, col: 3, reqLevel: 30, prereqs: [52], nameEn: 'Hydra', nameZh: '九头海蛇', descZh: '召唤多头喷火巨蛇攻击周围敌人', baseMana: 20, baseDmgMin: 24, baseDmgMax: 32, dmgPerLevel: 18 }, ] +export interface SkillTreeTabInfo { + readonly tab: 0 | 1 | 2 + readonly titleZh: string + readonly subZh: string + readonly y: number +} + +export const CLASS_VERTICAL_TABS: Record = { + ama: [ + { tab: 0, titleZh: '弓和', subZh: '十字弓', y: 95 }, + { tab: 1, titleZh: '被动和', subZh: '魔法', y: 195 }, + { tab: 2, titleZh: '标枪和', subZh: '长矛', y: 295 }, + ], + sor: [ + { tab: 2, titleZh: '冰冷', subZh: '法术', y: 95 }, + { tab: 1, titleZh: '闪电', subZh: '法术', y: 195 }, + { tab: 0, titleZh: '火焰', subZh: '法术', y: 295 }, + ], + nec: [ + { tab: 2, titleZh: '召唤', subZh: '法术', y: 95 }, + { tab: 1, titleZh: '毒素和', subZh: '白骨', y: 195 }, + { tab: 0, titleZh: '诅咒', subZh: '法术', y: 295 }, + ], + pal: [ + { tab: 0, titleZh: '作战', subZh: '技能', y: 95 }, + { tab: 1, titleZh: '攻击', subZh: '灵气', y: 195 }, + { tab: 2, titleZh: '防御', subZh: '灵气', y: 295 }, + ], + bar: [ + { tab: 0, titleZh: '战斗', subZh: '技能', y: 95 }, + { tab: 1, titleZh: '战斗', subZh: '专家', y: 195 }, + { tab: 2, titleZh: '呐喊', subZh: '技能', y: 295 }, + ], + dru: [ + { tab: 2, titleZh: '元素', subZh: '技能', y: 95 }, + { tab: 1, titleZh: '变形', subZh: '技能', y: 195 }, + { tab: 0, titleZh: '召唤', subZh: '技能', y: 295 }, + ], + ass: [ + { tab: 2, titleZh: '武学', subZh: '艺术', y: 95 }, + { tab: 1, titleZh: '暗影', subZh: '武艺', y: 195 }, + { tab: 0, titleZh: '陷阱', subZh: '技能', y: 295 }, + ], +} + +function buildClassSkillTrees(): Record { + const result: Record = { + ama: [], + sor: [], + nec: [], + pal: [], + bar: [], + dru: [], + ass: [], + } + for (const s of SKILLS_CATALOG) { + const list = result[s.classCode] + if (!list) continue + list.push({ + skillId: s.id, + tab: s.tabIndex as 0 | 1 | 2, + row: s.row as 1 | 2 | 3 | 4 | 5 | 6, + col: s.col as 1 | 2 | 3, + reqLevel: s.reqlevel, + prereqs: s.reqskills, + nameEn: s.name, + nameZh: s.nameZh, + descZh: s.shortDescZh || s.longDescZh || '', + baseMana: s.mana, + baseDmgMin: s.minDam, + baseDmgMax: s.maxDam, + dmgPerLevel: 5, + }) + } + return result +} + +export const CLASS_SKILL_TREES: Record = buildClassSkillTrees() + export class SkillTreePanel { visible = false - /** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom). */ + /** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */ activeTab: 0 | 1 | 2 = 2 unspentSkillPoints = 12 bonusAllSkills = 8 @@ -98,21 +178,59 @@ export class SkillTreePanel { private tabBgs = new Map() private skillIcons = new Map() + private tabBgClass: CharacterClassCode = 'sor' + + getSkillTree(): readonly SkillNodeDef[] { + return CLASS_SKILL_TREES[this.characterClass] ?? SORCERESS_SKILL_TREE + } + + getVerticalTabs(): readonly SkillTreeTabInfo[] { + return CLASS_VERTICAL_TABS[this.characterClass] ?? CLASS_VERTICAL_TABS.sor + } preloadAssets(): void { if (typeof Image === 'undefined') return for (const tab of [0, 1, 2]) { - const bg = new Image() - bg.src = `/skills/bg_sor_${tab}.png` - this.tabBgs.set(tab, bg) + if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) { + const bg = new Image() + bg.src = `/skills/bg_${this.characterClass}_${tab}.png` + this.tabBgs.set(tab, bg) + } } - for (const node of SORCERESS_SKILL_TREE) { - const icon = new Image() - icon.src = `/skills/icon_${node.skillId}.png` - this.skillIcons.set(node.skillId, icon) + this.tabBgClass = this.characterClass + for (const node of this.getSkillTree()) { + if (!this.skillIcons.has(node.skillId)) { + const icon = new Image() + icon.src = `/skills/icon_${node.skillId}.png` + this.skillIcons.set(node.skillId, icon) + } } } + setClass(classCode: CharacterClassCode): void { + this.characterClass = classCode + const vTabs = this.getVerticalTabs() + this.activeTab = vTabs[0]?.tab ?? 0 + this.preloadAssets() + } + + loadProfile( + classCode: CharacterClassCode, + hardPoints: Map, + unspent: number, + activeTab?: 0 | 1 | 2, + ): void { + this.characterClass = classCode + this.hardPoints.clear() + for (const [id, pts] of hardPoints.entries()) { + this.hardPoints.set(id, pts) + } + this.unspentSkillPoints = unspent + const vTabs = this.getVerticalTabs() + this.activeTab = activeTab !== undefined ? activeTab : (vTabs[0]?.tab ?? 0) + this.preloadAssets() + } + getHardPoints(skillId: number): number { return this.hardPoints.get(skillId) ?? 0 } @@ -142,7 +260,7 @@ export class SkillTreePanel { canAllocate(skillId: number, charLevel = 85): boolean { if (this.unspentSkillPoints <= 0) return false - const node = SORCERESS_SKILL_TREE.find(n => n.skillId === skillId) + const node = this.getSkillTree().find(n => n.skillId === skillId) if (!node) return false const cur = this.getHardPoints(skillId) if (cur >= 20) return false @@ -166,7 +284,7 @@ export class SkillTreePanel { handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredSkill = null if (!this.visible) return - const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab) + const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab) for (const node of tabNodes) { const r = SkillTreePanel.nodeScreenPos(node) if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) { @@ -193,22 +311,23 @@ export class SkillTreePanel { // Right vertical tabs (`x = 656..720`) if (logicalX >= 656 && logicalX <= 720) { - if (logicalY >= 60 + 95 && logicalY < 60 + 195) { - this.activeTab = 2 // Cold + const vTabs = this.getVerticalTabs() + if (logicalY >= 60 + 95 && logicalY < 60 + 195 && vTabs[0]) { + this.activeTab = vTabs[0].tab return true } - if (logicalY >= 60 + 195 && logicalY < 60 + 295) { - this.activeTab = 1 // Lightning + if (logicalY >= 60 + 195 && logicalY < 60 + 295 && vTabs[1]) { + this.activeTab = vTabs[1].tab return true } - if (logicalY >= 60 + 295 && logicalY < 60 + 385) { - this.activeTab = 0 // Fire + if (logicalY >= 60 + 295 && logicalY < 60 + 385 && vTabs[2]) { + this.activeTab = vTabs[2].tab return true } } // Skill nodes - const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab) + const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab) for (const node of tabNodes) { const r = SkillTreePanel.nodeScreenPos(node) if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) { @@ -252,10 +371,12 @@ export class SkillTreePanel { } // 3. Draw Right 64x432 Vertical Tab Column (`x = 656..720`) + const verticalTabs = this.getVerticalTabs() + const tabColIdx = verticalTabs.findIndex(vt => vt.tab === this.activeTab) + const safeColIdx = tabColIdx >= 0 ? tabColIdx : 0 + if (assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0) { - // 0 = Cold (Tab 2), 1 = Lightning (Tab 1), 2 = Fire (Tab 0) - const tabColIdx = this.activeTab === 2 ? 0 : this.activeTab === 1 ? 1 : 2 - ctx.drawImage(assets.skillTabsImg, tabColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432) + ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432) } else { ctx.fillStyle = '#14110d' ctx.fillRect(ox + 256, oy, 64, 432) @@ -291,12 +412,7 @@ export class SkillTreePanel { }) } - // 3 Vertical Tabs: Cold (2), Lightning (1), Fire (0) - const verticalTabs: readonly { tab: 0 | 1 | 2; titleZh: string; subZh: string; y: number }[] = [ - { tab: 2, titleZh: '冰冷', subZh: '法术', y: 95 }, - { tab: 1, titleZh: '闪电', subZh: '法术', y: 195 }, - { tab: 0, titleZh: '火焰', subZh: '法术', y: 295 }, - ] + // 3 Vertical Tabs for Active Class for (const vt of verticalTabs) { const active = this.activeTab === vt.tab if (!assets.skillTabsImg || !assets.skillTabsImg.complete || assets.skillTabsImg.naturalWidth === 0) { @@ -320,7 +436,7 @@ export class SkillTreePanel { } // 4. Draw Skill Nodes & Level Sockets on Active Tab - const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab) + const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab) for (const node of tabNodes) { const pos = SkillTreePanel.nodeScreenPos(node) const hard = this.getHardPoints(node.skillId) diff --git a/tests/attack-skill.test.ts b/tests/attack-skill.test.ts index 1c3fd25..edf746d 100644 --- a/tests/attack-skill.test.ts +++ b/tests/attack-skill.test.ts @@ -77,7 +77,7 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => describe('Icon Asset & DC6 Frame 0 Resolution', () => { it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => { - const iconMapping = resolveSkillIcon(0) + const iconMapping = resolveSkillIcon(0, false) expect(iconMapping).toBeDefined() expect(iconMapping.skillId).toBe(0) expect(iconMapping.name).toBe('Attack') diff --git a/tests/class-switching.test.ts b/tests/class-switching.test.ts new file mode 100644 index 0000000..a2442ab --- /dev/null +++ b/tests/class-switching.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect } from 'vitest' +import { + CLASS_EXCLUSIVE_SETS, + STARTER_STASH_ITEMS, + CLASS_STARTER_ATTRS, + CLASS_STARTER_HARD_POINTS, + CLASS_DEFAULT_SKILLS, + createStarterProfileForClass, +} from '../src/game/class-starter-profiles.ts' +import { HudManager } from '../src/ui/hud-manager.ts' +import { SkillTreePanel, CLASS_VERTICAL_TABS, CLASS_SKILL_TREES } from '../src/ui/skill-tree-panel.ts' +import { resolveItemSpriteRect, type EquipSlotId } from '../src/ui/inventory.ts' +import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' +import type { CharacterClassCode } from '../src/game/classes.ts' + +describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures & Class Switching', () => { + const CLASSES: readonly CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] + const itemRects = BAKED_UI_MANIFEST.itemRects + + describe('1. Class Exclusive Signature Sets (Body ONLY)', () => { + it('defines authentic signature sets for all 7 classes with exclusive body slots', () => { + // Amazon: M'avina's Battle Hymn (helm, armor, weapon1, gloves, belt) + const ama = CLASS_EXCLUSIVE_SETS.ama + expect(ama.helm?.name).toContain("M'avina's True Sight") + expect(ama.armor?.name).toContain("M'avina's Embrace") + expect(ama.weapon1?.name).toContain("M'avina's Caster") + expect(ama.gloves?.name).toContain("M'avina's Icy Clutch") + expect(ama.belt?.name).toContain("M'avina's Tenet") + expect(ama.weapon2).toBeUndefined() + expect(ama.boots).toBeUndefined() + expect(ama.amulet).toBeUndefined() + expect(ama.ring1).toBeUndefined() + expect(ama.ring2).toBeUndefined() + + // Sorceress: Tal Rasha's Wrappings (helm, armor, weapon1, belt, amulet) + const sor = CLASS_EXCLUSIVE_SETS.sor + expect(sor.helm?.name).toContain("Tal Rasha's Horadric Crest") + expect(sor.armor?.name).toContain("Tal Rasha's Guardianship") + expect(sor.weapon1?.name).toContain("Tal Rasha's Lidless Eye") + expect(sor.belt?.name).toContain("Tal Rasha's Fine-Spun Cloth") + expect(sor.amulet?.name).toContain("Tal Rasha's Adjudication") + expect(sor.weapon2).toBeUndefined() + expect(sor.gloves).toBeUndefined() + expect(sor.boots).toBeUndefined() + expect(sor.ring1).toBeUndefined() + expect(sor.ring2).toBeUndefined() + + // Necromancer: Trang-Oul's Avatar (helm, armor, weapon2/shield, gloves, belt) + const nec = CLASS_EXCLUSIVE_SETS.nec + expect(nec.helm?.name).toContain("Trang-Oul's Guise") + expect(nec.armor?.name).toContain("Trang-Oul's Scales") + expect(nec.weapon2?.name).toContain("Trang-Oul's Wing") + expect(nec.gloves?.name).toContain("Trang-Oul's Claws") + expect(nec.belt?.name).toContain("Trang-Oul's Girth") + expect(nec.weapon1).toBeUndefined() + expect(nec.boots).toBeUndefined() + expect(nec.amulet).toBeUndefined() + expect(nec.ring1).toBeUndefined() + expect(nec.ring2).toBeUndefined() + + // Paladin: Griswold's Legacy (helm, armor, weapon1, weapon2/shield) + const pal = CLASS_EXCLUSIVE_SETS.pal + expect(pal.helm?.name).toContain("Griswold's Valor") + expect(pal.armor?.name).toContain("Griswold's Heart") + expect(pal.weapon1?.name).toContain("Griswold's Redemption") + expect(pal.weapon2?.name).toContain("Griswold's Honor") + expect(pal.gloves).toBeUndefined() + expect(pal.belt).toBeUndefined() + expect(pal.boots).toBeUndefined() + expect(pal.amulet).toBeUndefined() + expect(pal.ring1).toBeUndefined() + expect(pal.ring2).toBeUndefined() + + // Barbarian: Immortal King (helm, armor, weapon1, gloves, belt, boots) + const bar = CLASS_EXCLUSIVE_SETS.bar + expect(bar.helm?.name).toContain("Immortal King's Will") + expect(bar.armor?.name).toContain("Immortal King's Soul Cage") + expect(bar.weapon1?.name).toContain("Immortal King's Stone Crusher") + expect(bar.gloves?.name).toContain("Immortal King's Forge") + expect(bar.belt?.name).toContain("Immortal King's Detail") + expect(bar.boots?.name).toContain("Immortal King's Pillar") + expect(bar.weapon2).toBeUndefined() + expect(bar.amulet).toBeUndefined() + expect(bar.ring1).toBeUndefined() + expect(bar.ring2).toBeUndefined() + + // Druid: Aldur's Watchtower (helm, armor, weapon1, boots) + const dru = CLASS_EXCLUSIVE_SETS.dru + expect(dru.helm?.name).toContain("Aldur's Stony Gaze") + expect(dru.armor?.name).toContain("Aldur's Deception") + expect(dru.weapon1?.name).toContain("Aldur's Rhythm") + expect(dru.boots?.name).toContain("Aldur's Advance") + expect(dru.weapon2).toBeUndefined() + expect(dru.gloves).toBeUndefined() + expect(dru.belt).toBeUndefined() + expect(dru.amulet).toBeUndefined() + expect(dru.ring1).toBeUndefined() + expect(dru.ring2).toBeUndefined() + + // Assassin: Natalya's Odium (helm, armor, weapon1, boots) + const ass = CLASS_EXCLUSIVE_SETS.ass + expect(ass.helm?.name).toContain("Natalya's Totem") + expect(ass.armor?.name).toContain("Natalya's Shadow") + expect(ass.weapon1?.name).toContain("Natalya's Mark") + expect(ass.boots?.name).toContain("Natalya's Soul") + expect(ass.weapon2).toBeUndefined() + expect(ass.gloves).toBeUndefined() + expect(ass.belt).toBeUndefined() + expect(ass.amulet).toBeUndefined() + expect(ass.ring1).toBeUndefined() + expect(ass.ring2).toBeUndefined() + }) + + it('resolves all 33 set items across 7 classes to authentic DC6 sprite rectangles', () => { + let totalItems = 0 + for (const cls of CLASSES) { + const set = CLASS_EXCLUSIVE_SETS[cls] + for (const [slot, item] of Object.entries(set)) { + if (!item) continue + totalItems++ + const rect = resolveItemSpriteRect(item, itemRects) + expect(rect, `Set item ${cls} ${slot} (${item.name}) must resolve`).not.toBeNull() + expect(rect!.w).toBeGreaterThan(0) + expect(rect!.h).toBeGreaterThan(0) + } + } + expect(totalItems).toBe(33) // 5 + 5 + 5 + 4 + 6 + 4 + 4 = 33 + }) + }) + + describe('2. Stash Treasures: Runes, Gems, and Jewels', () => { + it('contains 30 authentic treasure items (12 Runes, 12 Gems/Skulls, 6 Jewels)', () => { + expect(STARTER_STASH_ITEMS.length).toBe(30) + + const runes = STARTER_STASH_ITEMS.filter((gp) => gp.item.quality === 'rune') + expect(runes.length).toBe(12) // Jah, Ber, Lo, Ohm, Vex, Cham, Zod, Sur, Gul, Ist, Mal, Um + + const gems = STARTER_STASH_ITEMS.filter((gp) => gp.item.quality === 'normal') + expect(gems.length).toBe(12) // 6 Perfect + 6 Flawless + + const jewels = STARTER_STASH_ITEMS.filter((gp) => gp.item.code === 'jew') + expect(jewels.length).toBe(6) // 3 Facets, 2 Magic, 1 Rare + }) + + it('all 30 stash items fit inside 6x8 stash grid without out-of-bounds or overlapping cells', () => { + const occupied = new Set() + for (const gp of STARTER_STASH_ITEMS) { + expect(gp.col).toBeGreaterThanOrEqual(0) + expect(gp.row).toBeGreaterThanOrEqual(0) + expect(gp.col + gp.item.invWidth).toBeLessThanOrEqual(6) + expect(gp.row + gp.item.invHeight).toBeLessThanOrEqual(8) + + for (let c = gp.col; c < gp.col + gp.item.invWidth; c++) { + for (let r = gp.row; r < gp.row + gp.item.invHeight; r++) { + const key = `${c},${r}` + expect(occupied.has(key), `Cell (${key}) should not overlap with item ${gp.item.name}`).toBe(false) + occupied.add(key) + } + } + } + }) + + it('resolves all 30 stash items to authentic DC6 sprite rectangles', () => { + for (const gp of STARTER_STASH_ITEMS) { + const rect = resolveItemSpriteRect(gp.item, itemRects) + expect(rect, `Stash item (${gp.item.name}) must resolve`).not.toBeNull() + expect(rect!.w).toBeGreaterThan(0) + expect(rect!.h).toBeGreaterThan(0) + } + }) + }) + + describe('3. Multi-Class Skill Trees & Tabs', () => { + it('provides exactly 30 authentic skills for each of the 7 classes', () => { + for (const cls of CLASSES) { + const tree = CLASS_SKILL_TREES[cls] + expect(tree, `Skill tree for ${cls} must exist`).toBeDefined() + expect(tree.length, `${cls} must have 30 skills`).toBe(30) + + for (let tab = 0; tab < 3; tab++) { + const tabSkills = tree.filter((s) => s.tab === tab) + expect(tabSkills.length, `${cls} tab ${tab} must have 10 skills`).toBe(10) + } + } + }) + + it('defines authentic 3 vertical tabs with Chinese names for all 7 classes', () => { + for (const cls of CLASSES) { + const tabs = CLASS_VERTICAL_TABS[cls] + expect(tabs.length).toBe(3) + expect(tabs[0].y).toBe(95) + expect(tabs[1].y).toBe(195) + expect(tabs[2].y).toBe(295) + expect(tabs[0].titleZh).toBeTruthy() + expect(tabs[0].subZh).toBeTruthy() + } + }) + + it('SkillTreePanel dynamically switches class, tabs, and allocations', () => { + const panel = new SkillTreePanel() + expect(panel.characterClass).toBe('sor') + + // Switch to Paladin + panel.setClass('pal') + expect(panel.characterClass).toBe('pal') + expect(panel.getSkillTree().length).toBe(30) + expect(panel.getVerticalTabs()[0].titleZh).toBe('作战') + + // Load specific profile state + const profile = createStarterProfileForClass('bar') + panel.loadProfile(profile.classCode, profile.hardPoints, profile.unspentSkillPoints, profile.activeSkillTab) + expect(panel.characterClass).toBe('bar') + expect(panel.getHardPoints(151)).toBe(20) // Whirlwind 20 + expect(panel.unspentSkillPoints).toBe(12) + }) + }) + + describe('4. HudManager Multi-Class State Switching & Persistence', () => { + const mockCanvas = { + width: 800, + height: 600, + style: { cursor: '' }, + addEventListener: () => {}, + removeEventListener: () => {}, + getContext: () => null, + } as unknown as HTMLCanvasElement + + it('initializes with Sorceress exclusive set and stash treasures', () => { + const hud = new HudManager(mockCanvas, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + + hud.switchClass('sor') + expect(hud.currentClass).toBe('sor') + // Body has Tal Rasha set only + expect(hud.inventory.equipped.helm?.name).toContain("Tal Rasha's Horadric Crest") + expect(hud.inventory.equipped.armor?.name).toContain("Tal Rasha's Guardianship") + expect(hud.inventory.equipped.weapon1?.name).toContain("Tal Rasha's Lidless Eye") + expect(hud.inventory.equipped.weapon2).toBeUndefined() + expect(hud.inventory.equipped.gloves).toBeUndefined() + expect(hud.inventory.equipped.boots).toBeUndefined() + + // Stash has 30 treasure items and 2.5m gold + expect(hud.worldPanels.stashItems.length).toBe(30) + expect(hud.worldPanels.stashGold).toBe(2_500_000) + }) + + it('seamlessly switches between classes, updating inventory, stash, skills, and attributes', () => { + const hud = new HudManager(mockCanvas, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + + // Switch to Paladin + hud.switchClass('pal') + expect(hud.currentClass).toBe('pal') + expect(hud.charSheet.attrs.classNameZh).toBe('圣骑士') + expect(hud.inventory.equipped.helm?.name).toContain("Griswold's Valor") + expect(hud.inventory.equipped.weapon1?.name).toContain("Griswold's Redemption") + expect(hud.inventory.equipped.weapon2?.name).toContain("Griswold's Honor") + expect(hud.inventory.equipped.gloves).toBeUndefined() + expect(hud.hotkeys.leftSkillId).toBe(96) // Sacrifice + expect(hud.hotkeys.rightSkillId).toBe(112) // Blessed Hammer + expect(hud.worldPanels.stashItems.length).toBe(30) + + // Switch to Barbarian + hud.switchClass('bar') + expect(hud.currentClass).toBe('bar') + expect(hud.charSheet.attrs.classNameZh).toBe('野蛮人') + expect(hud.inventory.equipped.helm?.name).toContain("Immortal King's Will") + expect(hud.inventory.equipped.weapon1?.name).toContain("Immortal King's Stone Crusher") + expect(hud.inventory.equipped.boots?.name).toContain("Immortal King's Pillar") + expect(hud.skillTree.getHardPoints(151)).toBe(20) // Whirlwind + + // Switch to Amazon + hud.switchClass('ama') + expect(hud.currentClass).toBe('ama') + expect(hud.inventory.equipped.weapon1?.name).toContain("M'avina's Caster") + + // Switch to Necromancer + hud.switchClass('nec') + expect(hud.currentClass).toBe('nec') + expect(hud.inventory.equipped.weapon2?.name).toContain("Trang-Oul's Wing") + + // Switch to Druid + hud.switchClass('dru') + expect(hud.currentClass).toBe('dru') + expect(hud.inventory.equipped.weapon1?.name).toContain("Aldur's Rhythm") + + // Switch to Assassin + hud.switchClass('ass') + expect(hud.currentClass).toBe('ass') + expect(hud.inventory.equipped.weapon1?.name).toContain("Natalya's Mark") + }) + + it('preserves individual class modifications across switches', () => { + const hud = new HudManager(mockCanvas, { + onToggleAutomap: () => {}, + onWaypointTeleport: () => {}, + }) + + // Paladin: deposit gold and spend a skill point + hud.switchClass('pal') + hud.worldPanels.stashGold = 1_800_000 + hud.skillTree.unspentSkillPoints = 5 + + // Switch to Sorceress: has her own gold & skill points + hud.switchClass('sor') + expect(hud.worldPanels.stashGold).toBe(2_500_000) + expect(hud.skillTree.unspentSkillPoints).toBe(12) + + // Modify Sorceress + hud.worldPanels.stashGold = 2_000_000 + hud.skillTree.unspentSkillPoints = 10 + + // Switch back to Paladin: modifications were preserved! + hud.switchClass('pal') + expect(hud.worldPanels.stashGold).toBe(1_800_000) + expect(hud.skillTree.unspentSkillPoints).toBe(5) + + // Switch back to Sorceress: modifications were preserved! + hud.switchClass('sor') + expect(hud.worldPanels.stashGold).toBe(2_000_000) + expect(hud.skillTree.unspentSkillPoints).toBe(10) + }) + }) +})