feat(class): complete 7-class switching with signature sets and stash treasures (Fixes #379)
This commit is contained in:
parent
fb43185990
commit
7f810e40e3
File diff suppressed because it is too large
Load Diff
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
||||||
cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
||||||
} from '../game/d2map.ts'
|
} from '../game/d2map.ts'
|
||||||
|
import type { CharacterClassCode } from '../game/classes.ts'
|
||||||
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
|
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
|
||||||
import {
|
import {
|
||||||
updateAnimatableTiles,
|
updateAnimatableTiles,
|
||||||
|
|
@ -2981,24 +2982,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
state.character = true
|
state.character = true
|
||||||
state.characterMembers = nextChar.members
|
state.characterMembers = nextChar.members
|
||||||
if (hudManager !== null) {
|
if (hudManager !== null) {
|
||||||
if (hudManager.charSheet) {
|
hudManager.switchClass(nextInfo.code as CharacterClassCode)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
if (heroSelect !== null && heroSelect.value !== nextToken) {
|
if (heroSelect !== null && heroSelect.value !== nextToken) {
|
||||||
heroSelect.value = nextToken
|
heroSelect.value = nextToken
|
||||||
|
|
@ -3199,6 +3183,8 @@ if (typeof window !== 'undefined') {
|
||||||
if (hudManager !== null) {
|
if (hudManager !== null) {
|
||||||
try {
|
try {
|
||||||
await hudManager.init('/ui')
|
await hudManager.init('/ui')
|
||||||
|
const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
|
||||||
|
hudManager.switchClass(initialHeroInfo.code as CharacterClassCode)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to initialize HudManager:', err)
|
console.error('Failed to initialize HudManager:', err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null {
|
||||||
for (const [key, binding] of this.bindings.entries()) {
|
for (const [key, binding] of this.bindings.entries()) {
|
||||||
if (binding.side === side && binding.skillId === skillId) return key
|
if (binding.side === side && binding.skillId === skillId) return key
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
||||||
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
||||||
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
||||||
import { CursorController, type CursorState } from './cursor.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 {
|
export interface PublishedHudState {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|
@ -274,6 +279,9 @@ export class HudManager {
|
||||||
docking: computeDockingLayout(800, 600, 1),
|
docking: computeDockingLayout(800, 600, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentClass: CharacterClassCode | null = null
|
||||||
|
private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly hudCanvas: HTMLCanvasElement,
|
private readonly hudCanvas: HTMLCanvasElement,
|
||||||
private readonly callbacks: {
|
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<void> {
|
async init(baseUrl = '/ui'): Promise<void> {
|
||||||
this.hotkeys.preloadSkillIcons()
|
this.hotkeys.preloadSkillIcons()
|
||||||
this.skillTree.preloadAssets()
|
this.skillTree.preloadAssets()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import type { D2FontRenderer, D2ColorCode } from './font.ts'
|
import type { D2FontRenderer, D2ColorCode } from './font.ts'
|
||||||
import type { CharacterClassCode } from '../game/classes.ts'
|
import type { CharacterClassCode } from '../game/classes.ts'
|
||||||
|
import { SKILLS_CATALOG } from '../data/skills-catalog.ts'
|
||||||
import {
|
import {
|
||||||
buildTooltipViewModel,
|
buildTooltipViewModel,
|
||||||
type TooltipViewModel,
|
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 },
|
{ 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<CharacterClassCode, readonly SkillTreeTabInfo[]> = {
|
||||||
|
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<CharacterClassCode, readonly SkillNodeDef[]> {
|
||||||
|
const result: Record<CharacterClassCode, SkillNodeDef[]> = {
|
||||||
|
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<CharacterClassCode, readonly SkillNodeDef[]> = buildClassSkillTrees()
|
||||||
|
|
||||||
export class SkillTreePanel {
|
export class SkillTreePanel {
|
||||||
visible = false
|
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
|
activeTab: 0 | 1 | 2 = 2
|
||||||
unspentSkillPoints = 12
|
unspentSkillPoints = 12
|
||||||
bonusAllSkills = 8
|
bonusAllSkills = 8
|
||||||
|
|
@ -98,21 +178,59 @@ export class SkillTreePanel {
|
||||||
|
|
||||||
private tabBgs = new Map<number, HTMLImageElement>()
|
private tabBgs = new Map<number, HTMLImageElement>()
|
||||||
private skillIcons = new Map<number, HTMLImageElement>()
|
private skillIcons = new Map<number, HTMLImageElement>()
|
||||||
|
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 {
|
preloadAssets(): void {
|
||||||
if (typeof Image === 'undefined') return
|
if (typeof Image === 'undefined') return
|
||||||
for (const tab of [0, 1, 2]) {
|
for (const tab of [0, 1, 2]) {
|
||||||
const bg = new Image()
|
if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) {
|
||||||
bg.src = `/skills/bg_sor_${tab}.png`
|
const bg = new Image()
|
||||||
this.tabBgs.set(tab, bg)
|
bg.src = `/skills/bg_${this.characterClass}_${tab}.png`
|
||||||
|
this.tabBgs.set(tab, bg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const node of SORCERESS_SKILL_TREE) {
|
this.tabBgClass = this.characterClass
|
||||||
const icon = new Image()
|
for (const node of this.getSkillTree()) {
|
||||||
icon.src = `/skills/icon_${node.skillId}.png`
|
if (!this.skillIcons.has(node.skillId)) {
|
||||||
this.skillIcons.set(node.skillId, icon)
|
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<number, number>,
|
||||||
|
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 {
|
getHardPoints(skillId: number): number {
|
||||||
return this.hardPoints.get(skillId) ?? 0
|
return this.hardPoints.get(skillId) ?? 0
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +260,7 @@ export class SkillTreePanel {
|
||||||
|
|
||||||
canAllocate(skillId: number, charLevel = 85): boolean {
|
canAllocate(skillId: number, charLevel = 85): boolean {
|
||||||
if (this.unspentSkillPoints <= 0) return false
|
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
|
if (!node) return false
|
||||||
const cur = this.getHardPoints(skillId)
|
const cur = this.getHardPoints(skillId)
|
||||||
if (cur >= 20) return false
|
if (cur >= 20) return false
|
||||||
|
|
@ -166,7 +284,7 @@ export class SkillTreePanel {
|
||||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||||
this.hoveredSkill = null
|
this.hoveredSkill = null
|
||||||
if (!this.visible) return
|
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) {
|
for (const node of tabNodes) {
|
||||||
const r = SkillTreePanel.nodeScreenPos(node)
|
const r = SkillTreePanel.nodeScreenPos(node)
|
||||||
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
|
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`)
|
// Right vertical tabs (`x = 656..720`)
|
||||||
if (logicalX >= 656 && logicalX <= 720) {
|
if (logicalX >= 656 && logicalX <= 720) {
|
||||||
if (logicalY >= 60 + 95 && logicalY < 60 + 195) {
|
const vTabs = this.getVerticalTabs()
|
||||||
this.activeTab = 2 // Cold
|
if (logicalY >= 60 + 95 && logicalY < 60 + 195 && vTabs[0]) {
|
||||||
|
this.activeTab = vTabs[0].tab
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (logicalY >= 60 + 195 && logicalY < 60 + 295) {
|
if (logicalY >= 60 + 195 && logicalY < 60 + 295 && vTabs[1]) {
|
||||||
this.activeTab = 1 // Lightning
|
this.activeTab = vTabs[1].tab
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (logicalY >= 60 + 295 && logicalY < 60 + 385) {
|
if (logicalY >= 60 + 295 && logicalY < 60 + 385 && vTabs[2]) {
|
||||||
this.activeTab = 0 // Fire
|
this.activeTab = vTabs[2].tab
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill nodes
|
// 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) {
|
for (const node of tabNodes) {
|
||||||
const r = SkillTreePanel.nodeScreenPos(node)
|
const r = SkillTreePanel.nodeScreenPos(node)
|
||||||
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
|
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`)
|
// 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) {
|
if (assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0) {
|
||||||
// 0 = Cold (Tab 2), 1 = Lightning (Tab 1), 2 = Fire (Tab 0)
|
ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432)
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#14110d'
|
ctx.fillStyle = '#14110d'
|
||||||
ctx.fillRect(ox + 256, oy, 64, 432)
|
ctx.fillRect(ox + 256, oy, 64, 432)
|
||||||
|
|
@ -291,12 +412,7 @@ export class SkillTreePanel {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3 Vertical Tabs: Cold (2), Lightning (1), Fire (0)
|
// 3 Vertical Tabs for Active Class
|
||||||
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 },
|
|
||||||
]
|
|
||||||
for (const vt of verticalTabs) {
|
for (const vt of verticalTabs) {
|
||||||
const active = this.activeTab === vt.tab
|
const active = this.activeTab === vt.tab
|
||||||
if (!assets.skillTabsImg || !assets.skillTabsImg.complete || assets.skillTabsImg.naturalWidth === 0) {
|
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
|
// 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) {
|
for (const node of tabNodes) {
|
||||||
const pos = SkillTreePanel.nodeScreenPos(node)
|
const pos = SkillTreePanel.nodeScreenPos(node)
|
||||||
const hard = this.getHardPoints(node.skillId)
|
const hard = this.getHardPoints(node.skillId)
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () =>
|
||||||
|
|
||||||
describe('Icon Asset & DC6 Frame 0 Resolution', () => {
|
describe('Icon Asset & DC6 Frame 0 Resolution', () => {
|
||||||
it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => {
|
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).toBeDefined()
|
||||||
expect(iconMapping.skillId).toBe(0)
|
expect(iconMapping.skillId).toBe(0)
|
||||||
expect(iconMapping.name).toBe('Attack')
|
expect(iconMapping.name).toBe('Attack')
|
||||||
|
|
|
||||||
|
|
@ -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<string>()
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue