Fix inventory.txt layout, item graphicIndex, misc.txt usables, weapon swap tabs, slot colors, and socket overlays (Closes #680, #681, #682, #683, #684, #685)
This commit is contained in:
parent
f26fa9e636
commit
04bfa1a157
|
|
@ -42,6 +42,7 @@ import {
|
||||||
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
|
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
|
||||||
CANONICAL_ONE_HANDED_WEAPON_CODES,
|
CANONICAL_ONE_HANDED_WEAPON_CODES,
|
||||||
CANONICAL_SHIELD_AND_OFFHAND_CODES,
|
CANONICAL_SHIELD_AND_OFFHAND_CODES,
|
||||||
|
CANONICAL_VAR_INV_GFX,
|
||||||
STRICT_TWO_HANDED_WEAPON_CODES,
|
STRICT_TWO_HANDED_WEAPON_CODES,
|
||||||
getEffectiveItemReqClass,
|
getEffectiveItemReqClass,
|
||||||
itemToUiInventoryItem,
|
itemToUiInventoryItem,
|
||||||
|
|
@ -55,6 +56,7 @@ export {
|
||||||
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
|
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
|
||||||
CANONICAL_ONE_HANDED_WEAPON_CODES,
|
CANONICAL_ONE_HANDED_WEAPON_CODES,
|
||||||
CANONICAL_SHIELD_AND_OFFHAND_CODES,
|
CANONICAL_SHIELD_AND_OFFHAND_CODES,
|
||||||
|
CANONICAL_VAR_INV_GFX,
|
||||||
STRICT_TWO_HANDED_WEAPON_CODES,
|
STRICT_TWO_HANDED_WEAPON_CODES,
|
||||||
getEffectiveItemReqClass,
|
getEffectiveItemReqClass,
|
||||||
itemToUiInventoryItem,
|
itemToUiInventoryItem,
|
||||||
|
|
@ -85,29 +87,338 @@ export {
|
||||||
type GridPlacement,
|
type GridPlacement,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INV_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
|
export type InventoryTxtBodySlotKey =
|
||||||
export const INV_GRID_ORIGIN = { x: 400 + 18, y: 60 + 256, cols: 10, rows: 4, cellPx: 29 } as const
|
| 'rArm'
|
||||||
|
| 'torso'
|
||||||
|
| 'lArm'
|
||||||
|
| 'head'
|
||||||
|
| 'neck'
|
||||||
|
| 'rHand'
|
||||||
|
| 'lHand'
|
||||||
|
| 'belt'
|
||||||
|
| 'feet'
|
||||||
|
| 'gloves'
|
||||||
|
|
||||||
|
export interface InventoryTxtSlotRect {
|
||||||
|
readonly left: number
|
||||||
|
readonly right: number
|
||||||
|
readonly top: number
|
||||||
|
readonly bottom: number
|
||||||
|
readonly width: number
|
||||||
|
readonly height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryTxtRow {
|
||||||
|
readonly class: string
|
||||||
|
readonly invLeft: number
|
||||||
|
readonly invRight: number
|
||||||
|
readonly invTop: number
|
||||||
|
readonly invBottom: number
|
||||||
|
readonly invWidth: number
|
||||||
|
readonly invHeight: number
|
||||||
|
readonly gridX: number
|
||||||
|
readonly gridY: number
|
||||||
|
readonly gridLeft: number
|
||||||
|
readonly gridRight: number
|
||||||
|
readonly gridTop: number
|
||||||
|
readonly gridBottom: number
|
||||||
|
readonly gridBoxWidth: number
|
||||||
|
readonly gridBoxHeight: number
|
||||||
|
readonly slots: Readonly<Record<InventoryTxtBodySlotKey, InventoryTxtSlotRect>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground Truth (`data\global\excel\inventory.txt` from Diablo II v1.13c `patch_d2.mpq`):
|
||||||
|
* All 32 canonical panel layout definitions (640x480 base rows + 800x600 `*2` rows).
|
||||||
|
*/
|
||||||
|
export const RAW_INVENTORY_TXT = [
|
||||||
|
'class\tinvLeft\tinvRight\tinvTop\tinvBottom\tgridX\tgridY\tgridLeft\tgridRight\tgridTop\tgridBottom\tgridBoxWidth\tgridBoxHeight\trArmLeft\trArmRight\trArmTop\trArmBottom\trArmWidth\trArmHeight\ttorsoLeft\ttorsoRight\ttorsoTop\ttorsoBottom\ttorsoWidth\ttorsoHeight\tlArmLeft\tlArmRight\tlArmTop\tlArmBottom\tlArmWidth\tlArmHeight\theadLeft\theadRight\theadTop\theadBottom\theadWidth\theadHeight\tneckLeft\tneckRight\tneckTop\tneckBottom\tneckWidth\tneckHeight\trHandLeft\trHandRight\trHandTop\trHandBottom\trHandWidth\trHandHeight\tlHandLeft\tlHandRight\tlHandTop\tlHandBottom\tlHandWidth\tlHandHeight\tbeltLeft\tbeltRight\tbeltTop\tbeltBottom\tbeltWidth\tbeltHeight\tfeetLeft\tfeetRight\tfeetTop\tfeetBottom\tfeetWidth\tfeetHeight\tglovesLeft\tglovesRight\tglovesTop\tglovesBottom\tglovesWidth\tglovesHeight',
|
||||||
|
'Amazon\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
|
||||||
|
'Sorceress\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
|
||||||
|
'Necromancer\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
|
||||||
|
'Paladin\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
|
||||||
|
'Barbarian\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
|
||||||
|
'Monster\t0\t320\t0\t441\t10\t10\t19\t306\t82\t370\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Trade Page 1\t0\t320\t0\t441\t10\t4\t19\t306\t12\t127\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Trade Page 2\t0\t320\t0\t441\t10\t4\t19\t306\t223\t338\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Bank Page 1\t0\t320\t0\t441\t6\t4\t74\t249\t143\t258\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Transmogrify Box Page 1\t0\t320\t0\t441\t3\t4\t116\t202\t139\t254\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Guild Vault Page 1\t0\t320\t0\t441\t8\t8\t46\t279\t88\t321\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Trophy Case Page 1\t0\t320\t0\t441\t2\t2\t111\t216\t141\t254\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Big Bank Page 1\t0\t320\t0\t441\t6\t8\t74\t249\t82\t315\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Hireling\t0\t320\t0\t441\t0\t0\t-1\t-1\t-1\t-1\t29\t29\t20\t75\t47\t159\t55\t112\t133\t189\t77\t159\t56\t82\t251\t306\t47\t159\t55\t112\t135\t189\t8\t59\t54\t51\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1',
|
||||||
|
'Druid\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
|
||||||
|
'Assassin\t320\t640\t0\t441\t10\t4\t339\t626\t257\t370\t29\t29\t340\t396\t46\t159\t55\t112\t455\t512\t76\t162\t56\t82\t570\t627\t46\t159\t55\t112\t455\t510\t7\t61\t54\t51\t529\t556\t35\t62\t23\t24\t413\t440\t180\t208\t23\t24\t527\t556\t180\t208\t23\t24\t455\t511\t179\t206\t52\t25\t571\t628\t180\t236\t54\t52\t340\t396\t180\t236\t54\t53',
|
||||||
|
'Amazon2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
|
||||||
|
'Sorceress2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
|
||||||
|
'Necromancer2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
|
||||||
|
'Paladin2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
|
||||||
|
'Barbarian2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
|
||||||
|
'Monster2\t80\t400\t60\t501\t10\t10\t99\t386\t142\t430\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Trade Page 1-2\t80\t400\t60\t501\t10\t4\t99\t386\t72\t187\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Trade Page 2-2\t80\t400\t60\t501\t10\t4\t99\t386\t283\t398\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Bank Page 1-2\t80\t400\t60\t501\t6\t4\t154\t329\t203\t318\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Transmogrify Box Page 1-2\t80\t400\t60\t501\t3\t4\t196\t282\t199\t314\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Guild Vault Page 1-2\t80\t400\t60\t501\t8\t8\t126\t359\t148\t381\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Trophy Case Page 1-2\t80\t400\t60\t501\t2\t2\t191\t296\t201\t314\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Big Bank Page 1-2\t80\t400\t60\t501\t6\t8\t153\t325\t143\t373\t29\t29\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t56\t82\t-1\t-1\t-1\t-1\t55\t112\t-1\t-1\t-1\t-1\t54\t51\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t23\t24\t-1\t-1\t-1\t-1\t52\t25\t-1\t-1\t-1\t-1\t54\t52\t-1\t-1\t-1\t-1\t54\t53',
|
||||||
|
'Hireling2\t80\t400\t60\t501\t0\t0\t-1\t-1\t-1\t-1\t29\t29\t100\t155\t107\t219\t55\t112\t213\t269\t137\t219\t56\t82\t331\t386\t107\t219\t55\t112\t215\t269\t68\t119\t54\t51\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1\t-1',
|
||||||
|
'Druid2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
|
||||||
|
'Assassin2\t400\t720\t60\t501\t10\t4\t419\t706\t315\t429\t29\t29\t420\t475\t107\t219\t55\t112\t533\t589\t137\t219\t56\t82\t651\t706\t107\t219\t55\t112\t535\t589\t68\t119\t54\t51\t609\t632\t95\t119\t23\t24\t495\t518\t240\t264\t23\t24\t609\t632\t240\t264\t23\t24\t536\t588\t239\t264\t52\t25\t652\t706\t242\t294\t54\t52\t421\t475\t241\t294\t54\t53',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const INVENTORY_TXT_BODY_SLOT_KEYS: readonly InventoryTxtBodySlotKey[] = [
|
||||||
|
'rArm',
|
||||||
|
'torso',
|
||||||
|
'lArm',
|
||||||
|
'head',
|
||||||
|
'neck',
|
||||||
|
'rHand',
|
||||||
|
'lHand',
|
||||||
|
'belt',
|
||||||
|
'feet',
|
||||||
|
'gloves',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function parseInventoryTxt(tsv: string): readonly InventoryTxtRow[] {
|
||||||
|
const lines = tsv
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(l => l.trimEnd())
|
||||||
|
.filter(l => l.length > 0)
|
||||||
|
if (lines.length < 2) {
|
||||||
|
throw new Error('[inventory] Invalid inventory.txt: missing header or data rows')
|
||||||
|
}
|
||||||
|
const headers = lines[0]!.split('\t')
|
||||||
|
const colIndex = new Map<string, number>()
|
||||||
|
headers.forEach((h, i) => colIndex.set(h.trim(), i))
|
||||||
|
|
||||||
|
const num = (cols: string[], name: string): number => {
|
||||||
|
const idx = colIndex.get(name)
|
||||||
|
if (idx === undefined) {
|
||||||
|
throw new Error(`[inventory] Missing column "${name}" in inventory.txt`)
|
||||||
|
}
|
||||||
|
const val = Number(cols[idx])
|
||||||
|
if (!Number.isFinite(val)) {
|
||||||
|
throw new Error(`[inventory] Invalid numeric value "${cols[idx]}" for column "${name}" in inventory.txt`)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: InventoryTxtRow[] = []
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cols = lines[i]!.split('\t')
|
||||||
|
const className = (cols[colIndex.get('class') ?? 0] ?? '').trim()
|
||||||
|
if (!className || className.toLowerCase() === 'expansion') continue
|
||||||
|
|
||||||
|
const invLeft = num(cols, 'invLeft')
|
||||||
|
const invRight = num(cols, 'invRight')
|
||||||
|
const invTop = num(cols, 'invTop')
|
||||||
|
const invBottom = num(cols, 'invBottom')
|
||||||
|
|
||||||
|
const slots = {} as Record<InventoryTxtBodySlotKey, InventoryTxtSlotRect>
|
||||||
|
for (const key of INVENTORY_TXT_BODY_SLOT_KEYS) {
|
||||||
|
slots[key] = {
|
||||||
|
left: num(cols, `${key}Left`),
|
||||||
|
right: num(cols, `${key}Right`),
|
||||||
|
top: num(cols, `${key}Top`),
|
||||||
|
bottom: num(cols, `${key}Bottom`),
|
||||||
|
width: num(cols, `${key}Width`),
|
||||||
|
height: num(cols, `${key}Height`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
class: className,
|
||||||
|
invLeft,
|
||||||
|
invRight,
|
||||||
|
invTop,
|
||||||
|
invBottom,
|
||||||
|
invWidth: invRight - invLeft,
|
||||||
|
invHeight: invBottom - invTop,
|
||||||
|
gridX: num(cols, 'gridX'),
|
||||||
|
gridY: num(cols, 'gridY'),
|
||||||
|
gridLeft: num(cols, 'gridLeft'),
|
||||||
|
gridRight: num(cols, 'gridRight'),
|
||||||
|
gridTop: num(cols, 'gridTop'),
|
||||||
|
gridBottom: num(cols, 'gridBottom'),
|
||||||
|
gridBoxWidth: num(cols, 'gridBoxWidth'),
|
||||||
|
gridBoxHeight: num(cols, 'gridBoxHeight'),
|
||||||
|
slots,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INVENTORY_TXT_LAYOUTS: readonly InventoryTxtRow[] = parseInventoryTxt(RAW_INVENTORY_TXT)
|
||||||
|
export const INVENTORY_TXT_BY_CLASS: ReadonlyMap<string, InventoryTxtRow> = new Map(
|
||||||
|
INVENTORY_TXT_LAYOUTS.map(r => [r.class, r]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CLASS_CODE_TO_INVENTORY_TXT_NAME: Readonly<Record<string, string>> = {
|
||||||
|
ama: 'Amazon',
|
||||||
|
sor: 'Sorceress',
|
||||||
|
nec: 'Necromancer',
|
||||||
|
pal: 'Paladin',
|
||||||
|
bar: 'Barbarian',
|
||||||
|
dru: 'Druid',
|
||||||
|
ass: 'Assassin',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a canonical `inventory.txt` layout row by class code (`ama..ass`),
|
||||||
|
* class name (`Amazon..Assassin`), or panel name (`Big Bank Page 1`, `Hireling`, etc.)
|
||||||
|
* and resolution (`0` = 640x480, `1` = 800x600 `*2` / `*-2` rows).
|
||||||
|
* Fails fast if the requested layout does not exist.
|
||||||
|
*/
|
||||||
|
export function getInventoryLayout(classOrPanelName: string, resolution: 0 | 1 = 1): InventoryTxtRow {
|
||||||
|
const trimmed = classOrPanelName.trim()
|
||||||
|
const baseName = CLASS_CODE_TO_INVENTORY_TXT_NAME[trimmed.toLowerCase()] ?? trimmed
|
||||||
|
if (resolution === 1) {
|
||||||
|
if (baseName.endsWith('2') && INVENTORY_TXT_BY_CLASS.has(baseName)) {
|
||||||
|
return INVENTORY_TXT_BY_CLASS.get(baseName)!
|
||||||
|
}
|
||||||
|
const candidate2 = `${baseName}2`
|
||||||
|
if (INVENTORY_TXT_BY_CLASS.has(candidate2)) {
|
||||||
|
return INVENTORY_TXT_BY_CLASS.get(candidate2)!
|
||||||
|
}
|
||||||
|
const candidateDash2 = `${baseName}-2`
|
||||||
|
if (INVENTORY_TXT_BY_CLASS.has(candidateDash2)) {
|
||||||
|
return INVENTORY_TXT_BY_CLASS.get(candidateDash2)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const exact = INVENTORY_TXT_BY_CLASS.get(baseName)
|
||||||
|
if (!exact) {
|
||||||
|
throw new Error(`[inventory] Unknown inventory.txt layout "${classOrPanelName}" (resolution=${resolution})`)
|
||||||
|
}
|
||||||
|
return exact
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INV_LAYOUT_800 = getInventoryLayout('Amazon2', 1)
|
||||||
|
|
||||||
|
export const INV_PANEL_ORIGIN = {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.invLeft,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.invTop,
|
||||||
|
width: DEFAULT_INV_LAYOUT_800.invWidth,
|
||||||
|
height: DEFAULT_INV_LAYOUT_800.invHeight,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const INV_GRID_ORIGIN = {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.gridLeft,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.gridTop,
|
||||||
|
cols: DEFAULT_INV_LAYOUT_800.gridX,
|
||||||
|
rows: DEFAULT_INV_LAYOUT_800.gridY,
|
||||||
|
cellPx: DEFAULT_INV_LAYOUT_800.gridBoxWidth,
|
||||||
|
} as const
|
||||||
|
|
||||||
export const EQUIP_SLOTS_LAYOUT: Readonly<
|
export const EQUIP_SLOTS_LAYOUT: Readonly<
|
||||||
Record<EquipSlotId, { readonly x: number; readonly y: number; readonly w: number; readonly h: number; readonly label: string }>
|
Record<EquipSlotId, { readonly x: number; readonly y: number; readonly w: number; readonly h: number; readonly label: string }>
|
||||||
> = {
|
> = {
|
||||||
helm: { x: 400 + 132, y: 60 + 4, w: 58, h: 58, label: '头盔' },
|
helm: {
|
||||||
amulet: { x: 400 + 204, y: 60 + 33, w: 29, h: 29, label: '项链' },
|
x: DEFAULT_INV_LAYOUT_800.slots.head.left,
|
||||||
weapon1: { x: 400 + 18, y: 60 + 47, w: 58, h: 116, label: '主手武器' },
|
y: DEFAULT_INV_LAYOUT_800.slots.head.top,
|
||||||
armor: { x: 400 + 132, y: 60 + 76, w: 58, h: 87, label: '护甲' },
|
w: DEFAULT_INV_LAYOUT_800.slots.head.width,
|
||||||
weapon2: { x: 400 + 248, y: 60 + 47, w: 58, h: 116, label: '副手/盾牌' },
|
h: DEFAULT_INV_LAYOUT_800.slots.head.height,
|
||||||
gloves: { x: 400 + 18, y: 60 + 179, w: 58, h: 58, label: '手套' },
|
label: '头盔',
|
||||||
ring1: { x: 400 + 92, y: 60 + 179, w: 29, h: 29, label: '左戒指' },
|
},
|
||||||
belt: { x: 400 + 132, y: 60 + 179, w: 58, h: 29, label: '腰带' },
|
amulet: {
|
||||||
ring2: { x: 400 + 204, y: 60 + 179, w: 29, h: 29, label: '右戒指' },
|
x: DEFAULT_INV_LAYOUT_800.slots.neck.left,
|
||||||
boots: { x: 400 + 248, y: 60 + 179, w: 58, h: 58, label: '靴子' },
|
y: DEFAULT_INV_LAYOUT_800.slots.neck.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.neck.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.neck.height,
|
||||||
|
label: '项链',
|
||||||
|
},
|
||||||
|
weapon1: {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.slots.rArm.left,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.slots.rArm.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.rArm.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.rArm.height,
|
||||||
|
label: '主手武器',
|
||||||
|
},
|
||||||
|
armor: {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.slots.torso.left,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.slots.torso.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.torso.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.torso.height,
|
||||||
|
label: '护甲',
|
||||||
|
},
|
||||||
|
weapon2: {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.slots.lArm.left,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.slots.lArm.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.lArm.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.lArm.height,
|
||||||
|
label: '副手/盾牌',
|
||||||
|
},
|
||||||
|
gloves: {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.slots.gloves.left,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.slots.gloves.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.gloves.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.gloves.height,
|
||||||
|
label: '手套',
|
||||||
|
},
|
||||||
|
ring1: {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.slots.rHand.left,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.slots.rHand.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.rHand.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.rHand.height,
|
||||||
|
label: '左戒指',
|
||||||
|
},
|
||||||
|
belt: {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.slots.belt.left,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.slots.belt.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.belt.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.belt.height,
|
||||||
|
label: '腰带',
|
||||||
|
},
|
||||||
|
ring2: {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.slots.lHand.left,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.slots.lHand.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.lHand.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.lHand.height,
|
||||||
|
label: '右戒指',
|
||||||
|
},
|
||||||
|
boots: {
|
||||||
|
x: DEFAULT_INV_LAYOUT_800.slots.feet.left,
|
||||||
|
y: DEFAULT_INV_LAYOUT_800.slots.feet.top,
|
||||||
|
w: DEFAULT_INV_LAYOUT_800.slots.feet.width,
|
||||||
|
h: DEFAULT_INV_LAYOUT_800.slots.feet.height,
|
||||||
|
label: '靴子',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground Truth (`Panel\invchar6Tab.dc6` frames 0 & 1, 62x22 px each, drawn at `ox + 15, oy + 23` and `ox + 245, oy + 23`):
|
||||||
|
* Each 62x22 weapon swap tab strip has two 31x22 sub-tabs:
|
||||||
|
* - Left half (`w = 31`): Weapon Set `I` (`weaponSwapSet = 0`)
|
||||||
|
* - Right half (`w = 31`): Weapon Set `II` (`weaponSwapSet = 1`)
|
||||||
|
*/
|
||||||
export const WEAPON_SWAP_TABS_BOUNDS = {
|
export const WEAPON_SWAP_TABS_BOUNDS = {
|
||||||
left: { x: 400 + 16, y: 60 + 24, w: 60, h: 21 },
|
left: { x: 400 + 15, y: 60 + 23, w: 62, h: 22 },
|
||||||
right: { x: 400 + 246, y: 60 + 24, w: 60, h: 21 },
|
right: { x: 400 + 245, y: 60 + 23, w: 62, h: 22 },
|
||||||
|
leftI: { x: 400 + 15, y: 60 + 23, w: 31, h: 22, set: 0 as const },
|
||||||
|
leftII: { x: 400 + 46, y: 60 + 23, w: 31, h: 22, set: 1 as const },
|
||||||
|
rightI: { x: 400 + 245, y: 60 + 23, w: 31, h: 22, set: 0 as const },
|
||||||
|
rightII: { x: 400 + 276, y: 60 + 23, w: 31, h: 22, set: 1 as const },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export function resolveWeaponSwapTabClick(logicalX: number, logicalY: number): 0 | 1 | null {
|
||||||
|
for (const subTab of [
|
||||||
|
WEAPON_SWAP_TABS_BOUNDS.leftI,
|
||||||
|
WEAPON_SWAP_TABS_BOUNDS.leftII,
|
||||||
|
WEAPON_SWAP_TABS_BOUNDS.rightI,
|
||||||
|
WEAPON_SWAP_TABS_BOUNDS.rightII,
|
||||||
|
]) {
|
||||||
|
if (
|
||||||
|
logicalX >= subTab.x &&
|
||||||
|
logicalX < subTab.x + subTab.w &&
|
||||||
|
logicalY >= subTab.y &&
|
||||||
|
logicalY <= subTab.y + subTab.h
|
||||||
|
) {
|
||||||
|
return subTab.set
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export const INV_CLOSE_BTN_BOUNDS = { x: 400 + 18, y: 60 + 385, w: 32, h: 32 } as const
|
export const INV_CLOSE_BTN_BOUNDS = { x: 400 + 18, y: 60 + 385, w: 32, h: 32 } as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -165,6 +476,7 @@ export interface ResolvableItem {
|
||||||
readonly invtransform?: string | undefined
|
readonly invtransform?: string | undefined
|
||||||
readonly chrtransform?: string | undefined
|
readonly chrtransform?: string | undefined
|
||||||
readonly variant?: number | undefined
|
readonly variant?: number | undefined
|
||||||
|
readonly graphicIndex?: number | undefined
|
||||||
readonly id?: string | number | undefined
|
readonly id?: string | number | undefined
|
||||||
readonly invWidth?: number | undefined
|
readonly invWidth?: number | undefined
|
||||||
readonly invHeight?: number | undefined
|
readonly invHeight?: number | undefined
|
||||||
|
|
@ -234,124 +546,47 @@ export function resolveItemSpriteRect(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 0.5: Authentic Diablo II 1.13c Charm Resolution (cm1, cm2, cm3, Annihilus, Torch, Gheed's)
|
// Tier 0.5: Authentic Diablo II 1.13c ItemTypes.txt VarInvGfx (Charms, Rings, Amulets, Jewels)
|
||||||
if (item.code === 'cm1' || item.code === 'anni' || item.name?.includes('Annihilus')) {
|
const lowerCode = item.code?.toLowerCase()
|
||||||
const validSmall = ['invch1', 'invch4', 'invch7', 'invmss']
|
const lowerInv = item.invFile?.toLowerCase()
|
||||||
if (item.variant !== undefined && !item.name?.includes('Annihilus') && item.code !== 'anni') {
|
const varGfxCode =
|
||||||
const v = ['invch1', 'invch4', 'invch7'][Math.abs(Math.floor(item.variant)) % 3]
|
lowerCode && CANONICAL_VAR_INV_GFX[lowerCode]
|
||||||
if (itemRects[v]) return itemRects[v]
|
? lowerCode
|
||||||
}
|
: lowerInv && /^invrin\d?$/.test(lowerInv)
|
||||||
const file = item.invFile?.toLowerCase()
|
? 'rin'
|
||||||
const direct = file && validSmall.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
|
: lowerInv && /^invamu\d?$/.test(lowerInv)
|
||||||
if (item.code === 'anni' || item.name?.includes('Annihilus')) {
|
? 'amu'
|
||||||
return direct ?? itemRects['invmss'] ?? itemRects['invch1'] ?? null
|
: lowerInv && /^(invjw|invjew)\d?$/.test(lowerInv)
|
||||||
}
|
? 'jew'
|
||||||
return direct ?? itemRects['invch1'] ?? itemRects['invmss'] ?? null
|
: undefined
|
||||||
}
|
const varGfxList = varGfxCode ? CANONICAL_VAR_INV_GFX[varGfxCode] : undefined
|
||||||
if (item.code === 'cm2' || item.code === 'torch' || item.name?.includes('Torch')) {
|
|
||||||
const validLarge = ['invch2', 'invch5', 'invch8', 'invtrch']
|
|
||||||
if (item.variant !== undefined && !item.name?.includes('Torch') && item.code !== 'torch') {
|
|
||||||
const v = ['invch2', 'invch5', 'invch8'][Math.abs(Math.floor(item.variant)) % 3]
|
|
||||||
if (itemRects[v]) return itemRects[v]
|
|
||||||
}
|
|
||||||
const file = item.invFile?.toLowerCase()
|
|
||||||
const direct = file && validLarge.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
|
|
||||||
if (item.code === 'torch' || item.name?.includes('Torch')) {
|
|
||||||
return direct ?? itemRects['invtrch'] ?? itemRects['invch2'] ?? null
|
|
||||||
}
|
|
||||||
return direct ?? itemRects['invch2'] ?? itemRects['invtrch'] ?? null
|
|
||||||
}
|
|
||||||
if (item.code === 'cm3' || item.code === 'gheeds' || item.name?.includes('Gheed')) {
|
|
||||||
const validGrand = ['invch3', 'invch6', 'invch9']
|
|
||||||
if (item.variant !== undefined) {
|
|
||||||
const v = validGrand[Math.abs(Math.floor(item.variant)) % validGrand.length]
|
|
||||||
if (itemRects[v]) return itemRects[v]
|
|
||||||
}
|
|
||||||
const file = item.invFile?.toLowerCase()
|
|
||||||
const direct = file && validGrand.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
|
|
||||||
return direct ?? itemRects['invch3'] ?? itemRects['invch6'] ?? itemRects['invch9'] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 0.6: Authentic Diablo II 1.13c ItemTypes.txt VarInvGfx (Ring: 5, Amulet: 3, Jewel: 6)
|
if (varGfxList) {
|
||||||
if (item.code === 'rin' || item.code === 'ring' || (item.invFile && /^invrin\d?$/i.test(item.invFile))) {
|
const rawVariant = item.variant ?? item.graphicIndex
|
||||||
const ringVariants = ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5']
|
if (typeof rawVariant === 'number' && Number.isFinite(rawVariant) && rawVariant >= 0) {
|
||||||
if (item.variant !== undefined) {
|
const key = varGfxList[Math.trunc(rawVariant) % varGfxList.length]!
|
||||||
const idx = Math.abs(Math.floor(item.variant)) % ringVariants.length
|
|
||||||
const key = ringVariants[idx]
|
|
||||||
if (itemRects[key]) return itemRects[key]
|
if (itemRects[key]) return itemRects[key]
|
||||||
}
|
}
|
||||||
if (item.invFile && item.invFile.toLowerCase() !== 'invrin' && itemRects[item.invFile]) {
|
if (lowerInv && (varGfxList.includes(lowerInv) || lowerInv === 'invmss' || lowerInv === 'invtrch')) {
|
||||||
return itemRects[item.invFile]
|
const rect = itemRects[item.invFile!] ?? itemRects[lowerInv]
|
||||||
|
if (rect) return rect
|
||||||
}
|
}
|
||||||
if (item.id !== undefined && item.id !== '') {
|
if (item.name) {
|
||||||
const idStr = String(item.id)
|
const fromName = BAKED_UI_MANIFEST.codeToInvFile[item.name]
|
||||||
let hash = 0
|
if (
|
||||||
for (let i = 0; i < idStr.length; i++) {
|
fromName &&
|
||||||
hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
|
(varGfxList.includes(fromName) || fromName === 'invmss' || fromName === 'invtrch') &&
|
||||||
|
itemRects[fromName]
|
||||||
|
) {
|
||||||
|
return itemRects[fromName]
|
||||||
}
|
}
|
||||||
const idx = Math.abs(hash) % ringVariants.length
|
|
||||||
const key = ringVariants[idx]
|
|
||||||
if (itemRects[key]) return itemRects[key]
|
|
||||||
}
|
}
|
||||||
return itemRects['invrin1'] ?? itemRects['invrin'] ?? null
|
const defaultKey = varGfxList[0]!
|
||||||
|
return itemRects[defaultKey] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.code === 'amu' || item.code === 'amul' || (item.invFile && /^invamu\d?$/i.test(item.invFile))) {
|
// Tier 1: Direct invFile
|
||||||
const amuVariants = ['invamu1', 'invamu2', 'invamu3']
|
if (item.invFile) {
|
||||||
if (item.variant !== undefined) {
|
|
||||||
const idx = Math.abs(Math.floor(item.variant)) % amuVariants.length
|
|
||||||
const key = amuVariants[idx]
|
|
||||||
if (itemRects[key]) return itemRects[key]
|
|
||||||
}
|
|
||||||
if (item.invFile && item.invFile.toLowerCase() !== 'invamu' && itemRects[item.invFile]) {
|
|
||||||
return itemRects[item.invFile]
|
|
||||||
}
|
|
||||||
if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) {
|
|
||||||
return itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]
|
|
||||||
}
|
|
||||||
if (item.id !== undefined && item.id !== '') {
|
|
||||||
const idStr = String(item.id)
|
|
||||||
let hash = 0
|
|
||||||
for (let i = 0; i < idStr.length; i++) {
|
|
||||||
hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
|
|
||||||
}
|
|
||||||
const idx = Math.abs(hash) % amuVariants.length
|
|
||||||
const key = amuVariants[idx]
|
|
||||||
if (itemRects[key]) return itemRects[key]
|
|
||||||
}
|
|
||||||
return itemRects['invamu1'] ?? itemRects['invamu'] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.code === 'jew' || item.code === 'jewl' || (item.invFile && /^(invjw|invjew)\d?$/i.test(item.invFile))) {
|
|
||||||
const jewelVariants = ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6']
|
|
||||||
if (item.variant !== undefined) {
|
|
||||||
const idx = Math.abs(Math.floor(item.variant)) % jewelVariants.length
|
|
||||||
const key = jewelVariants[idx]
|
|
||||||
if (itemRects[key]) return itemRects[key]
|
|
||||||
}
|
|
||||||
if (item.invFile && itemRects[item.invFile]) {
|
|
||||||
return itemRects[item.invFile]
|
|
||||||
}
|
|
||||||
if (item.id !== undefined && item.id !== '') {
|
|
||||||
const idStr = String(item.id)
|
|
||||||
let hash = 0
|
|
||||||
for (let i = 0; i < idStr.length; i++) {
|
|
||||||
hash = ((hash << 5) - hash + idStr.charCodeAt(i)) | 0
|
|
||||||
}
|
|
||||||
const idx = Math.abs(hash) % jewelVariants.length
|
|
||||||
const key = jewelVariants[idx]
|
|
||||||
if (itemRects[key]) return itemRects[key]
|
|
||||||
}
|
|
||||||
return itemRects['invjw1'] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 1: Direct invFile (ignore misc.txt placeholder invfiles for charms)
|
|
||||||
const isCharmPlaceholder =
|
|
||||||
(item.code === 'cm3' && item.invFile?.toLowerCase() === 'invsst') ||
|
|
||||||
(item.code === 'cm2' && item.invFile?.toLowerCase() === 'invwnd') ||
|
|
||||||
(item.code === 'cm1' && item.invFile?.toLowerCase() === 'invchm')
|
|
||||||
|
|
||||||
if (item.invFile && !isCharmPlaceholder) {
|
|
||||||
if (itemRects[item.invFile]) return itemRects[item.invFile]
|
if (itemRects[item.invFile]) return itemRects[item.invFile]
|
||||||
const lower = item.invFile.toLowerCase()
|
const lower = item.invFile.toLowerCase()
|
||||||
if (itemRects[lower]) return itemRects[lower]
|
if (itemRects[lower]) return itemRects[lower]
|
||||||
|
|
@ -372,10 +607,7 @@ export function resolveItemSpriteRect(
|
||||||
// Tier 4: Canonical Diablo II 1.13c specific code overrides
|
// Tier 4: Canonical Diablo II 1.13c specific code overrides
|
||||||
if (item.code === 'tbk') return itemRects['invbbk'] ?? null
|
if (item.code === 'tbk') return itemRects['invbbk'] ?? null
|
||||||
if (item.code === 'ibk') return itemRects['invrbk'] ?? null
|
if (item.code === 'ibk') return itemRects['invrbk'] ?? null
|
||||||
if (item.code === 'box' || item.code === 'cube') return itemRects['invbox'] ?? null
|
if (item.code === 'box') return itemRects['invbox'] ?? null
|
||||||
if (item.code === 'crs' || item.code === 'cta' || item.name?.includes('Call to Arms')) {
|
|
||||||
return itemRects['invcrs'] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -500,9 +732,21 @@ export function isScrollForTome(scrollCode: string | undefined, tomeCode: string
|
||||||
return (sc === 'tsc' && tc === 'tbk') || (sc === 'isc' && tc === 'ibk')
|
return (sc === 'tsc' && tc === 'tbk') || (sc === 'isc' && tc === 'ibk')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground Truth (`data\global\excel\misc.txt` `useable === 1` in Diablo II v1.13c):
|
||||||
|
* The exact 35 miscellaneous item codes that can be activated via right-click in the inventory.
|
||||||
|
*/
|
||||||
|
export const CANONICAL_MISC_USABLE_CODES: ReadonlySet<string> = new Set([
|
||||||
|
'elx', 'hpo', 'mpo', 'hpf', 'mpf', 'vps', 'yps', 'rvs', 'rvl', 'wms',
|
||||||
|
'tbk', 'ibk', 'tsc', 'isc', 'xyz', 'box', 'ass',
|
||||||
|
'hp1', 'hp2', 'hp3', 'hp4', 'hp5',
|
||||||
|
'mp1', 'mp2', 'mp3', 'mp4', 'mp5',
|
||||||
|
'hrb', 'rps', 'rpl', 'bps', 'bpl', '0sc', 'tr2', 'toa',
|
||||||
|
])
|
||||||
|
|
||||||
export function isUsableRightClickItem(code: string | undefined): boolean {
|
export function isUsableRightClickItem(code: string | undefined): boolean {
|
||||||
const c = (code ?? '').trim().toLowerCase()
|
const c = (code ?? '').trim().toLowerCase()
|
||||||
return c === 'box' || c === 'cube' || c === 'tbk' || c === 'tsc' || c === 'ibk' || c === 'isc'
|
return CANONICAL_MISC_USABLE_CODES.has(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTomeQuantity(item: UiInventoryItem): number {
|
export function getTomeQuantity(item: UiInventoryItem): number {
|
||||||
|
|
@ -779,6 +1023,30 @@ export function resolveGridItemBackgroundFill(
|
||||||
: GRID_ITEM_BG_RED
|
: GRID_ITEM_BG_RED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground Truth (D2Client.dll 1.13c @ 0x6fb47be0 `INVENTORY_DrawEquippedItems`):
|
||||||
|
* Paperdoll equipment slots do NOT draw a green background for normal usable equipped items
|
||||||
|
* (the dark carved slot recess from `invchar6.dc6` shows through); a background fill is only
|
||||||
|
* drawn when the equipped item fails requirements (`GRID_ITEM_BG_RED`) or when hovered
|
||||||
|
* while holding a cursor item (`GRID_ITEM_BG_GREEN`).
|
||||||
|
*/
|
||||||
|
export function resolvePaperdollSlotBackgroundFill(
|
||||||
|
item: UiInventoryItem,
|
||||||
|
player: PlayerItemRequirementContext,
|
||||||
|
options?: {
|
||||||
|
readonly isHovered?: boolean | undefined
|
||||||
|
readonly hasCursorItem?: boolean | undefined
|
||||||
|
},
|
||||||
|
): string | null {
|
||||||
|
if (options?.isHovered && options?.hasCursorItem) {
|
||||||
|
return GRID_ITEM_BG_GREEN
|
||||||
|
}
|
||||||
|
if (!canPlayerUseUiItem(item, player, 'trade')) {
|
||||||
|
return GRID_ITEM_BG_RED
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
|
export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
|
||||||
;(item as { identified?: boolean; isGambleMystery?: boolean }).identified = true
|
;(item as { identified?: boolean; isGambleMystery?: boolean }).identified = true
|
||||||
;(item as { identified?: boolean; isGambleMystery?: boolean }).isGambleMystery = false
|
;(item as { identified?: boolean; isGambleMystery?: boolean }).isGambleMystery = false
|
||||||
|
|
@ -1430,8 +1698,14 @@ export function applySocketToUiItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws the authentic Diablo II socket circles and any socketed Gem/Rune/Jewel icons
|
* Canonical 28x28 `data\global\ui\PANEL\gemsocket.dc6` frame 0 sprite dimensions.
|
||||||
* overlaid on a socketed item in the Inventory, Equipment slot, Stash, or Horadric Cube.
|
*/
|
||||||
|
export const GEMSOCKET_SPRITE_RECT: SpriteRect = { x: 0, y: 0, w: 28, h: 28 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the authentic Diablo II `gemsocket.dc6` socket frame and any socketed Gem/Rune/Jewel icons
|
||||||
|
* overlaid on a hovered socketed item in the Inventory, Equipment slot, Stash, or Horadric Cube.
|
||||||
|
* Never draws procedural circles or colored dots; fails fast if a socketed item sprite is missing.
|
||||||
*/
|
*/
|
||||||
export function drawItemSocketOverlay(
|
export function drawItemSocketOverlay(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
|
|
@ -1442,16 +1716,24 @@ export function drawItemSocketOverlay(
|
||||||
h: number,
|
h: number,
|
||||||
assets: {
|
assets: {
|
||||||
itemsAtlasImg: HTMLImageElement | null
|
itemsAtlasImg: HTMLImageElement | null
|
||||||
|
gemSocketImg?: HTMLImageElement | null
|
||||||
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
|
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
|
||||||
},
|
},
|
||||||
|
options?: {
|
||||||
|
readonly isHovered?: boolean | undefined
|
||||||
|
},
|
||||||
): void {
|
): void {
|
||||||
|
if (options?.isHovered === false) return
|
||||||
const totalSockets = Math.min(6, Math.max(0, item.sockets ?? 0))
|
const totalSockets = Math.min(6, Math.max(0, item.sockets ?? 0))
|
||||||
if (totalSockets <= 0 || isUiItemUnidentified(item)) return
|
if (totalSockets <= 0 || isUiItemUnidentified(item)) return
|
||||||
|
|
||||||
const socketed = item.socketedItems ?? []
|
const socketed = item.socketedItems ?? []
|
||||||
const twoCols = totalSockets >= 4 && item.invWidth >= 2
|
const twoCols = totalSockets >= 4 && item.invWidth >= 2
|
||||||
const rows = twoCols ? Math.ceil(totalSockets / 2) : totalSockets
|
const rows = twoCols ? Math.ceil(totalSockets / 2) : totalSockets
|
||||||
const radius = 9
|
const socketRect =
|
||||||
|
assets.itemRects['gemsocket'] ??
|
||||||
|
BAKED_UI_MANIFEST.itemRects['gemsocket'] ??
|
||||||
|
GEMSOCKET_SPRITE_RECT
|
||||||
|
|
||||||
for (let idx = 0; idx < totalSockets; idx++) {
|
for (let idx = 0; idx < totalSockets; idx++) {
|
||||||
let cx: number
|
let cx: number
|
||||||
|
|
@ -1468,41 +1750,52 @@ export function drawItemSocketOverlay(
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.save?.()
|
ctx.save?.()
|
||||||
if (typeof ctx.arc === 'function') {
|
if (assets.gemSocketImg) {
|
||||||
ctx.beginPath?.()
|
ctx.drawImage(
|
||||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
|
assets.gemSocketImg,
|
||||||
ctx.fillStyle = 'rgba(10, 10, 14, 0.74)'
|
0,
|
||||||
ctx.fill?.()
|
0,
|
||||||
ctx.lineWidth = 1
|
GEMSOCKET_SPRITE_RECT.w,
|
||||||
ctx.strokeStyle = 'rgba(168, 152, 118, 0.78)'
|
GEMSOCKET_SPRITE_RECT.h,
|
||||||
ctx.stroke?.()
|
Math.round(cx - GEMSOCKET_SPRITE_RECT.w / 2),
|
||||||
|
Math.round(cy - GEMSOCKET_SPRITE_RECT.h / 2),
|
||||||
|
GEMSOCKET_SPRITE_RECT.w,
|
||||||
|
GEMSOCKET_SPRITE_RECT.h,
|
||||||
|
)
|
||||||
|
} else if (assets.itemsAtlasImg) {
|
||||||
|
ctx.drawImage(
|
||||||
|
assets.itemsAtlasImg,
|
||||||
|
socketRect.x,
|
||||||
|
socketRect.y,
|
||||||
|
socketRect.w,
|
||||||
|
socketRect.h,
|
||||||
|
Math.round(cx - socketRect.w / 2),
|
||||||
|
Math.round(cy - socketRect.h / 2),
|
||||||
|
socketRect.w,
|
||||||
|
socketRect.h,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sockItem = socketed[idx]
|
const sockItem = socketed[idx]
|
||||||
if (sockItem) {
|
if (sockItem) {
|
||||||
const sr = resolveItemSpriteRect(sockItem, assets.itemRects)
|
const sr = resolveItemSpriteRect(sockItem, assets.itemRects)
|
||||||
if (assets.itemsAtlasImg && sr) {
|
if (!sr) {
|
||||||
const iconSize = 16
|
throw new Error(
|
||||||
|
`[inventory] Missing sprite rect for socketed item code="${sockItem.code ?? ''}" invFile="${sockItem.invFile ?? ''}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (assets.itemsAtlasImg) {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
assets.itemsAtlasImg,
|
assets.itemsAtlasImg,
|
||||||
sr.x,
|
sr.x,
|
||||||
sr.y,
|
sr.y,
|
||||||
sr.w,
|
sr.w,
|
||||||
sr.h,
|
sr.h,
|
||||||
Math.round(cx - iconSize / 2),
|
Math.round(cx - sr.w / 2),
|
||||||
Math.round(cy - iconSize / 2),
|
Math.round(cy - sr.h / 2),
|
||||||
iconSize,
|
sr.w,
|
||||||
iconSize,
|
sr.h,
|
||||||
)
|
)
|
||||||
} else if (typeof ctx.arc === 'function') {
|
|
||||||
ctx.beginPath?.()
|
|
||||||
ctx.arc(cx, cy, 5, 0, Math.PI * 2)
|
|
||||||
ctx.fillStyle = isRuneCode(sockItem.code)
|
|
||||||
? '#e09840'
|
|
||||||
: isJewelCode(sockItem.code)
|
|
||||||
? '#68a8ff'
|
|
||||||
: '#e8d070'
|
|
||||||
ctx.fill?.()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.restore?.()
|
ctx.restore?.()
|
||||||
|
|
@ -1987,7 +2280,7 @@ export class InventoryPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-click use handler for usable items (`box`, `tbk`, `tsc`, `ibk`, `isc`) inside a container.
|
* Right-click use handler for usable items (`misc.txt` `useable === 1`) inside a container.
|
||||||
*/
|
*/
|
||||||
usePlacedItemByRightClick(
|
usePlacedItemByRightClick(
|
||||||
placed: GridPlacement,
|
placed: GridPlacement,
|
||||||
|
|
@ -1996,11 +2289,13 @@ export class InventoryPanel {
|
||||||
callbacks?: {
|
callbacks?: {
|
||||||
onOpenCube?: () => void
|
onOpenCube?: () => void
|
||||||
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||||
|
onUseItem?: (item: UiInventoryItem, container: 'inventory' | 'stash' | 'cube') => boolean | void
|
||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
if (this.cursorItem !== null) return false
|
if (this.cursorItem !== null) return false
|
||||||
const code = (placed.item.code ?? '').trim().toLowerCase()
|
const code = (placed.item.code ?? '').trim().toLowerCase()
|
||||||
if (code === 'box' || code === 'cube') {
|
if (!CANONICAL_MISC_USABLE_CODES.has(code)) return false
|
||||||
|
if (code === 'box') {
|
||||||
callbacks?.onOpenCube?.()
|
callbacks?.onOpenCube?.()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -2032,7 +2327,20 @@ export class InventoryPanel {
|
||||||
if (code === 'ibk' || code === 'isc') {
|
if (code === 'ibk' || code === 'isc') {
|
||||||
return this.startIdentifyMode(placed.item, container)
|
return this.startIdentifyMode(placed.item, container)
|
||||||
}
|
}
|
||||||
return false
|
const usedOk = callbacks?.onUseItem?.(placed.item, container)
|
||||||
|
if (usedOk !== false) {
|
||||||
|
const qty = placed.item.quantity ?? placed.item.rawItem?.quantity ?? 1
|
||||||
|
if (qty > 1) {
|
||||||
|
setTomeQuantity(placed.item, qty - 1)
|
||||||
|
} else {
|
||||||
|
const idx = containerList.indexOf(placed)
|
||||||
|
if (idx !== -1) containerList.splice(idx, 1)
|
||||||
|
if (this.hoveredItem?.item.id === placed.item.id) {
|
||||||
|
this.hoveredItem = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||||
|
|
@ -2073,7 +2381,7 @@ export class InventoryPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle mouse click on the Inventory Panel (`400..720, 60..492`).
|
* Handle mouse click on the Inventory Panel (`400..720, 60..501`).
|
||||||
*
|
*
|
||||||
* Ground Truth (Diablo II v1.13c):
|
* Ground Truth (Diablo II v1.13c):
|
||||||
* - `button === 0` (Left-Click):
|
* - `button === 0` (Left-Click):
|
||||||
|
|
@ -2081,7 +2389,7 @@ export class InventoryPanel {
|
||||||
* - In normal mode: picks up, places, swaps items in the 10x4 grid or equipment slots; stacks scrolls (`tsc`/`isc`) into matching tomes (`tbk`/`ibk`). Never uses `box` or `tbk`!
|
* - In normal mode: picks up, places, swaps items in the 10x4 grid or equipment slots; stacks scrolls (`tsc`/`isc`) into matching tomes (`tbk`/`ibk`). Never uses `box` or `tbk`!
|
||||||
* - `button === 2` (Right-Click):
|
* - `button === 2` (Right-Click):
|
||||||
* - In Identify Cursor Mode: cancels Identify mode.
|
* - In Identify Cursor Mode: cancels Identify mode.
|
||||||
* - In normal mode: uses usable items (`box` opens Horadric Cube, `tbk`/`tsc` casts Town Portal and consumes 1 charge/scroll, `ibk`/`isc` enters Identify Cursor Mode). Never picks up or moves items onto the cursor!
|
* - In normal mode: uses usable items (`misc.txt` `useable === 1`: `box` opens Horadric Cube, `tbk`/`tsc` casts Town Portal and consumes 1 charge/scroll, `ibk`/`isc` enters Identify Cursor Mode, potions/usables consume 1 item). Never picks up or moves items onto the cursor!
|
||||||
*/
|
*/
|
||||||
handleClick(
|
handleClick(
|
||||||
logicalX: number,
|
logicalX: number,
|
||||||
|
|
@ -2089,6 +2397,7 @@ export class InventoryPanel {
|
||||||
callbacks?: {
|
callbacks?: {
|
||||||
onOpenCube?: () => void
|
onOpenCube?: () => void
|
||||||
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||||
|
onUseItem?: (item: UiInventoryItem, container: 'inventory' | 'stash' | 'cube') => boolean | void
|
||||||
/** Inventory gold coin button pressed: open the gold dialog (drop, or deposit while the stash is open). */
|
/** Inventory gold coin button pressed: open the gold dialog (drop, or deposit while the stash is open). */
|
||||||
onGoldButton?: () => void
|
onGoldButton?: () => void
|
||||||
},
|
},
|
||||||
|
|
@ -2177,12 +2486,13 @@ export class InventoryPanel {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Weapon Swap `I / II` Tabs
|
// 2. Weapon Swap `I / II` Tabs (sub-tabs I -> set 0, II -> set 1; clicking active tab is a no-op)
|
||||||
for (const tab of [WEAPON_SWAP_TABS_BOUNDS.left, WEAPON_SWAP_TABS_BOUNDS.right]) {
|
const targetSwapSet = resolveWeaponSwapTabClick(logicalX, logicalY)
|
||||||
if (logicalX >= tab.x && logicalX <= tab.x + tab.w && logicalY >= tab.y && logicalY <= tab.y + tab.h) {
|
if (targetSwapSet !== null) {
|
||||||
|
if (this.weaponSwapSet !== targetSwapSet) {
|
||||||
this.toggleWeaponSwap()
|
this.toggleWeaponSwap()
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Equipment slots
|
// 3. Equipment slots
|
||||||
|
|
@ -2230,6 +2540,7 @@ export class InventoryPanel {
|
||||||
invTab1Img: HTMLImageElement | null
|
invTab1Img: HTMLImageElement | null
|
||||||
buySellBtnImg: HTMLImageElement | null
|
buySellBtnImg: HTMLImageElement | null
|
||||||
goldCoinBtnImg?: HTMLImageElement | null
|
goldCoinBtnImg?: HTMLImageElement | null
|
||||||
|
gemSocketImg?: HTMLImageElement | null
|
||||||
itemsAtlasImg: HTMLImageElement | null
|
itemsAtlasImg: HTMLImageElement | null
|
||||||
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
|
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
|
||||||
},
|
},
|
||||||
|
|
@ -2242,7 +2553,7 @@ export class InventoryPanel {
|
||||||
ctx.drawImage(assets.borderRightImg, 400, 0)
|
ctx.drawImage(assets.borderRightImg, 400, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Draw 320x432 Inventory Panel (`invchar6.dc6` frames 4..7) at (400, 60)
|
// 2. Draw 320x441 Inventory Panel (`invchar6.dc6` frames 4..7) at (400, 60)
|
||||||
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 {
|
||||||
|
|
@ -2250,51 +2561,43 @@ export class InventoryPanel {
|
||||||
ctx.fillRect(INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y, INV_PANEL_ORIGIN.width, INV_PANEL_ORIGIN.height)
|
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`)
|
// 3. Draw Weapon Swap `I / II` Tabs (`invchar6Tab.dc6` frames 0 & 1 already contain the carved Roman numerals I and II)
|
||||||
const tabImg = this.weaponSwapSet === 0 ? assets.invTab0Img : assets.invTab1Img
|
const tabImg = this.weaponSwapSet === 0 ? assets.invTab0Img : assets.invTab1Img
|
||||||
if (tabImg) {
|
if (tabImg) {
|
||||||
ctx.drawImage(tabImg, 400 + 15, 60 + 23)
|
ctx.drawImage(tabImg, WEAPON_SWAP_TABS_BOUNDS.left.x, WEAPON_SWAP_TABS_BOUNDS.left.y)
|
||||||
ctx.drawImage(tabImg, 400 + 245, 60 + 23)
|
ctx.drawImage(tabImg, WEAPON_SWAP_TABS_BOUNDS.right.x, WEAPON_SWAP_TABS_BOUNDS.right.y)
|
||||||
}
|
}
|
||||||
font.drawText(ctx, this.weaponSwapSet === 0 ? 'I' : 'II', 400 + 46, 60 + 38, {
|
|
||||||
font: 'fontexocet10',
|
|
||||||
color: 'gold',
|
|
||||||
align: 'center',
|
|
||||||
})
|
|
||||||
font.drawText(ctx, this.weaponSwapSet === 0 ? 'I' : 'II', 400 + 276, 60 + 38, {
|
|
||||||
font: 'fontexocet10',
|
|
||||||
color: 'gold',
|
|
||||||
align: 'center',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 4. Draw Equipped Items in Paperdoll Slots
|
// 4. Draw Equipped Items in Paperdoll Slots (no background fill for usable items; red only when unusable, green when hovered with cursor item)
|
||||||
for (const [slotId, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, typeof EQUIP_SLOTS_LAYOUT[EquipSlotId]][]) {
|
for (const [slotId, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, typeof EQUIP_SLOTS_LAYOUT[EquipSlotId]][]) {
|
||||||
const eq = this.equipped[slotId]
|
const eq = this.equipped[slotId]
|
||||||
if (!eq) continue
|
if (!eq) continue
|
||||||
ctx.fillStyle = canPlayerUseUiItem(eq, this.playerContext)
|
const isHovered = this.hoveredItem?.item === eq
|
||||||
? 'rgba(18, 42, 24, 0.52)'
|
const slotFill = resolvePaperdollSlotBackgroundFill(eq, this.playerContext, {
|
||||||
: 'rgba(72, 18, 18, 0.50)'
|
isHovered,
|
||||||
ctx.fillRect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2)
|
hasCursorItem: this.cursorItem !== null,
|
||||||
this.drawItemSprite(ctx, eq, rect.x, rect.y, rect.w, rect.h, assets)
|
})
|
||||||
|
if (slotFill) {
|
||||||
|
ctx.fillStyle = slotFill
|
||||||
|
ctx.fillRect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2)
|
||||||
|
}
|
||||||
|
this.drawItemSprite(ctx, eq, rect.x, rect.y, rect.w, rect.h, assets, isHovered)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Draw 10x4 Bag Grid Items
|
// 5. Draw 10x4 Bag Grid Items (D2Client.dll 0x6fb459f7: blue for usable, red for unusable/unidentified, green when hovered with cursor item)
|
||||||
for (const placed of this.gridItems) {
|
for (const placed of this.gridItems) {
|
||||||
const gx = INV_GRID_ORIGIN.x + placed.col * INV_GRID_ORIGIN.cellPx
|
const gx = INV_GRID_ORIGIN.x + placed.col * INV_GRID_ORIGIN.cellPx
|
||||||
const gy = INV_GRID_ORIGIN.y + placed.row * INV_GRID_ORIGIN.cellPx
|
const gy = INV_GRID_ORIGIN.y + placed.row * INV_GRID_ORIGIN.cellPx
|
||||||
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
|
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
|
||||||
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
|
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
|
||||||
|
const isHovered = this.hoveredItem?.item === placed.item
|
||||||
|
|
||||||
ctx.fillStyle =
|
ctx.fillStyle = resolveGridItemBackgroundFill(placed.item, this.playerContext, {
|
||||||
isUiItemUnidentified(placed.item) || !canPlayerUseUiItem(placed.item, this.playerContext)
|
isHovered,
|
||||||
? 'rgba(72, 18, 18, 0.50)'
|
hasCursorItem: this.cursorItem !== null,
|
||||||
: placed.item.quality === 'unique'
|
})
|
||||||
? 'rgba(58, 42, 16, 0.45)'
|
|
||||||
: placed.item.quality === 'set'
|
|
||||||
? 'rgba(16, 52, 22, 0.45)'
|
|
||||||
: 'rgba(20, 32, 54, 0.45)'
|
|
||||||
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
|
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
|
||||||
this.drawItemSprite(ctx, placed.item, gx, gy, gw, gh, assets)
|
this.drawItemSprite(ctx, placed.item, gx, gy, gw, gh, assets, isHovered)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Gold Coin Button (`Panel\goldcoinbtn.dc6` frame 0 at x=484, y=451, 20x18) & Gold Amount Readout
|
// 6. Gold Coin Button (`Panel\goldcoinbtn.dc6` frame 0 at x=484, y=451, 20x18) & Gold Amount Readout
|
||||||
|
|
@ -2342,18 +2645,28 @@ export class InventoryPanel {
|
||||||
h: number,
|
h: number,
|
||||||
assets: {
|
assets: {
|
||||||
itemsAtlasImg: HTMLImageElement | null
|
itemsAtlasImg: HTMLImageElement | null
|
||||||
|
gemSocketImg?: HTMLImageElement | null
|
||||||
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
|
itemRects: Record<string, { x: number; y: number; w: number; h: number }>
|
||||||
},
|
},
|
||||||
|
isHovered = false,
|
||||||
): void {
|
): void {
|
||||||
const sr = resolveItemSpriteRect(item, assets.itemRects)
|
const sr = resolveItemSpriteRect(item, assets.itemRects)
|
||||||
if (assets.itemsAtlasImg && sr) {
|
if (!sr) {
|
||||||
|
throw new Error(
|
||||||
|
`[inventory] Missing sprite rect for item code="${item.code ?? ''}" invFile="${item.invFile ?? ''}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (assets.itemsAtlasImg) {
|
||||||
const dx = x + Math.floor((w - sr.w) / 2)
|
const dx = x + Math.floor((w - sr.w) / 2)
|
||||||
const dy = y + Math.floor((h - sr.h) / 2)
|
const dy = y + Math.floor((h - sr.h) / 2)
|
||||||
ctx.drawImage(assets.itemsAtlasImg, sr.x, sr.y, sr.w, sr.h, dx, dy, sr.w, sr.h)
|
ctx.drawImage(assets.itemsAtlasImg, sr.x, sr.y, sr.w, sr.h, dx, dy, sr.w, sr.h)
|
||||||
} else {
|
|
||||||
ctx.strokeStyle = '#c8a15a'
|
|
||||||
ctx.strokeRect(x + 2, y + 2, w - 4, h - 4)
|
|
||||||
}
|
}
|
||||||
drawItemSocketOverlay(ctx, item, x, y, w, h, assets)
|
if (isHovered) {
|
||||||
|
drawItemSocketOverlay(ctx, item, x, gyOrY(y), w, h, assets, { isHovered: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gyOrY(y: number): number {
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,27 @@ export function resolveItemColorTransform(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground Truth (`data\global\excel\ItemTypes.txt` `VarInvGfx` + `InvGfx1..6` in Diablo II v1.13c):
|
||||||
|
* Maps item codes and item type codes with variable inventory graphics (`VarInvGfx > 0`)
|
||||||
|
* to their exact 0-indexed `InvGfx1..6` sprite keys (`graphicIndex = 0..N-1`).
|
||||||
|
*/
|
||||||
|
export const CANONICAL_VAR_INV_GFX: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
rin: ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5'],
|
||||||
|
ring: ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5'],
|
||||||
|
amu: ['invamu1', 'invamu2', 'invamu3'],
|
||||||
|
amul: ['invamu1', 'invamu2', 'invamu3'],
|
||||||
|
jew: ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6'],
|
||||||
|
jewl: ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6'],
|
||||||
|
cm1: ['invch1', 'invch4', 'invch7'],
|
||||||
|
scha: ['invch1', 'invch4', 'invch7'],
|
||||||
|
char: ['invch1', 'invch4', 'invch7'],
|
||||||
|
cm2: ['invch2', 'invch5', 'invch8'],
|
||||||
|
mcha: ['invch2', 'invch5', 'invch8'],
|
||||||
|
cm3: ['invch3', 'invch6', 'invch9'],
|
||||||
|
lcha: ['invch3', 'invch6', 'invch9'],
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the DC6 inventory graphic sprite key (`invFile`) following
|
* Resolves the DC6 inventory graphic sprite key (`invFile`) following
|
||||||
* the authentic Diablo II 1.13c resolution hierarchy and guaranteeing
|
* the authentic Diablo II 1.13c resolution hierarchy and guaranteeing
|
||||||
|
|
@ -570,6 +591,17 @@ export function resolveItemInvFile(
|
||||||
let invtransform: string | undefined = resolvedColors.invtransform
|
let invtransform: string | undefined = resolvedColors.invtransform
|
||||||
const chrtransform: string | undefined = resolvedColors.chrtransform
|
const chrtransform: string | undefined = resolvedColors.chrtransform
|
||||||
|
|
||||||
|
const rawVariant =
|
||||||
|
typeof (item as any).variant === 'number' && Number.isFinite((item as any).variant) && (item as any).variant >= 0
|
||||||
|
? Math.trunc((item as any).variant)
|
||||||
|
: typeof (item as any).graphicIndex === 'number' && Number.isFinite((item as any).graphicIndex) && (item as any).graphicIndex >= 0
|
||||||
|
? Math.trunc((item as any).graphicIndex)
|
||||||
|
: undefined
|
||||||
|
const varGfxList =
|
||||||
|
CANONICAL_VAR_INV_GFX[code] ??
|
||||||
|
(typeof (item.base as any)?.type === 'string'
|
||||||
|
? CANONICAL_VAR_INV_GFX[(item.base as any).type.trim().toLowerCase()]
|
||||||
|
: undefined)
|
||||||
|
|
||||||
// 1. Explicit Unique / Set Table Graphics
|
// 1. Explicit Unique / Set Table Graphics
|
||||||
if (quality === 'unique') {
|
if (quality === 'unique') {
|
||||||
|
|
@ -592,6 +624,29 @@ export function resolveItemInvFile(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
// rings, amulets, jewels, and charms select their sprite from `InvGfx1..6` via `graphicIndex` (`variant`).
|
||||||
|
if (!invFile && varGfxList) {
|
||||||
|
if (quality === 'unique' && item.name === 'Annihilus') {
|
||||||
|
invFile = 'invmss'
|
||||||
|
} else if (quality === 'unique' && item.name === 'Hellfire Torch') {
|
||||||
|
invFile = 'invtrch'
|
||||||
|
} else if (rawVariant !== undefined) {
|
||||||
|
invFile = varGfxList[rawVariant % varGfxList.length]
|
||||||
|
} else {
|
||||||
|
const explicitInv = ((item as any).invFile ?? (item.base as any)?.invfile ?? (item.base as any)?.invFile ?? '')
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (explicitInv && (varGfxList.includes(explicitInv) || explicitInv === 'invmss' || explicitInv === 'invtrch')) {
|
||||||
|
invFile = explicitInv
|
||||||
|
} else {
|
||||||
|
invFile = varGfxList[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Pre-baked Manifest Lookup by Item Display Name (handles dyed unique/set sprites e.g. "invcap_cgrn")
|
// 2. Pre-baked Manifest Lookup by Item Display Name (handles dyed unique/set sprites e.g. "invcap_cgrn")
|
||||||
if (!invFile && item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name]) {
|
if (!invFile && item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name]) {
|
||||||
const manifestInv = BAKED_UI_MANIFEST.codeToInvFile[item.name]
|
const manifestInv = BAKED_UI_MANIFEST.codeToInvFile[item.name]
|
||||||
|
|
@ -633,10 +688,10 @@ export function resolveItemInvFile(
|
||||||
if (code === 'cm3' && (!invFile || invFile === 'invsst')) invFile = 'invch3'
|
if (code === 'cm3' && (!invFile || invFile === 'invsst')) invFile = 'invch3'
|
||||||
if ((code === 'rin' || code === 'ring') && (!invFile || invFile === 'invrin')) invFile = 'invrin1'
|
if ((code === 'rin' || code === 'ring') && (!invFile || invFile === 'invrin')) invFile = 'invrin1'
|
||||||
if ((code === 'amu' || code === 'amul') && (!invFile || invFile === 'invamu')) invFile = 'invamu1'
|
if ((code === 'amu' || code === 'amul') && (!invFile || invFile === 'invamu')) invFile = 'invamu1'
|
||||||
if ((code === 'jew' || code === 'jewl') && (!invFile || invFile === 'invjew' || invFile === 'invjw')) invFile = 'invjw1'
|
if ((code === 'jew' || code === 'jewl') && (!invFile || invFile === 'invjew' || invFile === 'invjw' || invFile === 'invgswe')) invFile = 'invjw1'
|
||||||
if (code === 'tbk') invFile = 'invbbk'
|
if (code === 'tbk') invFile = 'invbbk'
|
||||||
if (code === 'ibk') invFile = 'invrbk'
|
if (code === 'ibk') invFile = 'invrbk'
|
||||||
if (code === 'box' || code === 'cube') invFile = 'invbox'
|
if (code === 'box') invFile = 'invbox'
|
||||||
|
|
||||||
// 7. Verify 100% Atlas Matching Against BAKED_UI_MANIFEST.itemRects
|
// 7. Verify 100% Atlas Matching Against BAKED_UI_MANIFEST.itemRects
|
||||||
if (invFile && BAKED_UI_MANIFEST.itemRects[invFile]) {
|
if (invFile && BAKED_UI_MANIFEST.itemRects[invFile]) {
|
||||||
|
|
@ -685,7 +740,7 @@ export function resolveItemInvFile(
|
||||||
export function itemToUiInventoryItem(
|
export function itemToUiInventoryItem(
|
||||||
item: Item,
|
item: Item,
|
||||||
dropTables?: DropTables,
|
dropTables?: DropTables,
|
||||||
): UiInventoryItem {
|
): UiInventoryItem & { readonly variant?: number | undefined; readonly graphicIndex?: number | undefined } {
|
||||||
// Defensive passthrough: already a complete UiInventoryItem
|
// Defensive passthrough: already a complete UiInventoryItem
|
||||||
if (
|
if (
|
||||||
item &&
|
item &&
|
||||||
|
|
@ -1044,6 +1099,12 @@ export function itemToUiInventoryItem(
|
||||||
const slotType =
|
const slotType =
|
||||||
(rawItem as any).slot ??
|
(rawItem as any).slot ??
|
||||||
(allowedSlots[0] === 'weapon1' ? 'weapon' : allowedSlots[0] ?? 'none')
|
(allowedSlots[0] === 'weapon1' ? 'weapon' : allowedSlots[0] ?? 'none')
|
||||||
|
const itemVariant =
|
||||||
|
typeof (rawItem as any).variant === 'number' && Number.isFinite((rawItem as any).variant) && (rawItem as any).variant >= 0
|
||||||
|
? Math.trunc((rawItem as any).variant)
|
||||||
|
: typeof (rawItem as any).graphicIndex === 'number' && Number.isFinite((rawItem as any).graphicIndex) && (rawItem as any).graphicIndex >= 0
|
||||||
|
? Math.trunc((rawItem as any).graphicIndex)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -1062,6 +1123,7 @@ export function itemToUiInventoryItem(
|
||||||
gridH: invHeight,
|
gridH: invHeight,
|
||||||
slotType,
|
slotType,
|
||||||
allowedSlots,
|
allowedSlots,
|
||||||
|
...(itemVariant !== undefined ? { variant: itemVariant, graphicIndex: itemVariant } : {}),
|
||||||
...(isTwoHandedWeapon ? { twoHanded: true } : {}),
|
...(isTwoHandedWeapon ? { twoHanded: true } : {}),
|
||||||
...(isOneOrTwoHandedWeapon ? { oneOrTwoHanded: true } : {}),
|
...(isOneOrTwoHandedWeapon ? { oneOrTwoHanded: true } : {}),
|
||||||
...(!isGambleMystery && tt.defense !== undefined
|
...(!isGambleMystery && tt.defense !== undefined
|
||||||
|
|
@ -1496,6 +1558,8 @@ export function onlineItemDataToUiInventoryItem(
|
||||||
readonly nameEn: string
|
readonly nameEn: string
|
||||||
readonly baseName: string
|
readonly baseName: string
|
||||||
readonly usedSockets: number
|
readonly usedSockets: number
|
||||||
|
readonly variant?: number | undefined
|
||||||
|
readonly graphicIndex?: number | undefined
|
||||||
readonly damageRange?: { readonly min: number; readonly max: number } | undefined
|
readonly damageRange?: { readonly min: number; readonly max: number } | undefined
|
||||||
readonly twoHandDamage?: { readonly min: number; readonly max: number } | undefined
|
readonly twoHandDamage?: { readonly min: number; readonly max: number } | undefined
|
||||||
} {
|
} {
|
||||||
|
|
@ -1818,6 +1882,13 @@ export function onlineItemDataToUiInventoryItem(
|
||||||
oneOrTwoHanded: Boolean(baseRow?.oneOrTwoHanded || baseRow?.['1or2handed'] === '1' || baseRow?.['1or2handed'] === 1),
|
oneOrTwoHanded: Boolean(baseRow?.oneOrTwoHanded || baseRow?.['1or2handed'] === '1' || baseRow?.['1or2handed'] === 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemVariant =
|
||||||
|
typeof (item as any).variant === 'number' && Number.isFinite((item as any).variant) && (item as any).variant >= 0
|
||||||
|
? Math.trunc((item as any).variant)
|
||||||
|
: typeof (item as any).graphicIndex === 'number' && Number.isFinite((item as any).graphicIndex) && (item as any).graphicIndex >= 0
|
||||||
|
? Math.trunc((item as any).graphicIndex)
|
||||||
|
: undefined
|
||||||
|
|
||||||
const syntheticItem = {
|
const syntheticItem = {
|
||||||
id: String(item.id ?? item.itemId ?? `item-${code}-${++itemBridgeSeqCounter}`),
|
id: String(item.id ?? item.itemId ?? `item-${code}-${++itemBridgeSeqCounter}`),
|
||||||
code,
|
code,
|
||||||
|
|
@ -1855,6 +1926,7 @@ export function onlineItemDataToUiInventoryItem(
|
||||||
socketed: Boolean(item.socketed || totalSockets > 0),
|
socketed: Boolean(item.socketed || totalSockets > 0),
|
||||||
runeword: isRuneword,
|
runeword: isRuneword,
|
||||||
},
|
},
|
||||||
|
...(itemVariant !== undefined ? { variant: itemVariant, graphicIndex: itemVariant } : {}),
|
||||||
...(hasDur ? { durability: curDur, maxDurability: maxDur } : {}),
|
...(hasDur ? { durability: curDur, maxDurability: maxDur } : {}),
|
||||||
...(totalSockets > 0 ? { sockets: totalSockets, totalSockets, socketedCount: usedSockets } : {}),
|
...(totalSockets > 0 ? { sockets: totalSockets, totalSockets, socketedCount: usedSockets } : {}),
|
||||||
...(childDomainItems ? { socketedItems: childDomainItems } : {}),
|
...(childDomainItems ? { socketedItems: childDomainItems } : {}),
|
||||||
|
|
@ -1924,6 +1996,7 @@ export function onlineItemDataToUiInventoryItem(
|
||||||
baseNameZh,
|
baseNameZh,
|
||||||
baseNameEn,
|
baseNameEn,
|
||||||
usedSockets,
|
usedSockets,
|
||||||
|
...(itemVariant !== undefined ? { variant: itemVariant, graphicIndex: itemVariant } : {}),
|
||||||
...(finalDefense !== undefined ? { defense: finalDefense } : {}),
|
...(finalDefense !== undefined ? { defense: finalDefense } : {}),
|
||||||
...(totalSockets > 0 ? { sockets: totalSockets } : {}),
|
...(totalSockets > 0 ? { sockets: totalSockets } : {}),
|
||||||
...(childUiItems
|
...(childUiItems
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
|
||||||
|
import {
|
||||||
|
CANONICAL_VAR_INV_GFX,
|
||||||
|
itemDataToUiInventoryItem,
|
||||||
|
itemToUiInventoryItem,
|
||||||
|
onlineItemDataToUiInventoryItem,
|
||||||
|
resolveItemInvFile,
|
||||||
|
} from '../../src/common/items/item-bridge.ts'
|
||||||
|
import { ItemQuality, type Item } from '../../src/common/items/items.ts'
|
||||||
|
import {
|
||||||
|
CANONICAL_MISC_USABLE_CODES,
|
||||||
|
EQUIP_SLOTS_LAYOUT,
|
||||||
|
GEMSOCKET_SPRITE_RECT,
|
||||||
|
GRID_ITEM_BG_BLUE,
|
||||||
|
GRID_ITEM_BG_GREEN,
|
||||||
|
GRID_ITEM_BG_RED,
|
||||||
|
INVENTORY_TXT_LAYOUTS,
|
||||||
|
INV_GRID_ORIGIN,
|
||||||
|
INV_PANEL_ORIGIN,
|
||||||
|
InventoryPanel,
|
||||||
|
WEAPON_SWAP_TABS_BOUNDS,
|
||||||
|
drawItemSocketOverlay,
|
||||||
|
getInventoryLayout,
|
||||||
|
isUsableRightClickItem,
|
||||||
|
parseInventoryTxt,
|
||||||
|
resolveGridItemBackgroundFill,
|
||||||
|
resolveItemSpriteRect,
|
||||||
|
resolvePaperdollSlotBackgroundFill,
|
||||||
|
resolveWeaponSwapTabClick,
|
||||||
|
type UiInventoryItem,
|
||||||
|
} from '../../src/client/ui/inventory.ts'
|
||||||
|
import type { D2FontRenderer } from '../../src/client/ui/font.ts'
|
||||||
|
|
||||||
|
function createMockCanvasContext() {
|
||||||
|
const fillRectCalls: { x: number; y: number; w: number; h: number; fillStyle: string }[] = []
|
||||||
|
const drawImageCalls: unknown[][] = []
|
||||||
|
const arcCalls: unknown[][] = []
|
||||||
|
const textCalls: string[] = []
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
fillStyle: '',
|
||||||
|
strokeStyle: '',
|
||||||
|
lineWidth: 1,
|
||||||
|
save: vi.fn(),
|
||||||
|
restore: vi.fn(),
|
||||||
|
beginPath: vi.fn(),
|
||||||
|
fill: vi.fn(),
|
||||||
|
stroke: vi.fn(),
|
||||||
|
fillRect: vi.fn((x: number, y: number, w: number, h: number) => {
|
||||||
|
fillRectCalls.push({ x, y, w, h, fillStyle: String(ctx.fillStyle) })
|
||||||
|
}),
|
||||||
|
strokeRect: vi.fn(),
|
||||||
|
drawImage: vi.fn((...args: unknown[]) => {
|
||||||
|
drawImageCalls.push(args)
|
||||||
|
}),
|
||||||
|
arc: vi.fn((...args: unknown[]) => {
|
||||||
|
arcCalls.push(args)
|
||||||
|
}),
|
||||||
|
} as unknown as CanvasRenderingContext2D
|
||||||
|
|
||||||
|
const font = {
|
||||||
|
drawText: vi.fn((_ctx: unknown, text: string) => {
|
||||||
|
textCalls.push(text)
|
||||||
|
}),
|
||||||
|
} as unknown as D2FontRenderer
|
||||||
|
|
||||||
|
return { ctx, font, fillRectCalls, drawImageCalls, arcCalls, textCalls }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Batch 6 Phase 2: Inventory Panel & Item Rendering Parity (Issues #680-#685)', () => {
|
||||||
|
describe('Issue #680: Right-Click Usable Items Driven by misc.txt useable Column', () => {
|
||||||
|
const EXPECTED_35_USABLE_CODES = [
|
||||||
|
'elx', 'hpo', 'mpo', 'hpf', 'mpf', 'vps', 'yps', 'rvs', 'rvl', 'wms',
|
||||||
|
'tbk', 'ibk', 'tsc', 'isc', 'xyz', 'box', 'ass',
|
||||||
|
'hp1', 'hp2', 'hp3', 'hp4', 'hp5',
|
||||||
|
'mp1', 'mp2', 'mp3', 'mp4', 'mp5',
|
||||||
|
'hrb', 'rps', 'rpl', 'bps', 'bpl', '0sc', 'tr2', 'toa',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
it('recognizes all 35 canonical misc.txt useable=1 codes and rejects non-canonical codes like cube', () => {
|
||||||
|
expect(CANONICAL_MISC_USABLE_CODES.size).toBe(35)
|
||||||
|
for (const code of EXPECTED_35_USABLE_CODES) {
|
||||||
|
expect(isUsableRightClickItem(code)).toBe(true)
|
||||||
|
expect(isUsableRightClickItem(` ${code.toUpperCase()} `)).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject non-canonical 'cube' alias and non-usable items
|
||||||
|
expect(isUsableRightClickItem('cube')).toBe(false)
|
||||||
|
expect(isUsableRightClickItem('cm1')).toBe(false)
|
||||||
|
expect(isUsableRightClickItem('rin')).toBe(false)
|
||||||
|
expect(isUsableRightClickItem('cap')).toBe(false)
|
||||||
|
expect(isUsableRightClickItem('r01')).toBe(false)
|
||||||
|
expect(isUsableRightClickItem(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('consumes usable potions/scrolls on right-click in InventoryPanel while rejecting non-usable items', () => {
|
||||||
|
const panel = new InventoryPanel()
|
||||||
|
panel.visible = true
|
||||||
|
const usedItems: string[] = []
|
||||||
|
|
||||||
|
const hpPotion: UiInventoryItem = {
|
||||||
|
id: 'pot-1',
|
||||||
|
code: 'hp4',
|
||||||
|
invFile: 'invbpot',
|
||||||
|
name: 'Greater Healing Potion',
|
||||||
|
nameZh: '强效治疗药剂',
|
||||||
|
baseNameZh: '强效治疗药剂',
|
||||||
|
quality: 'normal',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [],
|
||||||
|
}
|
||||||
|
panel.gridItems = [{ item: hpPotion, col: 0, row: 0 }]
|
||||||
|
|
||||||
|
const handled = panel.handleClick(
|
||||||
|
INV_GRID_ORIGIN.x + 5,
|
||||||
|
INV_GRID_ORIGIN.y + 5,
|
||||||
|
{
|
||||||
|
onUseItem: item => {
|
||||||
|
usedItems.push(item.code)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
expect(handled).toBe(true)
|
||||||
|
expect(usedItems).toEqual(['hp4'])
|
||||||
|
expect(panel.gridItems.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #681: Item Variant Graphic Index (graphicIndex / InvGfx1..6) without Name Guessing or String Hashing', () => {
|
||||||
|
const itemRects = BAKED_UI_MANIFEST.itemRects
|
||||||
|
|
||||||
|
it('forwards graphicIndex from D2GS ItemData through itemDataToUiInventoryItem to select InvGfx1..6', () => {
|
||||||
|
// Rings: 5 variants (invrin1..invrin5)
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const uiRing = itemDataToUiInventoryItem({
|
||||||
|
id: 100 + i,
|
||||||
|
code: 'rin',
|
||||||
|
quality: 4,
|
||||||
|
identified: true,
|
||||||
|
graphicIndex: i,
|
||||||
|
})
|
||||||
|
expect(uiRing.variant).toBe(i)
|
||||||
|
expect(uiRing.graphicIndex).toBe(i)
|
||||||
|
expect(uiRing.invFile).toBe(`invrin${i + 1}`)
|
||||||
|
expect(resolveItemSpriteRect(uiRing, itemRects)).toEqual(itemRects[`invrin${i + 1}`])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amulets: 3 variants (invamu1..invamu3)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const uiAmu = onlineItemDataToUiInventoryItem({
|
||||||
|
id: 200 + i,
|
||||||
|
code: 'amu',
|
||||||
|
quality: 6,
|
||||||
|
identified: true,
|
||||||
|
graphicIndex: i,
|
||||||
|
})
|
||||||
|
expect(uiAmu.variant).toBe(i)
|
||||||
|
expect(uiAmu.invFile).toBe(`invamu${i + 1}`)
|
||||||
|
expect(resolveItemSpriteRect(uiAmu, itemRects)).toEqual(itemRects[`invamu${i + 1}`])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jewels: 6 variants (invjw1..invjw6), never misc.txt placeholder invgswe
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const uiJewel = onlineItemDataToUiInventoryItem({
|
||||||
|
id: 300 + i,
|
||||||
|
code: 'jew',
|
||||||
|
quality: 7,
|
||||||
|
identified: true,
|
||||||
|
graphicIndex: i,
|
||||||
|
})
|
||||||
|
expect(uiJewel.variant).toBe(i)
|
||||||
|
expect(uiJewel.invFile).toBe(`invjw${i + 1}`)
|
||||||
|
expect(resolveItemSpriteRect(uiJewel, itemRects)).toEqual(itemRects[`invjw${i + 1}`])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charms: cm1 (invch1/4/7), cm2 (invch2/5/8), cm3 (invch3/6/9)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const cm1 = onlineItemDataToUiInventoryItem({ id: 400 + i, code: 'cm1', quality: 4, identified: true, graphicIndex: i })
|
||||||
|
const cm2 = onlineItemDataToUiInventoryItem({ id: 410 + i, code: 'cm2', quality: 4, identified: true, graphicIndex: i })
|
||||||
|
const cm3 = onlineItemDataToUiInventoryItem({ id: 420 + i, code: 'cm3', quality: 4, identified: true, graphicIndex: i })
|
||||||
|
expect(cm1.invFile).toBe(CANONICAL_VAR_INV_GFX.cm1![i])
|
||||||
|
expect(cm2.invFile).toBe(CANONICAL_VAR_INV_GFX.cm2![i])
|
||||||
|
expect(cm3.invFile).toBe(CANONICAL_VAR_INV_GFX.cm3![i])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not hash item.id to pick random ring/amulet/jewel graphics and rejects fake cta code', () => {
|
||||||
|
// Different item.id strings without variant/graphicIndex must deterministically use index 0
|
||||||
|
for (const id of ['ring-alpha', 'ring-beta', 'ring-gamma', '99999', '12345']) {
|
||||||
|
expect(resolveItemSpriteRect({ code: 'rin', id }, itemRects)).toEqual(itemRects['invrin1'])
|
||||||
|
expect(resolveItemSpriteRect({ code: 'amu', id }, itemRects)).toEqual(itemRects['invamu1'])
|
||||||
|
expect(resolveItemSpriteRect({ code: 'jew', id }, itemRects)).toEqual(itemRects['invjw1'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical crs resolves to invcrs, while fake code 'cta' returns null
|
||||||
|
expect(resolveItemSpriteRect({ code: 'crs' }, itemRects)).toEqual(itemRects['invcrs'])
|
||||||
|
expect(resolveItemSpriteRect({ code: 'cta' }, itemRects)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves unique items with dedicated UniqueItems.txt invfile (Annihilus, Hellfire Torch) via table lookup', () => {
|
||||||
|
const anniDomain = {
|
||||||
|
id: 1,
|
||||||
|
code: 'cm1',
|
||||||
|
name: 'Annihilus',
|
||||||
|
quality: ItemQuality.UNIQUE,
|
||||||
|
identified: true,
|
||||||
|
uniqueItemDef: { invfile: 'invmss', index: 'Annihilus' },
|
||||||
|
base: { id: 'cm1', name: 'Small Charm', kind: 'misc', invWidth: 1, invHeight: 1 },
|
||||||
|
} as unknown as Item
|
||||||
|
expect(resolveItemInvFile(anniDomain, 'unique', 'cm1').invFile).toBe('invmss')
|
||||||
|
expect(itemToUiInventoryItem(anniDomain).invFile).toBe('invmss')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #682: Canonical inventory.txt Layout Loading & Coordinates', () => {
|
||||||
|
it('parses all 32 rows of canonical 1.13c inventory.txt and exposes 640x480 and 800x600 layouts', () => {
|
||||||
|
expect(INVENTORY_TXT_LAYOUTS.length).toBe(32)
|
||||||
|
|
||||||
|
const ama640 = getInventoryLayout('Amazon', 0)
|
||||||
|
expect(ama640.invLeft).toBe(320)
|
||||||
|
expect(ama640.invRight).toBe(640)
|
||||||
|
expect(ama640.invTop).toBe(0)
|
||||||
|
expect(ama640.invBottom).toBe(441)
|
||||||
|
expect(ama640.gridLeft).toBe(339)
|
||||||
|
expect(ama640.gridTop).toBe(257)
|
||||||
|
|
||||||
|
const ama800 = getInventoryLayout('ama', 1)
|
||||||
|
expect(ama800.class).toBe('Amazon2')
|
||||||
|
expect(ama800.invLeft).toBe(400)
|
||||||
|
expect(ama800.invRight).toBe(720)
|
||||||
|
expect(ama800.invTop).toBe(60)
|
||||||
|
expect(ama800.invBottom).toBe(501)
|
||||||
|
expect(ama800.invWidth).toBe(320)
|
||||||
|
expect(ama800.invHeight).toBe(441)
|
||||||
|
expect(ama800.gridLeft).toBe(419)
|
||||||
|
expect(ama800.gridTop).toBe(315)
|
||||||
|
expect(ama800.gridX).toBe(10)
|
||||||
|
expect(ama800.gridY).toBe(4)
|
||||||
|
expect(ama800.gridBoxWidth).toBe(29)
|
||||||
|
expect(ama800.gridBoxHeight).toBe(29)
|
||||||
|
|
||||||
|
// Verify INV_PANEL_ORIGIN, INV_GRID_ORIGIN, and all 10 EQUIP_SLOTS_LAYOUT match Amazon2..Assassin2
|
||||||
|
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
|
||||||
|
expect(INV_GRID_ORIGIN).toEqual({ x: 419, y: 315, cols: 10, rows: 4, cellPx: 29 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.helm).toMatchObject({ x: 535, y: 68, w: 54, h: 51 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.amulet).toMatchObject({ x: 609, y: 95, w: 23, h: 24 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.weapon1).toMatchObject({ x: 420, y: 107, w: 55, h: 112 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.armor).toMatchObject({ x: 533, y: 137, w: 56, h: 82 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.weapon2).toMatchObject({ x: 651, y: 107, w: 55, h: 112 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.gloves).toMatchObject({ x: 421, y: 241, w: 54, h: 53 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.ring1).toMatchObject({ x: 495, y: 240, w: 23, h: 24 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.belt).toMatchObject({ x: 536, y: 239, w: 52, h: 25 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.ring2).toMatchObject({ x: 609, y: 240, w: 23, h: 24 })
|
||||||
|
expect(EQUIP_SLOTS_LAYOUT.boots).toMatchObject({ x: 652, y: 242, w: 54, h: 52 })
|
||||||
|
|
||||||
|
// Fail-fast on unknown layout or malformed TSV
|
||||||
|
expect(() => getInventoryLayout('NonExistentPanel', 1)).toThrow()
|
||||||
|
expect(() => parseInventoryTxt('')).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #683: Weapon Swap I / II Tabs Hitboxes & No Duplicate Text Overlay', () => {
|
||||||
|
it('splits left and right 62x22 weapon swap tab strips into I (set 0) and II (set 1) sub-tabs', () => {
|
||||||
|
expect(WEAPON_SWAP_TABS_BOUNDS.left).toEqual({ x: 415, y: 83, w: 62, h: 22 })
|
||||||
|
expect(WEAPON_SWAP_TABS_BOUNDS.right).toEqual({ x: 645, y: 83, w: 62, h: 22 })
|
||||||
|
expect(resolveWeaponSwapTabClick(420, 90)).toBe(0) // left I
|
||||||
|
expect(resolveWeaponSwapTabClick(455, 90)).toBe(1) // left II
|
||||||
|
expect(resolveWeaponSwapTabClick(650, 90)).toBe(0) // right I
|
||||||
|
expect(resolveWeaponSwapTabClick(685, 90)).toBe(1) // right II
|
||||||
|
expect(resolveWeaponSwapTabClick(500, 90)).toBeNull()
|
||||||
|
|
||||||
|
const panel = new InventoryPanel()
|
||||||
|
panel.visible = true
|
||||||
|
expect(panel.weaponSwapSet).toBe(0)
|
||||||
|
|
||||||
|
// Clicking already-active 'I' sub-tab is a no-op
|
||||||
|
panel.handleClick(420, 90)
|
||||||
|
expect(panel.weaponSwapSet).toBe(0)
|
||||||
|
|
||||||
|
// Clicking 'II' sub-tab switches to set 1
|
||||||
|
panel.handleClick(455, 90)
|
||||||
|
expect(panel.weaponSwapSet).toBe(1)
|
||||||
|
|
||||||
|
// Clicking 'II' again on right strip is a no-op
|
||||||
|
panel.handleClick(685, 90)
|
||||||
|
expect(panel.weaponSwapSet).toBe(1)
|
||||||
|
|
||||||
|
// Clicking 'I' on right strip switches back to set 0
|
||||||
|
panel.handleClick(650, 90)
|
||||||
|
expect(panel.weaponSwapSet).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders invTab0Img / invTab1Img without drawing duplicate Exocet Roman numeral text on top', () => {
|
||||||
|
const panel = new InventoryPanel()
|
||||||
|
panel.visible = true
|
||||||
|
const { ctx, font, textCalls } = createMockCanvasContext()
|
||||||
|
panel.draw(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
borderRightImg: null,
|
||||||
|
invSheetImg: null,
|
||||||
|
invTab0Img: {} as HTMLImageElement,
|
||||||
|
invTab1Img: {} as HTMLImageElement,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
itemsAtlasImg: null,
|
||||||
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
expect(textCalls).not.toContain('I')
|
||||||
|
expect(textCalls).not.toContain('II')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #684: Grid & Paperdoll Item Background Colors Follow 1.13c Usability Rules', () => {
|
||||||
|
const player = { level: 30, str: 60, dex: 60, classCode: 'sor' as const }
|
||||||
|
|
||||||
|
const usableUnique: UiInventoryItem = {
|
||||||
|
id: 'u-1',
|
||||||
|
code: 'cap',
|
||||||
|
invFile: 'invcap',
|
||||||
|
name: 'Biggin\'s Bonnet',
|
||||||
|
nameZh: '比金的软帽',
|
||||||
|
baseNameZh: '帽子',
|
||||||
|
quality: 'unique',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 2,
|
||||||
|
allowedSlots: ['helm'],
|
||||||
|
reqLevel: 3,
|
||||||
|
stats: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const unusableArmor: UiInventoryItem = {
|
||||||
|
id: 'a-1',
|
||||||
|
code: 'uar',
|
||||||
|
invFile: 'invful',
|
||||||
|
name: 'Sacred Armor',
|
||||||
|
nameZh: '神圣盔甲',
|
||||||
|
baseNameZh: '神圣盔甲',
|
||||||
|
quality: 'set',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['armor'],
|
||||||
|
reqStr: 232,
|
||||||
|
stats: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('uses blue for usable grid items regardless of quality, red for unusable/unidentified, and no green fill on normal equipped paperdoll slots', () => {
|
||||||
|
expect(resolveGridItemBackgroundFill(usableUnique, player)).toBe(GRID_ITEM_BG_BLUE)
|
||||||
|
expect(resolveGridItemBackgroundFill(unusableArmor, player)).toBe(GRID_ITEM_BG_RED)
|
||||||
|
expect(resolveGridItemBackgroundFill(usableUnique, player, { isHovered: true, hasCursorItem: true })).toBe(
|
||||||
|
GRID_ITEM_BG_GREEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paperdoll slots have no fill when usable, red when requirements unmet, green when hovered with cursor item
|
||||||
|
expect(resolvePaperdollSlotBackgroundFill(usableUnique, player)).toBeNull()
|
||||||
|
expect(resolvePaperdollSlotBackgroundFill(unusableArmor, player)).toBe(GRID_ITEM_BG_RED)
|
||||||
|
expect(resolvePaperdollSlotBackgroundFill(usableUnique, player, { isHovered: true, hasCursorItem: true })).toBe(
|
||||||
|
GRID_ITEM_BG_GREEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
const panel = new InventoryPanel()
|
||||||
|
panel.visible = true
|
||||||
|
panel.playerContext = player
|
||||||
|
panel.equipped.helm = usableUnique
|
||||||
|
panel.gridItems = [{ item: usableUnique, col: 0, row: 0 }]
|
||||||
|
|
||||||
|
const { ctx, font, fillRectCalls } = createMockCanvasContext()
|
||||||
|
panel.draw(
|
||||||
|
ctx,
|
||||||
|
{
|
||||||
|
borderRightImg: null,
|
||||||
|
invSheetImg: {} as HTMLImageElement,
|
||||||
|
invTab0Img: null,
|
||||||
|
invTab1Img: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
itemsAtlasImg: {} as HTMLImageElement,
|
||||||
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
||||||
|
},
|
||||||
|
font,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only the 10x4 grid item draws a background fillRect (in GRID_ITEM_BG_BLUE); the usable equipped helm draws no fillRect
|
||||||
|
expect(fillRectCalls).toHaveLength(1)
|
||||||
|
expect(fillRectCalls[0]?.fillStyle).toBe(GRID_ITEM_BG_BLUE)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Issue #685: Hover-Only Socket Overlays Using gemsocket DC6 Art (Zero Procedural Circles)', () => {
|
||||||
|
const socketedSword: UiInventoryItem = {
|
||||||
|
id: 'sw-1',
|
||||||
|
code: 'crs',
|
||||||
|
invFile: 'invcrs',
|
||||||
|
name: 'Crystal Sword',
|
||||||
|
nameZh: '水晶剑',
|
||||||
|
baseNameZh: '水晶剑',
|
||||||
|
quality: 'normal',
|
||||||
|
invWidth: 2,
|
||||||
|
invHeight: 3,
|
||||||
|
allowedSlots: ['weapon1', 'weapon2'],
|
||||||
|
sockets: 2,
|
||||||
|
socketedItems: [
|
||||||
|
{
|
||||||
|
id: 'r-1',
|
||||||
|
code: 'r07',
|
||||||
|
invFile: 'invr07',
|
||||||
|
name: 'Tal Rune',
|
||||||
|
nameZh: '塔尔符文',
|
||||||
|
baseNameZh: '塔尔符文',
|
||||||
|
quality: 'rune',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('draws socket overlays in InventoryPanel ONLY when the item is hovered and never calls ctx.arc', () => {
|
||||||
|
const panel = new InventoryPanel()
|
||||||
|
panel.visible = true
|
||||||
|
panel.gridItems = [{ item: socketedSword, col: 0, row: 0 }]
|
||||||
|
|
||||||
|
const mockAtlas = {} as HTMLImageElement
|
||||||
|
const mockGemSocket = {} as HTMLImageElement
|
||||||
|
|
||||||
|
// 1. Not hovered: only the base item sprite is drawn (1 drawImage call)
|
||||||
|
const unhovered = createMockCanvasContext()
|
||||||
|
panel.draw(
|
||||||
|
unhovered.ctx,
|
||||||
|
{
|
||||||
|
borderRightImg: null,
|
||||||
|
invSheetImg: null,
|
||||||
|
invTab0Img: null,
|
||||||
|
invTab1Img: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
itemsAtlasImg: mockAtlas,
|
||||||
|
gemSocketImg: mockGemSocket,
|
||||||
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
||||||
|
},
|
||||||
|
unhovered.font,
|
||||||
|
)
|
||||||
|
expect(unhovered.arcCalls).toHaveLength(0)
|
||||||
|
expect(unhovered.drawImageCalls).toHaveLength(1)
|
||||||
|
|
||||||
|
// 2. Hovered: base item sprite + 2 gemsocket frames (28x28) + 1 socketed rune sprite = 4 drawImage calls, 0 ctx.arc calls
|
||||||
|
panel.handleMouseMove(INV_GRID_ORIGIN.x + 10, INV_GRID_ORIGIN.y + 10)
|
||||||
|
expect(panel.hoveredItem?.item).toBe(socketedSword)
|
||||||
|
|
||||||
|
const hovered = createMockCanvasContext()
|
||||||
|
panel.draw(
|
||||||
|
hovered.ctx,
|
||||||
|
{
|
||||||
|
borderRightImg: null,
|
||||||
|
invSheetImg: null,
|
||||||
|
invTab0Img: null,
|
||||||
|
invTab1Img: null,
|
||||||
|
buySellBtnImg: null,
|
||||||
|
itemsAtlasImg: mockAtlas,
|
||||||
|
gemSocketImg: mockGemSocket,
|
||||||
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
||||||
|
},
|
||||||
|
hovered.font,
|
||||||
|
)
|
||||||
|
expect(hovered.arcCalls).toHaveLength(0)
|
||||||
|
expect(hovered.drawImageCalls).toHaveLength(4)
|
||||||
|
expect(GEMSOCKET_SPRITE_RECT).toEqual({ x: 0, y: 0, w: 28, h: 28 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails fast by throwing when a socketed child item has an unresolvable sprite rect', () => {
|
||||||
|
const brokenHost: UiInventoryItem = {
|
||||||
|
...socketedSword,
|
||||||
|
socketedItems: [
|
||||||
|
{
|
||||||
|
id: 'bad-sock',
|
||||||
|
code: 'nonexistent_socket_code',
|
||||||
|
invFile: 'nonexistent_invfile',
|
||||||
|
name: 'Invalid Socket Item',
|
||||||
|
nameZh: '无效镶嵌物',
|
||||||
|
baseNameZh: '无效镶嵌物',
|
||||||
|
quality: 'normal',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const { ctx } = createMockCanvasContext()
|
||||||
|
expect(() =>
|
||||||
|
drawItemSocketOverlay(ctx, brokenHost, 0, 0, 58, 87, {
|
||||||
|
itemsAtlasImg: {} as HTMLImageElement,
|
||||||
|
itemRects: BAKED_UI_MANIFEST.itemRects,
|
||||||
|
}),
|
||||||
|
).toThrow(/Missing sprite rect for socketed item/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -82,9 +82,9 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enforces D2Client.dll 800x600 panel and control bar coordinates', () => {
|
it('enforces D2Client.dll 800x600 panel and control bar coordinates', () => {
|
||||||
// Left Dock (80, 60, 320x432) + Right Dock (400, 60, 320x432)
|
// Left Dock (80, 60, 320x432) + Right Dock (400, 60, 320x441 per inventory.txt)
|
||||||
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
|
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
|
||||||
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
|
||||||
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
||||||
|
|
||||||
// Left (117..165) & Right (635..683) 48x48 Skill Slots per D2Client.dll 0x6fad7440..0x6fad7536
|
// Left (117..165) & Right (635..683) 48x48 Skill Slots per D2Client.dll 0x6fad7440..0x6fad7536
|
||||||
|
|
@ -458,11 +458,11 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
expect(codeRect).not.toBeNull()
|
expect(codeRect).not.toBeNull()
|
||||||
expect(codeRect!.w).toBeGreaterThan(0)
|
expect(codeRect!.w).toBeGreaterThan(0)
|
||||||
|
|
||||||
// Tier 4: Canonical 1.13c overrides (e.g. cta -> invcrs, tbk -> invbbk, cube -> invbox)
|
// Tier 4: Canonical 1.13c 3-letter item codes (e.g. crs -> invcrs, tbk -> invbbk, box -> invbox)
|
||||||
const ctaItem = { id: 'test4', code: 'cta', name: 'Call to Arms', invFile: 'nonexistent', width: 1, height: 2 }
|
const crsItem = { id: 'test4', code: 'crs', name: 'Call to Arms', invFile: 'nonexistent', width: 1, height: 2 }
|
||||||
expect(resolveItemSpriteRect(ctaItem, itemRects)).toEqual(itemRects['invcrs'])
|
expect(resolveItemSpriteRect(crsItem, itemRects)).toEqual(itemRects['invcrs'])
|
||||||
|
|
||||||
const cubeItem = { id: 'test5', code: 'cube', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 }
|
const cubeItem = { id: 'test5', code: 'box', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 }
|
||||||
expect(resolveItemSpriteRect(cubeItem, itemRects)).toEqual(itemRects['invbox'])
|
expect(resolveItemSpriteRect(cubeItem, itemRects)).toEqual(itemRects['invbox'])
|
||||||
|
|
||||||
// Unknown items without valid invFile or canonical code return null (no fake dimension fallback)
|
// Unknown items without valid invFile or canonical code return null (no fake dimension fallback)
|
||||||
|
|
|
||||||
|
|
@ -177,8 +177,8 @@ describe('Widescreen UI Panels Left/Right Docking & Central Vision Corridor (Iss
|
||||||
describe('Invariant Preservation', () => {
|
describe('Invariant Preservation', () => {
|
||||||
it('preserves canonical panel and grid origin constants unchanged', () => {
|
it('preserves canonical panel and grid origin constants unchanged', () => {
|
||||||
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
|
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
|
||||||
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
|
||||||
expect(INV_GRID_ORIGIN).toEqual({ x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 })
|
expect(INV_GRID_ORIGIN).toEqual({ x: 419, y: 315, cols: 10, rows: 4, cellPx: 29 })
|
||||||
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
||||||
expect(STASH_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 })
|
expect(STASH_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 })
|
||||||
expect(STASH_GRID_ORIGIN).toEqual({ x: 154, y: 142, cols: 6, rows: 8, cellPx: 29 })
|
expect(STASH_GRID_ORIGIN).toEqual({ x: 154, y: 142, cols: 6, rows: 8, cellPx: 29 })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue