Merge branch 'fix/vendor-item-bg-color' into main
This commit is contained in:
commit
ffda82d650
|
|
@ -505,6 +505,12 @@ export class HudManager {
|
|||
difficulty: 0 | 1 | 2 = 0,
|
||||
): boolean {
|
||||
this.worldPanels.activePlayerInventory = this.inventory
|
||||
this.worldPanels.setPlayerContext({
|
||||
level: this.charSheet.attrs.level,
|
||||
str: this.charSheet.attrs.str,
|
||||
dex: this.charSheet.attrs.dex,
|
||||
classCode: this.currentClass ?? 'sor',
|
||||
})
|
||||
const ok = this.worldPanels.openVendorForNpc(
|
||||
descriptorOrName,
|
||||
mode,
|
||||
|
|
@ -1106,6 +1112,12 @@ export class HudManager {
|
|||
}
|
||||
|
||||
syncPublishedState(): void {
|
||||
this.worldPanels.setPlayerContext({
|
||||
level: this.charSheet.attrs.level,
|
||||
str: this.charSheet.attrs.str,
|
||||
dex: this.charSheet.attrs.dex,
|
||||
classCode: this.currentClass ?? 'sor',
|
||||
})
|
||||
this.syncCursorMode(this.cursor.lastNowMs)
|
||||
const cursorInfo = this.cursor.getCurrentFrame(this.cursor.lastNowMs)
|
||||
this.state.dc6DecodeFailures = this.dc6DecodeFailures
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
||||
import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts'
|
||||
import type { Item } from '../game/items.ts'
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
|
||||
import { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts'
|
||||
|
||||
|
|
@ -51,6 +52,8 @@ export interface UiInventoryItem {
|
|||
readonly damage?: string | undefined
|
||||
readonly reqLevel?: number | undefined
|
||||
readonly reqStr?: number | undefined
|
||||
readonly reqDex?: number | undefined
|
||||
readonly reqClass?: CharacterClassCode | undefined
|
||||
readonly stats: readonly { text: string; color?: D2ColorCode }[]
|
||||
readonly invtransform?: string | undefined
|
||||
readonly chrtransform?: string | undefined
|
||||
|
|
@ -1091,6 +1094,90 @@ export function isUiItemUnidentified(item: UiInventoryItem): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground Truth (D2Client.dll 1.13c @ 0x6fb3c600..0x6fb3c710 & 0x6fbbb330..0x6fbbb334):
|
||||
* Palette tint indices used by `INVENTORY_DrawGridItems` (`0x6fb458a0..0x6fb45b58`):
|
||||
* - Index 0 (`RGB(128, 0, 0)`): Red backdrop — unusable, unmet requirements, unidentified, or gamble mystery item
|
||||
* - Index 1 (`RGB(0, 128, 0)`): Green backdrop — hovered cell only when holding an item on cursor (`pCursorItem != NULL` at `0x6fb45a04`)
|
||||
* - Index 2 (`RGB(0, 0, 128)`): Blue backdrop — usable / equippable item
|
||||
*/
|
||||
export const GRID_ITEM_BG_RED = 'rgba(72, 18, 18, 0.50)'
|
||||
export const GRID_ITEM_BG_GREEN = 'rgba(46, 68, 36, 0.55)'
|
||||
export const GRID_ITEM_BG_BLUE = 'rgba(20, 32, 54, 0.45)'
|
||||
|
||||
export interface PlayerItemRequirementContext {
|
||||
readonly level: number
|
||||
readonly str: number
|
||||
readonly dex: number
|
||||
readonly classCode?: CharacterClassCode | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground Truth (D2Client.dll 1.13c @ 0x6fb45ab8..0x6fb45b54 & D2Common.dll @ 0x6fd76db0 `ITEMS_CheckItemRequirements` #10244):
|
||||
* Checks whether the player meets all requirements to equip/use an item in a vendor or inventory grid:
|
||||
* 1. Gamble mode or Unidentified (`bIdentified == 0` at `0x6fd76e23` / `0x6fd77006`) -> false (Red)
|
||||
* 2. Broken durability (`durability.max > 0 && durability.current <= 0`) -> false (Red)
|
||||
* 3. Empty Tome (`tbk` / `ibk` with `quantity <= 0` at `0x6fd77072`) -> false (Red)
|
||||
* 4. Class restriction (`0x6fd74280` `ITEMS_GetClassOfClassSpecificItem` #10202) -> false if `item.reqClass !== player.classCode` (Red)
|
||||
* 5. Level requirement (`0x6fd76fc7`) -> false if `player.level < item.reqLevel` (Red)
|
||||
* 6. Strength requirement (`0x6fd76eab`) -> false if `player.str < item.reqStr` (Red)
|
||||
* 7. Dexterity requirement (`0x6fd76f83`) -> false if `player.dex < item.reqDex` (Red)
|
||||
*/
|
||||
export function canPlayerUseUiItem(
|
||||
item: UiInventoryItem,
|
||||
player: PlayerItemRequirementContext,
|
||||
vendorMode: 'trade' | 'gamble' = 'trade',
|
||||
): boolean {
|
||||
if (vendorMode === 'gamble' || isUiItemUnidentified(item)) {
|
||||
return false
|
||||
}
|
||||
if (item.durability !== undefined && item.durability.max > 0 && item.durability.current <= 0) {
|
||||
return false
|
||||
}
|
||||
const code = (item.code ?? '').trim().toLowerCase()
|
||||
if ((code === 'tbk' || code === 'ibk') && getTomeQuantity(item) <= 0) {
|
||||
return false
|
||||
}
|
||||
if (item.reqClass !== undefined && player.classCode !== undefined && item.reqClass !== player.classCode) {
|
||||
return false
|
||||
}
|
||||
if (item.reqLevel !== undefined && item.reqLevel > 0 && player.level < item.reqLevel) {
|
||||
return false
|
||||
}
|
||||
if (item.reqStr !== undefined && item.reqStr > 0 && player.str < item.reqStr) {
|
||||
return false
|
||||
}
|
||||
if (item.reqDex !== undefined && item.reqDex > 0 && player.dex < item.reqDex) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground Truth (D2Client.dll 1.13c @ 0x6fb459f7..0x6fb45b58 `INVENTORY_DrawGridItems`):
|
||||
* Resolves the exact cell background fill style for an item in a grid:
|
||||
* - At `0x6fb45a04..0x6fb45a0c`: Hovering turns the backdrop GREEN (`index 1`) ONLY when `hasCursorItem` is true (`ds:0x6fbcbc28 != 0`).
|
||||
* Hovering with an empty cursor jumps to `0x6fb45ab8` and preserves the item's RED (`index 0`) or BLUE (`index 2`) requirement color.
|
||||
* - In Gamble mode (`vendorMode === 'gamble'`), all items are unidentified mystery items -> RED (`index 0`).
|
||||
* - In Trade mode, items that fail `canPlayerUseUiItem` -> RED (`index 0`), usable items -> BLUE (`index 2`).
|
||||
*/
|
||||
export function resolveGridItemBackgroundFill(
|
||||
item: UiInventoryItem,
|
||||
player: PlayerItemRequirementContext,
|
||||
options?: {
|
||||
readonly isHovered?: boolean | undefined
|
||||
readonly hasCursorItem?: boolean | undefined
|
||||
readonly vendorMode?: 'trade' | 'gamble' | undefined
|
||||
},
|
||||
): string {
|
||||
if (options?.isHovered && options?.hasCursorItem) {
|
||||
return GRID_ITEM_BG_GREEN
|
||||
}
|
||||
return canPlayerUseUiItem(item, player, options?.vendorMode ?? 'trade')
|
||||
? GRID_ITEM_BG_BLUE
|
||||
: GRID_ITEM_BG_RED
|
||||
}
|
||||
|
||||
export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
|
||||
;(item as { identified?: boolean; isGambleMystery?: boolean }).identified = true
|
||||
;(item as { identified?: boolean; isGambleMystery?: boolean }).isGambleMystery = false
|
||||
|
|
@ -1124,6 +1211,8 @@ export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
|
|||
...(rebuilt.damage !== undefined ? { damage: rebuilt.damage } : {}),
|
||||
...(rebuilt.reqLevel !== undefined ? { reqLevel: rebuilt.reqLevel } : {}),
|
||||
...(rebuilt.reqStr !== undefined ? { reqStr: rebuilt.reqStr } : {}),
|
||||
...(rebuilt.reqDex !== undefined ? { reqDex: rebuilt.reqDex } : {}),
|
||||
...(rebuilt.reqClass !== undefined ? { reqClass: rebuilt.reqClass } : {}),
|
||||
...(rebuilt.durability !== undefined ? { durability: rebuilt.durability } : {}),
|
||||
...(rebuilt.sockets !== undefined ? { sockets: rebuilt.sockets } : {}),
|
||||
...(rebuilt.invtransform !== undefined ? { invtransform: rebuilt.invtransform } : {}),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,83 @@ import { translateItemName, translateRareName, translateMagicName } from '../gam
|
|||
import { isRuneCode } from '../game/gems-data.ts'
|
||||
import type { D2ColorCode } from './font.ts'
|
||||
import { isA } from '../game/item-types.ts'
|
||||
import { inferItemClassFromType } from '../game/affixes.ts'
|
||||
import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes.ts'
|
||||
|
||||
const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES)
|
||||
|
||||
/**
|
||||
* Ground Truth (D2Common.dll 1.13c @ 0x6fd74280 `ITEMS_GetClassOfClassSpecificItem` #10202):
|
||||
* Resolves the required playable class (`'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'`)
|
||||
* for class-specific items from `ItemTypes.txt` `Class` column and type hierarchy (`Equiv1`/`Equiv2`),
|
||||
* or `undefined` if any class can equip/use the item.
|
||||
*/
|
||||
export function resolveItemRequiredClass(
|
||||
base: {
|
||||
readonly type?: string | undefined
|
||||
readonly type2?: string | undefined
|
||||
readonly tags?: readonly string[] | undefined
|
||||
readonly class?: string | undefined
|
||||
readonly reqClass?: string | undefined
|
||||
readonly itemClass?: string | undefined
|
||||
},
|
||||
rawItem?: {
|
||||
readonly reqClass?: string | undefined
|
||||
readonly itemClass?: string | undefined
|
||||
},
|
||||
dropTables?: DropTables,
|
||||
): CharacterClassCode | undefined {
|
||||
const explicit =
|
||||
rawItem?.reqClass ?? rawItem?.itemClass ?? base.reqClass ?? base.itemClass ?? base.class
|
||||
if (typeof explicit === 'string') {
|
||||
const norm = explicit.trim().toLowerCase()
|
||||
if (VALID_CLASS_CODES.has(norm)) return norm as CharacterClassCode
|
||||
}
|
||||
|
||||
const isAPred = (type: string, target: string): boolean => {
|
||||
const t = type.trim().toLowerCase()
|
||||
if (!t) return false
|
||||
if (t === target) return true
|
||||
if (dropTables?.isA) {
|
||||
try {
|
||||
if (dropTables.isA(t, target)) return true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (dropTables?.itemTypes) {
|
||||
try {
|
||||
if (isA(t, target, dropTables.itemTypes)) return true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const candidateTypes: string[] = []
|
||||
if (base.type) candidateTypes.push(base.type)
|
||||
if (base.type2) candidateTypes.push(base.type2)
|
||||
if (base.tags) candidateTypes.push(...base.tags)
|
||||
|
||||
for (const rawType of candidateTypes) {
|
||||
const typeCode = rawType.trim().toLowerCase()
|
||||
if (!typeCode) continue
|
||||
if (dropTables?.itemTypes) {
|
||||
const def = dropTables.itemTypes.byCode.get(typeCode)
|
||||
const defClass = def?.class?.trim().toLowerCase()
|
||||
if (defClass && VALID_CLASS_CODES.has(defClass)) {
|
||||
return defClass as CharacterClassCode
|
||||
}
|
||||
}
|
||||
const inferred = inferItemClassFromType(typeCode, isAPred)
|
||||
if (inferred && VALID_CLASS_CODES.has(inferred)) {
|
||||
return inferred as CharacterClassCode
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the allowable paperdoll equipment slot(s) for an item base based on
|
||||
|
|
@ -496,6 +573,31 @@ export function itemToUiInventoryItem(
|
|||
const dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe
|
||||
const permStore = Boolean(rawItem.permStore ?? base.permStoreItem)
|
||||
|
||||
const reqClass = !isGambleMystery
|
||||
? resolveItemRequiredClass(base as any, rawItem as any, dropTables)
|
||||
: undefined
|
||||
const effectiveReqLevel = !isGambleMystery
|
||||
? Math.max(
|
||||
tt.reqLevel ?? 0,
|
||||
typeof (rawItem as any).reqLevel === 'number' ? (rawItem as any).reqLevel : 0,
|
||||
typeof (base as any).levelreq === 'number' ? (base as any).levelreq : 0,
|
||||
)
|
||||
: 0
|
||||
const effectiveReqStr = !isGambleMystery
|
||||
? Math.max(
|
||||
tt.reqStr ?? 0,
|
||||
typeof (rawItem as any).reqStr === 'number' ? (rawItem as any).reqStr : 0,
|
||||
typeof (base as any).reqstr === 'number' && tt.reqStr === undefined ? (base as any).reqstr : 0,
|
||||
)
|
||||
: 0
|
||||
const effectiveReqDex = !isGambleMystery
|
||||
? Math.max(
|
||||
tt.reqDex ?? 0,
|
||||
typeof (rawItem as any).reqDex === 'number' ? (rawItem as any).reqDex : 0,
|
||||
typeof (base as any).reqdex === 'number' && tt.reqDex === undefined ? (base as any).reqdex : 0,
|
||||
)
|
||||
: 0
|
||||
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
|
|
@ -514,16 +616,10 @@ export function itemToUiInventoryItem(
|
|||
? { defense: rawItem.defense ?? base.defense }
|
||||
: {}),
|
||||
...(damage !== undefined ? { damage } : {}),
|
||||
...(!isGambleMystery && tt.reqLevel !== undefined
|
||||
? { reqLevel: tt.reqLevel }
|
||||
: !isGambleMystery && (rawItem.level || base.level)
|
||||
? { reqLevel: rawItem.level || base.level }
|
||||
: {}),
|
||||
...(!isGambleMystery && tt.reqStr !== undefined
|
||||
? { reqStr: tt.reqStr }
|
||||
: !isGambleMystery && (base as any).reqstr
|
||||
? { reqStr: (base as any).reqstr }
|
||||
: {}),
|
||||
...(effectiveReqLevel > 0 ? { reqLevel: effectiveReqLevel } : {}),
|
||||
...(effectiveReqStr > 0 ? { reqStr: effectiveReqStr } : {}),
|
||||
...(effectiveReqDex > 0 ? { reqDex: effectiveReqDex } : {}),
|
||||
...(reqClass !== undefined ? { reqClass } : {}),
|
||||
stats,
|
||||
...(!isGambleMystery && invtransform ? { invtransform } : {}),
|
||||
...(!isGambleMystery && chrtransform ? { chrtransform } : {}),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
|
||||
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
||||
import {
|
||||
GRID_ITEM_BG_BLUE,
|
||||
GRID_ITEM_BG_GREEN,
|
||||
GRID_ITEM_BG_RED,
|
||||
TOME_MAX_QUANTITY,
|
||||
canPlayerUseUiItem,
|
||||
findFreeGridSlot,
|
||||
getTomeQuantity,
|
||||
identifyUiItem,
|
||||
|
|
@ -18,13 +22,16 @@ import {
|
|||
isUiItemUnidentified,
|
||||
isUsableRightClickItem,
|
||||
itemToUiInventoryItem,
|
||||
resolveGridItemBackgroundFill,
|
||||
resolveItemSpriteRect,
|
||||
setTomeQuantity,
|
||||
type EquipSlotId,
|
||||
type GridPlacement,
|
||||
type InventoryPanel,
|
||||
type PlayerItemRequirementContext,
|
||||
type UiInventoryItem,
|
||||
} from './inventory.ts'
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
||||
import {
|
||||
type TownNpcServiceDescriptor,
|
||||
|
|
@ -37,6 +44,15 @@ import { VendorSessionManager } from '../game/vendor-service.ts'
|
|||
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
||||
import { isItemRepairable } from '../game/item-cost.ts'
|
||||
|
||||
export {
|
||||
GRID_ITEM_BG_BLUE,
|
||||
GRID_ITEM_BG_GREEN,
|
||||
GRID_ITEM_BG_RED,
|
||||
canPlayerUseUiItem,
|
||||
resolveGridItemBackgroundFill,
|
||||
type PlayerItemRequirementContext,
|
||||
}
|
||||
|
||||
export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor'
|
||||
|
||||
export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
|
||||
|
|
@ -562,6 +578,12 @@ export class WorldPanelsHud {
|
|||
activeVendorTab: VendorTabId = 'armor'
|
||||
vendorDifficulty: 0 | 1 | 2 = 0
|
||||
vendorCharLevel = 85
|
||||
vendorPlayerStats: PlayerItemRequirementContext = {
|
||||
level: 85,
|
||||
str: 156,
|
||||
dex: 75,
|
||||
classCode: 'sor',
|
||||
}
|
||||
vendorTradeState: VendorTradeState = 'idle'
|
||||
hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null
|
||||
pressedMomentarySlot: 0 | 1 | 2 | 3 | null = null
|
||||
|
|
@ -576,6 +598,28 @@ export class WorldPanelsHud {
|
|||
}
|
||||
gamblePlacements: GridPlacement[] = []
|
||||
|
||||
setPlayerContext(ctx: Partial<PlayerItemRequirementContext>): void {
|
||||
const nextLevel = ctx.level !== undefined ? Math.max(1, ctx.level) : this.vendorCharLevel
|
||||
this.vendorCharLevel = nextLevel
|
||||
this.vendorPlayerStats = {
|
||||
level: nextLevel,
|
||||
str: ctx.str !== undefined ? Math.max(0, ctx.str) : this.vendorPlayerStats.str,
|
||||
dex: ctx.dex !== undefined ? Math.max(0, ctx.dex) : this.vendorPlayerStats.dex,
|
||||
classCode: ctx.classCode ?? this.vendorPlayerStats.classCode,
|
||||
}
|
||||
}
|
||||
|
||||
getEffectivePlayerContext(): PlayerItemRequirementContext {
|
||||
return {
|
||||
...this.vendorPlayerStats,
|
||||
level: this.vendorCharLevel,
|
||||
}
|
||||
}
|
||||
|
||||
canPlayerUseVendorItem(item: UiInventoryItem): boolean {
|
||||
return canPlayerUseUiItem(item, this.getEffectivePlayerContext(), this.vendorMode)
|
||||
}
|
||||
|
||||
get repairCursorMode(): boolean {
|
||||
return this.vendorTradeState === 'repair'
|
||||
}
|
||||
|
|
@ -2352,9 +2396,11 @@ export class WorldPanelsHud {
|
|||
// 1. D2Client.dll `0x6fb45c8a–0x6fb45d63`: `buysell.dc6` 320x432 stitched background
|
||||
ctx.drawImage(assets.vendorBgImg, ox, oy)
|
||||
|
||||
// 2. D2Client.dll `0x6fb458a0`: Items on the active store page (`Inventory.txt` Monster2 grid at `ox+16, oy+63`)
|
||||
// 2. D2Client.dll `0x6fb458a0..0x6fb45b58`: Items on the active store page (`Inventory.txt` Monster2 grid at `ox+16, oy+63`)
|
||||
const atlas = assets.itemsAtlasImg ?? null
|
||||
const placements = this.getActiveVendorPlacements()
|
||||
const playerCtx = this.getEffectivePlayerContext()
|
||||
const hasCursorItem = Boolean((assets.inventory ?? this.activePlayerInventory)?.cursorItem)
|
||||
for (const placed of placements) {
|
||||
const gx = VENDOR_GRID_ORIGIN.x + placed.col * VENDOR_GRID_ORIGIN.cellPx
|
||||
const gy = VENDOR_GRID_ORIGIN.y + placed.row * VENDOR_GRID_ORIGIN.cellPx
|
||||
|
|
@ -2362,11 +2408,11 @@ export class WorldPanelsHud {
|
|||
const gh = placed.item.invHeight * VENDOR_GRID_ORIGIN.cellPx
|
||||
const isHovered = this.hoveredVendorItem?.item.id === placed.item.id
|
||||
|
||||
ctx.fillStyle = isHovered
|
||||
? 'rgba(46, 68, 36, 0.55)'
|
||||
: placed.item.quality === 'magic'
|
||||
? 'rgba(20, 34, 62, 0.42)'
|
||||
: 'rgba(28, 24, 18, 0.42)'
|
||||
ctx.fillStyle = resolveGridItemBackgroundFill(placed.item, playerCtx, {
|
||||
isHovered,
|
||||
hasCursorItem,
|
||||
vendorMode: this.vendorMode,
|
||||
})
|
||||
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
|
||||
|
||||
const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects)
|
||||
|
|
@ -2723,8 +2769,39 @@ export class WorldPanelsHud {
|
|||
if (item.speedText) {
|
||||
lines.push({ text: item.speedText, color: 'white', font: 'font8' })
|
||||
}
|
||||
const playerCtx = this.getEffectivePlayerContext()
|
||||
if (!unid && item.reqClass !== undefined) {
|
||||
const classLabelMap: Record<CharacterClassCode, string> = {
|
||||
ama: '(限亚马逊使用)',
|
||||
sor: '(限法师使用)',
|
||||
nec: '(限死灵法师使用)',
|
||||
pal: '(限圣骑士使用)',
|
||||
bar: '(限野蛮人使用)',
|
||||
dru: '(限德鲁伊使用)',
|
||||
ass: '(限刺客使用)',
|
||||
}
|
||||
const classText = classLabelMap[item.reqClass]
|
||||
if (classText) {
|
||||
lines.push({
|
||||
text: classText,
|
||||
color: playerCtx.classCode && playerCtx.classCode !== item.reqClass ? 'red' : 'white',
|
||||
font: 'font8',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (item.reqDex !== undefined && item.reqDex > 0) {
|
||||
lines.push({
|
||||
text: '需要敏捷: ' + item.reqDex,
|
||||
color: playerCtx.dex < item.reqDex ? 'red' : 'white',
|
||||
font: 'font8',
|
||||
})
|
||||
}
|
||||
if (item.reqStr !== undefined) {
|
||||
lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' })
|
||||
lines.push({
|
||||
text: '需要力量: ' + item.reqStr,
|
||||
color: playerCtx.str < item.reqStr ? 'red' : 'white',
|
||||
font: 'font8',
|
||||
})
|
||||
}
|
||||
const displayReqLevel = unid
|
||||
? item.rawItem?.base?.level && item.rawItem.base.level > 1
|
||||
|
|
@ -2732,7 +2809,11 @@ export class WorldPanelsHud {
|
|||
: undefined
|
||||
: item.reqLevel
|
||||
if (displayReqLevel !== undefined) {
|
||||
lines.push({ text: '需要等级: ' + displayReqLevel, color: 'white', font: 'font8' })
|
||||
lines.push({
|
||||
text: '需要等级: ' + displayReqLevel,
|
||||
color: playerCtx.level < displayReqLevel ? 'red' : 'white',
|
||||
font: 'font8',
|
||||
})
|
||||
}
|
||||
|
||||
if (unid) {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,13 @@ import { fileSource } from '../src/mpq/file-source.ts'
|
|||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
import type { D2ColorCode, D2FontRenderer, D2FontName } from '../src/ui/font.ts'
|
||||
import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts'
|
||||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||||
import { InventoryPanel, itemToUiInventoryItem, type UiInventoryItem } from '../src/ui/inventory.ts'
|
||||
import {
|
||||
BLACKSMITH_CLASS_IDS,
|
||||
GRID_ITEM_BG_BLUE,
|
||||
GRID_ITEM_BG_GREEN,
|
||||
GRID_ITEM_BG_RED,
|
||||
TRADER_CLASS_IDS,
|
||||
VENDOR_BUTTON_DRAW_Y,
|
||||
VENDOR_BUTTON_HIT_W,
|
||||
|
|
@ -98,6 +102,7 @@ interface FontCall {
|
|||
function createMockCanvasAndFont() {
|
||||
const drawImageCalls: DrawImageCall[] = []
|
||||
const fillRectCalls: number[][] = []
|
||||
const fillRectRecords: Array<{ fillStyle: string; args: number[] }> = []
|
||||
const strokeRectCalls: number[][] = []
|
||||
const fontCalls: FontCall[] = []
|
||||
|
||||
|
|
@ -109,6 +114,7 @@ function createMockCanvasAndFont() {
|
|||
}),
|
||||
fillRect: vi.fn((...nums: number[]) => {
|
||||
fillRectCalls.push(nums)
|
||||
fillRectRecords.push({ fillStyle: String(ctx.fillStyle), args: nums })
|
||||
}),
|
||||
strokeRect: vi.fn((...nums: number[]) => {
|
||||
strokeRectCalls.push(nums)
|
||||
|
|
@ -175,7 +181,7 @@ function createMockCanvasAndFont() {
|
|||
),
|
||||
} as unknown as D2FontRenderer
|
||||
|
||||
return { ctx, font, drawImageCalls, fillRectCalls, strokeRectCalls, fontCalls }
|
||||
return { ctx, font, drawImageCalls, fillRectCalls, fillRectRecords, strokeRectCalls, fontCalls }
|
||||
}
|
||||
|
||||
describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
||||
|
|
@ -642,5 +648,238 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
|||
expect(openedVendorMode).toBe('trade')
|
||||
expect(wp.npcMenu).toBeNull()
|
||||
})
|
||||
|
||||
it('draws Vendor Trade items with BLUE background when usable and RED background when failing class, level, str, or dex requirements (D2Client.dll 0x6fb458a0..0x6fb45b58 & D2Common #10244)', () => {
|
||||
const tables = getEmbeddedDropTables()
|
||||
const wp = new WorldPanelsHud()
|
||||
const inv = new InventoryPanel()
|
||||
wp.activePlayerInventory = inv
|
||||
expect(wp.openVendorForNpc('Charsi', 'trade', 10, 0)).toBe(true)
|
||||
|
||||
// Set player context: Level 10 Sorceress with 25 Str, 25 Dex
|
||||
wp.setPlayerContext({
|
||||
level: 10,
|
||||
str: 25,
|
||||
dex: 25,
|
||||
classCode: 'sor',
|
||||
})
|
||||
|
||||
// 1. Usable Normal Cap (levelreq=0, reqstr=0, high ilvl=12 must NOT falsely set reqLevel=12!)
|
||||
const capBase = tables.getBase('cap')!
|
||||
const usableNormalCap = itemToUiInventoryItem(
|
||||
{
|
||||
base: capBase,
|
||||
code: 'cap',
|
||||
name: 'Cap',
|
||||
level: 12,
|
||||
ilvl: 12,
|
||||
quality: 2,
|
||||
rarity: 'normal',
|
||||
identified: true,
|
||||
invWidth: capBase.invWidth,
|
||||
invHeight: capBase.invHeight,
|
||||
stack: 1,
|
||||
value: 10,
|
||||
stats: {},
|
||||
} as any,
|
||||
tables,
|
||||
)
|
||||
expect(usableNormalCap.reqLevel).toBeUndefined()
|
||||
|
||||
// 2. Usable Magic Leather Armor (reqstr=15 <= 25, reqLevel=3 <= 10)
|
||||
const leaBase = tables.getBase('lea')!
|
||||
const usableMagicArmor = itemToUiInventoryItem(
|
||||
{
|
||||
base: leaBase,
|
||||
code: 'lea',
|
||||
name: 'Sturdy Leather Armor',
|
||||
level: 12,
|
||||
ilvl: 12,
|
||||
quality: 4,
|
||||
rarity: 'magic',
|
||||
identified: true,
|
||||
reqLevel: 3,
|
||||
invWidth: leaBase.invWidth,
|
||||
invHeight: leaBase.invHeight,
|
||||
stack: 1,
|
||||
value: 50,
|
||||
stats: {},
|
||||
} as any,
|
||||
tables,
|
||||
)
|
||||
|
||||
// 3. Unusable due to Class Restriction: Assassin Katar ('ktr', type 'h2h' -> reqClass 'ass' !== 'sor')
|
||||
const ktrBase = tables.getBase('ktr')!
|
||||
const classRestrictedKatar = itemToUiInventoryItem(
|
||||
{
|
||||
base: ktrBase,
|
||||
code: 'ktr',
|
||||
name: 'Katar',
|
||||
level: 12,
|
||||
ilvl: 12,
|
||||
quality: 2,
|
||||
rarity: 'normal',
|
||||
identified: true,
|
||||
invWidth: ktrBase.invWidth,
|
||||
invHeight: ktrBase.invHeight,
|
||||
stack: 1,
|
||||
value: 40,
|
||||
stats: {},
|
||||
} as any,
|
||||
tables,
|
||||
)
|
||||
expect(classRestrictedKatar.reqClass).toBe('ass')
|
||||
|
||||
// 4. Unusable due to Strength Requirement: Full Plate Mail ('ful', reqstr=80 > 25)
|
||||
const fulBase = tables.getBase('ful')!
|
||||
const highStrArmor = itemToUiInventoryItem(
|
||||
{
|
||||
base: fulBase,
|
||||
code: 'ful',
|
||||
name: 'Full Plate Mail',
|
||||
level: 12,
|
||||
ilvl: 12,
|
||||
quality: 2,
|
||||
rarity: 'normal',
|
||||
identified: true,
|
||||
invWidth: fulBase.invWidth,
|
||||
invHeight: fulBase.invHeight,
|
||||
stack: 1,
|
||||
value: 500,
|
||||
stats: {},
|
||||
} as any,
|
||||
tables,
|
||||
)
|
||||
expect(highStrArmor.reqStr).toBe(80)
|
||||
|
||||
// 5. Unusable due to Dexterity Requirement: Long Bow ('lbw', reqdex=19, overridden to reqDex=40 > 25)
|
||||
const lbwBase = tables.getBase('lbw')!
|
||||
const highDexBow = itemToUiInventoryItem(
|
||||
{
|
||||
base: lbwBase,
|
||||
code: 'lbw',
|
||||
name: 'Long Bow',
|
||||
level: 12,
|
||||
ilvl: 12,
|
||||
quality: 2,
|
||||
rarity: 'normal',
|
||||
identified: true,
|
||||
reqDex: 40,
|
||||
invWidth: lbwBase.invWidth,
|
||||
invHeight: lbwBase.invHeight,
|
||||
stack: 1,
|
||||
value: 200,
|
||||
stats: {},
|
||||
} as any,
|
||||
tables,
|
||||
)
|
||||
|
||||
// 6. Unusable due to Level Requirement: Magic Ring requiring level 25 > 10
|
||||
const rinBase = tables.getBase('rin')!
|
||||
const highLevelRing = itemToUiInventoryItem(
|
||||
{
|
||||
base: rinBase,
|
||||
code: 'rin',
|
||||
name: 'Fortuitous Ring',
|
||||
level: 30,
|
||||
ilvl: 30,
|
||||
quality: 4,
|
||||
rarity: 'magic',
|
||||
identified: true,
|
||||
reqLevel: 25,
|
||||
invWidth: rinBase.invWidth,
|
||||
invHeight: rinBase.invHeight,
|
||||
stack: 1,
|
||||
value: 1000,
|
||||
stats: {},
|
||||
} as any,
|
||||
tables,
|
||||
)
|
||||
|
||||
wp.vendorTabPlacements.armor = [
|
||||
{ item: usableNormalCap, col: 0, row: 0 },
|
||||
{ item: usableMagicArmor, col: 2, row: 0 },
|
||||
{ item: classRestrictedKatar, col: 4, row: 0 },
|
||||
{ item: highStrArmor, col: 5, row: 0 },
|
||||
{ item: highDexBow, col: 7, row: 0 },
|
||||
{ item: highLevelRing, col: 9, row: 0 },
|
||||
]
|
||||
wp.activeVendorTab = 'armor'
|
||||
|
||||
// Hover over the usable normal cap WITHOUT holding a cursorItem:
|
||||
// Per D2Client.dll 0x6fb45a04..0x6fb45a0c, hovering with empty cursor must NOT turn green!
|
||||
wp.hoveredVendorItem = { item: usableNormalCap, x: 100, y: 130, col: 0, row: 0 }
|
||||
inv.cursorItem = null
|
||||
|
||||
const { ctx, font, fillRectRecords } = createMockCanvasAndFont()
|
||||
wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font)
|
||||
|
||||
expect(fillRectRecords.map(r => r.fillStyle)).toEqual([
|
||||
GRID_ITEM_BG_BLUE, // usableNormalCap (normal quality, usable -> BLUE, never brown!)
|
||||
GRID_ITEM_BG_BLUE, // usableMagicArmor (magic quality, usable -> BLUE)
|
||||
GRID_ITEM_BG_RED, // classRestrictedKatar ('ass' !== 'sor' -> RED)
|
||||
GRID_ITEM_BG_RED, // highStrArmor (reqStr 80 > 25 -> RED)
|
||||
GRID_ITEM_BG_RED, // highDexBow (reqDex 40 > 25 -> RED)
|
||||
GRID_ITEM_BG_RED, // highLevelRing (reqLevel 25 > 10 -> RED)
|
||||
])
|
||||
|
||||
// When holding a cursorItem and hovering over an item, its cell backdrop turns GREEN per 0x6fb45a04
|
||||
inv.cursorItem = usableNormalCap
|
||||
const { ctx: ctx2, font: font2, fillRectRecords: recordsWithCursor } = createMockCanvasAndFont()
|
||||
wp.drawLeftDockPanel(ctx2, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font2)
|
||||
expect(recordsWithCursor[0]!.fillStyle).toBe(GRID_ITEM_BG_GREEN)
|
||||
|
||||
// When switching to level 30 Assassin with 100 Str and 100 Dex, all 6 items become usable -> ALL BLUE!
|
||||
inv.cursorItem = null
|
||||
wp.hoveredVendorItem = null
|
||||
wp.setPlayerContext({
|
||||
level: 30,
|
||||
str: 100,
|
||||
dex: 100,
|
||||
classCode: 'ass',
|
||||
})
|
||||
const { ctx: ctx3, font: font3, fillRectRecords: recordsAllMet } = createMockCanvasAndFont()
|
||||
wp.drawLeftDockPanel(ctx3, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font3)
|
||||
expect(recordsAllMet.map(r => r.fillStyle)).toEqual([
|
||||
GRID_ITEM_BG_BLUE,
|
||||
GRID_ITEM_BG_BLUE,
|
||||
GRID_ITEM_BG_BLUE,
|
||||
GRID_ITEM_BG_BLUE,
|
||||
GRID_ITEM_BG_BLUE,
|
||||
GRID_ITEM_BG_BLUE,
|
||||
])
|
||||
})
|
||||
|
||||
it('draws ALL items in Gamble mode with RED background regardless of player level, stats, or class (D2Common.dll 0x6fd76e23 / 0x6fd77006)', () => {
|
||||
const wp = new WorldPanelsHud()
|
||||
const inv = new InventoryPanel()
|
||||
wp.activePlayerInventory = inv
|
||||
wp.setPlayerContext({
|
||||
level: 99,
|
||||
str: 500,
|
||||
dex: 500,
|
||||
classCode: 'sor',
|
||||
})
|
||||
expect(wp.openVendorForNpc('Gheed', 'gamble', 99, 0)).toBe(true)
|
||||
expect(wp.gamblePlacements.length).toBeGreaterThan(0)
|
||||
|
||||
// Even when hovering a gamble item with an empty cursor, every gamble item background must be RED
|
||||
wp.hoveredVendorItem = {
|
||||
item: wp.gamblePlacements[0]!.item,
|
||||
x: 100,
|
||||
y: 130,
|
||||
col: wp.gamblePlacements[0]!.col,
|
||||
row: wp.gamblePlacements[0]!.row,
|
||||
}
|
||||
|
||||
const { ctx, font, fillRectRecords } = createMockCanvasAndFont()
|
||||
wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font)
|
||||
|
||||
expect(fillRectRecords).toHaveLength(wp.gamblePlacements.length)
|
||||
for (const rec of fillRectRecords) {
|
||||
expect(rec.fillStyle).toBe(GRID_ITEM_BG_RED)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue