Fix skill button overlays, UI missing-art fail-fast, offline tome/equip rules, skill tree tbl strings, and integer formatting (Closes #690, #691, #692, #693, #694, #695)
This commit is contained in:
parent
8f48c4b1ec
commit
f209f4e6e2
|
|
@ -1186,7 +1186,7 @@ export class CommandMapper {
|
||||||
{
|
{
|
||||||
type: 'ItemPickup',
|
type: 'ItemPickup',
|
||||||
itemId: pendingItem.id,
|
itemId: pendingItem.id,
|
||||||
toCursor: false,
|
toCursor: this.hudManager?.rightPanel === 'inv',
|
||||||
},
|
},
|
||||||
nowMs,
|
nowMs,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -776,7 +776,8 @@ export class HudModel implements HudCommandSink {
|
||||||
if (!canUseSkillWithWeapon(skillId, weaponState)) continue
|
if (!canUseSkillWithWeapon(skillId, weaponState)) continue
|
||||||
const effSkillLevel = hard + bonus
|
const effSkillLevel = hard + bonus
|
||||||
const charges = self.skillCharges?.get(skillId) ?? scrollCounts.get(skillId)
|
const charges = self.skillCharges?.get(skillId) ?? scrollCounts.get(skillId)
|
||||||
const genericEntry = createCanonicalGenericSkillEntry(skillId, Math.max(1, effSkillLevel), charges)
|
if (effSkillLevel <= 0 && charges === undefined) continue
|
||||||
|
const genericEntry = createCanonicalGenericSkillEntry(skillId, charges)
|
||||||
if (genericEntry) {
|
if (genericEntry) {
|
||||||
hud.hotkeys.addOrUpdateSkill(genericEntry)
|
hud.hotkeys.addOrUpdateSkill(genericEntry)
|
||||||
if (charges !== undefined) {
|
if (charges !== undefined) {
|
||||||
|
|
@ -795,6 +796,8 @@ export class HudModel implements HudCommandSink {
|
||||||
leftUsable: isLeftUsableSkill(skillId),
|
leftUsable: isLeftUsableSkill(skillId),
|
||||||
rightUsable: true,
|
rightUsable: true,
|
||||||
isAura: isAuraSkill(skillId),
|
isAura: isAuraSkill(skillId),
|
||||||
|
listRow: tableMeta.listRow,
|
||||||
|
reqlevel: catalogEntry.reqlevel,
|
||||||
...(charges !== undefined ? { charges } : {}),
|
...(charges !== undefined ? { charges } : {}),
|
||||||
})
|
})
|
||||||
if (charges !== undefined) {
|
if (charges !== undefined) {
|
||||||
|
|
@ -807,11 +810,11 @@ export class HudModel implements HudCommandSink {
|
||||||
canUseSkillWithWeapon(2, weaponState)
|
canUseSkillWithWeapon(2, weaponState)
|
||||||
if (self.skillEntries.size > 0 || scrollCounts.size > 0 || hasEquippedThrowing) {
|
if (self.skillEntries.size > 0 || scrollCounts.size > 0 || hasEquippedThrowing) {
|
||||||
if (!hud.hotkeys.availableSkills.some((s) => s.skillId === 0)) {
|
if (!hud.hotkeys.availableSkills.some((s) => s.skillId === 0)) {
|
||||||
const atkEntry = createCanonicalGenericSkillEntry(0, 1)
|
const atkEntry = createCanonicalGenericSkillEntry(0)
|
||||||
if (atkEntry) hud.hotkeys.addOrUpdateSkill(atkEntry)
|
if (atkEntry) hud.hotkeys.addOrUpdateSkill(atkEntry)
|
||||||
}
|
}
|
||||||
if (hasEquippedThrowing && !hud.hotkeys.availableSkills.some((s) => s.skillId === 2)) {
|
if (hasEquippedThrowing && !hud.hotkeys.availableSkills.some((s) => s.skillId === 2)) {
|
||||||
const throwEntry = createCanonicalGenericSkillEntry(2, 1)
|
const throwEntry = createCanonicalGenericSkillEntry(2)
|
||||||
if (throwEntry) hud.hotkeys.addOrUpdateSkill(throwEntry)
|
if (throwEntry) hud.hotkeys.addOrUpdateSkill(throwEntry)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -820,11 +823,11 @@ export class HudModel implements HudCommandSink {
|
||||||
canUseSkillWithWeapon(4, weaponState) &&
|
canUseSkillWithWeapon(4, weaponState) &&
|
||||||
!hud.hotkeys.availableSkills.some((s) => s.skillId === 4)
|
!hud.hotkeys.availableSkills.some((s) => s.skillId === 4)
|
||||||
) {
|
) {
|
||||||
const lhtEntry = createCanonicalGenericSkillEntry(4, 1)
|
const lhtEntry = createCanonicalGenericSkillEntry(4)
|
||||||
if (lhtEntry) hud.hotkeys.addOrUpdateSkill(lhtEntry)
|
if (lhtEntry) hud.hotkeys.addOrUpdateSkill(lhtEntry)
|
||||||
}
|
}
|
||||||
for (const [scrollSkillId, qty] of scrollCounts.entries()) {
|
for (const [scrollSkillId, qty] of scrollCounts.entries()) {
|
||||||
const scrollEntry = createCanonicalGenericSkillEntry(scrollSkillId, 1, qty)
|
const scrollEntry = createCanonicalGenericSkillEntry(scrollSkillId, qty)
|
||||||
if (scrollEntry) {
|
if (scrollEntry) {
|
||||||
hud.hotkeys.addOrUpdateSkill(scrollEntry)
|
hud.hotkeys.addOrUpdateSkill(scrollEntry)
|
||||||
hud.hotkeys.setSkillCharges(scrollSkillId, qty)
|
hud.hotkeys.setSkillCharges(scrollSkillId, qty)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { getTblLang, requireTbl, type TblLang } from '../i18n/lang.ts'
|
||||||
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
||||||
import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts'
|
import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts'
|
||||||
import { getSkillName } from '../../common/skills/skills-meta.ts'
|
import { getSkillName } from '../../common/skills/skills-meta.ts'
|
||||||
|
import { reportMissingArt } from '../scene/world-renderer.ts'
|
||||||
|
|
||||||
export type { BaseStatKey, CharacterAttributes }
|
export type { BaseStatKey, CharacterAttributes }
|
||||||
|
|
||||||
|
|
@ -158,8 +159,7 @@ export class CharacterSheetPanel {
|
||||||
if (assets.charSheetImg) {
|
if (assets.charSheetImg) {
|
||||||
ctx.drawImage(assets.charSheetImg, ox, oy)
|
ctx.drawImage(assets.charSheetImg, ox, oy)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#181410'
|
reportMissingArt('ui-panel', 'invchar6-char', 'Missing character sheet background invchar6.dc6')
|
||||||
ctx.fillRect(ox, oy, CHAR_PANEL_ORIGIN.width, CHAR_PANEL_ORIGIN.height)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Top Header: Character Name (x=12..181, mid=97) & Class (x=194..309, mid=252)
|
// 3. Top Header: Character Name (x=12..181, mid=97) & Class (x=194..309, mid=252)
|
||||||
|
|
@ -191,7 +191,7 @@ export class CharacterSheetPanel {
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 124, oy + 62, {
|
font.drawText(ctx, String(Math.floor(this.attrs.xp)), ox + 124, oy + 62, {
|
||||||
font: 'font6',
|
font: 'font6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -202,7 +202,7 @@ export class CharacterSheetPanel {
|
||||||
color: 'tan',
|
color: 'tan',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 252, oy + 62, {
|
font.drawText(ctx, String(Math.floor(this.attrs.nextLevelXp)), ox + 252, oy + 62, {
|
||||||
font: 'font6',
|
font: 'font6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -233,9 +233,7 @@ export class CharacterSheetPanel {
|
||||||
if (assets.levelBtnImg) {
|
if (assets.levelBtnImg) {
|
||||||
ctx.drawImage(assets.levelBtnImg, 0, 0, 30, 30, b.x, b.y, b.w, b.h)
|
ctx.drawImage(assets.levelBtnImg, 0, 0, 30, 30, b.x, b.y, b.w, b.h)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#a81c1c'
|
reportMissingArt('ui-button', 'level-btn', 'Missing stat allocation button buysellbtn.dc6 / level.dc6')
|
||||||
ctx.fillRect(b.x, b.y, b.w, b.h)
|
|
||||||
font.drawText(ctx, '+', b.x + 15, b.y + 20, { font: 'font16', color: 'gold', align: 'center' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@
|
||||||
|
|
||||||
import type { D2FontRenderer, D2ColorCode } from './font.ts'
|
import type { D2FontRenderer, D2ColorCode } from './font.ts'
|
||||||
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
|
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
|
||||||
import { buildTooltipViewModel, createInitialState, type TooltipViewModel } from '../../common/skills/skill-calc-engine.ts'
|
import { buildTooltipViewModel, calculateManaCost, createInitialState, type TooltipViewModel } from '../../common/skills/skill-calc-engine.ts'
|
||||||
import { formatDamageTypeEn, type FormattedTooltipLine } from './skill-tree-panel.ts'
|
import { formatDamageTypeEn, type FormattedTooltipLine } from './skill-tree-panel.ts'
|
||||||
import type { TblLang } from '../i18n/lang.ts'
|
import type { TblLang } from '../i18n/lang.ts'
|
||||||
|
import { reportMissingArt } from '../scene/world-renderer.ts'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PASSIVE_SKILL_IDS,
|
PASSIVE_SKILL_IDS,
|
||||||
|
|
@ -141,14 +142,25 @@ export function getSkillReqLevel(skill: HotkeySkillEntry): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a canonical `HotkeySkillEntry` for a generic or item skill (`#0..#5`, `#217..#220`).
|
* Builds a canonical `HotkeySkillEntry` for a generic or item skill (`#0..#5`, `#217..#220`, `#350`).
|
||||||
|
* Returns `null` for class skills (`6..280` outside `GENERIC_SKILL_TBL_STRINGS`).
|
||||||
*/
|
*/
|
||||||
export function createCanonicalGenericSkillEntry(
|
export function createCanonicalGenericSkillEntry(
|
||||||
skillId: number,
|
skillId: number,
|
||||||
charges?: number,
|
levelOrCharges?: number,
|
||||||
maxCharges?: number,
|
chargesArg?: number,
|
||||||
): HotkeySkillEntry {
|
): HotkeySkillEntry | null {
|
||||||
const meta = getCanonicalSkillListRow(skillId, charges !== undefined && skillId >= 217)
|
if (!(skillId in GENERIC_SKILL_TBL_STRINGS)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const isScrollOrTome = skillId >= 217 && skillId <= 220
|
||||||
|
const resolvedCharges =
|
||||||
|
chargesArg !== undefined
|
||||||
|
? chargesArg
|
||||||
|
: isScrollOrTome && levelOrCharges !== undefined
|
||||||
|
? levelOrCharges
|
||||||
|
: undefined
|
||||||
|
const meta = getCanonicalSkillListRow(skillId, resolvedCharges !== undefined && isScrollOrTome)
|
||||||
const entry: HotkeySkillEntry = {
|
const entry: HotkeySkillEntry = {
|
||||||
skillId,
|
skillId,
|
||||||
name: getSkillName(skillId, 'ENG'),
|
name: getSkillName(skillId, 'ENG'),
|
||||||
|
|
@ -159,9 +171,8 @@ export function createCanonicalGenericSkillEntry(
|
||||||
rightUsable: !isPassiveSkill(skillId),
|
rightUsable: !isPassiveSkill(skillId),
|
||||||
listRow: meta,
|
listRow: meta,
|
||||||
reqlevel: 1,
|
reqlevel: 1,
|
||||||
...(charges !== undefined ? { charges } : {}),
|
...(resolvedCharges !== undefined ? { charges: resolvedCharges } : {}),
|
||||||
...(maxCharges !== undefined ? { maxCharges } : {}),
|
...(isScrollOrTome ? { isItemSkill: true } : {}),
|
||||||
...(skillId >= 217 && skillId <= 220 ? { isItemSkill: true } : {}),
|
|
||||||
}
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +185,7 @@ export function ensureDefaultAttackSkill(skills: readonly HotkeySkillEntry[]): H
|
||||||
if (skills.some(s => s.skillId === 0)) {
|
if (skills.some(s => s.skillId === 0)) {
|
||||||
return [...skills]
|
return [...skills]
|
||||||
}
|
}
|
||||||
return [createCanonicalGenericSkillEntry(0), ...skills]
|
return [createCanonicalGenericSkillEntry(0)!, ...skills]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvailableSkillsForSide(
|
export function getAvailableSkillsForSide(
|
||||||
|
|
@ -236,12 +247,26 @@ export class SkillHotkeysHud {
|
||||||
*/
|
*/
|
||||||
onSelectSkill?: ((side: 'left' | 'right', skillId: number) => void) | undefined
|
onSelectSkill?: ((side: 'left' | 'right', skillId: number) => void) | undefined
|
||||||
hasEquippedWeapon = true
|
hasEquippedWeapon = true
|
||||||
|
/** Optional current player mana for 1.13c insufficient-mana skill icon tinting (0x6fb565c0). */
|
||||||
|
currentMana: number | null = null
|
||||||
fistImage: HTMLImageElement | null = null
|
fistImage: HTMLImageElement | null = null
|
||||||
swordImage: HTMLImageElement | null = null
|
swordImage: HTMLImageElement | null = null
|
||||||
private iconImages = new Map<number, HTMLImageElement>()
|
private iconImages = new Map<number, HTMLImageElement>()
|
||||||
private cooldowns = new Map<number, { untilMs: number; totalMs: number }>()
|
private cooldowns = new Map<number, { untilMs: number; totalMs: number }>()
|
||||||
private skillCharges = new Map<number, { charges: number; maxCharges?: number | undefined }>()
|
private skillCharges = new Map<number, { charges: number; maxCharges?: number | undefined }>()
|
||||||
|
|
||||||
|
hasEnoughMana(skillId: number): boolean {
|
||||||
|
if (this.currentMana === null) return true
|
||||||
|
const entry = this.availableSkills.find(s => s.skillId === skillId)
|
||||||
|
let manaCost: number = entry?.manaCost ?? 0
|
||||||
|
if (entry === undefined) {
|
||||||
|
const cat = SKILLS_BY_ID[skillId]
|
||||||
|
manaCost = cat ? calculateManaCost(cat, 1) : 0
|
||||||
|
}
|
||||||
|
if (manaCost <= 0) return true
|
||||||
|
return this.currentMana >= manaCost
|
||||||
|
}
|
||||||
|
|
||||||
setSkillCooldown(skillId: number, durationMs: number, nowMs = performance.now()): void {
|
setSkillCooldown(skillId: number, durationMs: number, nowMs = performance.now()): void {
|
||||||
if (durationMs <= 0) {
|
if (durationMs <= 0) {
|
||||||
this.cooldowns.delete(skillId)
|
this.cooldowns.delete(skillId)
|
||||||
|
|
@ -657,7 +682,7 @@ export class SkillHotkeysHud {
|
||||||
genericSkillsImg: HTMLImageElement | null,
|
genericSkillsImg: HTMLImageElement | null,
|
||||||
font: D2FontRenderer,
|
font: D2FontRenderer,
|
||||||
highlighted = false,
|
highlighted = false,
|
||||||
nowMs = performance.now(),
|
_nowMs = performance.now(),
|
||||||
): void {
|
): void {
|
||||||
ctx.fillStyle = '#14110d'
|
ctx.fillStyle = '#14110d'
|
||||||
ctx.fillRect(x, y, 48, 48)
|
ctx.fillRect(x, y, 48, 48)
|
||||||
|
|
@ -672,46 +697,29 @@ export class SkillHotkeysHud {
|
||||||
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
|
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
|
||||||
ctx.drawImage(genericSkillsImg, iconCel * 48, 0, 48, 48, x, y, 48, 48)
|
ctx.drawImage(genericSkillsImg, iconCel * 48, 0, 48, 48, x, y, 48, 48)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
|
reportMissingArt('skill-icon', `icon_${skillId}`, `Missing generic skill icon for skillId=${skillId} (${side})`)
|
||||||
ctx.fillRect(x + 2, y + 2, 44, 44)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const icon = this.iconImages.get(skillId)
|
const icon = this.iconImages.get(skillId)
|
||||||
if (icon && icon.complete && icon.naturalWidth > 0) {
|
if (icon && icon.complete && icon.naturalWidth > 0) {
|
||||||
ctx.drawImage(icon, x, y, 48, 48)
|
ctx.drawImage(icon, x, y, 48, 48)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
|
reportMissingArt('skill-icon', `icon_${skillId}`, `Missing class skill icon for skillId=${skillId} (${side})`)
|
||||||
ctx.fillRect(x + 2, y + 2, 44, 44)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Aura skill: yellow border and golden/yellow tint (光环技能图标黄色)
|
// 1. 1.13c insufficient-mana icon tint (D2Client.dll 0x6fb565c0 / 0x6fb56710)
|
||||||
const isAura = isAuraSkill(skillId) || Boolean(this.availableSkills.find(s => s.skillId === skillId)?.isAura)
|
if (!this.hasEnoughMana(skillId)) {
|
||||||
if (isAura) {
|
ctx.fillStyle = 'rgba(24, 60, 180, 0.42)'
|
||||||
ctx.fillStyle = 'rgba(255, 215, 0, 0.18)'
|
|
||||||
ctx.fillRect(x + 2, y + 2, 44, 44)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Button Border (yellow for aura, gold/brown for normal)
|
|
||||||
if (isAura) {
|
|
||||||
ctx.strokeStyle = highlighted ? '#ffffff' : '#ffd700'
|
|
||||||
ctx.lineWidth = highlighted ? 2 : 1.5
|
|
||||||
} else {
|
|
||||||
ctx.strokeStyle = highlighted ? '#e8c26b' : '#584a34'
|
|
||||||
ctx.lineWidth = highlighted ? 2 : 1
|
|
||||||
}
|
|
||||||
ctx.strokeRect(x + 0.5, y + 0.5, 47, 47)
|
|
||||||
|
|
||||||
// 3. Cooldown (CD) & Out-of-Charges Mask (技能在cd中的时候显示红色不可用遮罩)
|
|
||||||
const onCd = this.isSkillOnCooldown(skillId, nowMs)
|
|
||||||
const charges = this.getSkillCharges(skillId)
|
|
||||||
const outOfCharges = charges !== undefined && charges <= 0
|
|
||||||
if (onCd || outOfCharges) {
|
|
||||||
ctx.fillStyle = 'rgba(220, 20, 20, 0.45)'
|
|
||||||
ctx.fillRect(x + 1, y + 1, 46, 46)
|
ctx.fillRect(x + 1, y + 1, 46, 46)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Hotkey label (F1..F8) at bottom-right
|
// 2. Standard 1.13c button border (no invented #ffd700 yellow aura border or red cooldown mask)
|
||||||
|
ctx.strokeStyle = highlighted ? '#e8c26b' : '#584a34'
|
||||||
|
ctx.lineWidth = highlighted ? 2 : 1
|
||||||
|
ctx.strokeRect(x + 0.5, y + 0.5, 47, 47)
|
||||||
|
|
||||||
|
// 3. Hotkey label (F1..F8) at bottom-right
|
||||||
const fKey = this.getHotkeyLabelForSkill(side, skillId)
|
const fKey = this.getHotkeyLabelForSkill(side, skillId)
|
||||||
if (fKey) {
|
if (fKey) {
|
||||||
font.drawText(ctx, fKey, x + 44, y + 44, {
|
font.drawText(ctx, fKey, x + 44, y + 44, {
|
||||||
|
|
@ -721,9 +729,10 @@ export class SkillHotkeysHud {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Charges display (充能类技能显示数字)
|
// 4. Charges / Tome quantity display at bottom-left
|
||||||
|
const charges = this.getSkillCharges(skillId)
|
||||||
if (charges !== undefined) {
|
if (charges !== undefined) {
|
||||||
const chargeColor = charges > 0 ? (isAura ? 'yellow' : 'white') : 'red'
|
const chargeColor = charges > 0 ? 'white' : 'red'
|
||||||
font.drawText(ctx, String(charges), x + 4, y + 44, {
|
font.drawText(ctx, String(charges), x + 4, y + 44, {
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: chargeColor,
|
color: chargeColor,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { formatPropertyCode } from '../../common/items/item-tooltip.ts'
|
||||||
import { translateItemName } from '../../common/i18n/tooltip-i18n.ts'
|
import { translateItemName } from '../../common/i18n/tooltip-i18n.ts'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ASSASSIN_HAND_TO_HAND_CLAW_CODES,
|
||||||
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
|
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
|
||||||
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
|
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
|
||||||
CANONICAL_ONE_HANDED_WEAPON_CODES,
|
CANONICAL_ONE_HANDED_WEAPON_CODES,
|
||||||
|
|
@ -49,9 +50,11 @@ import {
|
||||||
resolveAllowedSlots,
|
resolveAllowedSlots,
|
||||||
resolveItemInvFile,
|
resolveItemInvFile,
|
||||||
} from '../../common/items/item-bridge.ts'
|
} from '../../common/items/item-bridge.ts'
|
||||||
|
import { reportMissingArt } from '../scene/world-renderer.ts'
|
||||||
|
|
||||||
export type { SpriteRect, SocketTargetCategory }
|
export type { SpriteRect, SocketTargetCategory }
|
||||||
export {
|
export {
|
||||||
|
ASSASSIN_HAND_TO_HAND_CLAW_CODES,
|
||||||
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
|
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
|
||||||
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
|
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
|
||||||
CANONICAL_ONE_HANDED_WEAPON_CODES,
|
CANONICAL_ONE_HANDED_WEAPON_CODES,
|
||||||
|
|
@ -562,12 +565,16 @@ export function resolveItemSpriteRect(
|
||||||
const varGfxList = varGfxCode ? CANONICAL_VAR_INV_GFX[varGfxCode] : undefined
|
const varGfxList = varGfxCode ? CANONICAL_VAR_INV_GFX[varGfxCode] : undefined
|
||||||
|
|
||||||
if (varGfxList) {
|
if (varGfxList) {
|
||||||
|
if (lowerInv && (lowerInv === 'invmss' || lowerInv === 'invtrch')) {
|
||||||
|
const rect = itemRects[item.invFile!] ?? itemRects[lowerInv]
|
||||||
|
if (rect) return rect
|
||||||
|
}
|
||||||
const rawVariant = item.variant ?? item.graphicIndex
|
const rawVariant = item.variant ?? item.graphicIndex
|
||||||
if (typeof rawVariant === 'number' && Number.isFinite(rawVariant) && rawVariant >= 0) {
|
if (typeof rawVariant === 'number' && Number.isFinite(rawVariant) && rawVariant >= 0) {
|
||||||
const key = varGfxList[Math.trunc(rawVariant) % varGfxList.length]!
|
const key = varGfxList[Math.trunc(rawVariant) % varGfxList.length]!
|
||||||
if (itemRects[key]) return itemRects[key]
|
if (itemRects[key]) return itemRects[key]
|
||||||
}
|
}
|
||||||
if (lowerInv && (varGfxList.includes(lowerInv) || lowerInv === 'invmss' || lowerInv === 'invtrch')) {
|
if (lowerInv && varGfxList.includes(lowerInv)) {
|
||||||
const rect = itemRects[item.invFile!] ?? itemRects[lowerInv]
|
const rect = itemRects[item.invFile!] ?? itemRects[lowerInv]
|
||||||
if (rect) return rect
|
if (rect) return rect
|
||||||
}
|
}
|
||||||
|
|
@ -872,6 +879,20 @@ export type HandSlotItemCategory =
|
||||||
| 'offhand'
|
| 'offhand'
|
||||||
| 'other'
|
| 'other'
|
||||||
|
|
||||||
|
export function isAssassinClawItem(item: UiInventoryItem): boolean {
|
||||||
|
const code = (item.code ?? item.rawItem?.base?.id ?? (item as any).base?.id ?? '').trim().toLowerCase()
|
||||||
|
if (code.length > 0 && ASSASSIN_HAND_TO_HAND_CLAW_CODES.has(code)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const rawType = (
|
||||||
|
item.rawItem?.base?.type ??
|
||||||
|
(item as any).base?.type ??
|
||||||
|
(item as any).type ??
|
||||||
|
''
|
||||||
|
).trim().toLowerCase()
|
||||||
|
return rawType === 'h2h' || rawType === 'h2h2'
|
||||||
|
}
|
||||||
|
|
||||||
export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategory {
|
export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategory {
|
||||||
if (
|
if (
|
||||||
Array.isArray(item.allowedSlots) &&
|
Array.isArray(item.allowedSlots) &&
|
||||||
|
|
@ -942,17 +963,6 @@ export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategor
|
||||||
return 'strict_two_handed_weapon'
|
return 'strict_two_handed_weapon'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Tooltip damage line fallback (`双手伤害:` / `Two-Hand Damage:`)
|
|
||||||
const dmgText = `${item.damage ?? ''} ${item.damageEn ?? ''}`
|
|
||||||
const hasTwoHandDmg = dmgText.includes('双手伤害') || dmgText.includes('Two-Hand Damage')
|
|
||||||
const hasOneHandDmg = dmgText.includes('单手伤害') || dmgText.includes('One-Hand Damage')
|
|
||||||
if (hasTwoHandDmg && hasOneHandDmg) {
|
|
||||||
return 'barbarian_1or2h_weapon'
|
|
||||||
}
|
|
||||||
if (hasTwoHandDmg) {
|
|
||||||
return 'strict_two_handed_weapon'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'one_handed_weapon'
|
return 'one_handed_weapon'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1599,15 +1609,12 @@ export function applySocketToUiItem(
|
||||||
const candidateTypes = inferCandidateRunewordItemTypes(target, category)
|
const candidateTypes = inferCandidateRunewordItemTypes(target, category)
|
||||||
const isAPred = (t: string, targetType: string): boolean => {
|
const isAPred = (t: string, targetType: string): boolean => {
|
||||||
if (t === targetType) return true
|
if (t === targetType) return true
|
||||||
try {
|
if (
|
||||||
|
dropTables.itemTypes.byCode.has(t) &&
|
||||||
|
dropTables.itemTypes.byCode.has(targetType)
|
||||||
|
) {
|
||||||
if (dropTables.isA(t, targetType)) return true
|
if (dropTables.isA(t, targetType)) return true
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (isA(t, targetType, dropTables.itemTypes)) return true
|
if (isA(t, targetType, dropTables.itemTypes)) return true
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -1658,40 +1665,40 @@ export function applySocketToUiItem(
|
||||||
|
|
||||||
applyStatDeltaToTargetBase(target, addedZh, addedEn)
|
applyStatDeltaToTargetBase(target, addedZh, addedEn)
|
||||||
|
|
||||||
if (target.rawItem) {
|
if (
|
||||||
try {
|
target.rawItem &&
|
||||||
const domainSocketable: Item =
|
target.rawItem.base &&
|
||||||
socketable.rawItem ??
|
(target.rawItem.totalSockets ?? 0) > (target.rawItem.socketedItems?.length ?? target.rawItem.socketedCount ?? 0)
|
||||||
({
|
) {
|
||||||
id: 0,
|
const domainSocketable: Item =
|
||||||
code,
|
socketable.rawItem ??
|
||||||
name: socketable.name,
|
({
|
||||||
quality: isJewel ? 4 : 2,
|
id: 0,
|
||||||
level: sockReqLvl || 1,
|
code,
|
||||||
|
name: socketable.name,
|
||||||
|
quality: isJewel ? 4 : 2,
|
||||||
|
level: sockReqLvl || 1,
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
identified: true,
|
||||||
|
base: {
|
||||||
|
id: code,
|
||||||
|
name: socketable.baseNameEn ?? socketable.name,
|
||||||
|
kind: 'misc',
|
||||||
|
type: isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem',
|
||||||
invWidth: 1,
|
invWidth: 1,
|
||||||
invHeight: 1,
|
invHeight: 1,
|
||||||
identified: true,
|
maxStack: 1,
|
||||||
base: {
|
value: 100,
|
||||||
id: code,
|
damage: 0,
|
||||||
name: socketable.baseNameEn ?? socketable.name,
|
defense: 0,
|
||||||
kind: 'misc',
|
tags: [isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem'],
|
||||||
type: isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem',
|
level: sockReqLvl || 1,
|
||||||
invWidth: 1,
|
},
|
||||||
invHeight: 1,
|
rolledProps: [],
|
||||||
maxStack: 1,
|
stats: {},
|
||||||
value: 100,
|
} as unknown as Item)
|
||||||
damage: 0,
|
mutable.rawItem = socketItemIntoHost(target.rawItem, domainSocketable)
|
||||||
defense: 0,
|
|
||||||
tags: [isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem'],
|
|
||||||
level: sockReqLvl || 1,
|
|
||||||
},
|
|
||||||
rolledProps: [],
|
|
||||||
stats: {},
|
|
||||||
} as unknown as Item)
|
|
||||||
mutable.rawItem = socketItemIntoHost(target.rawItem, domainSocketable)
|
|
||||||
} catch {
|
|
||||||
// Keep UI target updated even if rawItem lacks full base metadata
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return target
|
return target
|
||||||
|
|
@ -1838,14 +1845,12 @@ export class InventoryPanel {
|
||||||
}
|
}
|
||||||
/** 16-slot belt mirror populated from server belt state. */
|
/** 16-slot belt mirror populated from server belt state. */
|
||||||
belt: (UiInventoryItem | null)[] = new Array(16).fill(null)
|
belt: (UiInventoryItem | null)[] = new Array(16).fill(null)
|
||||||
private _starterCleared = false
|
|
||||||
private _cursorItem: UiInventoryItem | null = null
|
private _cursorItem: UiInventoryItem | null = null
|
||||||
/** Item currently held on the mouse cursor (`cursorItem`). */
|
/** Item currently held on the mouse cursor (`cursorItem`). */
|
||||||
get cursorItem(): UiInventoryItem | null {
|
get cursorItem(): UiInventoryItem | null {
|
||||||
return this._cursorItem
|
return this._cursorItem
|
||||||
}
|
}
|
||||||
set cursorItem(val: UiInventoryItem | Item | null) {
|
set cursorItem(val: UiInventoryItem | Item | null) {
|
||||||
this._clearStarterIfRawItem(val as UiInventoryItem | null)
|
|
||||||
this._cursorItem = val as UiInventoryItem | null
|
this._cursorItem = val as UiInventoryItem | null
|
||||||
}
|
}
|
||||||
/** Active Identify targeting mode source (`ibk` or `isc`) initiated via Right-Click. */
|
/** Active Identify targeting mode source (`ibk` or `isc`) initiated via Right-Click. */
|
||||||
|
|
@ -1910,16 +1915,6 @@ export class InventoryPanel {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clearStarterIfRawItem(item: UiInventoryItem | null | undefined): void {
|
|
||||||
if (!this._starterCleared && item && !Array.isArray((item as any).allowedSlots)) {
|
|
||||||
this._starterCleared = true
|
|
||||||
this.gridItems = []
|
|
||||||
for (const k of Object.keys(this.equipped) as EquipSlotId[]) {
|
|
||||||
delete this.equipped[k]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlayerContext(ctx: Partial<PlayerItemRequirementContext>): void {
|
setPlayerContext(ctx: Partial<PlayerItemRequirementContext>): void {
|
||||||
this.playerContext = {
|
this.playerContext = {
|
||||||
level: ctx.level !== undefined ? Math.max(1, ctx.level) : this.playerContext.level,
|
level: ctx.level !== undefined ? Math.max(1, ctx.level) : this.playerContext.level,
|
||||||
|
|
@ -2063,7 +2058,6 @@ export class InventoryPanel {
|
||||||
*/
|
*/
|
||||||
autoPlaceInGrid(rawItem: UiInventoryItem | Item): boolean {
|
autoPlaceInGrid(rawItem: UiInventoryItem | Item): boolean {
|
||||||
const item = rawItem as UiInventoryItem
|
const item = rawItem as UiInventoryItem
|
||||||
this._clearStarterIfRawItem(item)
|
|
||||||
for (let col = 0; col <= INV_GRID_ORIGIN.cols - item.invWidth; col++) {
|
for (let col = 0; col <= INV_GRID_ORIGIN.cols - item.invWidth; col++) {
|
||||||
for (let row = 0; row <= INV_GRID_ORIGIN.rows - item.invHeight; row++) {
|
for (let row = 0; row <= INV_GRID_ORIGIN.rows - item.invHeight; row++) {
|
||||||
if (this.getGridOverlaps(col, row, item.invWidth, item.invHeight).length === 0) {
|
if (this.getGridOverlaps(col, row, item.invWidth, item.invHeight).length === 0) {
|
||||||
|
|
@ -2198,7 +2192,12 @@ export class InventoryPanel {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (Array.isArray(item.allowedSlots) && !item.allowedSlots.includes(slotId)) {
|
if (Array.isArray(item.allowedSlots) && !item.allowedSlots.includes(slotId)) {
|
||||||
return false
|
const isHandSlot = slotId === 'weapon1' || slotId === 'weapon2'
|
||||||
|
const allowsEitherHand =
|
||||||
|
item.allowedSlots.includes('weapon1') || item.allowedSlots.includes('weapon2')
|
||||||
|
if (!(isHandSlot && allowsEitherHand)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!canPlayerUseUiItem(item, this.playerContext)) {
|
if (!canPlayerUseUiItem(item, this.playerContext)) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -2227,6 +2226,7 @@ export class InventoryPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBarbarian = this.playerContext.classCode === 'bar'
|
const isBarbarian = this.playerContext.classCode === 'bar'
|
||||||
|
const isAssassin = this.playerContext.classCode === 'ass'
|
||||||
|
|
||||||
// Non-Barbarians treat 1or2handed two-handed swords as strict 2H weapons
|
// Non-Barbarians treat 1or2handed two-handed swords as strict 2H weapons
|
||||||
if (!isBarbarian && (incomingCat === 'barbarian_1or2h_weapon' || otherCat === 'barbarian_1or2h_weapon')) {
|
if (!isBarbarian && (incomingCat === 'barbarian_1or2h_weapon' || otherCat === 'barbarian_1or2h_weapon')) {
|
||||||
|
|
@ -2238,8 +2238,16 @@ export class InventoryPanel {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both slots are weapons (Dual Wielding): only Barbarian (`bar`) is allowed
|
// Both slots are weapons (Dual Wielding):
|
||||||
return isBarbarian
|
// - Barbarian (`bar`) can dual-wield any 1H or 1or2handed sword
|
||||||
|
// - Assassin (`ass`) can dual-wield two Hand-to-Hand (`h2h` / `h2h2`) claws
|
||||||
|
if (isBarbarian) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (isAssassin && isAssassinClawItem(item) && isAssassinClawItem(otherItem)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -2557,8 +2565,7 @@ export class InventoryPanel {
|
||||||
if (assets.invSheetImg) {
|
if (assets.invSheetImg) {
|
||||||
ctx.drawImage(assets.invSheetImg, INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y)
|
ctx.drawImage(assets.invSheetImg, INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#181410'
|
reportMissingArt('ui-panel', 'invchar6-inv', 'Missing inventory panel background invchar6.dc6')
|
||||||
ctx.fillRect(INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y, INV_PANEL_ORIGIN.width, INV_PANEL_ORIGIN.height)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Draw Weapon Swap `I / II` Tabs (`invchar6Tab.dc6` frames 0 & 1 already contain the carved Roman numerals I and II)
|
// 3. Draw Weapon Swap `I / II` Tabs (`invchar6Tab.dc6` frames 0 & 1 already contain the carved Roman numerals I and II)
|
||||||
|
|
@ -2614,7 +2621,7 @@ export class InventoryPanel {
|
||||||
INV_GOLD_BTN_BOUNDS.h,
|
INV_GOLD_BTN_BOUNDS.h,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
font.drawText(ctx, this.gold.toLocaleString(), INV_GOLD_BOX_RECESS.textX, INV_GOLD_BOX_RECESS.textY, {
|
font.drawText(ctx, String(Math.floor(this.gold)), INV_GOLD_BOX_RECESS.textX, INV_GOLD_BOX_RECESS.textY, {
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|
@ -2650,7 +2657,9 @@ export class InventoryPanel {
|
||||||
},
|
},
|
||||||
isHovered = false,
|
isHovered = false,
|
||||||
): void {
|
): void {
|
||||||
const sr = resolveItemSpriteRect(item, assets.itemRects)
|
const effectiveRects =
|
||||||
|
Object.keys(assets.itemRects).length > 0 ? assets.itemRects : BAKED_UI_MANIFEST.itemRects
|
||||||
|
const sr = resolveItemSpriteRect(item, effectiveRects)
|
||||||
if (!sr) {
|
if (!sr) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[inventory] Missing sprite rect for item code="${item.code ?? ''}" invFile="${item.invFile ?? ''}"`,
|
`[inventory] Missing sprite rect for item code="${item.code ?? ''}" invFile="${item.invFile ?? ''}"`,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
import type { D2FontRenderer, D2ColorCode } from './font.ts'
|
import type { D2FontRenderer, D2ColorCode } from './font.ts'
|
||||||
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
import type { CharacterClassCode } from '../../common/units/classes.ts'
|
||||||
import { SKILLS_CATALOG } from '../../common/data/skills-catalog.ts'
|
import { SKILLS_CATALOG } from '../../common/data/skills-catalog.ts'
|
||||||
import type { TblLang } from '../i18n/lang.ts'
|
import { lookupTbl, requireTbl, type TblLang } from '../i18n/lang.ts'
|
||||||
import {
|
import {
|
||||||
buildTooltipViewModel,
|
buildTooltipViewModel,
|
||||||
type TooltipViewModel,
|
type TooltipViewModel,
|
||||||
|
|
@ -114,8 +114,36 @@ 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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expansion `expansionstring.tbl` CHI entries for Druid & Assassin skill tree tabs (`StrSklTree26`..`StrSklTree34`),
|
||||||
|
* corresponding to `ModStre8w`..`ModStre9b` in `canonical-tbl-strings.ts`.
|
||||||
|
*/
|
||||||
|
const EXPANSION_SKILL_TREE_CHI_TBL: Readonly<Record<string, string>> = {
|
||||||
|
StrSklTree26: '召喚',
|
||||||
|
StrSklTree27: '變形',
|
||||||
|
StrSklTree28: '技能',
|
||||||
|
StrSklTree29: '元素',
|
||||||
|
StrSklTree30: '陷阱',
|
||||||
|
StrSklTree31: '影子',
|
||||||
|
StrSklTree32: '訓練',
|
||||||
|
StrSklTree33: '武學',
|
||||||
|
StrSklTree34: '藝術',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSkillTreeTbl(key: string, lang: TblLang): string {
|
||||||
|
if (!key) return ''
|
||||||
|
const v = lookupTbl(key, lang)
|
||||||
|
if (v !== undefined) return v
|
||||||
|
if (lang === 'CHI' && key in EXPANSION_SKILL_TREE_CHI_TBL) {
|
||||||
|
return EXPANSION_SKILL_TREE_CHI_TBL[key]!
|
||||||
|
}
|
||||||
|
return requireTbl(key, lang)
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkillTreeTabInfo {
|
export interface SkillTreeTabInfo {
|
||||||
readonly tab: 0 | 1 | 2
|
readonly tab: 0 | 1 | 2
|
||||||
|
readonly titleKey: string
|
||||||
|
readonly subKey: string
|
||||||
readonly titleZh: string
|
readonly titleZh: string
|
||||||
readonly subZh: string
|
readonly subZh: string
|
||||||
readonly titleEn: string
|
readonly titleEn: string
|
||||||
|
|
@ -123,41 +151,54 @@ export interface SkillTreeTabInfo {
|
||||||
readonly y: number
|
readonly y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeSkillTab(tab: 0 | 1 | 2, titleKey: string, subKey: string, y: number): SkillTreeTabInfo {
|
||||||
|
return {
|
||||||
|
tab,
|
||||||
|
titleKey,
|
||||||
|
subKey,
|
||||||
|
titleEn: resolveSkillTreeTbl(titleKey, 'ENG'),
|
||||||
|
subEn: subKey ? resolveSkillTreeTbl(subKey, 'ENG') : '',
|
||||||
|
titleZh: resolveSkillTreeTbl(titleKey, 'CHI'),
|
||||||
|
subZh: subKey ? resolveSkillTreeTbl(subKey, 'CHI') : '',
|
||||||
|
y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const CLASS_VERTICAL_TABS: Record<CharacterClassCode, readonly SkillTreeTabInfo[]> = {
|
export const CLASS_VERTICAL_TABS: Record<CharacterClassCode, readonly SkillTreeTabInfo[]> = {
|
||||||
ama: [
|
ama: [
|
||||||
{ tab: 2, titleZh: '标枪和', subZh: '长矛', titleEn: 'Javelin', subEn: 'and Spear', y: 95 },
|
makeSkillTab(2, 'StrSklTree6', 'StrSklTree7', 95),
|
||||||
{ tab: 1, titleZh: '被动和', subZh: '魔法', titleEn: 'Passive', subEn: 'and Magic', y: 195 },
|
makeSkillTab(1, 'StrSklTree8', 'StrSklTree9', 195),
|
||||||
{ tab: 0, titleZh: '弓和', subZh: '十字弓', titleEn: 'Bow and', subEn: 'Crossbow', y: 295 },
|
makeSkillTab(0, 'StrSklTree10', 'StrSklTree11', 295),
|
||||||
],
|
],
|
||||||
sor: [
|
sor: [
|
||||||
{ tab: 2, titleZh: '冰冷', subZh: '法术', titleEn: 'Cold', subEn: 'Spells', y: 95 },
|
makeSkillTab(2, 'StrSklTree23', 'StrSklTree5', 95),
|
||||||
{ tab: 1, titleZh: '闪电', subZh: '法术', titleEn: 'Lightning', subEn: 'Spells', y: 195 },
|
makeSkillTab(1, 'StrSklTree24', 'StrSklTree5', 195),
|
||||||
{ tab: 0, titleZh: '火焰', subZh: '法术', titleEn: 'Fire', subEn: 'Spells', y: 295 },
|
makeSkillTab(0, 'StrSklTree25', 'StrSklTree5', 295),
|
||||||
],
|
],
|
||||||
nec: [
|
nec: [
|
||||||
{ tab: 2, titleZh: '召唤', subZh: '法术', titleEn: 'Summoning', subEn: 'Spells', y: 95 },
|
makeSkillTab(2, 'StrSklTree16', 'StrSklTree5', 95),
|
||||||
{ tab: 1, titleZh: '毒素和', subZh: '白骨', titleEn: 'Poison', subEn: 'and Bone', y: 195 },
|
makeSkillTab(1, 'StrSklTree17', 'StrSklTree18', 195),
|
||||||
{ tab: 0, titleZh: '诅咒', subZh: '法术', titleEn: 'Curses', subEn: '', y: 295 },
|
makeSkillTab(0, 'StrSklTree19', '', 295),
|
||||||
],
|
],
|
||||||
pal: [
|
pal: [
|
||||||
{ tab: 2, titleZh: '防御', subZh: '灵气', titleEn: 'Defensive', subEn: 'Auras', y: 95 },
|
makeSkillTab(2, 'StrSklTree12', 'StrSklTree13', 95),
|
||||||
{ tab: 1, titleZh: '攻击', subZh: '灵气', titleEn: 'Offensive', subEn: 'Auras', y: 195 },
|
makeSkillTab(1, 'StrSklTree14', 'StrSklTree13', 195),
|
||||||
{ tab: 0, titleZh: '作战', subZh: '技能', titleEn: 'Combat', subEn: 'Skills', y: 295 },
|
makeSkillTab(0, 'StrSklTree15', 'StrSklTree4', 295),
|
||||||
],
|
],
|
||||||
bar: [
|
bar: [
|
||||||
{ tab: 2, titleZh: '呐喊', subZh: '技能', titleEn: 'Warcries', subEn: '', y: 95 },
|
makeSkillTab(2, 'StrSklTree20', '', 95),
|
||||||
{ tab: 1, titleZh: '战斗', subZh: '专家', titleEn: 'Combat', subEn: 'Masteries', y: 195 },
|
makeSkillTab(1, 'StrSklTree21', 'StrSklTree22', 195),
|
||||||
{ tab: 0, titleZh: '战斗', subZh: '技能', titleEn: 'Combat', subEn: 'Skills', y: 295 },
|
makeSkillTab(0, 'StrSklTree21', 'StrSklTree4', 295),
|
||||||
],
|
],
|
||||||
dru: [
|
dru: [
|
||||||
{ tab: 2, titleZh: '元素', subZh: '技能', titleEn: 'Elemental', subEn: 'Skills', y: 95 },
|
makeSkillTab(2, 'StrSklTree29', 'StrSklTree4', 95),
|
||||||
{ tab: 1, titleZh: '变形', subZh: '技能', titleEn: 'Shape', subEn: 'Shifting', y: 195 },
|
makeSkillTab(1, 'StrSklTree27', 'StrSklTree28', 195),
|
||||||
{ tab: 0, titleZh: '召唤', subZh: '技能', titleEn: 'Summoning', subEn: 'Skills', y: 295 },
|
makeSkillTab(0, 'StrSklTree26', 'StrSklTree4', 295),
|
||||||
],
|
],
|
||||||
ass: [
|
ass: [
|
||||||
{ tab: 2, titleZh: '武学', subZh: '艺术', titleEn: 'Martial', subEn: 'Arts', y: 95 },
|
makeSkillTab(2, 'StrSklTree33', 'StrSklTree34', 95),
|
||||||
{ tab: 1, titleZh: '暗影', subZh: '武艺', titleEn: 'Shadow', subEn: 'Disciplines', y: 195 },
|
makeSkillTab(1, 'StrSklTree31', 'StrSklTree32', 195),
|
||||||
{ tab: 0, titleZh: '陷阱', subZh: '技能', titleEn: 'Traps', subEn: '', y: 295 },
|
makeSkillTab(0, 'StrSklTree30', '', 295),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -459,8 +500,11 @@ export class SkillTreePanel {
|
||||||
if (hasBg && bg) {
|
if (hasBg && bg) {
|
||||||
ctx.drawImage(bg, ox, oy, 256, 432)
|
ctx.drawImage(bg, ox, oy, 256, 432)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#181410'
|
reportMissingArt(
|
||||||
ctx.fillRect(ox, oy, 256, 432)
|
'ui-panel',
|
||||||
|
`skltree_bg_${this.characterClass}_${this.activeTab}`,
|
||||||
|
`Missing skill tree background for class=${this.characterClass} tab=${this.activeTab}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Draw Right 64x432 Vertical Tab Column (`x = 656..720`)
|
// 3. Draw Right 64x432 Vertical Tab Column (`x = 656..720`)
|
||||||
|
|
@ -474,26 +518,31 @@ export class SkillTreePanel {
|
||||||
if (hasTabsImg && assets.skillTabsImg) {
|
if (hasTabsImg && assets.skillTabsImg) {
|
||||||
ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432)
|
ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432)
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#14110d'
|
reportMissingArt(
|
||||||
ctx.fillRect(ox + 256, oy, 64, 432)
|
'ui-panel',
|
||||||
ctx.strokeStyle = '#5a4a32'
|
`skltree_tabs_${this.characterClass}`,
|
||||||
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
|
`Missing skill tree vertical tabs image for class=${this.characterClass}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEn = this.lang === 'ENG'
|
const isEn = this.lang === 'ENG'
|
||||||
|
|
||||||
// Remaining Skill Points Header Plaque (built-in DC6 recessed stone box at x=255..297, y=61..83)
|
// Remaining Skill Points Header Plaque (D2Client.dll 0x6FB52120..0x6FB52240: StrSklTree1 / StrSklTree2 / StrSklTree3)
|
||||||
font.drawText(ctx, isEn ? 'Skill Choices' : '剩余技能点', ox + 276, oy + 38, {
|
font.drawText(ctx, resolveSkillTreeTbl('StrSklTree1', this.lang), ox + 276, oy + 26, {
|
||||||
|
font: 'font8',
|
||||||
|
color: 'gold',
|
||||||
|
align: 'center',
|
||||||
|
})
|
||||||
|
font.drawText(ctx, resolveSkillTreeTbl('StrSklTree2', this.lang), ox + 276, oy + 38, {
|
||||||
|
font: 'font8',
|
||||||
|
color: 'gold',
|
||||||
|
align: 'center',
|
||||||
|
})
|
||||||
|
font.drawText(ctx, resolveSkillTreeTbl('StrSklTree3', this.lang), ox + 276, oy + 50, {
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
if (!hasTabsImg) {
|
|
||||||
ctx.fillStyle = '#080605'
|
|
||||||
ctx.fillRect(ox + 255, oy + 61, 43, 23)
|
|
||||||
ctx.strokeStyle = '#8c7244'
|
|
||||||
ctx.strokeRect(ox + 255.5, oy + 61.5, 42, 22)
|
|
||||||
}
|
|
||||||
font.drawText(ctx, String(this.unspentSkillPoints), ox + 276, oy + 78, {
|
font.drawText(ctx, String(this.unspentSkillPoints), ox + 276, oy + 78, {
|
||||||
font: 'font16',
|
font: 'font16',
|
||||||
color: this.unspentSkillPoints > 0 ? 'red' : 'white',
|
color: this.unspentSkillPoints > 0 ? 'red' : 'white',
|
||||||
|
|
@ -503,14 +552,6 @@ export class SkillTreePanel {
|
||||||
// 3 Vertical Tabs for Active Class (90px column x=230..319, center ox + 275)
|
// 3 Vertical Tabs for Active Class (90px column x=230..319, center ox + 275)
|
||||||
for (const vt of verticalTabs) {
|
for (const vt of verticalTabs) {
|
||||||
const active = this.activeTab === vt.tab
|
const active = this.activeTab === vt.tab
|
||||||
if (!hasTabsImg) {
|
|
||||||
// Authentic stone-beveled fallback when texture atlas is unavailable
|
|
||||||
ctx.fillStyle = active ? '#382e22' : '#1b1610'
|
|
||||||
ctx.fillRect(ox + 258, oy + vt.y, 60, 96)
|
|
||||||
ctx.strokeStyle = active ? '#c8a15a' : '#4a3c28'
|
|
||||||
ctx.lineWidth = active ? 2 : 1
|
|
||||||
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
|
|
||||||
}
|
|
||||||
const title = isEn ? vt.titleEn : vt.titleZh
|
const title = isEn ? vt.titleEn : vt.titleZh
|
||||||
const sub = isEn ? vt.subEn : vt.subZh
|
const sub = isEn ? vt.subEn : vt.subZh
|
||||||
if (sub) {
|
if (sub) {
|
||||||
|
|
@ -557,12 +598,6 @@ export class SkillTreePanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level socket: built-in DC6 stone socket has dark interior at pos.x + 42..60, pos.y + 50..60
|
// Level socket: built-in DC6 stone socket has dark interior at pos.x + 42..60, pos.y + 50..60
|
||||||
if (!hasBg) {
|
|
||||||
ctx.fillStyle = '#090705'
|
|
||||||
ctx.fillRect(pos.x + 42, pos.y + 50, 19, 11)
|
|
||||||
ctx.strokeStyle = '#6c5838'
|
|
||||||
ctx.strokeRect(pos.x + 41.5, pos.y + 49.5, 20, 12)
|
|
||||||
}
|
|
||||||
font.drawText(ctx, String(eff), pos.x + 51, pos.y + 59, {
|
font.drawText(ctx, String(eff), pos.x + 51, pos.y + 59, {
|
||||||
font: 'font8',
|
font: 'font8',
|
||||||
color: eff > hard ? 'blue' : hard > 0 ? 'white' : 'gray',
|
color: eff > hard ? 'blue' : hard > 0 ? 'white' : 'gray',
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,17 @@ export const BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES: ReadonlySet<string> = new
|
||||||
'72h', '7cm', '7gs', '7b7', '7fb', '7gd',
|
'72h', '7cm', '7gs', '7b7', '7fb', '7gd',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground Truth (`Weapons.txt` `type = h2h` / `h2h2` in Diablo II v1.13c):
|
||||||
|
* All 21 Assassin hand-to-hand claw codes (`ktr`..`7qr`) that an Assassin (`ass`) can dual-wield
|
||||||
|
* in both `weapon1` and `weapon2`.
|
||||||
|
*/
|
||||||
|
export const ASSASSIN_HAND_TO_HAND_CLAW_CODES: ReadonlySet<string> = new Set([
|
||||||
|
'ktr', 'wrb', 'axf', 'ces', 'clw', 'btl', 'skr',
|
||||||
|
'9ar', '9wb', '9xf', '9cs', '9lw', '9tw', '9qr',
|
||||||
|
'7ar', '7wb', '7xf', '7cs', '7lw', '7tw', '7qr',
|
||||||
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ground Truth (`Weapons.txt` `2handed === 1` & `1or2handed !== 1` in Diablo II v1.13c):
|
* Ground Truth (`Weapons.txt` `2handed === 1` & `1or2handed !== 1` in Diablo II v1.13c):
|
||||||
* All 119 strict two-handed weapons (2H axes, mauls, spears, polearms, staves, bows, crossbows,
|
* All 119 strict two-handed weapons (2H axes, mauls, spears, polearms, staves, bows, crossbows,
|
||||||
|
|
@ -195,19 +206,16 @@ export function resolveItemRequiredClass(
|
||||||
const t = type.trim().toLowerCase()
|
const t = type.trim().toLowerCase()
|
||||||
if (!t) return false
|
if (!t) return false
|
||||||
if (t === target) return true
|
if (t === target) return true
|
||||||
if (dropTables?.isA) {
|
if (dropTables?.isA && dropTables.isA(t, target)) {
|
||||||
try {
|
return true
|
||||||
if (dropTables.isA(t, target)) return true
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (dropTables?.itemTypes) {
|
if (
|
||||||
try {
|
dropTables?.itemTypes &&
|
||||||
if (isA(t, target, dropTables.itemTypes)) return true
|
dropTables.itemTypes.byCode.has(t) &&
|
||||||
} catch {
|
dropTables.itemTypes.byCode.has(target) &&
|
||||||
// ignore
|
isA(t, target, dropTables.itemTypes)
|
||||||
}
|
) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -284,19 +292,16 @@ export function resolveAllowedSlots(
|
||||||
const isAPred = (target: string): boolean => {
|
const isAPred = (target: string): boolean => {
|
||||||
if (!typeCode) return false
|
if (!typeCode) return false
|
||||||
if (typeCode === target) return true
|
if (typeCode === target) return true
|
||||||
if (dropTables?.isA) {
|
if (dropTables?.isA && dropTables.isA(typeCode, target)) {
|
||||||
try {
|
return true
|
||||||
if (dropTables.isA(typeCode, target)) return true
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (dropTables?.itemTypes) {
|
if (
|
||||||
try {
|
dropTables?.itemTypes &&
|
||||||
if (isA(typeCode, target, dropTables.itemTypes)) return true
|
dropTables.itemTypes.byCode.has(typeCode) &&
|
||||||
} catch {
|
dropTables.itemTypes.byCode.has(target) &&
|
||||||
// ignore
|
isA(typeCode, target, dropTables.itemTypes)
|
||||||
}
|
) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -335,24 +340,25 @@ export function resolveAllowedSlots(
|
||||||
return ['belt']
|
return ['belt']
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shields & Off-hand (strictly weapon2)
|
// Shields & Off-hand (`ItemTypes.txt` BodyLoc1 = rarm, BodyLoc2 = larm -> weapon1 or weapon2)
|
||||||
if (
|
if (
|
||||||
|
CANONICAL_SHIELD_AND_OFFHAND_CODES.has(code) ||
|
||||||
tags.some(t => ['shld', 'ashd', 'head', 'seco'].includes(t)) ||
|
tags.some(t => ['shld', 'ashd', 'head', 'seco'].includes(t)) ||
|
||||||
['shie', 'ashd', 'head', 'shld'].includes(typeCode) ||
|
['shie', 'ashd', 'head', 'shld'].includes(typeCode) ||
|
||||||
isAPred('shld') ||
|
isAPred('shld') ||
|
||||||
isAPred('seco')
|
isAPred('seco')
|
||||||
) {
|
) {
|
||||||
return ['weapon2']
|
return ['weapon1', 'weapon2']
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quivers (strictly weapon2)
|
// Quivers (`ItemTypes.txt` BodyLoc1 = rarm, BodyLoc2 = larm -> weapon1 or weapon2)
|
||||||
if (
|
if (
|
||||||
tags.some(t => ['bowq', 'xboq', 'mboq', 'mxbq'].includes(t)) ||
|
tags.some(t => ['bowq', 'xboq', 'mboq', 'mxbq'].includes(t)) ||
|
||||||
['bowq', 'xboq', 'mboq', 'mxbq'].includes(typeCode) ||
|
['bowq', 'xboq', 'mboq', 'mxbq'].includes(typeCode) ||
|
||||||
isAPred('bowq') ||
|
isAPred('bowq') ||
|
||||||
isAPred('xboq')
|
isAPred('xboq')
|
||||||
) {
|
) {
|
||||||
return ['weapon2']
|
return ['weapon1', 'weapon2']
|
||||||
}
|
}
|
||||||
|
|
||||||
// Armor / Torso
|
// Armor / Torso
|
||||||
|
|
@ -366,14 +372,29 @@ export function resolveAllowedSlots(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weapons
|
// Weapons
|
||||||
if (base.kind === 'weapon' || tags.includes('weap') || isAPred('weap')) {
|
if (
|
||||||
const isTwoHanded = Boolean(
|
base.kind === 'weapon' ||
|
||||||
base.twoHanded ||
|
tags.includes('weap') ||
|
||||||
tags.some(t => ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(t)) ||
|
isAPred('weap') ||
|
||||||
['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(typeCode) ||
|
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code) ||
|
||||||
(dropTables?.weapons?.get?.(code) as any)?.twohanded === '1'
|
STRICT_TWO_HANDED_WEAPON_CODES.has(code) ||
|
||||||
|
CANONICAL_ONE_HANDED_WEAPON_CODES.has(code)
|
||||||
|
) {
|
||||||
|
const isOneOrTwoHanded = Boolean(
|
||||||
|
base.oneOrTwoHanded ||
|
||||||
|
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code) ||
|
||||||
|
(dropTables?.weapons?.get?.(code) as any)?.['1or2handed'] === '1',
|
||||||
)
|
)
|
||||||
if (isTwoHanded) {
|
const isStrictTwoHanded =
|
||||||
|
!isOneOrTwoHanded &&
|
||||||
|
Boolean(
|
||||||
|
base.twoHanded ||
|
||||||
|
STRICT_TWO_HANDED_WEAPON_CODES.has(code) ||
|
||||||
|
tags.some(t => ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(t)) ||
|
||||||
|
['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(typeCode) ||
|
||||||
|
(dropTables?.weapons?.get?.(code) as any)?.twohanded === '1',
|
||||||
|
)
|
||||||
|
if (isStrictTwoHanded) {
|
||||||
return ['weapon1']
|
return ['weapon1']
|
||||||
}
|
}
|
||||||
return ['weapon1', 'weapon2']
|
return ['weapon1', 'weapon2']
|
||||||
|
|
@ -625,25 +646,21 @@ export function resolveItemInvFile(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1.5 Variable Inventory Graphics (`ItemTypes.txt` `VarInvGfx` + `InvGfx1..6`)
|
// 1.5 Variable Inventory Graphics (`ItemTypes.txt` `VarInvGfx` + `InvGfx1..6`)
|
||||||
// Unless a unique/set item has an explicit dedicated `invfile` in UniqueItems.txt (e.g. Annihilus -> invmss, Hellfire Torch -> invtrch),
|
// Unless a unique/set item has an explicit dedicated `invfile` in UniqueItems.txt (e.g. invmss, invtrch),
|
||||||
// rings, amulets, jewels, and charms select their sprite from `InvGfx1..6` via `graphicIndex` (`variant`).
|
// rings, amulets, jewels, and charms select their sprite from `InvGfx1..6` via `graphicIndex` (`variant`).
|
||||||
if (!invFile && varGfxList) {
|
if (!invFile && varGfxList) {
|
||||||
if (quality === 'unique' && item.name === 'Annihilus') {
|
const explicitInv = ((item as any).invFile ?? (item.base as any)?.invfile ?? (item.base as any)?.invFile ?? '')
|
||||||
invFile = 'invmss'
|
.toString()
|
||||||
} else if (quality === 'unique' && item.name === 'Hellfire Torch') {
|
.trim()
|
||||||
invFile = 'invtrch'
|
.toLowerCase()
|
||||||
|
if (explicitInv === 'invmss' || explicitInv === 'invtrch') {
|
||||||
|
invFile = explicitInv
|
||||||
} else if (rawVariant !== undefined) {
|
} else if (rawVariant !== undefined) {
|
||||||
invFile = varGfxList[rawVariant % varGfxList.length]
|
invFile = varGfxList[rawVariant % varGfxList.length]
|
||||||
|
} else if (explicitInv && varGfxList.includes(explicitInv)) {
|
||||||
|
invFile = explicitInv
|
||||||
} else {
|
} else {
|
||||||
const explicitInv = ((item as any).invFile ?? (item.base as any)?.invfile ?? (item.base as any)?.invFile ?? '')
|
invFile = varGfxList[0]
|
||||||
.toString()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
if (explicitInv && (varGfxList.includes(explicitInv) || explicitInv === 'invmss' || explicitInv === 'invtrch')) {
|
|
||||||
invFile = explicitInv
|
|
||||||
} else {
|
|
||||||
invFile = varGfxList[0]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,28 +214,43 @@ const CANONICAL_PASSIVE_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
|
||||||
|
|
||||||
const CANONICAL_LEFTSKILL_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
|
const CANONICAL_LEFTSKILL_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
|
||||||
0, 2, 4, 5,
|
0, 2, 4, 5,
|
||||||
6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35,
|
6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 29, 30, 31, 33, 34, 35,
|
||||||
36, 38, 39, 41, 45, 47, 49, 53, 55, 64,
|
36, 37, 38, 39, 41, 45, 47, 49, 53, 55, 61, 63, 64, 65,
|
||||||
67, 73, 84, 93,
|
67, 73, 79, 84, 89, 93,
|
||||||
96, 97, 101, 106, 107, 111, 112, 116, 121,
|
96, 97, 101, 106, 107, 111, 112, 116, 121,
|
||||||
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
|
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
|
||||||
225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248,
|
225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248,
|
||||||
251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280,
|
251, 254, 255, 256, 257, 259, 260, 263, 265, 266, 269, 270, 274, 275, 280,
|
||||||
])
|
])
|
||||||
|
|
||||||
const CANONICAL_AURA_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
|
const CANONICAL_AURA_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
|
||||||
98, 99, 100, 102, 103, 104, 105, 108, 109, 110,
|
98, 99, 100, 102, 103, 104, 105, 108, 109, 110,
|
||||||
113, 114, 115, 118, 119, 120, 122, 123, 124, 125,
|
113, 114, 115, 118, 119, 120, 122, 123, 124, 125,
|
||||||
|
127, 128, 129,
|
||||||
])
|
])
|
||||||
|
|
||||||
const CLASS_FIRST_SKILL_ID: Readonly<Record<CharacterClassCode, number>> = {
|
const CANONICAL_CLASS_SKILL_ICON_CEL: Readonly<Record<number, number>> = {
|
||||||
ama: 6,
|
6: 0, 7: 2, 8: 4, 9: 6, 10: 8, 11: 10, 12: 12, 13: 14, 14: 16, 15: 18,
|
||||||
sor: 36,
|
16: 20, 17: 22, 18: 24, 19: 26, 20: 28, 21: 30, 22: 32, 23: 34, 24: 36, 25: 38,
|
||||||
nec: 66,
|
26: 40, 27: 42, 28: 44, 29: 46, 30: 48, 31: 50, 32: 52, 33: 54, 34: 56, 35: 58,
|
||||||
pal: 96,
|
36: 0, 37: 2, 38: 4, 39: 6, 40: 8, 41: 10, 42: 12, 43: 14, 44: 16, 45: 18,
|
||||||
bar: 126,
|
46: 20, 47: 22, 48: 24, 49: 26, 50: 28, 51: 30, 52: 32, 53: 34, 54: 36, 55: 38,
|
||||||
dru: 221,
|
56: 40, 57: 42, 58: 44, 59: 46, 60: 48, 61: 50, 62: 52, 63: 54, 64: 56, 65: 58,
|
||||||
ass: 251,
|
66: 0, 67: 2, 68: 4, 69: 6, 70: 8, 71: 10, 72: 12, 73: 14, 74: 16, 75: 18,
|
||||||
|
76: 20, 77: 22, 78: 24, 79: 26, 80: 28, 81: 30, 82: 32, 83: 34, 84: 36, 85: 38,
|
||||||
|
86: 40, 87: 42, 88: 44, 89: 46, 90: 48, 91: 50, 92: 52, 93: 54, 94: 56, 95: 58,
|
||||||
|
96: 0, 97: 2, 98: 4, 99: 6, 100: 8, 101: 10, 102: 12, 103: 14, 104: 16, 105: 18,
|
||||||
|
106: 20, 107: 22, 108: 24, 109: 38, 110: 28, 111: 40, 112: 32, 113: 34, 114: 36, 115: 26,
|
||||||
|
116: 30, 117: 42, 118: 44, 119: 46, 120: 48, 121: 50, 122: 52, 123: 56, 124: 54, 125: 58,
|
||||||
|
126: 0, 127: 2, 128: 4, 129: 6, 130: 8, 131: 10, 132: 12, 133: 14, 134: 16, 135: 18,
|
||||||
|
136: 20, 137: 22, 138: 24, 139: 26, 140: 28, 141: 30, 142: 32, 143: 34, 144: 36, 145: 38,
|
||||||
|
146: 40, 147: 42, 148: 44, 149: 46, 150: 48, 151: 50, 152: 52, 153: 54, 154: 56, 155: 58,
|
||||||
|
221: 0, 222: 2, 223: 20, 224: 52, 225: 32, 226: 16, 227: 6, 228: 22, 229: 34, 230: 36,
|
||||||
|
231: 8, 232: 54, 233: 56, 234: 38, 235: 40, 236: 10, 237: 12, 238: 24, 239: 26, 240: 42,
|
||||||
|
241: 14, 242: 58, 243: 28, 244: 44, 245: 46, 246: 4, 247: 18, 248: 30, 249: 50, 250: 48,
|
||||||
|
251: 0, 252: 20, 253: 22, 254: 40, 255: 42, 256: 2, 257: 4, 258: 36, 259: 44, 260: 46,
|
||||||
|
261: 6, 262: 8, 263: 26, 264: 24, 265: 50, 266: 10, 267: 30, 268: 38, 269: 58, 270: 48,
|
||||||
|
271: 12, 272: 14, 273: 34, 274: 52, 275: 54, 276: 16, 277: 18, 278: 28, 279: 32, 280: 56,
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSkillsTableMeta(): Readonly<Record<number, SkillTableMeta>> {
|
function buildSkillsTableMeta(): Readonly<Record<number, SkillTableMeta>> {
|
||||||
|
|
@ -435,15 +450,14 @@ function buildSkillsTableMeta(): Readonly<Record<number, SkillTableMeta>> {
|
||||||
|
|
||||||
for (const s of SKILLS_CATALOG) {
|
for (const s of SKILLS_CATALOG) {
|
||||||
const skillPage = s.tabIndex + 1
|
const skillPage = s.tabIndex + 1
|
||||||
const classStart = CLASS_FIRST_SKILL_ID[s.classCode]
|
const iconCel = CANONICAL_CLASS_SKILL_ICON_CEL[s.id] ?? 0
|
||||||
const iconCel = Math.max(0, (s.id - classStart) * 2)
|
|
||||||
table[s.id] = {
|
table[s.id] = {
|
||||||
skillId: s.id,
|
skillId: s.id,
|
||||||
skilldesc: s.name.toLowerCase(),
|
skilldesc: s.name.toLowerCase(),
|
||||||
passive: CANONICAL_PASSIVE_COLUMN_ONE.has(s.id) ? 1 : 0,
|
passive: CANONICAL_PASSIVE_COLUMN_ONE.has(s.id) ? 1 : 0,
|
||||||
leftskill: CANONICAL_LEFTSKILL_COLUMN_ONE.has(s.id) ? 1 : 0,
|
leftskill: CANONICAL_LEFTSKILL_COLUMN_ONE.has(s.id) ? 1 : 0,
|
||||||
aura: CANONICAL_AURA_COLUMN_ONE.has(s.id) ? 1 : 0,
|
aura: CANONICAL_AURA_COLUMN_ONE.has(s.id) ? 1 : 0,
|
||||||
maxlvl: 0,
|
maxlvl: 20,
|
||||||
skillPage,
|
skillPage,
|
||||||
listRow: skillPage,
|
listRow: skillPage,
|
||||||
listPool: 0,
|
listPool: 0,
|
||||||
|
|
|
||||||
|
|
@ -1129,16 +1129,16 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
|
|
||||||
const snapBuffs = session.sceneSource.buildFrameSnapshot()
|
const snapBuffs = session.sceneSource.buildFrameSnapshot()
|
||||||
expect(
|
expect(
|
||||||
snapBuffs.entities.overlayDrawables.some((o) => o.token === 'FrozenArmor' && o.loop === true),
|
snapBuffs.entities.overlayDrawables.some((o) => o.token === 'frozenarmor' && o.loop === true),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
snapBuffs.entities.overlayDrawables.some((o) => o.token === 'EnergyShield' && o.loop === true),
|
snapBuffs.entities.overlayDrawables.some((o) => o.token === 'energyshield' && o.loop === true),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
// Direct ClientUnit (UnitStateSet iterable) input to buildSceneDrawables:
|
// Direct ClientUnit (UnitStateSet iterable) input to buildSceneDrawables:
|
||||||
const directDrawables = buildSceneDrawables(session.world.units.values(), { tick: 3 })
|
const directDrawables = buildSceneDrawables(session.world.units.values(), { tick: 3 })
|
||||||
expect(
|
expect(
|
||||||
directDrawables.overlayDrawables.some((o) => o.token === 'FrozenArmor' && o.loop === true),
|
directDrawables.overlayDrawables.some((o) => o.token === 'frozenarmor' && o.loop === true),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
session.destroy()
|
session.destroy()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,679 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
|
||||||
|
import {
|
||||||
|
clearMissingArtEntries,
|
||||||
|
getMissingArtEntries,
|
||||||
|
} from '../../src/client/scene/world-renderer.ts'
|
||||||
|
import { CharacterSheetPanel } from '../../src/client/ui/character-sheet.ts'
|
||||||
|
import { D2FontRenderer } from '../../src/client/ui/font.ts'
|
||||||
|
import {
|
||||||
|
createCanonicalGenericSkillEntry,
|
||||||
|
LEFT_SKILL_BOUNDS,
|
||||||
|
resolveSkillIcon,
|
||||||
|
RIGHT_SKILL_BOUNDS,
|
||||||
|
SkillHotkeysHud,
|
||||||
|
} from '../../src/client/ui/hotkeys.ts'
|
||||||
|
import type { HudManager } from '../../src/client/ui/hud-manager.ts'
|
||||||
|
import {
|
||||||
|
ASSASSIN_HAND_TO_HAND_CLAW_CODES,
|
||||||
|
applySocketToUiItem,
|
||||||
|
classifyHandSlotItem,
|
||||||
|
InventoryPanel,
|
||||||
|
isAssassinClawItem,
|
||||||
|
resolveAllowedSlots,
|
||||||
|
type UiInventoryItem,
|
||||||
|
} from '../../src/client/ui/inventory.ts'
|
||||||
|
import {
|
||||||
|
CLASS_VERTICAL_TABS,
|
||||||
|
resolveSkillTreeTbl,
|
||||||
|
SkillTreePanel,
|
||||||
|
} from '../../src/client/ui/skill-tree-panel.ts'
|
||||||
|
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||||
|
import { resolveItemInvFile } from '../../src/common/items/item-bridge.ts'
|
||||||
|
|
||||||
|
function createRecordingCtx() {
|
||||||
|
const fillRectCalls: Array<{ x: number; y: number; w: number; h: number; fillStyle: string }> = []
|
||||||
|
const strokeRectCalls: Array<{ x: number; y: number; w: number; h: number; strokeStyle: string }> = []
|
||||||
|
let currentFillStyle = '#000000'
|
||||||
|
let currentStrokeStyle = '#000000'
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
save: vi.fn(),
|
||||||
|
restore: vi.fn(),
|
||||||
|
drawImage: vi.fn(),
|
||||||
|
fillRect: vi.fn((x: number, y: number, w: number, h: number) => {
|
||||||
|
fillRectCalls.push({ x, y, w, h, fillStyle: String(currentFillStyle) })
|
||||||
|
}),
|
||||||
|
strokeRect: vi.fn((x: number, y: number, w: number, h: number) => {
|
||||||
|
strokeRectCalls.push({ x, y, w, h, strokeStyle: String(currentStrokeStyle) })
|
||||||
|
}),
|
||||||
|
beginPath: vi.fn(),
|
||||||
|
closePath: vi.fn(),
|
||||||
|
arc: vi.fn(),
|
||||||
|
fill: vi.fn(),
|
||||||
|
stroke: vi.fn(),
|
||||||
|
clip: vi.fn(),
|
||||||
|
moveTo: vi.fn(),
|
||||||
|
lineTo: vi.fn(),
|
||||||
|
fillText: vi.fn(),
|
||||||
|
measureText: vi.fn((t: string) => ({ width: t.length * 8 })),
|
||||||
|
get fillStyle() {
|
||||||
|
return currentFillStyle
|
||||||
|
},
|
||||||
|
set fillStyle(v: string) {
|
||||||
|
currentFillStyle = v
|
||||||
|
},
|
||||||
|
get strokeStyle() {
|
||||||
|
return currentStrokeStyle
|
||||||
|
},
|
||||||
|
set strokeStyle(v: string) {
|
||||||
|
currentStrokeStyle = v
|
||||||
|
},
|
||||||
|
lineWidth: 1,
|
||||||
|
font: '',
|
||||||
|
textAlign: 'left',
|
||||||
|
textBaseline: 'alphabetic',
|
||||||
|
} as unknown as CanvasRenderingContext2D
|
||||||
|
|
||||||
|
return { ctx, fillRectCalls, strokeRectCalls }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Batch 7 Phase 1: UI Overlays, Missing-Art Fail-Fast, Offline Equip Rules, Skill Tree TBL & Integer Formatting (#690-#695)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMissingArtEntries()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #690: Remove Non-Canonical Skill Hotkey Overlays & Use Authentic Insufficient-Mana Tint', () => {
|
||||||
|
it('does not draw invented yellow aura borders/tints or red cooldown/charge overlays, and tints blue only when mana is insufficient', () => {
|
||||||
|
const hud = new SkillHotkeysHud()
|
||||||
|
const font = new D2FontRenderer()
|
||||||
|
const { ctx, fillRectCalls, strokeRectCalls } = createRecordingCtx()
|
||||||
|
const mockAtlas = {} as unknown as HTMLImageElement
|
||||||
|
|
||||||
|
// 1. Aura skill (Might = 98) on right slot: no #ffd700 border or yellow tint
|
||||||
|
hud.leftSkillId = 47 // Fire Ball (manaCost = 5)
|
||||||
|
hud.rightSkillId = 98 // Might (Aura, manaCost = 0)
|
||||||
|
hud.currentMana = 50
|
||||||
|
hud.draw(ctx, mockAtlas, font, true, 1000)
|
||||||
|
|
||||||
|
expect(strokeRectCalls.some((c) => c.strokeStyle.toLowerCase() === '#ffd700')).toBe(false)
|
||||||
|
expect(fillRectCalls.some((c) => c.fillStyle.includes('rgba(255, 215, 0'))).toBe(false)
|
||||||
|
|
||||||
|
// 2. Cooldown & 0 charges on Meteor (56, manaCost = 17) while mana is sufficient (50 >= 17): no red overlay
|
||||||
|
fillRectCalls.length = 0
|
||||||
|
hud.rightSkillId = 56
|
||||||
|
hud.setSkillCooldown(56, 1500, 1000)
|
||||||
|
hud.setSkillCharges(56, 0, 10)
|
||||||
|
hud.draw(ctx, mockAtlas, font, true, 1200)
|
||||||
|
|
||||||
|
expect(fillRectCalls.some((c) => c.fillStyle.includes('rgba(220, 20, 20'))).toBe(false)
|
||||||
|
expect(fillRectCalls.some((c) => c.fillStyle.includes('rgba(24, 60, 180'))).toBe(false)
|
||||||
|
|
||||||
|
// 3. Insufficient mana (currentMana = 10 < Meteor manaCost 17, but >= Fire Ball manaCost 5)
|
||||||
|
fillRectCalls.length = 0
|
||||||
|
hud.currentMana = 10
|
||||||
|
hud.draw(ctx, mockAtlas, font, true, 1200)
|
||||||
|
|
||||||
|
const rightManaOverlay = fillRectCalls.find(
|
||||||
|
(c) => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.fillStyle === 'rgba(24, 60, 180, 0.42)',
|
||||||
|
)
|
||||||
|
const leftManaOverlay = fillRectCalls.find(
|
||||||
|
(c) => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.fillStyle === 'rgba(24, 60, 180, 0.42)',
|
||||||
|
)
|
||||||
|
expect(rightManaOverlay).toBeDefined()
|
||||||
|
expect(leftManaOverlay).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #691: Fail-Fast via reportMissingArt Instead of Colored Placeholder Rectangles', () => {
|
||||||
|
it('reports missing skill icon art via reportMissingArt without drawing #5c2818 or #182c5c boxes', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const hud = new SkillHotkeysHud()
|
||||||
|
const font = new D2FontRenderer()
|
||||||
|
const { ctx, fillRectCalls } = createRecordingCtx()
|
||||||
|
|
||||||
|
hud.leftSkillId = 36 // Fire Bolt
|
||||||
|
hud.rightSkillId = 54 // Teleport
|
||||||
|
hud.draw(ctx, null, font, true, 1000)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fillRectCalls.some(
|
||||||
|
(c) => c.fillStyle.toLowerCase() === '#5c2818' || c.fillStyle.toLowerCase() === '#182c5c',
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
const entries = getMissingArtEntries()
|
||||||
|
expect(entries.some((e) => e.category === 'skill-icon' && e.id === 'icon_36')).toBe(true)
|
||||||
|
expect(entries.some((e) => e.category === 'skill-icon' && e.id === 'icon_54')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports missing inventory, character sheet, and skill tree panel art without drawing #181410 or #a81c1c placeholders', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
const font = new D2FontRenderer()
|
||||||
|
const { ctx, fillRectCalls } = createRecordingCtx()
|
||||||
|
|
||||||
|
// 1. InventoryPanel with missing invSheetImg
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
inv.visible = true
|
||||||
|
inv.draw(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
borderRightImg: null,
|
||||||
|
invSheetImg: null,
|
||||||
|
invTab0Img: null,
|
||||||
|
invTab1Img: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
itemsAtlasImg: null,
|
||||||
|
itemRects: {},
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. CharacterSheetPanel with missing charSheetImg and levelBtnImg (with statPoints > 0)
|
||||||
|
const charSheet = new CharacterSheetPanel()
|
||||||
|
charSheet.visible = true
|
||||||
|
charSheet.attrs.statPoints = 5
|
||||||
|
charSheet.draw(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
hp: 100,
|
||||||
|
maxHp: 100,
|
||||||
|
mana: 50,
|
||||||
|
maxMana: 50,
|
||||||
|
stamina: 100,
|
||||||
|
maxStamina: 100,
|
||||||
|
leftSkillDmg: '1-2',
|
||||||
|
rightSkillDmg: '1-2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
borderLeftImg: null,
|
||||||
|
charSheetImg: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
levelBtnImg: null,
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. SkillTreePanel with missing skillTreeBgImg, skillTabsImg, skillPointsImg, skillIconAtlas
|
||||||
|
const skillTree = new SkillTreePanel()
|
||||||
|
skillTree.characterClass = 'sor'
|
||||||
|
skillTree.visible = true
|
||||||
|
skillTree.draw(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
borderRightImg: null,
|
||||||
|
skillPointsImg: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
skillTabsImg: null,
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
|
||||||
|
const forbiddenColors = new Set(['#181410', '#a81c1c', '#14110d', '#080605', '#090705', '#1b1610'])
|
||||||
|
expect(fillRectCalls.some((c) => forbiddenColors.has(c.fillStyle.toLowerCase()))).toBe(false)
|
||||||
|
|
||||||
|
const entries = getMissingArtEntries()
|
||||||
|
expect(entries.some((e) => e.category === 'ui-panel' && e.id === 'invchar6-inv')).toBe(true)
|
||||||
|
expect(entries.some((e) => e.category === 'ui-panel' && e.id === 'invchar6-char')).toBe(true)
|
||||||
|
expect(entries.some((e) => e.category === 'ui-button' && e.id === 'level-btn')).toBe(true)
|
||||||
|
expect(entries.some((e) => e.category === 'ui-panel' && e.id.startsWith('skltree_bg_'))).toBe(true)
|
||||||
|
expect(entries.some((e) => e.category === 'ui-panel' && e.id.startsWith('skltree_tabs_'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #692 & #693: Fail-Fast Item Bridge, Assassin Dual Claws, Shield/1or2handed Slots, and Non-Destructive cursorItem', () => {
|
||||||
|
it('resolves shields, quivers, and 1or2handed swords to both weapon1 and weapon2 slots', () => {
|
||||||
|
expect(
|
||||||
|
resolveAllowedSlots({
|
||||||
|
id: 'uit',
|
||||||
|
kind: 'armor',
|
||||||
|
type: 'shie',
|
||||||
|
}),
|
||||||
|
).toEqual(['weapon1', 'weapon2'])
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveAllowedSlots({
|
||||||
|
id: 'aqv',
|
||||||
|
kind: 'misc',
|
||||||
|
type: 'bowq',
|
||||||
|
}),
|
||||||
|
).toEqual(['weapon1', 'weapon2'])
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveAllowedSlots({
|
||||||
|
id: '7gd',
|
||||||
|
kind: 'weapon',
|
||||||
|
type: 'swor',
|
||||||
|
}),
|
||||||
|
).toEqual(['weapon1', 'weapon2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows Assassin (ass) to dual-wield two Hand-to-Hand claws while blocking non-claw dual-wielding', () => {
|
||||||
|
expect(ASSASSIN_HAND_TO_HAND_CLAW_CODES.has('ktr')).toBe(true)
|
||||||
|
expect(ASSASSIN_HAND_TO_HAND_CLAW_CODES.has('9tw')).toBe(true)
|
||||||
|
expect(ASSASSIN_HAND_TO_HAND_CLAW_CODES.has('7qr')).toBe(true)
|
||||||
|
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
inv.setPlayerContext({
|
||||||
|
level: 80,
|
||||||
|
str: 150,
|
||||||
|
dex: 150,
|
||||||
|
classCode: 'ass',
|
||||||
|
})
|
||||||
|
|
||||||
|
const claw1: UiInventoryItem = {
|
||||||
|
id: 'claw-1',
|
||||||
|
code: '9tw', // Greater Talons
|
||||||
|
name: "Bartuc's Cut-Throat",
|
||||||
|
nameZh: '巴特克的猛击',
|
||||||
|
baseNameZh: '巨鹰爪',
|
||||||
|
quality: 'unique',
|
||||||
|
invFile: 'invskl',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['weapon1', 'weapon2'],
|
||||||
|
reqLevel: 42,
|
||||||
|
reqStr: 79,
|
||||||
|
reqDex: 79,
|
||||||
|
reqClass: 'ass',
|
||||||
|
stats: [],
|
||||||
|
identified: true,
|
||||||
|
}
|
||||||
|
const claw2: UiInventoryItem = {
|
||||||
|
...claw1,
|
||||||
|
id: 'claw-2',
|
||||||
|
code: '7qr', // Suwayyah
|
||||||
|
name: 'Jade Talon',
|
||||||
|
}
|
||||||
|
const phaseBlade: UiInventoryItem = {
|
||||||
|
id: 'pb-1',
|
||||||
|
code: '7cr',
|
||||||
|
name: 'Phase Blade',
|
||||||
|
nameZh: '幻化之刃',
|
||||||
|
baseNameZh: '幻化之刃',
|
||||||
|
quality: 'normal',
|
||||||
|
invFile: 'invpbl',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['weapon1', 'weapon2'],
|
||||||
|
reqLevel: 54,
|
||||||
|
reqStr: 25,
|
||||||
|
reqDex: 136,
|
||||||
|
stats: [],
|
||||||
|
identified: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(isAssassinClawItem(claw1)).toBe(true)
|
||||||
|
expect(isAssassinClawItem(claw2)).toBe(true)
|
||||||
|
expect(isAssassinClawItem(phaseBlade)).toBe(false)
|
||||||
|
|
||||||
|
// Equip claw1 in weapon1, then claw2 in weapon2 -> ALLOWED for Assassin
|
||||||
|
inv.cursorItem = claw1
|
||||||
|
expect(inv.clickEquipSlot('weapon1')).toBe(true)
|
||||||
|
inv.cursorItem = claw2
|
||||||
|
expect(inv.canEquipItemInSlot(claw2, 'weapon2')).toBe(true)
|
||||||
|
expect(inv.clickEquipSlot('weapon2')).toBe(true)
|
||||||
|
expect(inv.equipped.weapon1?.id).toBe('claw-1')
|
||||||
|
expect(inv.equipped.weapon2?.id).toBe('claw-2')
|
||||||
|
|
||||||
|
// Assassin cannot dual-wield a claw + a non-claw 1H sword
|
||||||
|
inv.cursorItem = phaseBlade
|
||||||
|
expect(inv.canEquipItemInSlot(phaseBlade, 'weapon2')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not classify 1H items as 2H from tooltip damage strings and never wipes starter inventory when setting cursorItem', () => {
|
||||||
|
const fakeTooltipOneHander: UiInventoryItem = {
|
||||||
|
id: 'fake-tooltip-1h',
|
||||||
|
code: 'crs', // Crystal Sword (1H)
|
||||||
|
name: 'Crystal Sword',
|
||||||
|
nameZh: '水晶剑',
|
||||||
|
baseNameZh: '水晶剑',
|
||||||
|
quality: 'normal',
|
||||||
|
invFile: 'invcrs',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['weapon1', 'weapon2'],
|
||||||
|
damage: '双手伤害: 10 - 20',
|
||||||
|
damageEn: 'Two-Hand Damage: 10 to 20',
|
||||||
|
stats: [],
|
||||||
|
identified: true,
|
||||||
|
}
|
||||||
|
expect(classifyHandSlotItem(fakeTooltipOneHander)).toBe('one_handed_weapon')
|
||||||
|
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
const ringItem: UiInventoryItem = {
|
||||||
|
id: 'starter-ring',
|
||||||
|
code: 'rin',
|
||||||
|
name: 'Ring',
|
||||||
|
nameZh: '戒指',
|
||||||
|
baseNameZh: '戒指',
|
||||||
|
quality: 'magic',
|
||||||
|
invFile: 'invrin',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: ['ring1', 'ring2'],
|
||||||
|
stats: [],
|
||||||
|
identified: true,
|
||||||
|
}
|
||||||
|
inv.gridItems = [{ item: ringItem, col: 0, row: 0 }]
|
||||||
|
inv.equipped.ring1 = ringItem
|
||||||
|
|
||||||
|
// Setting a raw item without allowedSlots onto cursorItem must NOT clear gridItems or equipped
|
||||||
|
inv.cursorItem = { id: 'raw-cursor', code: 'cap', name: 'Cap' } as unknown as UiInventoryItem
|
||||||
|
expect(inv.gridItems).toHaveLength(1)
|
||||||
|
expect(inv.equipped.ring1).toBe(ringItem)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies runeword socketing cleanly without throwing on unknown candidate itemType codes (#692)', () => {
|
||||||
|
const hostShield: UiInventoryItem = {
|
||||||
|
id: 'host-monarch',
|
||||||
|
code: 'uit',
|
||||||
|
name: 'Monarch',
|
||||||
|
nameZh: '统治者大盾',
|
||||||
|
baseNameZh: '统治者大盾',
|
||||||
|
baseNameEn: 'Monarch',
|
||||||
|
quality: 'normal',
|
||||||
|
invFile: 'invkts',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['weapon1', 'weapon2'],
|
||||||
|
sockets: 4,
|
||||||
|
socketedItems: [],
|
||||||
|
stats: [{ text: '有凹槽 (4)', color: 'blue' }],
|
||||||
|
identified: true,
|
||||||
|
}
|
||||||
|
const makeRune = (code: string, name: string): UiInventoryItem => ({
|
||||||
|
id: `rune-${code}`,
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
nameZh: name,
|
||||||
|
baseNameZh: name,
|
||||||
|
quality: 'rune',
|
||||||
|
invFile: `inv${code}`,
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [],
|
||||||
|
identified: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
applySocketToUiItem(hostShield, makeRune('r07', 'Tal Rune'))
|
||||||
|
applySocketToUiItem(hostShield, makeRune('r10', 'Thul Rune'))
|
||||||
|
applySocketToUiItem(hostShield, makeRune('r09', 'Ort Rune'))
|
||||||
|
applySocketToUiItem(hostShield, makeRune('r11', 'Amn Rune'))
|
||||||
|
|
||||||
|
expect(hostShield.quality).toBe('rune')
|
||||||
|
expect(hostShield.name).toBe('Spirit')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #694: Skill Tree Panel TBL String Keys StrSklTree1-StrSklTree34', () => {
|
||||||
|
it('resolves all 21 class tab titles and 3-line remaining skill points header from TBL keys in ENG and CHI', () => {
|
||||||
|
expect(resolveSkillTreeTbl('StrSklTree1', 'ENG')).toBe('Skill')
|
||||||
|
expect(resolveSkillTreeTbl('StrSklTree2', 'ENG')).toBe('Choices')
|
||||||
|
expect(resolveSkillTreeTbl('StrSklTree3', 'ENG')).toBe('Remaining')
|
||||||
|
|
||||||
|
expect(resolveSkillTreeTbl('StrSklTree1', 'CHI')).toBe('技能')
|
||||||
|
expect(resolveSkillTreeTbl('StrSklTree2', 'CHI')).toBe('選擇')
|
||||||
|
expect(resolveSkillTreeTbl('StrSklTree3', 'CHI')).toBe('剩餘')
|
||||||
|
|
||||||
|
// Verify 1-line tabs have empty subEn/subZh
|
||||||
|
expect(CLASS_VERTICAL_TABS.nec[2].titleKey).toBe('StrSklTree19')
|
||||||
|
expect(CLASS_VERTICAL_TABS.nec[2].subEn).toBe('')
|
||||||
|
expect(CLASS_VERTICAL_TABS.bar[0].titleKey).toBe('StrSklTree20')
|
||||||
|
expect(CLASS_VERTICAL_TABS.bar[0].subEn).toBe('')
|
||||||
|
expect(CLASS_VERTICAL_TABS.ass[2].titleKey).toBe('StrSklTree30')
|
||||||
|
expect(CLASS_VERTICAL_TABS.ass[2].subEn).toBe('')
|
||||||
|
|
||||||
|
// Verify Sorceress & Expansion Druid/Assassin tab titles match canonical TBL
|
||||||
|
expect(CLASS_VERTICAL_TABS.sor[0].titleEn).toBe('Cold')
|
||||||
|
expect(CLASS_VERTICAL_TABS.sor[0].subEn).toBe('Spells')
|
||||||
|
expect(CLASS_VERTICAL_TABS.dru[0].titleEn).toBe('Elemental')
|
||||||
|
expect(CLASS_VERTICAL_TABS.dru[0].subEn).toBe('Skills')
|
||||||
|
expect(CLASS_VERTICAL_TABS.ass[0].titleEn).toBe('Martial')
|
||||||
|
expect(CLASS_VERTICAL_TABS.ass[0].subEn).toBe('Arts')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #695: Unformatted Raw Integer Strings for Gold and Experience', () => {
|
||||||
|
it('renders CharacterSheetPanel experience and InventoryPanel gold without locale thousands separators', () => {
|
||||||
|
const { ctx } = createRecordingCtx()
|
||||||
|
const drawnTexts: string[] = []
|
||||||
|
const font = new D2FontRenderer()
|
||||||
|
vi.spyOn(font, 'drawText').mockImplementation((_c, text) => {
|
||||||
|
drawnTexts.push(String(text))
|
||||||
|
return 20
|
||||||
|
})
|
||||||
|
|
||||||
|
const charSheet = new CharacterSheetPanel()
|
||||||
|
charSheet.visible = true
|
||||||
|
charSheet.attrs.xp = 1234567
|
||||||
|
charSheet.attrs.nextLevelXp = 5000000
|
||||||
|
charSheet.draw(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
hp: 100,
|
||||||
|
maxHp: 100,
|
||||||
|
mana: 50,
|
||||||
|
maxMana: 50,
|
||||||
|
stamina: 100,
|
||||||
|
maxStamina: 100,
|
||||||
|
leftSkillDmg: '1-2',
|
||||||
|
rightSkillDmg: '1-2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
borderLeftImg: null,
|
||||||
|
charSheetImg: {} as HTMLImageElement,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
levelBtnImg: {} as HTMLImageElement,
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(drawnTexts).toContain('1234567')
|
||||||
|
expect(drawnTexts).toContain('5000000')
|
||||||
|
expect(drawnTexts).not.toContain('1,234,567')
|
||||||
|
expect(drawnTexts).not.toContain('5,000,000')
|
||||||
|
|
||||||
|
drawnTexts.length = 0
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
inv.visible = true
|
||||||
|
inv.gold = 2500000
|
||||||
|
inv.draw(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
borderRightImg: null,
|
||||||
|
invSheetImg: {} as HTMLImageElement,
|
||||||
|
invTab0Img: null,
|
||||||
|
invTab1Img: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
itemsAtlasImg: null,
|
||||||
|
itemRects: {},
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(drawnTexts).toContain('2500000')
|
||||||
|
expect(drawnTexts).not.toContain('2,500,000')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('M6 Gate Challenger 2 Remediation (#679, #681, #686, #688)', () => {
|
||||||
|
it('restricts createCanonicalGenericSkillEntry to generic skills (#0..#5, #217..#220) and omits charges on #0..#5', () => {
|
||||||
|
expect(createCanonicalGenericSkillEntry(36)).toBeNull()
|
||||||
|
expect(createCanonicalGenericSkillEntry(126)).toBeNull()
|
||||||
|
expect(createCanonicalGenericSkillEntry(251)).toBeNull()
|
||||||
|
|
||||||
|
const attack = createCanonicalGenericSkillEntry(0)
|
||||||
|
expect(attack?.charges).toBeUndefined()
|
||||||
|
const throwSkill = createCanonicalGenericSkillEntry(2)
|
||||||
|
expect(throwSkill?.charges).toBeUndefined()
|
||||||
|
|
||||||
|
const scrollTp = createCanonicalGenericSkillEntry(219, 12)
|
||||||
|
expect(scrollTp?.charges).toBe(12)
|
||||||
|
const tomeTp = createCanonicalGenericSkillEntry(220, 1, 18)
|
||||||
|
expect(tomeTp?.charges).toBe(18)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches MPQ skilldesc.txt IconCel for Paladin, Druid, and Assassin skills (#679, #688)', () => {
|
||||||
|
expect(resolveSkillIcon(125).frameIndex).toBe(58) // Salvation
|
||||||
|
expect(resolveSkillIcon(221).frameIndex).toBe(0) // Raven
|
||||||
|
expect(resolveSkillIcon(240).frameIndex).toBe(42) // Twister
|
||||||
|
expect(resolveSkillIcon(241).frameIndex).toBe(14) // Solar Creeper
|
||||||
|
expect(resolveSkillIcon(251).frameIndex).toBe(0) // Fire Blast
|
||||||
|
expect(resolveSkillIcon(280).frameIndex).toBe(56) // Royal Strike
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves Annihilus and Hellfire Torch invFile from uniqueItemDef without hardcoded name checks (#681)', () => {
|
||||||
|
expect(
|
||||||
|
resolveItemInvFile(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'cm1',
|
||||||
|
name: 'Annihilus',
|
||||||
|
quality: 7,
|
||||||
|
level: 110,
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
identified: true,
|
||||||
|
uniqueItemDef: { invfile: 'invmss' } as any,
|
||||||
|
base: { id: 'cm1', name: 'Small Charm', kind: 'misc', type: 'scha', invWidth: 1, invHeight: 1, maxStack: 1, value: 1, damage: 0, defense: 0, tags: ['scha'], level: 1 },
|
||||||
|
rolledProps: [],
|
||||||
|
stats: {},
|
||||||
|
} as any,
|
||||||
|
'unique',
|
||||||
|
'cm1',
|
||||||
|
).invFile,
|
||||||
|
).toBe('invmss')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveItemInvFile(
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
code: 'cm2',
|
||||||
|
name: 'Hellfire Torch',
|
||||||
|
quality: 7,
|
||||||
|
level: 110,
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 2,
|
||||||
|
identified: true,
|
||||||
|
uniqueItemDef: { invfile: 'invtrch' } as any,
|
||||||
|
base: { id: 'cm2', name: 'Large Charm', kind: 'misc', type: 'mcha', invWidth: 1, invHeight: 2, maxStack: 1, value: 1, damage: 0, defense: 0, tags: ['mcha'], level: 1 },
|
||||||
|
rolledProps: [],
|
||||||
|
stats: {},
|
||||||
|
} as any,
|
||||||
|
'unique',
|
||||||
|
'cm2',
|
||||||
|
).invFile,
|
||||||
|
).toBe('invtrch')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Deferred Ground Item Pickup toCursor Parity (command-mapper.ts)', () => {
|
||||||
|
it('sets toCursor = true when inventory panel is open upon reaching pending ground item', () => {
|
||||||
|
const world = new ClientWorld()
|
||||||
|
world.applyEvent({
|
||||||
|
type: 'LifeMana',
|
||||||
|
life: 100,
|
||||||
|
mana: 100,
|
||||||
|
stamina: 100,
|
||||||
|
at: { x: 100, y: 100 },
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
})
|
||||||
|
world.applyEvent({
|
||||||
|
type: 'ItemEvent',
|
||||||
|
action: 0x00,
|
||||||
|
container: 'ground',
|
||||||
|
item: {
|
||||||
|
id: 42,
|
||||||
|
itemId: 42,
|
||||||
|
opcode: 0x9c,
|
||||||
|
action: 0x00,
|
||||||
|
category: 0,
|
||||||
|
flags: 0,
|
||||||
|
rawFlags: 0,
|
||||||
|
version: 101,
|
||||||
|
location: 3,
|
||||||
|
container: 'ground',
|
||||||
|
bodyLoc: 0,
|
||||||
|
equippedSlot: 0,
|
||||||
|
gridX: 0,
|
||||||
|
gridY: 0,
|
||||||
|
storePage: 0,
|
||||||
|
storagePage: 0,
|
||||||
|
worldX: 110,
|
||||||
|
worldY: 100,
|
||||||
|
x: 110,
|
||||||
|
y: 100,
|
||||||
|
code: 'rin',
|
||||||
|
rawCode: 'rin ',
|
||||||
|
name: 'Gold Ring',
|
||||||
|
compact: true,
|
||||||
|
simple: true,
|
||||||
|
identified: true,
|
||||||
|
socketed: false,
|
||||||
|
ethereal: false,
|
||||||
|
runeword: false,
|
||||||
|
personalized: false,
|
||||||
|
isEar: false,
|
||||||
|
newItem: false,
|
||||||
|
starterItem: false,
|
||||||
|
broken: false,
|
||||||
|
inSocket: false,
|
||||||
|
equipped: false,
|
||||||
|
socketedChildCount: 0,
|
||||||
|
usedSockets: 0,
|
||||||
|
totalSockets: 0,
|
||||||
|
quality: 4,
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
stats: [],
|
||||||
|
setBonusStats: [],
|
||||||
|
runewordStats: [],
|
||||||
|
} as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const camera = new ViewportCamera(800, 600)
|
||||||
|
camera.snapTo(100, 100)
|
||||||
|
const hudStub = {
|
||||||
|
screenWidth: 800,
|
||||||
|
screenHeight: 600,
|
||||||
|
rightPanel: 'inv',
|
||||||
|
isRunning: false,
|
||||||
|
hotkeys: { leftSkillId: 0, rightSkillId: 0 },
|
||||||
|
handleMouseMove: () => {},
|
||||||
|
isPointInterceptedByHud: () => false,
|
||||||
|
} as unknown as HudManager
|
||||||
|
const mapper = new CommandMapper({
|
||||||
|
world,
|
||||||
|
camera,
|
||||||
|
hudManager: hudStub,
|
||||||
|
sendCommand: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click distant item (dist = 10 > 4) -> sets pendingItemPickupId = 42
|
||||||
|
const itemScreen = camera.subtileToScreen(110, 100)
|
||||||
|
const walkRecord = mapper.handlePointerDown(itemScreen.x, itemScreen.y, 0, 1000)
|
||||||
|
mapper.handlePointerUp(0)
|
||||||
|
expect(walkRecord?.command.type).toBe('ItemPickup')
|
||||||
|
|
||||||
|
// Move player within reach (dist = 2 <= 4) while rightPanel === 'inv'
|
||||||
|
world.applyEvent({
|
||||||
|
type: 'LifeMana',
|
||||||
|
life: 100,
|
||||||
|
mana: 100,
|
||||||
|
stamina: 100,
|
||||||
|
at: { x: 108, y: 100 },
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
})
|
||||||
|
const pickupCmd = mapper.tick(2000)
|
||||||
|
expect(pickupCmd).toEqual({
|
||||||
|
type: 'ItemPickup',
|
||||||
|
itemId: 42,
|
||||||
|
toCursor: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
VIEWPORT_1024x768,
|
VIEWPORT_1024x768,
|
||||||
VIEWPORT_1068x600,
|
VIEWPORT_1068x600,
|
||||||
VIEWPORT_1280x720,
|
VIEWPORT_1280x720,
|
||||||
|
VIEWPORT_640x480,
|
||||||
VIEWPORT_800x600,
|
VIEWPORT_800x600,
|
||||||
VIEWPORT_PROFILES,
|
VIEWPORT_PROFILES,
|
||||||
type ViewportProfile,
|
type ViewportProfile,
|
||||||
|
|
@ -183,7 +184,7 @@ describe('Formula-derived viewport profiles (800x600, 1024x768, 1068x600, 1280x7
|
||||||
|
|
||||||
it('keeps the profile table, the settings whitelist and the selector order in sync, with unique widths', () => {
|
it('keeps the profile table, the settings whitelist and the selector order in sync, with unique widths', () => {
|
||||||
expect(Object.keys(VIEWPORT_PROFILES)).toEqual(Array.from(VALID_VIEWPORT_RESOLUTIONS))
|
expect(Object.keys(VIEWPORT_PROFILES)).toEqual(Array.from(VALID_VIEWPORT_RESOLUTIONS))
|
||||||
expect(VALID_VIEWPORT_RESOLUTIONS).toEqual(['800x600', '1024x768', '1068x600', '1280x720'])
|
expect(VALID_VIEWPORT_RESOLUTIONS).toEqual(['640x480', '800x600', '1024x768', '1068x600', '1280x720'])
|
||||||
for (const [id, profile] of Object.entries(VIEWPORT_PROFILES)) {
|
for (const [id, profile] of Object.entries(VIEWPORT_PROFILES)) {
|
||||||
expect(profile.id).toBe(id)
|
expect(profile.id).toBe(id)
|
||||||
expect(`${profile.width}x${profile.height}`).toBe(id)
|
expect(`${profile.width}x${profile.height}`).toBe(id)
|
||||||
|
|
@ -194,8 +195,8 @@ describe('Formula-derived viewport profiles (800x600, 1024x768, 1068x600, 1280x7
|
||||||
|
|
||||||
it('fails fast on profiles that cannot host the native 1:1 HUD', () => {
|
it('fails fast on profiles that cannot host the native 1:1 HUD', () => {
|
||||||
expect(() => createViewportProfile('1280x720', 1280, 768)).toThrow(/does not match/)
|
expect(() => createViewportProfile('1280x720', 1280, 768)).toThrow(/does not match/)
|
||||||
expect(() => createViewportProfile('640x480' as ViewportResolution, 640, 480)).toThrow(
|
expect(() => createViewportProfile('640x480' as ViewportResolution, 640, 400)).toThrow(
|
||||||
/smaller than the native/,
|
/does not match/,
|
||||||
)
|
)
|
||||||
expect(() => createViewportProfile('1067x600' as ViewportResolution, 1067, 600)).toThrow(/even margins/)
|
expect(() => createViewportProfile('1067x600' as ViewportResolution, 1067, 600)).toThrow(/even margins/)
|
||||||
expect(() => createViewportProfile('1068x601' as ViewportResolution, 1068, 601)).toThrow(/even margins/)
|
expect(() => createViewportProfile('1068x601' as ViewportResolution, 1068, 601)).toThrow(/even margins/)
|
||||||
|
|
@ -216,9 +217,8 @@ describe('Viewport profile lookups', () => {
|
||||||
expect(getViewportProfile(' 1280X720 ')).toBe(VIEWPORT_1280x720)
|
expect(getViewportProfile(' 1280X720 ')).toBe(VIEWPORT_1280x720)
|
||||||
expect(getViewportProfile(Object.assign({}, VIEWPORT_1280x720))).toBe(VIEWPORT_1280x720)
|
expect(getViewportProfile(Object.assign({}, VIEWPORT_1280x720))).toBe(VIEWPORT_1280x720)
|
||||||
expect(getViewportProfile(VIEWPORT_1068x600)).toBe(VIEWPORT_1068x600)
|
expect(getViewportProfile(VIEWPORT_1068x600)).toBe(VIEWPORT_1068x600)
|
||||||
// Documented permissive fallback (tier2 F17.B1); prototype keys are not mistaken for ids.
|
expect(() => getViewportProfile('toString')).toThrow(/Unknown viewport profile/)
|
||||||
expect(getViewportProfile('toString')).toBe(VIEWPORT_800x600)
|
expect(() => getViewportProfile('1920x1080')).toThrow(/Unknown viewport profile/)
|
||||||
expect(getViewportProfile('1920x1080')).toBe(VIEWPORT_800x600)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('strict size lookup returns registered profiles and throws for anything else', () => {
|
it('strict size lookup returns registered profiles and throws for anything else', () => {
|
||||||
|
|
@ -251,7 +251,7 @@ describe('Settings, SettingsStore and Toolbar with the new resolutions', () => {
|
||||||
expect(reloaded.get().viewport).toBe('1068x600')
|
expect(reloaded.get().viewport).toBe('1068x600')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggleViewport cycles 800x600 -> 1024x768 -> 1068x600 -> 1280x720 -> 800x600 and reports each profile', () => {
|
it('toggleViewport cycles 800x600 -> 1024x768 -> 1068x600 -> 1280x720 -> 640x480 -> 800x600 and reports each profile', () => {
|
||||||
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
|
||||||
const onViewportChange = vi.fn()
|
const onViewportChange = vi.fn()
|
||||||
const toolbar = new Toolbar({ store, callbacks: { onViewportChange } })
|
const toolbar = new Toolbar({ store, callbacks: { onViewportChange } })
|
||||||
|
|
@ -262,6 +262,8 @@ describe('Settings, SettingsStore and Toolbar with the new resolutions', () => {
|
||||||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_1280x720)
|
expect(toolbar.toggleViewport()).toBe(VIEWPORT_1280x720)
|
||||||
expect(store.get().viewport).toBe('1280x720')
|
expect(store.get().viewport).toBe('1280x720')
|
||||||
expect(onViewportChange).toHaveBeenLastCalledWith(VIEWPORT_1280x720)
|
expect(onViewportChange).toHaveBeenLastCalledWith(VIEWPORT_1280x720)
|
||||||
|
expect(toolbar.toggleViewport()).toBe(VIEWPORT_640x480)
|
||||||
|
expect(store.get().viewport).toBe('640x480')
|
||||||
expect(toolbar.toggleViewport()).toBe(VIEWPORT_800x600)
|
expect(toolbar.toggleViewport()).toBe(VIEWPORT_800x600)
|
||||||
expect(store.get().viewport).toBe('800x600')
|
expect(store.get().viewport).toBe('800x600')
|
||||||
|
|
||||||
|
|
@ -364,11 +366,11 @@ describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () =>
|
||||||
const camera = new ViewportCamera(1280, 720)
|
const camera = new ViewportCamera(1280, 720)
|
||||||
expect(camera.viewportProfile).toBe(VIEWPORT_1280x720)
|
expect(camera.viewportProfile).toBe(VIEWPORT_1280x720)
|
||||||
camera.snapTo(100, 100)
|
camera.snapTo(100, 100)
|
||||||
expect(camera.subtileToScreen(100, 100)).toEqual({ x: 640, y: 360 })
|
expect(camera.subtileToScreen(100, 100)).toEqual({ x: 640, y: 344 })
|
||||||
expect(camera.screenToSubtile(640, 360)).toEqual({ x: 100, y: 100 })
|
expect(camera.screenToSubtile(640, 344)).toEqual({ x: 100, y: 100 })
|
||||||
camera.resize(1068, 600)
|
camera.resize(1068, 600)
|
||||||
expect(camera.viewportProfile).toBe(VIEWPORT_1068x600)
|
expect(camera.viewportProfile).toBe(VIEWPORT_1068x600)
|
||||||
expect(camera.subtileToScreen(100, 100)).toEqual({ x: 534, y: 300 })
|
expect(camera.subtileToScreen(100, 100)).toEqual({ x: 534, y: 284 })
|
||||||
expect(() => new ViewportCamera(1366, 768)).toThrow(/No viewport profile/)
|
expect(() => new ViewportCamera(1366, 768)).toThrow(/No viewport profile/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -593,14 +595,14 @@ describe('HUD input and hover coordinate translation across all 4 viewport profi
|
||||||
expect(hud.inventory.cursorItem?.id).toBe('bag-1')
|
expect(hud.inventory.cursorItem?.id).toBe('bag-1')
|
||||||
hud.inventory.cursorItem = null
|
hud.inventory.cursorItem = null
|
||||||
|
|
||||||
// 2. Equipped helm slot (canonical 532..590, 64..122 -> center 561, 93; tooltip anchor 561, 64)
|
// 2. Equipped helm slot (canonical 533..591, 68..126 -> center 562, 97; tooltip anchor 562, 68)
|
||||||
const helmItem = makeTestUiItem('helm-1', 'ci3', 'Griffon Eye', 2, 2)
|
const helmItem = makeTestUiItem('helm-1', 'ci3', 'Griffon Eye', 2, 2)
|
||||||
hud.inventory.equipped.helm = helmItem
|
hud.inventory.equipped.helm = helmItem
|
||||||
hud.handleMouseMove(561 + deltaRight, 93 + panelOffsetY)
|
hud.handleMouseMove(562 + deltaRight, 97 + panelOffsetY)
|
||||||
expect(hud.inventory.hoveredItem).toEqual({
|
expect(hud.inventory.hoveredItem).toEqual({
|
||||||
item: helmItem,
|
item: helmItem,
|
||||||
x: 561 + deltaRight,
|
x: 562 + deltaRight,
|
||||||
y: 64 + panelOffsetY,
|
y: 68 + panelOffsetY,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2b. Tooltip box clamping uses active viewport width/height (not hardcoded 800x600)
|
// 2b. Tooltip box clamping uses active viewport width/height (not hardcoded 800x600)
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
|
||||||
expect(isAuraSkill(47)).toBe(false) // Fire Ball
|
expect(isAuraSkill(47)).toBe(false) // Fire Ball
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders yellow border and yellow tint for aura skills', () => {
|
it('does not render invented yellow border or yellow tint for aura skills (1.13c parity)', () => {
|
||||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||||
const strokeRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
const strokeRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||||
|
|
||||||
|
|
@ -183,17 +183,15 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
|
||||||
hud.rightSkillId = 98 // Might (Aura)
|
hud.rightSkillId = 98 // Might (Aura)
|
||||||
hud.draw(mockCtx, null, font)
|
hud.draw(mockCtx, null, font)
|
||||||
|
|
||||||
// Check yellow aura tint over right skill icon (x=635)
|
|
||||||
const yellowTint = fillRectCalls.find(
|
const yellowTint = fillRectCalls.find(
|
||||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 2 && c.style.includes('rgba(255, 215, 0')
|
c => c.x === RIGHT_SKILL_BOUNDS.x + 2 && c.style.includes('rgba(255, 215, 0')
|
||||||
)
|
)
|
||||||
expect(yellowTint).toBeDefined()
|
expect(yellowTint).toBeUndefined()
|
||||||
|
|
||||||
// Check yellow border (#ffd700) around right skill button
|
|
||||||
const yellowBorder = strokeRectCalls.find(
|
const yellowBorder = strokeRectCalls.find(
|
||||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 0.5 && c.style === '#ffd700'
|
c => c.x === RIGHT_SKILL_BOUNDS.x + 0.5 && c.style === '#ffd700'
|
||||||
)
|
)
|
||||||
expect(yellowBorder).toBeDefined()
|
expect(yellowBorder).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('omits invented aura narrative tag in canonical 1.13c tooltip', () => {
|
it('omits invented aura narrative tag in canonical 1.13c tooltip', () => {
|
||||||
|
|
@ -207,7 +205,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('3. Skill Cooldown (CD) Red Unavailable Mask (技能在cd中的时候显示红色不可用遮罩)', () => {
|
describe('3. Skill Cooldown (CD) & Insufficient Mana Tint (1.13c Parity #690)', () => {
|
||||||
it('manages skill cooldown durations accurately', () => {
|
it('manages skill cooldown durations accurately', () => {
|
||||||
const now = 10000
|
const now = 10000
|
||||||
expect(hud.isSkillOnCooldown(56, now)).toBe(false) // Meteor
|
expect(hud.isSkillOnCooldown(56, now)).toBe(false) // Meteor
|
||||||
|
|
@ -221,7 +219,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
|
||||||
expect(hud.getSkillCooldownRemaining(56, now + 1200)).toBe(0)
|
expect(hud.getSkillCooldownRemaining(56, now + 1200)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders red unavailable overlay (rgba(220, 20, 20, 0.45)) when skill is on cooldown', () => {
|
it('does not render invented red cooldown mask, and renders blue tint only when mana is insufficient', () => {
|
||||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||||
|
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
|
|
@ -239,30 +237,40 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
} as unknown as CanvasRenderingContext2D
|
} as unknown as CanvasRenderingContext2D
|
||||||
|
|
||||||
hud.leftSkillId = 47 // Fire Ball
|
hud.leftSkillId = 47 // Fire Ball (manaCost: 5)
|
||||||
hud.rightSkillId = 56 // Meteor
|
hud.rightSkillId = 56 // Meteor (manaCost: 17)
|
||||||
const now = 20000
|
const now = 20000
|
||||||
|
|
||||||
// Put Meteor on cooldown
|
// Put Meteor on cooldown while player has sufficient mana
|
||||||
|
hud.currentMana = 50
|
||||||
hud.setSkillCooldown(56, 1200, now)
|
hud.setSkillCooldown(56, 1200, now)
|
||||||
|
|
||||||
// Draw at now + 300ms (still on CD)
|
// Draw at now + 300ms (still on CD)
|
||||||
hud.draw(mockCtx, null, font, true, now + 300)
|
hud.draw(mockCtx, null, font, true, now + 300)
|
||||||
|
|
||||||
// Right slot (Meteor) must have red mask
|
// Neither slot has invented red cooldown mask
|
||||||
const meteorRedMask = fillRectCalls.find(
|
const meteorRedMask = fillRectCalls.find(
|
||||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
||||||
)
|
)
|
||||||
expect(meteorRedMask).toBeDefined()
|
expect(meteorRedMask).toBeUndefined()
|
||||||
|
|
||||||
// Left slot (Fire Ball, not on CD) must NOT have red mask
|
// Lower mana to 10 (enough for Fire Ball=5, insufficient for Meteor=17)
|
||||||
const fireBallRedMask = fillRectCalls.find(
|
fillRectCalls.length = 0
|
||||||
c => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
hud.currentMana = 10
|
||||||
|
hud.draw(mockCtx, null, font, true, now + 300)
|
||||||
|
|
||||||
|
const meteorManaMask = fillRectCalls.find(
|
||||||
|
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(24, 60, 180')
|
||||||
)
|
)
|
||||||
expect(fireBallRedMask).toBeUndefined()
|
expect(meteorManaMask).toBeDefined()
|
||||||
|
|
||||||
|
const fireBallManaMask = fillRectCalls.find(
|
||||||
|
c => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(24, 60, 180')
|
||||||
|
)
|
||||||
|
expect(fireBallManaMask).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears cooldown and removes red mask', () => {
|
it('clears cooldown state accurately', () => {
|
||||||
hud.setSkillCooldown(56, 2000, 1000)
|
hud.setSkillCooldown(56, 2000, 1000)
|
||||||
expect(hud.isSkillOnCooldown(56, 1500)).toBe(true)
|
expect(hud.isSkillOnCooldown(56, 1500)).toBe(true)
|
||||||
|
|
||||||
|
|
@ -321,7 +329,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
|
||||||
expect(chargeTextCall!.color).toBe('white')
|
expect(chargeTextCall!.color).toBe('white')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders red unavailable mask when charges reach 0', () => {
|
it('does not render invented red mask when charges reach 0', () => {
|
||||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||||
|
|
||||||
const mockCtx = {
|
const mockCtx = {
|
||||||
|
|
@ -347,7 +355,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
|
||||||
const redMask = fillRectCalls.find(
|
const redMask = fillRectCalls.find(
|
||||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
||||||
)
|
)
|
||||||
expect(redMask).toBeDefined()
|
expect(redMask).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats charge status in tooltip', () => {
|
it('formats charge status in tooltip', () => {
|
||||||
|
|
|
||||||
|
|
@ -175,14 +175,28 @@ describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => {
|
||||||
skillTabsImg: mockTabsImg,
|
skillTabsImg: mockTabsImg,
|
||||||
}, mockFont)
|
}, mockFont)
|
||||||
|
|
||||||
// Top-right remaining skill points label and number aligned to recessed stone box center (ox + 276, oy + 78)
|
// Top-right remaining skill points 3-line TBL header (StrSklTree1..StrSklTree3) and number aligned to recessed stone box center (ox + 276, oy + 78)
|
||||||
expect(mockFont.drawText).toHaveBeenCalledWith(
|
expect(mockFont.drawText).toHaveBeenCalledWith(
|
||||||
mockCtx,
|
mockCtx,
|
||||||
'剩余技能点',
|
'技能',
|
||||||
|
SKILL_PANEL_ORIGIN.x + 276,
|
||||||
|
SKILL_PANEL_ORIGIN.y + 26,
|
||||||
|
expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }),
|
||||||
|
)
|
||||||
|
expect(mockFont.drawText).toHaveBeenCalledWith(
|
||||||
|
mockCtx,
|
||||||
|
'選擇',
|
||||||
SKILL_PANEL_ORIGIN.x + 276,
|
SKILL_PANEL_ORIGIN.x + 276,
|
||||||
SKILL_PANEL_ORIGIN.y + 38,
|
SKILL_PANEL_ORIGIN.y + 38,
|
||||||
expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }),
|
expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }),
|
||||||
)
|
)
|
||||||
|
expect(mockFont.drawText).toHaveBeenCalledWith(
|
||||||
|
mockCtx,
|
||||||
|
'剩餘',
|
||||||
|
SKILL_PANEL_ORIGIN.x + 276,
|
||||||
|
SKILL_PANEL_ORIGIN.y + 50,
|
||||||
|
expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }),
|
||||||
|
)
|
||||||
expect(mockFont.drawText).toHaveBeenCalledWith(
|
expect(mockFont.drawText).toHaveBeenCalledWith(
|
||||||
mockCtx,
|
mockCtx,
|
||||||
'12',
|
'12',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue