Compare commits
2 Commits
2fe9138fb1
...
7295dc8b3e
| Author | SHA1 | Date |
|---|---|---|
|
|
7295dc8b3e | |
|
|
43599963e4 |
|
|
@ -0,0 +1,148 @@
|
||||||
|
{
|
||||||
|
"name": "town_portal_on_hd",
|
||||||
|
"celFile": "tphdlitonhth",
|
||||||
|
"width": 1024,
|
||||||
|
"height": 319,
|
||||||
|
"directions": 1,
|
||||||
|
"frames": 15,
|
||||||
|
"framesPerDirection": 15,
|
||||||
|
"animRate": 16,
|
||||||
|
"trans": 3,
|
||||||
|
"preDraw": false,
|
||||||
|
"lightRadius": 19,
|
||||||
|
"lightColor": [
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"left": -54,
|
||||||
|
"top": -120,
|
||||||
|
"width": 109,
|
||||||
|
"height": 159
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
110,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
220,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
330,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
440,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
550,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
660,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
770,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
880,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
110,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
220,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
330,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
440,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
550,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
|
|
@ -0,0 +1,148 @@
|
||||||
|
{
|
||||||
|
"name": "town_portal_on_tr",
|
||||||
|
"celFile": "tptrlitonhth",
|
||||||
|
"width": 512,
|
||||||
|
"height": 181,
|
||||||
|
"directions": 1,
|
||||||
|
"frames": 15,
|
||||||
|
"framesPerDirection": 15,
|
||||||
|
"animRate": 16,
|
||||||
|
"trans": 0,
|
||||||
|
"preDraw": false,
|
||||||
|
"lightRadius": 19,
|
||||||
|
"lightColor": [
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"left": -23,
|
||||||
|
"top": -85,
|
||||||
|
"width": 46,
|
||||||
|
"height": 90
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
47,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
94,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
141,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
188,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
235,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
282,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
329,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
376,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
423,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
47,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
94,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
141,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
188,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
|
|
@ -0,0 +1,148 @@
|
||||||
|
{
|
||||||
|
"name": "town_portal_op_hd",
|
||||||
|
"celFile": "tphdlitophth",
|
||||||
|
"width": 1024,
|
||||||
|
"height": 608,
|
||||||
|
"directions": 1,
|
||||||
|
"frames": 15,
|
||||||
|
"framesPerDirection": 15,
|
||||||
|
"animRate": 16,
|
||||||
|
"trans": 3,
|
||||||
|
"preDraw": false,
|
||||||
|
"lightRadius": 19,
|
||||||
|
"lightColor": [
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"left": -93,
|
||||||
|
"top": -141,
|
||||||
|
"width": 188,
|
||||||
|
"height": 202
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
189,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
378,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
567,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
756,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
189,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
378,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
567,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
756,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
189,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
378,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
567,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
756,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -0,0 +1,148 @@
|
||||||
|
{
|
||||||
|
"name": "town_portal_op_tr",
|
||||||
|
"celFile": "tptrlitophth",
|
||||||
|
"width": 512,
|
||||||
|
"height": 181,
|
||||||
|
"directions": 1,
|
||||||
|
"frames": 15,
|
||||||
|
"framesPerDirection": 15,
|
||||||
|
"animRate": 16,
|
||||||
|
"trans": 0,
|
||||||
|
"preDraw": false,
|
||||||
|
"lightRadius": 19,
|
||||||
|
"lightColor": [
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"left": -23,
|
||||||
|
"top": -85,
|
||||||
|
"width": 45,
|
||||||
|
"height": 90
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
46,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
92,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
138,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
184,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
230,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
276,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
322,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
368,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
414,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
460,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
91,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
46,
|
||||||
|
91,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
92,
|
||||||
|
91,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
138,
|
||||||
|
91,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -1503,6 +1503,47 @@ export const OVERLAY_TARGETS: readonly OverlayTarget[] = [
|
||||||
lightRadius: 0,
|
lightRadius: 0,
|
||||||
lightColor: [255, 255, 255] as const,
|
lightColor: [255, 255, 255] as const,
|
||||||
},
|
},
|
||||||
|
// Authentic Diablo II v1.13c Town Portal Object Art (Objects.txt ID 59, Token TP: tpophth.cof & tponhth.cof)
|
||||||
|
{
|
||||||
|
name: 'town_portal_op_tr',
|
||||||
|
celFile: 'tptrlitophth',
|
||||||
|
aliases: ['tptrlitophth'],
|
||||||
|
animRate: 16,
|
||||||
|
trans: 0,
|
||||||
|
preDraw: false,
|
||||||
|
lightRadius: 19,
|
||||||
|
lightColor: [64, 64, 255] as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'town_portal_op_hd',
|
||||||
|
celFile: 'tphdlitophth',
|
||||||
|
aliases: ['tphdlitophth'],
|
||||||
|
animRate: 16,
|
||||||
|
trans: 3,
|
||||||
|
preDraw: false,
|
||||||
|
lightRadius: 19,
|
||||||
|
lightColor: [64, 64, 255] as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'town_portal_on_tr',
|
||||||
|
celFile: 'tptrlitonhth',
|
||||||
|
aliases: ['tptrlitonhth'],
|
||||||
|
animRate: 16,
|
||||||
|
trans: 0,
|
||||||
|
preDraw: false,
|
||||||
|
lightRadius: 19,
|
||||||
|
lightColor: [64, 64, 255] as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'town_portal_on_hd',
|
||||||
|
celFile: 'tphdlitonhth',
|
||||||
|
aliases: ['tphdlitonhth'],
|
||||||
|
animRate: 16,
|
||||||
|
trans: 3,
|
||||||
|
preDraw: false,
|
||||||
|
lightRadius: 19,
|
||||||
|
lightColor: [64, 64, 255] as const,
|
||||||
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export async function bakeOverlays(
|
export async function bakeOverlays(
|
||||||
|
|
@ -1549,7 +1590,10 @@ export async function bakeOverlays(
|
||||||
const metaRecord: Record<string, OverlayMeta> = {}
|
const metaRecord: Record<string, OverlayMeta> = {}
|
||||||
|
|
||||||
for (const target of OVERLAY_TARGETS) {
|
for (const target of OVERLAY_TARGETS) {
|
||||||
const raw = await readMpqFile(`data/global/overlays/${target.celFile}.dcc`)
|
const raw =
|
||||||
|
(await readMpqFile(`data/global/overlays/${target.celFile}.dcc`)) ??
|
||||||
|
(await readMpqFile(`data/global/objects/tp/tr/${target.celFile}.dcc`)) ??
|
||||||
|
(await readMpqFile(`data/global/objects/tp/hd/${target.celFile}.dcc`))
|
||||||
if (!raw) throw new Error(`Overlay DCC not found: data/global/overlays/${target.celFile}.dcc`)
|
if (!raw) throw new Error(`Overlay DCC not found: data/global/overlays/${target.celFile}.dcc`)
|
||||||
|
|
||||||
const dcc = decodeDcc(raw)
|
const dcc = decodeDcc(raw)
|
||||||
|
|
|
||||||
|
|
@ -1376,6 +1376,8 @@ export function createStarterBagForClass(classCode: CharacterClassCode): GridPla
|
||||||
invWidth: 1,
|
invWidth: 1,
|
||||||
invHeight: 2,
|
invHeight: 2,
|
||||||
allowedSlots: [],
|
allowedSlots: [],
|
||||||
|
quantity: 20,
|
||||||
|
maxQuantity: 20,
|
||||||
stats: [{ text: '右键施放城镇传送门', color: 'white' }],
|
stats: [{ text: '右键施放城镇传送门', color: 'white' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1393,6 +1395,8 @@ export function createStarterBagForClass(classCode: CharacterClassCode): GridPla
|
||||||
invWidth: 1,
|
invWidth: 1,
|
||||||
invHeight: 2,
|
invHeight: 2,
|
||||||
allowedSlots: [],
|
allowedSlots: [],
|
||||||
|
quantity: 20,
|
||||||
|
maxQuantity: 20,
|
||||||
stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }],
|
stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,81 @@ export interface OpenPortal {
|
||||||
/** The mouth in town, in sub-tiles. */
|
/** The mouth in town, in sub-tiles. */
|
||||||
readonly townX: number
|
readonly townX: number
|
||||||
readonly townY: number
|
readonly townY: number
|
||||||
|
/** Optional creation timestamp (ms) for the 15-frame opening animation (OP -> ON). */
|
||||||
|
readonly createdAtMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentic Diablo II v1.13c Town Portal DS1 special tile coordinates (`subTile = tile * 5 + 3`).
|
||||||
|
*
|
||||||
|
* Extracted from `D2GAME_CreateLinkPortal_6FD13B20` (`Skills.cpp:3006`) ->
|
||||||
|
* `DUNGEON_FindActSpawnLocationEx(pAct, nDestLevel, 11, &nX, &nY, 3)` (`D2Dungeon.cpp:391`) ->
|
||||||
|
* `DrlgPreset.cpp:1406–1438` (`style = 33, seq = 0` -> `nTileIndex = 11`) and
|
||||||
|
* `sub_6FD788D0` (`DrlgDrlgWarp.cpp:153–174` fallback to `pTileInfo[0]` for Harrogath `townWest.ds1`).
|
||||||
|
*/
|
||||||
|
export const TOWN_PORTAL_SUBTILES_BY_VARIANT: Readonly<Record<string, { readonly x: number; readonly y: number }>> = {
|
||||||
|
'1-act-1-town-townn1': { x: 153, y: 73 }, // TownN1.ds1 tile (30, 14)
|
||||||
|
'1-act-1-town-towne1': { x: 173, y: 103 }, // TownE1.ds1 tile (34, 20)
|
||||||
|
'1-act-1-town-towns1': { x: 153, y: 158 }, // TownS1.ds1 tile (30, 31)
|
||||||
|
'1-act-1-town-townw1': { x: 83, y: 128 }, // TownW1.ds1 tile (16, 25)
|
||||||
|
'40-act-2-town-lutw': { x: 178, y: 53 }, // LutW.ds1 tile (35, 10)
|
||||||
|
'40-act-2-town-lutn': { x: 178, y: 53 }, // LutN.ds1 tile (35, 10)
|
||||||
|
'75-act-3-town-docktown3': { x: 158, y: 68 }, // DockTown3.ds1 tile (31, 13)
|
||||||
|
'103-act-4-town-fortress': { x: 48, y: 43 }, // Fortress.ds1 tile (9, 8)
|
||||||
|
'109-act-5-town-townwest': { x: 98, y: 23 }, // townWest.ds1 tile (19, 4) via pTileInfo[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TOWN_PORTAL_SUBTILES_BY_LEVEL: Readonly<Record<number, { readonly x: number; readonly y: number }>> = {
|
||||||
|
1: { x: 153, y: 158 }, // Default Act 1 town variant is TownS1 (south gate to Blood Moor)
|
||||||
|
40: { x: 178, y: 53 },
|
||||||
|
75: { x: 158, y: 68 },
|
||||||
|
103: { x: 48, y: 43 },
|
||||||
|
109: { x: 98, y: 23 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the authentic 1.13c Town Portal sub-tile position in a town level.
|
||||||
|
*
|
||||||
|
* @param townLevelId - Town `Levels.txt` ID (`1, 40, 75, 103, 109`).
|
||||||
|
* @param townVariantLabel - Optional town preset variant label or DS1 name.
|
||||||
|
* @returns Sub-tile `{ x, y }` for the town portal mouth in town.
|
||||||
|
*/
|
||||||
|
export function resolveTownPortalSubTile(
|
||||||
|
townLevelId: number,
|
||||||
|
townVariantLabel?: string,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
if (townVariantLabel) {
|
||||||
|
const key = townVariantLabel.trim().toLowerCase()
|
||||||
|
const direct = TOWN_PORTAL_SUBTILES_BY_VARIANT[key]
|
||||||
|
if (direct !== undefined) {
|
||||||
|
return { x: direct.x, y: direct.y }
|
||||||
|
}
|
||||||
|
if (townLevelId === 1) {
|
||||||
|
if (key.includes('townn')) return { x: 153, y: 73 }
|
||||||
|
if (key.includes('towne')) return { x: 173, y: 103 }
|
||||||
|
if (key.includes('towns')) return { x: 153, y: 158 }
|
||||||
|
if (key.includes('townw')) return { x: 83, y: 128 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const byLevel = DEFAULT_TOWN_PORTAL_SUBTILES_BY_LEVEL[townLevelId]
|
||||||
|
if (byLevel !== undefined) {
|
||||||
|
return { x: byLevel.x, y: byLevel.y }
|
||||||
|
}
|
||||||
|
throw new Error(`resolveTownPortalSubTile: invalid town levelId ${String(townLevelId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Description of the portal mouth visible in a given level. */
|
||||||
|
export interface PortalMouthInLevel {
|
||||||
|
/** Sub-tile X of the portal mouth in this level. */
|
||||||
|
readonly x: number
|
||||||
|
/** Sub-tile Y of the portal mouth in this level. */
|
||||||
|
readonly y: number
|
||||||
|
/** Destination `Levels.txt` ID when stepping into this mouth. */
|
||||||
|
readonly toLevelId: number
|
||||||
|
/** True when this mouth is the one inside town (clicking it closes the portal). */
|
||||||
|
readonly isTownMouth: boolean
|
||||||
|
/** Optional creation timestamp (ms) for opening animation playback. */
|
||||||
|
readonly createdAtMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -195,7 +270,37 @@ export class TownPortalSlot {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where stepping into a mouth in the given level leads.
|
* Describe the portal mouth present in `levelId`, if any.
|
||||||
|
*
|
||||||
|
* @param levelId - the level currently loaded.
|
||||||
|
* @returns the mouth present in `levelId`, or null.
|
||||||
|
*/
|
||||||
|
mouthInLevel(levelId: number): PortalMouthInLevel | null {
|
||||||
|
const portal = this.open
|
||||||
|
if (portal === null) return null
|
||||||
|
if (levelId === portal.fromLevelId) {
|
||||||
|
return {
|
||||||
|
x: portal.fromX,
|
||||||
|
y: portal.fromY,
|
||||||
|
toLevelId: portal.townLevelId,
|
||||||
|
isTownMouth: false,
|
||||||
|
...(portal.createdAtMs !== undefined ? { createdAtMs: portal.createdAtMs } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (levelId === portal.townLevelId) {
|
||||||
|
return {
|
||||||
|
x: portal.townX,
|
||||||
|
y: portal.townY,
|
||||||
|
toLevelId: portal.fromLevelId,
|
||||||
|
isTownMouth: true,
|
||||||
|
...(portal.createdAtMs !== undefined ? { createdAtMs: portal.createdAtMs } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where stepping into a mouth in the given level leads (pure query).
|
||||||
*
|
*
|
||||||
* @param levelId - the level the player is standing in.
|
* @param levelId - the level the player is standing in.
|
||||||
* @returns the other mouth, or null when this level has no mouth.
|
* @returns the other mouth, or null when this level has no mouth.
|
||||||
|
|
@ -211,6 +316,31 @@ export class TownPortalSlot {
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step into the portal mouth in `levelId` (`OBJECTS_OperateFunction15_Portal`, `ObjMode.cpp:3125`):
|
||||||
|
* - Entering from the wilderness (`levelId === fromLevelId`) transports the player to town
|
||||||
|
* and keeps the portal open so the return mouth remains in town.
|
||||||
|
* - Entering from town (`levelId === townLevelId`) transports the player back to the wilderness
|
||||||
|
* and **closes the portal** (`ObjMode.cpp:3245`).
|
||||||
|
*
|
||||||
|
* @param levelId - the level the player is entering the portal from.
|
||||||
|
* @returns the travel destination, or null if no portal mouth is in `levelId`.
|
||||||
|
*/
|
||||||
|
enterFrom(levelId: number): TravelTarget | null {
|
||||||
|
const portal = this.open
|
||||||
|
if (portal === null) return null
|
||||||
|
if (levelId === portal.fromLevelId) {
|
||||||
|
return { levelId: portal.townLevelId, x: portal.townX, y: portal.townY }
|
||||||
|
}
|
||||||
|
if (levelId === portal.townLevelId) {
|
||||||
|
const target: TravelTarget = { levelId: portal.fromLevelId, x: portal.fromX, y: portal.fromY }
|
||||||
|
this.open = null
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ACT_TOWNS, townLevelForAct } from './acts.ts'
|
export { ACT_TOWNS, townLevelForAct } from './acts.ts'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22372,6 +22372,602 @@ export const DEATHSENTRY_EXPLODE_META: OverlayMeta = {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TOWN_PORTAL_OP_TR_META: OverlayMeta = {
|
||||||
|
"name": "town_portal_op_tr",
|
||||||
|
"celFile": "tptrlitophth",
|
||||||
|
"width": 512,
|
||||||
|
"height": 181,
|
||||||
|
"directions": 1,
|
||||||
|
"frames": 15,
|
||||||
|
"framesPerDirection": 15,
|
||||||
|
"animRate": 16,
|
||||||
|
"trans": 0,
|
||||||
|
"preDraw": false,
|
||||||
|
"lightRadius": 19,
|
||||||
|
"lightColor": [
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"left": -23,
|
||||||
|
"top": -85,
|
||||||
|
"width": 45,
|
||||||
|
"height": 90
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
46,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
92,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
138,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
184,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
230,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
276,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
322,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
368,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
414,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
460,
|
||||||
|
0,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
91,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
46,
|
||||||
|
91,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
92,
|
||||||
|
91,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
138,
|
||||||
|
91,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOWN_PORTAL_OP_HD_META: OverlayMeta = {
|
||||||
|
"name": "town_portal_op_hd",
|
||||||
|
"celFile": "tphdlitophth",
|
||||||
|
"width": 1024,
|
||||||
|
"height": 608,
|
||||||
|
"directions": 1,
|
||||||
|
"frames": 15,
|
||||||
|
"framesPerDirection": 15,
|
||||||
|
"animRate": 16,
|
||||||
|
"trans": 3,
|
||||||
|
"preDraw": false,
|
||||||
|
"lightRadius": 19,
|
||||||
|
"lightColor": [
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"left": -93,
|
||||||
|
"top": -141,
|
||||||
|
"width": 188,
|
||||||
|
"height": 202
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
189,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
378,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
567,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
756,
|
||||||
|
0,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
189,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
378,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
567,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
756,
|
||||||
|
203,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
189,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
378,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
567,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
],
|
||||||
|
[
|
||||||
|
756,
|
||||||
|
406,
|
||||||
|
188,
|
||||||
|
202,
|
||||||
|
-93,
|
||||||
|
-141
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOWN_PORTAL_ON_TR_META: OverlayMeta = {
|
||||||
|
"name": "town_portal_on_tr",
|
||||||
|
"celFile": "tptrlitonhth",
|
||||||
|
"width": 512,
|
||||||
|
"height": 181,
|
||||||
|
"directions": 1,
|
||||||
|
"frames": 15,
|
||||||
|
"framesPerDirection": 15,
|
||||||
|
"animRate": 16,
|
||||||
|
"trans": 0,
|
||||||
|
"preDraw": false,
|
||||||
|
"lightRadius": 19,
|
||||||
|
"lightColor": [
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"left": -23,
|
||||||
|
"top": -85,
|
||||||
|
"width": 46,
|
||||||
|
"height": 90
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
47,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
94,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
141,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
188,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
235,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
282,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
329,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
376,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
423,
|
||||||
|
0,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
47,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
94,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
141,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
],
|
||||||
|
[
|
||||||
|
188,
|
||||||
|
91,
|
||||||
|
46,
|
||||||
|
90,
|
||||||
|
-23,
|
||||||
|
-85
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOWN_PORTAL_ON_HD_META: OverlayMeta = {
|
||||||
|
"name": "town_portal_on_hd",
|
||||||
|
"celFile": "tphdlitonhth",
|
||||||
|
"width": 1024,
|
||||||
|
"height": 319,
|
||||||
|
"directions": 1,
|
||||||
|
"frames": 15,
|
||||||
|
"framesPerDirection": 15,
|
||||||
|
"animRate": 16,
|
||||||
|
"trans": 3,
|
||||||
|
"preDraw": false,
|
||||||
|
"lightRadius": 19,
|
||||||
|
"lightColor": [
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
255
|
||||||
|
],
|
||||||
|
"box": {
|
||||||
|
"left": -54,
|
||||||
|
"top": -120,
|
||||||
|
"width": 109,
|
||||||
|
"height": 159
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
110,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
220,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
330,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
440,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
550,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
660,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
770,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
880,
|
||||||
|
0,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
110,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
220,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
330,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
440,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
],
|
||||||
|
[
|
||||||
|
550,
|
||||||
|
160,
|
||||||
|
109,
|
||||||
|
159,
|
||||||
|
-54,
|
||||||
|
-120
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Backward compatibility aliases for primary aura constants
|
// Backward compatibility aliases for primary aura constants
|
||||||
export const MIGHT_AURA_META: OverlayMeta = MIGHT_FRONT_AURA_META
|
export const MIGHT_AURA_META: OverlayMeta = MIGHT_FRONT_AURA_META
|
||||||
|
|
@ -22613,6 +23209,10 @@ export const ALL_OVERLAY_NAMES: readonly string[] = [
|
||||||
"direwolfcharged",
|
"direwolfcharged",
|
||||||
"blade_shield",
|
"blade_shield",
|
||||||
"deathsentry_explode",
|
"deathsentry_explode",
|
||||||
|
"town_portal_op_tr",
|
||||||
|
"town_portal_op_hd",
|
||||||
|
"town_portal_on_tr",
|
||||||
|
"town_portal_on_hd",
|
||||||
]
|
]
|
||||||
|
|
||||||
export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
|
export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
|
||||||
|
|
@ -23223,4 +23823,12 @@ export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
|
||||||
"expansion/dethsentry01": DEATHSENTRY_EXPLODE_META,
|
"expansion/dethsentry01": DEATHSENTRY_EXPLODE_META,
|
||||||
"deathsentry": DEATHSENTRY_EXPLODE_META,
|
"deathsentry": DEATHSENTRY_EXPLODE_META,
|
||||||
"dethsentry01": DEATHSENTRY_EXPLODE_META,
|
"dethsentry01": DEATHSENTRY_EXPLODE_META,
|
||||||
|
"town_portal_op_tr": TOWN_PORTAL_OP_TR_META,
|
||||||
|
"tptrlitophth": TOWN_PORTAL_OP_TR_META,
|
||||||
|
"town_portal_op_hd": TOWN_PORTAL_OP_HD_META,
|
||||||
|
"tphdlitophth": TOWN_PORTAL_OP_HD_META,
|
||||||
|
"town_portal_on_tr": TOWN_PORTAL_ON_TR_META,
|
||||||
|
"tptrlitonhth": TOWN_PORTAL_ON_TR_META,
|
||||||
|
"town_portal_on_hd": TOWN_PORTAL_ON_HD_META,
|
||||||
|
"tphdlitonhth": TOWN_PORTAL_ON_HD_META,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ import { KeyboardInput } from '../sim/input.ts'
|
||||||
import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts'
|
import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../game/level-links.ts'
|
||||||
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
|
import { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.ts'
|
||||||
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
|
import { FadeOverlay, SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from './transition.ts'
|
||||||
import { TownPortalSlot, WaypointNetwork, townLevelForAct } from '../game/portal.ts'
|
import { TownPortalSlot, WaypointNetwork, resolveTownPortalSubTile, townLevelForAct, type OpenPortal } from '../game/portal.ts'
|
||||||
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.ts'
|
import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } from '../game/variants.ts'
|
||||||
import { Minimap } from '../ui/minimap.ts'
|
import { Minimap } from '../ui/minimap.ts'
|
||||||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||||
|
|
@ -360,6 +360,8 @@ export interface ActSceneState {
|
||||||
eliteMultiplier?: EliteMultiplier | undefined
|
eliteMultiplier?: EliteMultiplier | undefined
|
||||||
/** Dynamic monster re-budget and re-stream method without page reload. */
|
/** Dynamic monster re-budget and re-stream method without page reload. */
|
||||||
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
||||||
|
/** Active player Town Portal (if open). */
|
||||||
|
portal?: OpenPortal | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -374,6 +376,9 @@ declare global {
|
||||||
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
||||||
setPlayers?: (count: number, partyCount?: number) => void
|
setPlayers?: (count: number, partyCount?: number) => void
|
||||||
executeCommand?: (cmd: string) => boolean
|
executeCommand?: (cmd: string) => boolean
|
||||||
|
castTownPortal?: () => boolean
|
||||||
|
enterTownPortal?: () => void
|
||||||
|
getPortal?: () => OpenPortal | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1521,6 +1526,8 @@ export function getSkillRange(
|
||||||
case 62: return 450
|
case 62: return 450
|
||||||
case 63: return 0
|
case 63: return 0
|
||||||
case 64: return 480
|
case 64: return 480
|
||||||
|
case 219:
|
||||||
|
case 220: return 0
|
||||||
default: return 450
|
default: return 450
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1564,6 +1571,8 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
||||||
case 57: return 19
|
case 57: return 19
|
||||||
case 58: return 5
|
case 58: return 5
|
||||||
case 63: return 0
|
case 63: return 0
|
||||||
|
case 219:
|
||||||
|
case 220: return 0
|
||||||
default: return 10
|
default: return 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1586,6 +1595,9 @@ export function castSkill(
|
||||||
hasAmmo?: boolean | undefined
|
hasAmmo?: boolean | undefined
|
||||||
waypointNetwork?: any | undefined
|
waypointNetwork?: any | undefined
|
||||||
openStash?: ((stash: any) => void) | undefined
|
openStash?: ((stash: any) => void) | undefined
|
||||||
|
portalSlot?: TownPortalSlot | undefined
|
||||||
|
onEnterPortal?: (() => void) | undefined
|
||||||
|
onCastTownPortal?: (() => boolean) | undefined
|
||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
const { engine, runtime, hudManager, status, playerAnimator, character } = context
|
const { engine, runtime, hudManager, status, playerAnimator, character } = context
|
||||||
|
|
@ -2002,6 +2014,23 @@ export function castSkill(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.portalSlot && context.onEnterPortal && runtime.grid) {
|
||||||
|
const mouth = context.portalSlot.mouthInLevel(runtime.levelId)
|
||||||
|
if (mouth) {
|
||||||
|
const portalPos = subTileToScene(mouth.x, mouth.y, runtime.grid)
|
||||||
|
if (
|
||||||
|
Math.hypot(portalPos.x - targetX, portalPos.y - targetY) <= 64 ||
|
||||||
|
Math.hypot(portalPos.x - targetX, (portalPos.y - 40) - targetY) <= 64
|
||||||
|
) {
|
||||||
|
player.cooldown = 0
|
||||||
|
engine.metrics.casts += 1
|
||||||
|
status.textContent = '心灵传动进入传送门'
|
||||||
|
context.onEnterPortal()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Generic ground cast / feedback
|
// 4. Generic ground cast / feedback
|
||||||
player.cooldown = 0
|
player.cooldown = 0
|
||||||
engine.metrics.casts += 1
|
engine.metrics.casts += 1
|
||||||
|
|
@ -2009,6 +2038,18 @@ export function castSkill(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skillId === 219 || skillId === 220) {
|
||||||
|
// Scroll of Town Portal (219) / Book of Town Portal (220)
|
||||||
|
if (context.onCastTownPortal) {
|
||||||
|
const ok = context.onCastTownPortal()
|
||||||
|
if (!ok) return false
|
||||||
|
player.cooldown = 10
|
||||||
|
engine.metrics.casts += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (skillId === 59) {
|
if (skillId === 59) {
|
||||||
// Blizzard (Skill 59): spawn invisible blizzardcenter emitter missile at target coordinates
|
// Blizzard (Skill 59): spawn invisible blizzardcenter emitter missile at target coordinates
|
||||||
// 1.13c Skills.txt: Param1: 7 (radius subtiles), Param3: 4 (interval ticks), delay: 45 (1.8s cooldown)
|
// 1.13c Skills.txt: Param1: 7 (radius subtiles), Param3: 4 (interval ticks), delay: 45 (1.8s cooldown)
|
||||||
|
|
@ -3277,6 +3318,7 @@ export type PendingInteraction =
|
||||||
| { readonly kind: 'npc'; readonly npc: NpcEntity }
|
| { readonly kind: 'npc'; readonly npc: NpcEntity }
|
||||||
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
|
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
|
||||||
| { readonly kind: 'stash'; readonly stash: TownStashEntity }
|
| { readonly kind: 'stash'; readonly stash: TownStashEntity }
|
||||||
|
| { readonly kind: 'portal'; readonly worldPos: { readonly x: number; readonly y: number } }
|
||||||
| { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number }
|
| { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number }
|
||||||
| { readonly kind: 'ground-item'; readonly item: GroundItemEntity }
|
| { readonly kind: 'ground-item'; readonly item: GroundItemEntity }
|
||||||
|
|
||||||
|
|
@ -3298,6 +3340,9 @@ export interface SceneMouseControllerOptions {
|
||||||
readonly status: HTMLElement
|
readonly status: HTMLElement
|
||||||
readonly playerAnimator?: ActorAnimator | undefined
|
readonly playerAnimator?: ActorAnimator | undefined
|
||||||
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
||||||
|
readonly portalSlot?: TownPortalSlot | undefined
|
||||||
|
readonly onEnterPortal?: (() => void) | undefined
|
||||||
|
readonly onCastTownPortal?: (() => boolean) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -3396,10 +3441,13 @@ export class SceneMouseController {
|
||||||
readonly status: HTMLElement
|
readonly status: HTMLElement
|
||||||
readonly playerAnimator?: ActorAnimator | undefined
|
readonly playerAnimator?: ActorAnimator | undefined
|
||||||
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
||||||
|
readonly portalSlot?: TownPortalSlot | undefined
|
||||||
|
readonly onEnterPortal?: (() => void) | undefined
|
||||||
|
readonly onCastTownPortal?: (() => boolean) | undefined
|
||||||
|
|
||||||
navTarget: { x: number; y: number } | null = null
|
navTarget: { x: number; y: number } | null = null
|
||||||
pendingInteraction: PendingInteraction | null = null
|
pendingInteraction: PendingInteraction | null = null
|
||||||
activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'monster' = 'none'
|
activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'portal' | 'monster' = 'none'
|
||||||
isLeftMouseDown = false
|
isLeftMouseDown = false
|
||||||
isRightMouseDown = false
|
isRightMouseDown = false
|
||||||
lastClientPos: { x: number; y: number } | null = null
|
lastClientPos: { x: number; y: number } | null = null
|
||||||
|
|
@ -3419,6 +3467,9 @@ export class SceneMouseController {
|
||||||
this.status = options.status
|
this.status = options.status
|
||||||
this.playerAnimator = options.playerAnimator
|
this.playerAnimator = options.playerAnimator
|
||||||
this.getCharacter = options.getCharacter
|
this.getCharacter = options.getCharacter
|
||||||
|
this.portalSlot = options.portalSlot
|
||||||
|
this.onEnterPortal = options.onEnterPortal
|
||||||
|
this.onCastTownPortal = options.onCastTownPortal
|
||||||
;(this.engine as any).dialog = null
|
;(this.engine as any).dialog = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3847,6 +3898,30 @@ export class SceneMouseController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3b. Check Town Portal: within ~48 px of portal vertical oval [portalPos.y - 80, portalPos.y]
|
||||||
|
const activePortalMouth = this.portalSlot?.mouthInLevel(runtime.levelId) ?? null
|
||||||
|
if (activePortalMouth !== null) {
|
||||||
|
const portalPos = subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid)
|
||||||
|
const clampedPortalY = Math.max(portalPos.y - 80, Math.min(portalPos.y, worldY))
|
||||||
|
const minPortalDist = Math.hypot(portalPos.x - worldX, clampedPortalY - worldY)
|
||||||
|
if (minPortalDist <= 48) {
|
||||||
|
this.activeClickType = 'portal'
|
||||||
|
const distToPlayer = Math.hypot(portalPos.x - player.x, portalPos.y - player.y)
|
||||||
|
if (distToPlayer <= 80) { // OperateRange = 2 tiles (~80 px)
|
||||||
|
this.navTarget = null
|
||||||
|
this.pendingInteraction = null
|
||||||
|
player.facing = facingOf(portalPos.x - player.x, portalPos.y - player.y)
|
||||||
|
this.onEnterPortal?.()
|
||||||
|
} else {
|
||||||
|
this.pendingInteraction = { kind: 'portal', worldPos: portalPos }
|
||||||
|
this.navTarget = { x: portalPos.x, y: portalPos.y }
|
||||||
|
this.lastDist = distToPlayer
|
||||||
|
this.stuckTicks = 0
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Check Monster: living monster within ~32 px
|
// 4. Check Monster: living monster within ~32 px
|
||||||
let clickedMonster: Monster | null = null
|
let clickedMonster: Monster | null = null
|
||||||
let bestMonDist = 36
|
let bestMonDist = 36
|
||||||
|
|
@ -3931,6 +4006,9 @@ export class SceneMouseController {
|
||||||
hasAmmo: options?.hasAmmo,
|
hasAmmo: options?.hasAmmo,
|
||||||
waypointNetwork: this.waypointNetwork,
|
waypointNetwork: this.waypointNetwork,
|
||||||
openStash: (s: any) => this.openStash(s),
|
openStash: (s: any) => this.openStash(s),
|
||||||
|
portalSlot: this.portalSlot,
|
||||||
|
onEnterPortal: this.onEnterPortal,
|
||||||
|
onCastTownPortal: this.onCastTownPortal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4053,6 +4131,17 @@ export class SceneMouseController {
|
||||||
} else {
|
} else {
|
||||||
this.navTarget = { x: stash.x, y: stash.y }
|
this.navTarget = { x: stash.x, y: stash.y }
|
||||||
}
|
}
|
||||||
|
} else if (this.pendingInteraction.kind === 'portal') {
|
||||||
|
const { worldPos } = this.pendingInteraction
|
||||||
|
const dist = Math.hypot(worldPos.x - player.x, worldPos.y - player.y)
|
||||||
|
if (dist <= 80) {
|
||||||
|
this.navTarget = null
|
||||||
|
this.pendingInteraction = null
|
||||||
|
player.facing = facingOf(worldPos.x - player.x, worldPos.y - player.y)
|
||||||
|
this.onEnterPortal?.()
|
||||||
|
} else {
|
||||||
|
this.navTarget = { x: worldPos.x, y: worldPos.y }
|
||||||
|
}
|
||||||
} else if (this.pendingInteraction.kind === 'monster') {
|
} else if (this.pendingInteraction.kind === 'monster') {
|
||||||
const monster = this.pendingInteraction.monster
|
const monster = this.pendingInteraction.monster
|
||||||
if (monster.state === 'dead' || monster.hp <= 0) {
|
if (monster.state === 'dead' || monster.hp <= 0) {
|
||||||
|
|
@ -4371,13 +4460,25 @@ async function loadRuntimeForLevel(
|
||||||
viewport: { width: number; height: number },
|
viewport: { width: number; height: number },
|
||||||
charBases: readonly string[],
|
charBases: readonly string[],
|
||||||
fromSide?: Side,
|
fromSide?: Side,
|
||||||
|
preferredVariantLabel?: string,
|
||||||
): Promise<MapRuntime | null> {
|
): Promise<MapRuntime | null> {
|
||||||
const index = await getPackIndex(packBase)
|
const index = await getPackIndex(packBase)
|
||||||
if (index === null) return null
|
if (index === null) return null
|
||||||
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
||||||
if (candidates.length === 0) return null
|
if (candidates.length === 0) return null
|
||||||
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
|
const requiredSide = fromSide ? oppositeSide(fromSide) : undefined
|
||||||
const entry = findMatchingVariant(candidates, requiredSide, variantSeed)
|
let entry: PackIndexEntry | undefined
|
||||||
|
if (requiredSide === undefined && preferredVariantLabel !== undefined) {
|
||||||
|
entry = candidates.find(
|
||||||
|
c => c.label === preferredVariantLabel || c.slug === preferredVariantLabel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (entry === undefined && requiredSide === undefined && isTownLevel(levelId)) {
|
||||||
|
entry = getDefaultTownVariant(candidates, candidates[0]!.act)
|
||||||
|
}
|
||||||
|
if (entry === undefined) {
|
||||||
|
entry = findMatchingVariant(candidates, requiredSide, variantSeed)
|
||||||
|
}
|
||||||
if (entry === undefined) return null
|
if (entry === undefined) return null
|
||||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||||
}
|
}
|
||||||
|
|
@ -5884,6 +5985,64 @@ export function drawCastOverlay(
|
||||||
renderer.setBlendMode('normal')
|
renderer.setBlendMode('normal')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an active Diablo II 1.13c Town Portal (`Objects.txt` ID 59, Token `TP`):
|
||||||
|
* - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total),
|
||||||
|
* then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`).
|
||||||
|
* - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending.
|
||||||
|
* - Falls back to a procedural blue portal vortex oval in unit test / offline environments.
|
||||||
|
*/
|
||||||
|
export function drawTownPortal(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
elapsedMs: number,
|
||||||
|
overlayArtMap: ReadonlyMap<string, LoadedOverlayArt>,
|
||||||
|
): void {
|
||||||
|
const frameDurationMs = 51.2 // 1000 / (25 * 200 / 256)
|
||||||
|
const openDurationMs = 15 * frameDurationMs // 768 ms
|
||||||
|
const isOpening = elapsedMs < openDurationMs
|
||||||
|
const trArt = overlayArtMap.get(isOpening ? 'town_portal_op_tr' : 'town_portal_on_tr')
|
||||||
|
const hdArt = overlayArtMap.get(isOpening ? 'town_portal_op_hd' : 'town_portal_on_hd')
|
||||||
|
const rawFrameIdx = isOpening
|
||||||
|
? Math.min(14, Math.max(0, Math.floor(elapsedMs / frameDurationMs)))
|
||||||
|
: Math.max(0, Math.floor((elapsedMs - openDurationMs) / frameDurationMs)) % 15
|
||||||
|
|
||||||
|
let drewSprite = false
|
||||||
|
if (trArt !== undefined) {
|
||||||
|
const group = trArt.frames[0]
|
||||||
|
const frame = group?.[rawFrameIdx] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: trArt.handle,
|
||||||
|
blendMode: 'normal',
|
||||||
|
})
|
||||||
|
drewSprite = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hdArt !== undefined) {
|
||||||
|
const group = hdArt.frames[0]
|
||||||
|
const frame = group?.[rawFrameIdx] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: hdArt.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
elevationY: Math.max(0, frame.height - 16),
|
||||||
|
})
|
||||||
|
renderer.setBlendMode('normal')
|
||||||
|
drewSprite = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!drewSprite) {
|
||||||
|
renderer.drawSolid(x - 18, y - 82, 36, 82, [0.22, 0.48, 1.0, 0.75], { blendMode: 'additive' })
|
||||||
|
renderer.setBlendMode('normal')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Quality colors for ground item highlights and glints. */
|
/** Quality colors for ground item highlights and glints. */
|
||||||
export const GROUND_ITEM_QUALITY_COLORS: Record<string, readonly [number, number, number, number]> = {
|
export const GROUND_ITEM_QUALITY_COLORS: Record<string, readonly [number, number, number, number]> = {
|
||||||
normal: [0.88, 0.88, 0.88, 1.0],
|
normal: [0.88, 0.88, 0.88, 1.0],
|
||||||
|
|
@ -6635,6 +6794,83 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
const minimap = new Minimap()
|
const minimap = new Minimap()
|
||||||
const waypointNetwork = new WaypointNetwork()
|
const waypointNetwork = new WaypointNetwork()
|
||||||
const portalSlot = new TownPortalSlot()
|
const portalSlot = new TownPortalSlot()
|
||||||
|
const townVariantByAct = new Map<number, string>()
|
||||||
|
if (isTownLevel(runtime.levelId) && runtime.variant) {
|
||||||
|
townVariantByAct.set(runtime.act, runtime.variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the player is, in sub-tiles.
|
||||||
|
*
|
||||||
|
* The inverse of the isometric projection: scene pixels back to the grid the
|
||||||
|
* links are expressed in.
|
||||||
|
*
|
||||||
|
* @returns the player's sub-tile.
|
||||||
|
*/
|
||||||
|
const playerSubTile = (): { x: number; y: number } => {
|
||||||
|
const dx = (engine.world.player.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||||
|
const dy = (engine.world.player.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||||
|
return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast a Town Portal from the current non-town level, placing the wilderness
|
||||||
|
* mouth on a walkable sub-tile next to the player and the town mouth at the
|
||||||
|
* authentic 1.13c DS1 Object #33 (`Objects.txt` ID 59) Town Portal Spot.
|
||||||
|
*/
|
||||||
|
const castTownPortal = (): boolean => {
|
||||||
|
if (isTownLevel(runtime.levelId)) {
|
||||||
|
playInventoryFullFeedback()
|
||||||
|
showNotification('无法在城镇中开启回城传送门', 2000, status)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = playerSubTile()
|
||||||
|
const candidateOffsets = [
|
||||||
|
{ dx: 2, dy: -1 },
|
||||||
|
{ dx: 2, dy: 0 },
|
||||||
|
{ dx: 0, dy: -2 },
|
||||||
|
{ dx: -2, dy: 1 },
|
||||||
|
{ dx: 1, dy: -1 },
|
||||||
|
{ dx: 0, dy: 0 },
|
||||||
|
]
|
||||||
|
let fromX = here.x
|
||||||
|
let fromY = here.y
|
||||||
|
for (const off of candidateOffsets) {
|
||||||
|
const cx = here.x + off.dx
|
||||||
|
const cy = here.y + off.dy
|
||||||
|
const scenePos = subTileToScene(cx, cy, runtime.grid)
|
||||||
|
if (!isBlockedAt(runtime.grid, scenePos.x, scenePos.y)) {
|
||||||
|
fromX = cx
|
||||||
|
fromY = cy
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const townLevelId = townLevelForAct(runtime.act)
|
||||||
|
const townSub = resolveTownPortalSubTile(
|
||||||
|
townLevelId,
|
||||||
|
townVariantByAct.get(runtime.act),
|
||||||
|
)
|
||||||
|
portalSlot.cast({
|
||||||
|
fromLevelId: runtime.levelId,
|
||||||
|
fromX,
|
||||||
|
fromY,
|
||||||
|
townLevelId,
|
||||||
|
townX: townSub.x,
|
||||||
|
townY: townSub.y,
|
||||||
|
createdAtMs: performance.now(),
|
||||||
|
})
|
||||||
|
state.portal = portalSlot.current()
|
||||||
|
|
||||||
|
if (character?.clips?.['sc']) {
|
||||||
|
playerAnimator.play('sc', character.clips['sc'], { restart: true })
|
||||||
|
}
|
||||||
|
const townNames = getLevelNames(townLevelId)
|
||||||
|
status.textContent = `已开启返回 ${townNames.nameZh} 的传送门`
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const hudCanvas = document.querySelector<HTMLCanvasElement>('#d2-hud-canvas')
|
const hudCanvas = document.querySelector<HTMLCanvasElement>('#d2-hud-canvas')
|
||||||
hudManager = hudCanvas !== null
|
hudManager = hudCanvas !== null
|
||||||
? new HudManager(hudCanvas, {
|
? new HudManager(hudCanvas, {
|
||||||
|
|
@ -6644,9 +6880,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
onWaypointTeleport: (targetAct, slug, levelId) => {
|
onWaypointTeleport: (targetAct, slug, levelId) => {
|
||||||
void teleportViaWaypoint(targetAct, slug, levelId)
|
void teleportViaWaypoint(targetAct, slug, levelId)
|
||||||
},
|
},
|
||||||
onCastTownPortal: () => {
|
onCastTownPortal: () => castTownPortal(),
|
||||||
status.textContent = `已通过回城之书开启返回 ${ACT_NAMES_ZH[runtime.act] ?? '营地'} 的传送门`
|
|
||||||
},
|
|
||||||
onDropItemToGround: (item) => {
|
onDropItemToGround: (item) => {
|
||||||
const player = engine.world.player
|
const player = engine.world.player
|
||||||
const groundItem = engine.dropItem(item, player.x, player.y)
|
const groundItem = engine.dropItem(item, player.x, player.y)
|
||||||
|
|
@ -6677,6 +6911,34 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while a transition is in flight, so nothing triggers a second one. */
|
||||||
|
let travelling = false
|
||||||
|
let lastContactWaypointId: number | null = null
|
||||||
|
let minimapCache: { key: string; level: MinimapLevel } | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter the open Town Portal from the current level:
|
||||||
|
* - Entering from wilderness transitions to town beside the Town Portal and keeps the portal open.
|
||||||
|
* - Entering from town transitions back to the wilderness mouth and closes the portal.
|
||||||
|
*/
|
||||||
|
const enterTownPortal = (): void => {
|
||||||
|
if (travelling || runtime.source !== 'pack') return
|
||||||
|
const dest = portalSlot.enterFrom(runtime.levelId)
|
||||||
|
state.portal = portalSlot.current()
|
||||||
|
if (dest !== null) {
|
||||||
|
void travel(dest.levelId, WARP_FADE_MS, { x: dest.x, y: dest.y })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
;(window as any).__d2webPortalSlot = portalSlot
|
||||||
|
if (window.__d2webActScene) {
|
||||||
|
window.__d2webActScene.castTownPortal = castTownPortal
|
||||||
|
window.__d2webActScene.enterTownPortal = enterTownPortal
|
||||||
|
window.__d2webActScene.getPortal = () => portalSlot.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mouseController = new SceneMouseController({
|
const mouseController = new SceneMouseController({
|
||||||
canvas,
|
canvas,
|
||||||
engine,
|
engine,
|
||||||
|
|
@ -6688,6 +6950,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
status,
|
status,
|
||||||
playerAnimator,
|
playerAnimator,
|
||||||
getCharacter: () => character,
|
getCharacter: () => character,
|
||||||
|
portalSlot,
|
||||||
|
onEnterPortal: enterTownPortal,
|
||||||
|
onCastTownPortal: castTownPortal,
|
||||||
})
|
})
|
||||||
mouseController.attach()
|
mouseController.attach()
|
||||||
if (groundLabelOverlay !== null) {
|
if (groundLabelOverlay !== null) {
|
||||||
|
|
@ -6702,11 +6967,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
(window as any).__d2webMouseController = mouseController
|
(window as any).__d2webMouseController = mouseController
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True while a transition is in flight, so nothing triggers a second one. */
|
|
||||||
let travelling = false
|
|
||||||
let lastContactWaypointId: number | null = null
|
|
||||||
let minimapCache: { key: string; level: MinimapLevel } | null = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell the waypoint network about whatever level is loaded.
|
* Tell the waypoint network about whatever level is loaded.
|
||||||
*
|
*
|
||||||
|
|
@ -6726,20 +6986,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
}
|
}
|
||||||
registerWaypoints(runtime)
|
registerWaypoints(runtime)
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the player is, in sub-tiles.
|
|
||||||
*
|
|
||||||
* The inverse of the isometric projection: scene pixels back to the grid the
|
|
||||||
* links are expressed in.
|
|
||||||
*
|
|
||||||
* @returns the player's sub-tile.
|
|
||||||
*/
|
|
||||||
const playerSubTile = (): { x: number; y: number } => {
|
|
||||||
const dx = (engine.world.player.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
|
||||||
const dy = (engine.world.player.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
|
||||||
return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the level under the running loop.
|
* Replace the level under the running loop.
|
||||||
*
|
*
|
||||||
|
|
@ -6760,6 +7006,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
monsterArtMap = new Map()
|
monsterArtMap = new Map()
|
||||||
|
|
||||||
runtime = next
|
runtime = next
|
||||||
|
if (isTownLevel(next.levelId) && next.variant) {
|
||||||
|
townVariantByAct.set(next.act, next.variant)
|
||||||
|
}
|
||||||
floorBounds = buildBounds(next.floors)
|
floorBounds = buildBounds(next.floors)
|
||||||
shadowBounds = buildBounds(next.shadows ?? [])
|
shadowBounds = buildBounds(next.shadows ?? [])
|
||||||
wallBounds = buildBounds(next.walls)
|
wallBounds = buildBounds(next.walls)
|
||||||
|
|
@ -6934,6 +7183,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
{ width: canvas.width, height: canvas.height },
|
{ width: canvas.width, height: canvas.height },
|
||||||
runtime.charBases,
|
runtime.charBases,
|
||||||
fromSide,
|
fromSide,
|
||||||
|
isTownLevel(toLevelId) ? townVariantByAct.get(runtime.act) : undefined,
|
||||||
)
|
)
|
||||||
if (next === null) {
|
if (next === null) {
|
||||||
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
|
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
|
||||||
|
|
@ -6947,13 +7197,25 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
// that no edge describes.
|
// that no edge describes.
|
||||||
const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId)
|
const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId)
|
||||||
?? next.warps.find(warp => warp.toLevelId === fromLevelId)
|
?? next.warps.find(warp => warp.toLevelId === fromLevelId)
|
||||||
const landing = override
|
const rawLanding = override
|
||||||
?? (back === undefined
|
?? (back === undefined
|
||||||
? {
|
? {
|
||||||
x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2),
|
x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2),
|
||||||
y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2),
|
y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2),
|
||||||
}
|
}
|
||||||
: { x: back.arriveX, y: back.arriveY })
|
: { x: back.arriveX, y: back.arriveY })
|
||||||
|
let landing = rawLanding
|
||||||
|
const activeMouthInNext = portalSlot.mouthInLevel(next.levelId)
|
||||||
|
if (
|
||||||
|
activeMouthInNext !== null &&
|
||||||
|
activeMouthInNext.x === rawLanding.x &&
|
||||||
|
activeMouthInNext.y === rawLanding.y
|
||||||
|
) {
|
||||||
|
const besidePos = subTileToScene(rawLanding.x + 1, rawLanding.y + 1, next.grid)
|
||||||
|
if (!isBlockedAt(next.grid, besidePos.x, besidePos.y)) {
|
||||||
|
landing = { x: rawLanding.x + 1, y: rawLanding.y + 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
await swapLevel(next, landing)
|
await swapLevel(next, landing)
|
||||||
preloadRemaining(next, renderer)
|
preloadRemaining(next, renderer)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -7058,14 +7320,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
|
|
||||||
const wantsUse = input.takeUse()
|
const wantsUse = input.takeUse()
|
||||||
if (wantsUse) {
|
if (wantsUse) {
|
||||||
const portalTarget = portalSlot.otherEnd(runtime.levelId)
|
|
||||||
for (const warp of runtime.warps) {
|
for (const warp of runtime.warps) {
|
||||||
if (!near(warp.x, warp.y, WARP_TRIGGER_SUBTILES)) continue
|
if (!near(warp.x, warp.y, WARP_TRIGGER_SUBTILES)) continue
|
||||||
void travel(warp.toLevelId, WARP_FADE_MS)
|
void travel(warp.toLevelId, WARP_FADE_MS)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (portalTarget !== null) {
|
const activeMouth = portalSlot.mouthInLevel(runtime.levelId)
|
||||||
void travel(portalTarget.levelId, WARP_FADE_MS, { x: portalTarget.x, y: portalTarget.y })
|
if (activeMouth !== null && near(activeMouth.x, activeMouth.y, WARP_TRIGGER_SUBTILES)) {
|
||||||
|
enterTownPortal()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7103,19 +7365,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.takePortal()) {
|
if (input.takePortal()) {
|
||||||
const townLevelId = townLevelForAct(runtime.act)
|
castTownPortal()
|
||||||
if (runtime.levelId !== townLevelId) {
|
|
||||||
const townSite = waypointNetwork.destinations().find(site => site.levelId === townLevelId)
|
|
||||||
portalSlot.cast({
|
|
||||||
fromLevelId: runtime.levelId,
|
|
||||||
fromX: here.x,
|
|
||||||
fromY: here.y,
|
|
||||||
townLevelId,
|
|
||||||
townX: townSite?.x ?? here.x,
|
|
||||||
townY: townSite?.y ?? here.y,
|
|
||||||
})
|
|
||||||
status.textContent = `已打开回城门(${runtime.level})。走到原地按 G 返回。`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7437,6 +7687,20 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activePortalMouthForLight = portalSlot.mouthInLevel(runtime.levelId)
|
||||||
|
if (activePortalMouthForLight !== null) {
|
||||||
|
const portalFlicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0
|
||||||
|
lightGrid.addLight({
|
||||||
|
subTileX: activePortalMouthForLight.x,
|
||||||
|
subTileY: activePortalMouthForLight.y,
|
||||||
|
radius: 19 + portalFlicker,
|
||||||
|
intensity: 235,
|
||||||
|
red: 64,
|
||||||
|
green: 110,
|
||||||
|
blue: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5
|
const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5
|
||||||
const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185)
|
const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185)
|
||||||
const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175)
|
const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175)
|
||||||
|
|
@ -7769,6 +8033,18 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activePortalMouth = portalSlot.mouthInLevel(runtime.levelId)
|
||||||
|
const activePortalPos =
|
||||||
|
activePortalMouth !== null
|
||||||
|
? subTileToScene(activePortalMouth.x, activePortalMouth.y, runtime.grid)
|
||||||
|
: null
|
||||||
|
if (activePortalMouth !== null && activePortalPos !== null) {
|
||||||
|
const portalElapsedMs = Math.max(0, renderStarted - (activePortalMouth.createdAtMs ?? 0))
|
||||||
|
pushEntity(activePortalPos.x, activePortalPos.y, () => {
|
||||||
|
drawTownPortal(renderer, activePortalPos.x, activePortalPos.y, portalElapsedMs, overlayArtMap)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const groundSimTime = (engine.world.tick + ((engine.world as any).alpha ?? 0)) * 40
|
const groundSimTime = (engine.world.tick + ((engine.world as any).alpha ?? 0)) * 40
|
||||||
for (const gItem of engine.groundItems.all) {
|
for (const gItem of engine.groundItems.all) {
|
||||||
pushEntity(gItem.x, gItem.y, () => {
|
pushEntity(gItem.x, gItem.y, () => {
|
||||||
|
|
@ -7857,12 +8133,42 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
labelsContainer.appendChild(label)
|
labelsContainer.appendChild(label)
|
||||||
}
|
}
|
||||||
if (label.textContent !== npc.def.name) label.textContent = npc.def.name
|
if (label.textContent !== npc.def.name) label.textContent = npc.def.name
|
||||||
|
label.style.color = '#c8a15a'
|
||||||
label.style.left = `${domX}px`
|
label.style.left = `${domX}px`
|
||||||
label.style.top = `${domY - 2}px`
|
label.style.top = `${domY - 2}px`
|
||||||
label.style.fontSize = fontSizePx
|
label.style.fontSize = fontSizePx
|
||||||
label.hidden = false
|
label.hidden = false
|
||||||
labelCount += 1
|
labelCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activePortalMouth !== null && activePortalPos !== null) {
|
||||||
|
const topX = activePortalPos.x
|
||||||
|
const topY = activePortalPos.y - 94
|
||||||
|
const domX = rect.left + rect.width * 0.5 + (topX - camX) * camera.zoom * scaleX
|
||||||
|
const domY = rect.top + rect.height * 0.5 + (topY - camY) * camera.zoom * scaleY
|
||||||
|
if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) {
|
||||||
|
let label = labelPool[labelCount]
|
||||||
|
if (label === undefined) {
|
||||||
|
label = document.createElement('div')
|
||||||
|
label.style.position = 'absolute'
|
||||||
|
label.style.transform = 'translate(-50%, -100%)'
|
||||||
|
label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
|
||||||
|
label.style.whiteSpace = 'nowrap'
|
||||||
|
label.style.pointerEvents = 'none'
|
||||||
|
labelPool.push(label)
|
||||||
|
labelsContainer.appendChild(label)
|
||||||
|
}
|
||||||
|
const portalLabelText = `传送门 -> ${getLevelNames(activePortalMouth.toLevelId).nameZh}`
|
||||||
|
if (label.textContent !== portalLabelText) label.textContent = portalLabelText
|
||||||
|
label.style.color = '#6cb4ff'
|
||||||
|
label.style.left = `${domX}px`
|
||||||
|
label.style.top = `${domY - 2}px`
|
||||||
|
label.style.fontSize = fontSizePx
|
||||||
|
label.hidden = false
|
||||||
|
labelCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true
|
for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7899,6 +8205,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.portal = portalSlot.current()
|
||||||
state.drawCalls = renderer.drawCalls
|
state.drawCalls = renderer.drawCalls
|
||||||
state.quadsDrawn = renderer.quadsSubmitted
|
state.quadsDrawn = renderer.quadsSubmitted
|
||||||
state.culledDraws = culledDraws
|
state.culledDraws = culledDraws
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,14 @@ import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from
|
||||||
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
|
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
|
||||||
import { SKILLS_BY_ID } from '../data/skills-catalog.ts'
|
import { SKILLS_BY_ID } from '../data/skills-catalog.ts'
|
||||||
import { calculateManaCost } from '../game/skill-calc-engine.ts'
|
import { calculateManaCost } from '../game/skill-calc-engine.ts'
|
||||||
import { InventoryPanel, type EquipSlotId, type UiInventoryItem, resolveItemSpriteRect, INV_GRID_ORIGIN } from './inventory.ts'
|
import {
|
||||||
|
InventoryPanel,
|
||||||
|
type EquipSlotId,
|
||||||
|
type UiInventoryItem,
|
||||||
|
resolveItemSpriteRect,
|
||||||
|
INV_GRID_ORIGIN,
|
||||||
|
isUsableRightClickItem,
|
||||||
|
} from './inventory.ts'
|
||||||
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
||||||
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
||||||
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
||||||
|
|
@ -295,7 +302,7 @@ export class HudManager {
|
||||||
private readonly callbacks: {
|
private readonly callbacks: {
|
||||||
onToggleAutomap: () => void
|
onToggleAutomap: () => void
|
||||||
onWaypointTeleport: (act: number, slug: string, levelId?: number) => void
|
onWaypointTeleport: (act: number, slug: string, levelId?: number) => void
|
||||||
onCastTownPortal?: () => void
|
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||||
onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void
|
onDropItemToGround?: (item: UiInventoryItem, mousePos?: { x: number; y: number }) => void
|
||||||
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
|
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
|
||||||
},
|
},
|
||||||
|
|
@ -545,11 +552,13 @@ export class HudManager {
|
||||||
this.rightPanel !== 'none' ||
|
this.rightPanel !== 'none' ||
|
||||||
this.belt.lockedOpen ||
|
this.belt.lockedOpen ||
|
||||||
this.hotkeys.openPopup !== null ||
|
this.hotkeys.openPopup !== null ||
|
||||||
this.worldPanels.npcMenu !== null
|
this.worldPanels.npcMenu !== null ||
|
||||||
|
this.inventory.isIdentifyMode()
|
||||||
this.leftPanel = 'none'
|
this.leftPanel = 'none'
|
||||||
this.rightPanel = 'none'
|
this.rightPanel = 'none'
|
||||||
this.charSheet.visible = false
|
this.charSheet.visible = false
|
||||||
this.inventory.visible = false
|
this.inventory.visible = false
|
||||||
|
this.inventory.cancelIdentifyMode()
|
||||||
this.skillTree.visible = false
|
this.skillTree.visible = false
|
||||||
this.belt.lockedOpen = false
|
this.belt.lockedOpen = false
|
||||||
this.hotkeys.openPopup = null
|
this.hotkeys.openPopup = null
|
||||||
|
|
@ -712,13 +721,23 @@ export class HudManager {
|
||||||
this.inventory.hoveredItem.x += deltaRight
|
this.inventory.hoveredItem.x += deltaRight
|
||||||
}
|
}
|
||||||
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
|
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
|
||||||
if (this.leftPanel === 'stash' || this.leftPanel === 'waypoint' || this.leftPanel === 'vendor') {
|
if (
|
||||||
|
this.leftPanel === 'stash' ||
|
||||||
|
this.leftPanel === 'cube' ||
|
||||||
|
this.leftPanel === 'waypoint' ||
|
||||||
|
this.leftPanel === 'vendor'
|
||||||
|
) {
|
||||||
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
|
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
|
||||||
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
|
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
|
||||||
this.inventory.hoveredItem = {
|
this.inventory.hoveredItem = {
|
||||||
...this.worldPanels.hoveredStashItem,
|
...this.worldPanels.hoveredStashItem,
|
||||||
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
|
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
|
||||||
}
|
}
|
||||||
|
} else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) {
|
||||||
|
this.inventory.hoveredItem = {
|
||||||
|
...this.worldPanels.hoveredCubeItem,
|
||||||
|
x: this.worldPanels.hoveredCubeItem.x + deltaLeft,
|
||||||
|
}
|
||||||
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
|
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
|
||||||
this.inventory.hoveredItem = {
|
this.inventory.hoveredItem = {
|
||||||
item: this.worldPanels.hoveredVendorItem.item,
|
item: this.worldPanels.hoveredVendorItem.item,
|
||||||
|
|
@ -746,6 +765,13 @@ export class HudManager {
|
||||||
if (e.button !== 0 && e.button !== 2) return
|
if (e.button !== 0 && e.button !== 2) return
|
||||||
const pt = this.clientToLogical(e.clientX, e.clientY)
|
const pt = this.clientToLogical(e.clientX, e.clientY)
|
||||||
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
|
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
|
||||||
|
if (this.inventory.isIdentifyMode()) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.inventory.cancelIdentifyMode()
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
// If holding cursorItem and clicking outside panels onto the ground, drop it
|
// If holding cursorItem and clicking outside panels onto the ground, drop it
|
||||||
if (this.inventory.cursorItem !== null) {
|
if (this.inventory.cursorItem !== null) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -846,6 +872,8 @@ export class HudManager {
|
||||||
onWaypointTeleport: (act, slug, levelId) => {
|
onWaypointTeleport: (act, slug, levelId) => {
|
||||||
this.callbacks.onWaypointTeleport(act, slug, levelId)
|
this.callbacks.onWaypointTeleport(act, slug, levelId)
|
||||||
},
|
},
|
||||||
|
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||||
|
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
|
||||||
inventory: this.inventory,
|
inventory: this.inventory,
|
||||||
isShiftClick: e.shiftKey,
|
isShiftClick: e.shiftKey,
|
||||||
isRightClick: e.button === 2,
|
isRightClick: e.button === 2,
|
||||||
|
|
@ -859,74 +887,106 @@ export class HudManager {
|
||||||
if (pt.x >= 400 + deltaRight) {
|
if (pt.x >= 400 + deltaRight) {
|
||||||
const rightX = pt.x - deltaRight
|
const rightX = pt.x - deltaRight
|
||||||
if (this.rightPanel === 'inv') {
|
if (this.rightPanel === 'inv') {
|
||||||
// Stash quick transfer
|
if (!this.inventory.isIdentifyMode()) {
|
||||||
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
// Stash quick transfer (Shift+Click, or Right-Click on non-usable items)
|
||||||
if (
|
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
||||||
rightX >= INV_GRID_ORIGIN.x &&
|
if (
|
||||||
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
rightX >= INV_GRID_ORIGIN.x &&
|
||||||
pt.y >= INV_GRID_ORIGIN.y &&
|
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||||
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
pt.y >= INV_GRID_ORIGIN.y &&
|
||||||
) {
|
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||||
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
) {
|
||||||
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||||
const hit = this.inventory.gridItems.find(
|
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
const hit = this.inventory.gridItems.find(
|
||||||
)
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
if (hit) {
|
)
|
||||||
this.worldPanels.quickTransferToStash(hit.item, this.inventory)
|
if (hit && (e.shiftKey || !isUsableRightClickItem(hit.item.code))) {
|
||||||
|
this.worldPanels.quickTransferToStash(hit.item, this.inventory)
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horadric Cube quick transfer (Shift+Click, or Right-Click on non-usable items)
|
||||||
|
if (this.leftPanel === 'cube' && (e.shiftKey || e.button === 2)) {
|
||||||
|
if (
|
||||||
|
rightX >= INV_GRID_ORIGIN.x &&
|
||||||
|
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||||
|
pt.y >= INV_GRID_ORIGIN.y &&
|
||||||
|
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||||
|
) {
|
||||||
|
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||||
|
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||||
|
const hit = this.inventory.gridItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hit && (e.shiftKey || !isUsableRightClickItem(hit.item.code))) {
|
||||||
|
this.worldPanels.quickTransferToCube(hit.item, this.inventory)
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vendor Repair or Sell/Quick-Sell from Player Inventory
|
||||||
|
if (this.leftPanel === 'vendor') {
|
||||||
|
if (
|
||||||
|
rightX >= INV_GRID_ORIGIN.x &&
|
||||||
|
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||||
|
pt.y >= INV_GRID_ORIGIN.y &&
|
||||||
|
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||||
|
) {
|
||||||
|
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||||
|
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||||
|
const hitIdx = this.inventory.gridItems.findIndex(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hitIdx !== -1) {
|
||||||
|
const hit = this.inventory.gridItems[hitIdx]!
|
||||||
|
if (this.worldPanels.vendorTradeState === 'repair') {
|
||||||
|
this.worldPanels.repairSingleUiItem(hit.item, this.inventory)
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) {
|
||||||
|
const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory)
|
||||||
|
if (res.ok) {
|
||||||
|
this.inventory.gridItems.splice(hitIdx, 1)
|
||||||
|
this.inventory.hoveredItem = null
|
||||||
|
}
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.worldPanels.vendorTradeState === 'repair' && this.inventory.hoveredItem) {
|
||||||
|
this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory)
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendor Repair or Sell/Quick-Sell from Player Inventory
|
this.inventory.handleClick(
|
||||||
if (this.leftPanel === 'vendor') {
|
rightX,
|
||||||
if (
|
pt.y,
|
||||||
rightX >= INV_GRID_ORIGIN.x &&
|
{
|
||||||
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||||
pt.y >= INV_GRID_ORIGIN.y &&
|
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
|
||||||
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
onDropGold: (amt) => {
|
||||||
) {
|
const dropped = this.inventory.dropGold(amt)
|
||||||
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
if (dropped > 0) {
|
||||||
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
this.callbacks.onDropGoldToGround?.(dropped, pt)
|
||||||
const hitIdx = this.inventory.gridItems.findIndex(
|
|
||||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
|
||||||
)
|
|
||||||
if (hitIdx !== -1) {
|
|
||||||
const hit = this.inventory.gridItems[hitIdx]!
|
|
||||||
if (this.worldPanels.vendorTradeState === 'repair') {
|
|
||||||
this.worldPanels.repairSingleUiItem(hit.item, this.inventory)
|
|
||||||
this.syncPublishedState()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (this.worldPanels.vendorTradeState === 'sell' || e.shiftKey || e.button === 2) {
|
},
|
||||||
const res = this.worldPanels.sellToActiveVendor(hit.item, this.inventory)
|
|
||||||
if (res.ok) {
|
|
||||||
this.inventory.gridItems.splice(hitIdx, 1)
|
|
||||||
this.inventory.hoveredItem = null
|
|
||||||
}
|
|
||||||
this.syncPublishedState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (this.worldPanels.vendorTradeState === 'repair' && this.inventory.hoveredItem) {
|
|
||||||
this.worldPanels.repairSingleUiItem(this.inventory.hoveredItem.item, this.inventory)
|
|
||||||
this.syncPublishedState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.inventory.handleClick(rightX, pt.y, {
|
|
||||||
onOpenCube: () => this.toggleLeftPanel('cube'),
|
|
||||||
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
|
||||||
onDropGold: (amt) => {
|
|
||||||
const dropped = this.inventory.dropGold(amt)
|
|
||||||
if (dropped > 0) {
|
|
||||||
this.callbacks.onDropGoldToGround?.(dropped, pt)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
e.button === 2 ? 2 : 0,
|
||||||
|
{
|
||||||
|
stashItems: this.worldPanels.stashItems,
|
||||||
|
cubeItems: this.worldPanels.cubeItems,
|
||||||
|
},
|
||||||
|
)
|
||||||
if (!this.inventory.visible) this.rightPanel = 'none'
|
if (!this.inventory.visible) this.rightPanel = 'none'
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
return
|
return
|
||||||
|
|
@ -1230,9 +1290,12 @@ export class HudManager {
|
||||||
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
|
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Hover Item Tooltip (Inventory / Equipment / Stash / Vendor)
|
// 10. Hover Item Tooltip (Inventory / Equipment / Stash / Cube / Vendor)
|
||||||
if (
|
if (
|
||||||
(this.rightPanel === 'inv' || this.leftPanel === 'stash' || this.leftPanel === 'vendor') &&
|
(this.rightPanel === 'inv' ||
|
||||||
|
this.leftPanel === 'stash' ||
|
||||||
|
this.leftPanel === 'cube' ||
|
||||||
|
this.leftPanel === 'vendor') &&
|
||||||
this.inventory.hoveredItem &&
|
this.inventory.hoveredItem &&
|
||||||
!this.inventory.cursorItem
|
!this.inventory.cursorItem
|
||||||
) {
|
) {
|
||||||
|
|
@ -1250,6 +1313,9 @@ export class HudManager {
|
||||||
* Renders the authentic animated hand cursor or held cursorItem at cursor hotspot.
|
* Renders the authentic animated hand cursor or held cursorItem at cursor hotspot.
|
||||||
*/
|
*/
|
||||||
renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void {
|
renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void {
|
||||||
|
if (this.inventory.isIdentifyMode()) {
|
||||||
|
this.cursor.resetIdle(nowMs)
|
||||||
|
}
|
||||||
const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null
|
const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null
|
||||||
const heldItem = this.inventory.cursorItem
|
const heldItem = this.inventory.cursorItem
|
||||||
? {
|
? {
|
||||||
|
|
@ -1266,5 +1332,12 @@ export class HudManager {
|
||||||
? { atlas: heldItem.atlas, rect: heldItem.rect }
|
? { atlas: heldItem.atlas, rect: heldItem.rect }
|
||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (this.inventory.isIdentifyMode() && !this.inventory.cursorItem) {
|
||||||
|
this.font.drawText(ctx, '?', this.mouseX + 12, this.mouseY - 4, {
|
||||||
|
font: 'fontexocet10',
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,10 @@ import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
||||||
import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts'
|
||||||
import type { Item } from '../game/items.ts'
|
import type { Item } from '../game/items.ts'
|
||||||
|
|
||||||
|
import { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts'
|
||||||
|
|
||||||
export type { SpriteRect }
|
export type { SpriteRect }
|
||||||
export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts'
|
export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile }
|
||||||
export { PLAYER_GOLD_CAP } from './world-panels.ts'
|
export { PLAYER_GOLD_CAP } from './world-panels.ts'
|
||||||
|
|
||||||
export type EquipSlotId =
|
export type EquipSlotId =
|
||||||
|
|
@ -67,6 +69,9 @@ export interface UiInventoryItem {
|
||||||
readonly repairCost?: number | undefined
|
readonly repairCost?: number | undefined
|
||||||
readonly permStore?: boolean | undefined
|
readonly permStore?: boolean | undefined
|
||||||
readonly isGambleMystery?: boolean | undefined
|
readonly isGambleMystery?: boolean | undefined
|
||||||
|
readonly identified?: boolean | undefined
|
||||||
|
readonly quantity?: number | undefined
|
||||||
|
readonly maxQuantity?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridPlacement {
|
export interface GridPlacement {
|
||||||
|
|
@ -816,6 +821,8 @@ export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [
|
||||||
invWidth: 1,
|
invWidth: 1,
|
||||||
invHeight: 2,
|
invHeight: 2,
|
||||||
allowedSlots: [],
|
allowedSlots: [],
|
||||||
|
quantity: 20,
|
||||||
|
maxQuantity: 20,
|
||||||
stats: [{ text: '右键施放城镇传送门', color: 'white' }],
|
stats: [{ text: '右键施放城镇传送门', color: 'white' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -833,6 +840,8 @@ export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [
|
||||||
invWidth: 1,
|
invWidth: 1,
|
||||||
invHeight: 2,
|
invHeight: 2,
|
||||||
allowedSlots: [],
|
allowedSlots: [],
|
||||||
|
quantity: 20,
|
||||||
|
maxQuantity: 20,
|
||||||
stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }],
|
stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -943,6 +952,146 @@ export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Maximum scrolls held in a Tome of Town Portal (`tbk`) or Tome of Identify (`ibk`) per `Books.txt`. */
|
||||||
|
export const TOME_MAX_QUANTITY = 20
|
||||||
|
|
||||||
|
export function isTomeCode(code: string | undefined): code is 'tbk' | 'ibk' {
|
||||||
|
const c = (code ?? '').trim().toLowerCase()
|
||||||
|
return c === 'tbk' || c === 'ibk'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isScrollForTome(scrollCode: string | undefined, tomeCode: string | undefined): boolean {
|
||||||
|
const sc = (scrollCode ?? '').trim().toLowerCase()
|
||||||
|
const tc = (tomeCode ?? '').trim().toLowerCase()
|
||||||
|
return (sc === 'tsc' && tc === 'tbk') || (sc === 'isc' && tc === 'ibk')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsableRightClickItem(code: string | undefined): boolean {
|
||||||
|
const c = (code ?? '').trim().toLowerCase()
|
||||||
|
return c === 'box' || c === 'cube' || c === 'tbk' || c === 'tsc' || c === 'ibk' || c === 'isc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTomeQuantity(item: UiInventoryItem): number {
|
||||||
|
if (typeof item.quantity === 'number' && Number.isFinite(item.quantity)) {
|
||||||
|
return Math.max(0, Math.trunc(item.quantity))
|
||||||
|
}
|
||||||
|
if (typeof item.rawItem?.quantity === 'number' && Number.isFinite(item.rawItem.quantity)) {
|
||||||
|
return Math.max(0, Math.trunc(item.rawItem.quantity))
|
||||||
|
}
|
||||||
|
if (isTomeCode(item.code) && typeof item.rawItem?.stack === 'number' && Number.isFinite(item.rawItem.stack)) {
|
||||||
|
return Math.max(0, Math.trunc(item.rawItem.stack))
|
||||||
|
}
|
||||||
|
const baseMatch = /数量\s*[::]\s*(\d+)|Quantity\s*[::]\s*(\d+)/i.exec(item.baseNameZh ?? '')
|
||||||
|
if (baseMatch) {
|
||||||
|
return Math.max(0, Number(baseMatch[1] ?? baseMatch[2]))
|
||||||
|
}
|
||||||
|
for (const s of item.stats ?? []) {
|
||||||
|
const statMatch = /数量\s*[::]\s*(\d+)|Quantity\s*[::]\s*(\d+)/i.exec(s.text)
|
||||||
|
if (statMatch) {
|
||||||
|
return Math.max(0, Number(statMatch[1] ?? statMatch[2]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isTomeCode(item.code) ? TOME_MAX_QUANTITY : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTomeQuantity(item: UiInventoryItem, newQty: number): UiInventoryItem {
|
||||||
|
const maxQty = item.maxQuantity ?? (isTomeCode(item.code) ? TOME_MAX_QUANTITY : 99)
|
||||||
|
const qty = Math.max(0, Math.min(maxQty, Math.trunc(newQty)))
|
||||||
|
;(item as { quantity?: number }).quantity = qty
|
||||||
|
|
||||||
|
if (item.baseNameZh && /数量\s*[::]\s*\d+/i.test(item.baseNameZh)) {
|
||||||
|
;(item as { baseNameZh: string }).baseNameZh = item.baseNameZh.replace(/数量\s*[::]\s*\d+/gi, `数量: ${qty}`)
|
||||||
|
} else if (isTomeCode(item.code) && item.baseNameZh && !item.stats?.some(s => /数量\s*[::]\s*\d+/i.test(s.text))) {
|
||||||
|
;(item as { baseNameZh: string }).baseNameZh = `${item.baseNameZh} (数量: ${qty})`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(item.stats)) {
|
||||||
|
const hasQtyLine = item.stats.some(s => /^(数量|Quantity)\s*[::]\s*\d+/i.test(s.text))
|
||||||
|
if (hasQtyLine) {
|
||||||
|
;(item as { stats: readonly { text: string; color?: D2ColorCode }[] }).stats = item.stats.map(s =>
|
||||||
|
/^(数量|Quantity)\s*[::]\s*\d+/i.test(s.text) ? { ...s, text: `数量: ${qty}` } : s,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.rawItem) {
|
||||||
|
;(item.rawItem as { quantity?: number; stack?: number }).quantity = qty
|
||||||
|
;(item.rawItem as { quantity?: number; stack?: number }).stack = qty
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withTomeQuantity(item: UiInventoryItem, quantity: number): UiInventoryItem {
|
||||||
|
const cloned: UiInventoryItem = { ...item, stats: [...item.stats] }
|
||||||
|
return setTomeQuantity(cloned, quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUiItemUnidentified(item: UiInventoryItem): boolean {
|
||||||
|
return Boolean(
|
||||||
|
item.identified === false ||
|
||||||
|
item.isGambleMystery === true ||
|
||||||
|
item.rawItem?.identified === false ||
|
||||||
|
item.rawItem?.isGambleMystery === true ||
|
||||||
|
(item.rawItem?.flags !== undefined && item.rawItem.flags.identified === false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
|
||||||
|
;(item as { identified?: boolean; isGambleMystery?: boolean }).identified = true
|
||||||
|
;(item as { identified?: boolean; isGambleMystery?: boolean }).isGambleMystery = false
|
||||||
|
|
||||||
|
if (item.rawItem) {
|
||||||
|
item.rawItem.identified = true
|
||||||
|
;(item.rawItem as { isGambleMystery?: boolean }).isGambleMystery = false
|
||||||
|
if (item.rawItem.flags) {
|
||||||
|
;(item.rawItem as { flags: typeof item.rawItem.flags }).flags = {
|
||||||
|
...item.rawItem.flags,
|
||||||
|
identified: true,
|
||||||
|
gamble: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof item.rawItem.rawFlags === 'number') {
|
||||||
|
;(item.rawItem as { rawFlags: number }).rawFlags = (item.rawItem.rawFlags | 1) & ~0x8000
|
||||||
|
}
|
||||||
|
const rebuilt = itemToUiInventoryItem(item.rawItem)
|
||||||
|
Object.assign(item, {
|
||||||
|
name: rebuilt.name,
|
||||||
|
nameZh: rebuilt.nameZh,
|
||||||
|
baseNameZh: rebuilt.baseNameZh,
|
||||||
|
quality: rebuilt.quality,
|
||||||
|
stats: rebuilt.stats.filter(
|
||||||
|
(s: { text: string; color?: D2ColorCode }) =>
|
||||||
|
!s.text.includes('未辨识') && !s.text.includes('未鉴定') && !s.text.includes('Unidentified'),
|
||||||
|
),
|
||||||
|
...(rebuilt.defense !== undefined ? { defense: rebuilt.defense } : {}),
|
||||||
|
...(rebuilt.damage !== undefined ? { damage: rebuilt.damage } : {}),
|
||||||
|
...(rebuilt.reqLevel !== undefined ? { reqLevel: rebuilt.reqLevel } : {}),
|
||||||
|
...(rebuilt.reqStr !== undefined ? { reqStr: rebuilt.reqStr } : {}),
|
||||||
|
...(rebuilt.durability !== undefined ? { durability: rebuilt.durability } : {}),
|
||||||
|
...(rebuilt.sockets !== undefined ? { sockets: rebuilt.sockets } : {}),
|
||||||
|
...(rebuilt.invtransform !== undefined ? { invtransform: rebuilt.invtransform } : {}),
|
||||||
|
...(rebuilt.chrtransform !== undefined ? { chrtransform: rebuilt.chrtransform } : {}),
|
||||||
|
...(rebuilt.setPieces !== undefined ? { setPieces: rebuilt.setPieces } : {}),
|
||||||
|
...(rebuilt.setBonuses !== undefined ? { setBonuses: rebuilt.setBonuses } : {}),
|
||||||
|
identified: true,
|
||||||
|
isGambleMystery: false,
|
||||||
|
})
|
||||||
|
} else if (Array.isArray(item.stats)) {
|
||||||
|
;(item as { stats: readonly { text: string; color?: D2ColorCode }[] }).stats = item.stats.filter(
|
||||||
|
s => !s.text.includes('未辨识') && !s.text.includes('未鉴定') && !s.text.includes('Unidentified'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentifyCursorSource {
|
||||||
|
readonly container: 'inventory' | 'stash' | 'cube'
|
||||||
|
readonly itemId: string
|
||||||
|
readonly code: 'ibk' | 'isc'
|
||||||
|
}
|
||||||
|
|
||||||
export class InventoryPanel {
|
export class InventoryPanel {
|
||||||
visible = false
|
visible = false
|
||||||
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
|
/** Active weapon swap set: 0 = Weapon Set I, 1 = Weapon Set II. */
|
||||||
|
|
@ -954,10 +1103,15 @@ export class InventoryPanel {
|
||||||
weapon1: null,
|
weapon1: null,
|
||||||
weapon2: null,
|
weapon2: null,
|
||||||
}
|
}
|
||||||
/** 10x4 grid items. */
|
/** 10x4 grid items (deep-cloned per instance so quantity/stat mutations do not leak across instances). */
|
||||||
gridItems: GridPlacement[] = [...STARTER_BAG_ITEMS]
|
gridItems: GridPlacement[] = STARTER_BAG_ITEMS.map(p => ({
|
||||||
|
...p,
|
||||||
|
item: { ...p.item, stats: [...p.item.stats] },
|
||||||
|
}))
|
||||||
/** Item currently held on the mouse cursor (`cursorItem`). */
|
/** Item currently held on the mouse cursor (`cursorItem`). */
|
||||||
cursorItem: UiInventoryItem | null = null
|
cursorItem: UiInventoryItem | null = null
|
||||||
|
/** Active Identify targeting mode source (`ibk` or `isc`) initiated via Right-Click. */
|
||||||
|
identifyCursorSource: IdentifyCursorSource | null = null
|
||||||
/** Currently hovered item (either in a slot or in the 10x4 grid) for Tooltip rendering. */
|
/** Currently hovered item (either in a slot or in the 10x4 grid) for Tooltip rendering. */
|
||||||
hoveredItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
hoveredItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||||
/** Player character level for authentic inventory gold capacity calculation. */
|
/** Player character level for authentic inventory gold capacity calculation. */
|
||||||
|
|
@ -971,6 +1125,89 @@ export class InventoryPanel {
|
||||||
/** Player gold in inventory. */
|
/** Player gold in inventory. */
|
||||||
gold = 850_000
|
gold = 850_000
|
||||||
|
|
||||||
|
isIdentifyMode(): boolean {
|
||||||
|
return this.identifyCursorSource !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelIdentifyMode(): void {
|
||||||
|
this.identifyCursorSource = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter Identify Cursor Mode from a Tome of Identify (`ibk`) or Scroll of Identify (`isc`).
|
||||||
|
*/
|
||||||
|
startIdentifyMode(item: UiInventoryItem, container: 'inventory' | 'stash' | 'cube' = 'inventory'): boolean {
|
||||||
|
const code = (item.code ?? '').trim().toLowerCase()
|
||||||
|
if (code === 'ibk') {
|
||||||
|
if (getTomeQuantity(item) <= 0) return false
|
||||||
|
this.identifyCursorSource = { container, itemId: item.id, code: 'ibk' }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (code === 'isc') {
|
||||||
|
this.identifyCursorSource = { container, itemId: item.id, code: 'isc' }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume 1 charge from the active `identifyCursorSource` (`ibk` decrements quantity, `isc` is removed)
|
||||||
|
* and identify `targetItem` if it is currently unidentified.
|
||||||
|
*/
|
||||||
|
applyIdentifyToItem(
|
||||||
|
targetItem: UiInventoryItem,
|
||||||
|
externalContainers?: {
|
||||||
|
stashItems?: GridPlacement[]
|
||||||
|
cubeItems?: GridPlacement[]
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
if (!this.identifyCursorSource) return false
|
||||||
|
if (!isUiItemUnidentified(targetItem)) {
|
||||||
|
this.identifyCursorSource = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = this.identifyCursorSource
|
||||||
|
const containerList =
|
||||||
|
src.container === 'stash'
|
||||||
|
? externalContainers?.stashItems
|
||||||
|
: src.container === 'cube'
|
||||||
|
? externalContainers?.cubeItems
|
||||||
|
: this.gridItems
|
||||||
|
|
||||||
|
if (!containerList) {
|
||||||
|
this.identifyCursorSource = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcIdx = containerList.findIndex(p => p.item.id === src.itemId)
|
||||||
|
if (srcIdx === -1) {
|
||||||
|
this.identifyCursorSource = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcPlaced = containerList[srcIdx]!
|
||||||
|
if (src.code === 'ibk') {
|
||||||
|
const qty = getTomeQuantity(srcPlaced.item)
|
||||||
|
if (qty <= 0) {
|
||||||
|
this.identifyCursorSource = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setTomeQuantity(srcPlaced.item, qty - 1)
|
||||||
|
} else {
|
||||||
|
const qty = srcPlaced.item.quantity ?? srcPlaced.item.rawItem?.quantity ?? 1
|
||||||
|
if (qty > 1) {
|
||||||
|
setTomeQuantity(srcPlaced.item, qty - 1)
|
||||||
|
} else {
|
||||||
|
containerList.splice(srcIdx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identifyUiItem(targetItem)
|
||||||
|
this.identifyCursorSource = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
toggleWeaponSwap(): void {
|
toggleWeaponSwap(): void {
|
||||||
const curW1 = this.equipped.weapon1 ?? null
|
const curW1 = this.equipped.weapon1 ?? null
|
||||||
const curW2 = this.equipped.weapon2 ?? null
|
const curW2 = this.equipped.weapon2 ?? null
|
||||||
|
|
@ -1025,6 +1262,8 @@ export class InventoryPanel {
|
||||||
/**
|
/**
|
||||||
* Handle left-click on a grid cell `(col, row)`:
|
* Handle left-click on a grid cell `(col, row)`:
|
||||||
* - If `cursorItem` is null: pick up the item at `(col, row)` into `cursorItem`.
|
* - If `cursorItem` is null: pick up the item at `(col, row)` into `cursorItem`.
|
||||||
|
* - If `cursorItem` is a Scroll (`tsc`/`isc`) and hits a matching Tome (`tbk`/`ibk`) with `quantity < 20`:
|
||||||
|
* stack the scroll into the tome (`quantity + 1`) and clear `cursorItem` (`Books.txt`).
|
||||||
* - If `cursorItem` is held: place it if 0 overlaps, or swap if exactly 1 overlap!
|
* - If `cursorItem` is held: place it if 0 overlaps, or swap if exactly 1 overlap!
|
||||||
*/
|
*/
|
||||||
clickGridCell(col: number, row: number): boolean {
|
clickGridCell(col: number, row: number): boolean {
|
||||||
|
|
@ -1038,6 +1277,26 @@ export class InventoryPanel {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check direct hit for Scroll-into-Tome stacking (`tsc` -> `tbk`, `isc` -> `ibk`)
|
||||||
|
const directHit = this.gridItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (directHit && isScrollForTome(this.cursorItem.code, directHit.item.code)) {
|
||||||
|
const curQty = getTomeQuantity(directHit.item)
|
||||||
|
const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY
|
||||||
|
if (curQty < maxQty) {
|
||||||
|
const scrollQty = this.cursorItem.quantity ?? this.cursorItem.rawItem?.quantity ?? 1
|
||||||
|
const addQty = Math.min(maxQty - curQty, scrollQty)
|
||||||
|
setTomeQuantity(directHit.item, curQty + addQty)
|
||||||
|
if (scrollQty > addQty) {
|
||||||
|
setTomeQuantity(this.cursorItem, scrollQty - addQty)
|
||||||
|
} else {
|
||||||
|
this.cursorItem = null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const targetCol = Math.min(INV_GRID_ORIGIN.cols - this.cursorItem.invWidth, Math.max(0, col))
|
const targetCol = Math.min(INV_GRID_ORIGIN.cols - this.cursorItem.invWidth, Math.max(0, col))
|
||||||
const targetRow = Math.min(INV_GRID_ORIGIN.rows - this.cursorItem.invHeight, Math.max(0, row))
|
const targetRow = Math.min(INV_GRID_ORIGIN.rows - this.cursorItem.invHeight, Math.max(0, row))
|
||||||
const overlaps = this.getGridOverlaps(targetCol, targetRow, this.cursorItem.invWidth, this.cursorItem.invHeight)
|
const overlaps = this.getGridOverlaps(targetCol, targetRow, this.cursorItem.invWidth, this.cursorItem.invHeight)
|
||||||
|
|
@ -1048,6 +1307,21 @@ export class InventoryPanel {
|
||||||
}
|
}
|
||||||
if (overlaps.length === 1) {
|
if (overlaps.length === 1) {
|
||||||
const single = overlaps[0]!
|
const single = overlaps[0]!
|
||||||
|
if (isScrollForTome(this.cursorItem.code, single.item.code)) {
|
||||||
|
const curQty = getTomeQuantity(single.item)
|
||||||
|
const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY
|
||||||
|
if (curQty < maxQty) {
|
||||||
|
const scrollQty = this.cursorItem.quantity ?? this.cursorItem.rawItem?.quantity ?? 1
|
||||||
|
const addQty = Math.min(maxQty - curQty, scrollQty)
|
||||||
|
setTomeQuantity(single.item, curQty + addQty)
|
||||||
|
if (scrollQty > addQty) {
|
||||||
|
setTomeQuantity(this.cursorItem, scrollQty - addQty)
|
||||||
|
} else {
|
||||||
|
this.cursorItem = null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
this.gridItems = this.gridItems.filter(p => p !== single)
|
this.gridItems = this.gridItems.filter(p => p !== single)
|
||||||
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
|
this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow })
|
||||||
this.cursorItem = single.item
|
this.cursorItem = single.item
|
||||||
|
|
@ -1058,6 +1332,7 @@ export class InventoryPanel {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle left-click on an equipment slot (`helm`, `weapon1`, `armor`, etc.).
|
* Handle left-click on an equipment slot (`helm`, `weapon1`, `armor`, etc.).
|
||||||
|
* Unidentified items cannot be equipped in Diablo II v1.13c.
|
||||||
*/
|
*/
|
||||||
clickEquipSlot(slotId: EquipSlotId): boolean {
|
clickEquipSlot(slotId: EquipSlotId): boolean {
|
||||||
const existing = this.equipped[slotId] ?? null
|
const existing = this.equipped[slotId] ?? null
|
||||||
|
|
@ -1067,6 +1342,9 @@ export class InventoryPanel {
|
||||||
this.cursorItem = existing
|
this.cursorItem = existing
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (isUiItemUnidentified(this.cursorItem)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (!this.cursorItem.allowedSlots.includes(slotId)) {
|
if (!this.cursorItem.allowedSlots.includes(slotId)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -1075,6 +1353,55 @@ export class InventoryPanel {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-click use handler for usable items (`box`, `tbk`, `tsc`, `ibk`, `isc`) inside a container.
|
||||||
|
*/
|
||||||
|
usePlacedItemByRightClick(
|
||||||
|
placed: GridPlacement,
|
||||||
|
container: 'inventory' | 'stash' | 'cube',
|
||||||
|
containerList: GridPlacement[],
|
||||||
|
callbacks?: {
|
||||||
|
onOpenCube?: () => void
|
||||||
|
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
if (this.cursorItem !== null) return false
|
||||||
|
const code = (placed.item.code ?? '').trim().toLowerCase()
|
||||||
|
if (code === 'box' || code === 'cube') {
|
||||||
|
callbacks?.onOpenCube?.()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (code === 'tbk') {
|
||||||
|
const qty = getTomeQuantity(placed.item)
|
||||||
|
if (qty <= 0) return false
|
||||||
|
const castOk = callbacks?.onCastTownPortal?.('tbk', placed.item)
|
||||||
|
if (castOk !== false) {
|
||||||
|
setTomeQuantity(placed.item, qty - 1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (code === 'tsc') {
|
||||||
|
const castOk = callbacks?.onCastTownPortal?.('tsc', placed.item)
|
||||||
|
if (castOk !== 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
|
||||||
|
}
|
||||||
|
if (code === 'ibk' || code === 'isc') {
|
||||||
|
return this.startIdentifyMode(placed.item, container)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||||
this.hoveredItem = null
|
this.hoveredItem = null
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
|
|
@ -1112,18 +1439,99 @@ export class InventoryPanel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse click on the Inventory Panel (`400..720, 60..492`).
|
||||||
|
*
|
||||||
|
* Ground Truth (Diablo II v1.13c):
|
||||||
|
* - `button === 0` (Left-Click):
|
||||||
|
* - In Identify Cursor Mode: identifies an unidentified item under the cursor (consuming 1 tome charge or scroll), or cancels Identify mode if clicking elsewhere.
|
||||||
|
* - 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):
|
||||||
|
* - 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!
|
||||||
|
*/
|
||||||
handleClick(
|
handleClick(
|
||||||
logicalX: number,
|
logicalX: number,
|
||||||
logicalY: number,
|
logicalY: number,
|
||||||
callbacks: {
|
callbacks?: {
|
||||||
onOpenCube: () => void
|
onOpenCube?: () => void
|
||||||
onCastTownPortal: () => void
|
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||||
onDropGold?: (amount: number) => void
|
onDropGold?: (amount: number) => void
|
||||||
},
|
},
|
||||||
|
button: 0 | 2 = 0,
|
||||||
|
externalContainers?: {
|
||||||
|
stashItems?: GridPlacement[]
|
||||||
|
cubeItems?: GridPlacement[]
|
||||||
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!this.visible) return false
|
if (!this.visible) return false
|
||||||
if (logicalX < 400) return false
|
if (logicalX < 400) return false
|
||||||
|
|
||||||
|
// Active Identify Cursor Mode handling (`ibk` / `isc`)
|
||||||
|
if (this.identifyCursorSource !== null) {
|
||||||
|
if (button === 2) {
|
||||||
|
this.cancelIdentifyMode()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left-Click on equipment slot in Identify Mode
|
||||||
|
for (const [slotId, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, typeof EQUIP_SLOTS_LAYOUT[EquipSlotId]][]) {
|
||||||
|
if (logicalX >= rect.x && logicalX <= rect.x + rect.w && logicalY >= rect.y && logicalY <= rect.y + rect.h) {
|
||||||
|
const eq = this.equipped[slotId]
|
||||||
|
if (eq && isUiItemUnidentified(eq)) {
|
||||||
|
this.applyIdentifyToItem(eq, externalContainers)
|
||||||
|
} else {
|
||||||
|
this.cancelIdentifyMode()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left-Click on 10x4 grid in Identify Mode
|
||||||
|
if (
|
||||||
|
logicalX >= INV_GRID_ORIGIN.x &&
|
||||||
|
logicalX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||||
|
logicalY >= INV_GRID_ORIGIN.y &&
|
||||||
|
logicalY < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||||
|
) {
|
||||||
|
const col = Math.floor((logicalX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||||
|
const row = Math.floor((logicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||||
|
const hit = this.gridItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hit && isUiItemUnidentified(hit.item)) {
|
||||||
|
this.applyIdentifyToItem(hit.item, externalContainers)
|
||||||
|
} else {
|
||||||
|
this.cancelIdentifyMode()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cancelIdentifyMode()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-Click (`button === 2`): Use items only; NEVER pick up or move items onto cursor!
|
||||||
|
if (button === 2) {
|
||||||
|
if (
|
||||||
|
logicalX >= INV_GRID_ORIGIN.x &&
|
||||||
|
logicalX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||||
|
logicalY >= INV_GRID_ORIGIN.y &&
|
||||||
|
logicalY < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||||
|
) {
|
||||||
|
const col = Math.floor((logicalX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||||
|
const row = Math.floor((logicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||||
|
const hit = this.gridItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hit) {
|
||||||
|
return this.usePlacedItemByRightClick(hit, 'inventory', this.gridItems, callbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left-Click (`button === 0`): UI Buttons & Item Move / Pick Up / Place / Swap / Stack
|
||||||
// 1. Close button
|
// 1. Close button
|
||||||
if (
|
if (
|
||||||
logicalX >= INV_CLOSE_BTN_BOUNDS.x &&
|
logicalX >= INV_CLOSE_BTN_BOUNDS.x &&
|
||||||
|
|
@ -1151,7 +1559,7 @@ export class InventoryPanel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 10x4 Bag Grid
|
// 4. 10x4 Bag Grid (Left-Click always moves/picks up/places/stacks; never opens cube or casts TP)
|
||||||
if (
|
if (
|
||||||
logicalX >= INV_GRID_ORIGIN.x &&
|
logicalX >= INV_GRID_ORIGIN.x &&
|
||||||
logicalX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
logicalX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||||
|
|
@ -1160,17 +1568,6 @@ export class InventoryPanel {
|
||||||
) {
|
) {
|
||||||
const col = Math.floor((logicalX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
const col = Math.floor((logicalX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||||
const row = Math.floor((logicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
const row = Math.floor((logicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||||
const hit = this.gridItems.find(
|
|
||||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
|
||||||
)
|
|
||||||
if (!this.cursorItem && hit?.item.code === 'box') {
|
|
||||||
callbacks.onOpenCube()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (!this.cursorItem && hit?.item.code === 'tbk') {
|
|
||||||
callbacks.onCastTownPortal()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
this.clickGridCell(col, row)
|
this.clickGridCell(col, row)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -1179,7 +1576,7 @@ export class InventoryPanel {
|
||||||
if (logicalX >= 500 && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) {
|
if (logicalX >= 500 && logicalX <= 625 && logicalY >= 450 && logicalY <= 485) {
|
||||||
if (this.gold > 0) {
|
if (this.gold > 0) {
|
||||||
const dropAmt = Math.min(10_000, this.gold)
|
const dropAmt = Math.min(10_000, this.gold)
|
||||||
callbacks.onDropGold?.(dropAmt)
|
callbacks?.onDropGold?.(dropAmt)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,33 @@ export function itemToUiInventoryItem(
|
||||||
(rolledSockProp > 0 ? rolledSockProp : undefined)
|
(rolledSockProp > 0 ? rolledSockProp : undefined)
|
||||||
const ethereal = !isGambleMystery && Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal)
|
const ethereal = !isGambleMystery && Boolean((tt as any).ethereal || (rawItem as any).ethereal || rawItem.flags?.ethereal)
|
||||||
|
|
||||||
|
const isUnidentified = Boolean(
|
||||||
|
isGambleMystery ||
|
||||||
|
rawItem.identified === false ||
|
||||||
|
(rawItem.flags !== undefined && rawItem.flags.identified === false),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
isUnidentified &&
|
||||||
|
!isGambleMystery &&
|
||||||
|
!stats.some(s => s.text.includes('未鉴定') || s.text.includes('未辨识') || s.text.includes('Unidentified'))
|
||||||
|
) {
|
||||||
|
stats = [{ text: '未辨识 (Unidentified)', color: 'red' }, ...stats]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTome = code === 'tbk' || code === 'ibk'
|
||||||
|
const rawQty =
|
||||||
|
typeof rawItem.quantity === 'number'
|
||||||
|
? rawItem.quantity
|
||||||
|
: isTome && typeof rawItem.stack === 'number'
|
||||||
|
? rawItem.stack
|
||||||
|
: isTome
|
||||||
|
? 20
|
||||||
|
: undefined
|
||||||
|
const maxQuantity = isTome ? 20 : base.maxStack > 1 ? base.maxStack : undefined
|
||||||
|
if (isTome && rawQty !== undefined && !baseNameZh.includes('数量:') && !stats.some(s => s.text.includes('数量:'))) {
|
||||||
|
stats = [...stats, { text: `数量: ${rawQty}`, color: 'white' }]
|
||||||
|
}
|
||||||
|
|
||||||
const id = rawItem.uniqueId
|
const id = rawItem.uniqueId
|
||||||
? `item-${code}-${rawItem.uniqueId}`
|
? `item-${code}-${rawItem.uniqueId}`
|
||||||
: (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}`
|
: (rawItem as any).id ?? `item-${code}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
|
@ -497,7 +524,7 @@ export function itemToUiInventoryItem(
|
||||||
...(sockets !== undefined && sockets > 0 ? { sockets } : {}),
|
...(sockets !== undefined && sockets > 0 ? { sockets } : {}),
|
||||||
...(ethereal ? { ethereal: true } : {}),
|
...(ethereal ? { ethereal: true } : {}),
|
||||||
...(!isGambleMystery && ((rawItem as any).speedText || tt.speedText) ? { speedText: (rawItem as any).speedText || tt.speedText } : {}),
|
...(!isGambleMystery && ((rawItem as any).speedText || tt.speedText) ? { speedText: (rawItem as any).speedText || tt.speedText } : {}),
|
||||||
...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes || tt.runewordRunes } : {}),
|
...(!isGambleMystery && ((rawItem as any).runewordRunes || tt.runewordRunes) ? { runewordRunes: (rawItem as any).runewordRunes } : {}),
|
||||||
...(!isGambleMystery && tt.setPieces ? { setPieces: tt.setPieces } : {}),
|
...(!isGambleMystery && tt.setPieces ? { setPieces: tt.setPieces } : {}),
|
||||||
...(!isGambleMystery && tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),
|
...(!isGambleMystery && tt.setBonuses ? { setBonuses: tt.setBonuses.map(b => ({ text: b.text, color: (b.color ?? 'green') as D2ColorCode })) } : {}),
|
||||||
...(typeof flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}),
|
...(typeof flippyFile === 'string' && flippyFile.length > 0 ? { flippyFile } : {}),
|
||||||
|
|
@ -506,6 +533,9 @@ export function itemToUiInventoryItem(
|
||||||
rawItem: { ...rawItem, base } as Item,
|
rawItem: { ...rawItem, base } as Item,
|
||||||
...(permStore ? { permStore: true } : {}),
|
...(permStore ? { permStore: true } : {}),
|
||||||
...(isGambleMystery ? { isGambleMystery: true } : {}),
|
...(isGambleMystery ? { isGambleMystery: true } : {}),
|
||||||
|
...(isUnidentified ? { identified: false } : { identified: true }),
|
||||||
|
...(rawQty !== undefined ? { quantity: rawQty } : {}),
|
||||||
|
...(maxQuantity !== undefined ? { maxQuantity } : {}),
|
||||||
...((rawItem as any).buyCost !== undefined ? { buyCost: (rawItem as any).buyCost } : {}),
|
...((rawItem as any).buyCost !== undefined ? { buyCost: (rawItem as any).buyCost } : {}),
|
||||||
...((rawItem as any).sellCost !== undefined ? { sellCost: (rawItem as any).sellCost } : {}),
|
...((rawItem as any).sellCost !== undefined ? { sellCost: (rawItem as any).sellCost } : {}),
|
||||||
...((rawItem as any).repairCost !== undefined ? { repairCost: (rawItem as any).repairCost } : {}),
|
...((rawItem as any).repairCost !== undefined ? { repairCost: (rawItem as any).repairCost } : {}),
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,22 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
||||||
import { findFreeGridSlot, type EquipSlotId, type GridPlacement, type InventoryPanel, type UiInventoryItem } from './inventory.ts'
|
import {
|
||||||
import { resolveItemSpriteRect, itemToUiInventoryItem } from './inventory.ts'
|
TOME_MAX_QUANTITY,
|
||||||
|
findFreeGridSlot,
|
||||||
|
getTomeQuantity,
|
||||||
|
identifyUiItem,
|
||||||
|
isScrollForTome,
|
||||||
|
isUiItemUnidentified,
|
||||||
|
isUsableRightClickItem,
|
||||||
|
itemToUiInventoryItem,
|
||||||
|
resolveItemSpriteRect,
|
||||||
|
setTomeQuantity,
|
||||||
|
type EquipSlotId,
|
||||||
|
type GridPlacement,
|
||||||
|
type InventoryPanel,
|
||||||
|
type UiInventoryItem,
|
||||||
|
} from './inventory.ts'
|
||||||
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
||||||
import {
|
import {
|
||||||
type TownNpcServiceDescriptor,
|
type TownNpcServiceDescriptor,
|
||||||
|
|
@ -33,6 +47,10 @@ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 }
|
||||||
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
|
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
|
||||||
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
|
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
|
||||||
|
|
||||||
|
/** `Inventory.txt` `Transmogrify Box Page 1` / `Transmogrify Box2` (`supertransmogrifier.dc6` 3x4 grid) */
|
||||||
|
export const CUBE_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
|
||||||
|
export const CUBE_GRID_ORIGIN = { x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 } as const
|
||||||
|
|
||||||
export const VENDOR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
|
export const VENDOR_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
|
||||||
/** `Inventory.txt` row `Monster2`: gridLeft=96 (`80 + 16`), gridTop=123 (`60 + 63`), 10x10 cells of 29x29px */
|
/** `Inventory.txt` row `Monster2`: gridLeft=96 (`80 + 16`), gridTop=123 (`60 + 63`), 10x10 cells of 29x29px */
|
||||||
export const VENDOR_GRID_ORIGIN = { x: 80 + 16, y: 60 + 63, cols: 10, rows: 10, cellPx: 29 } as const
|
export const VENDOR_GRID_ORIGIN = { x: 80 + 16, y: 60 + 63, cols: 10, rows: 10, cellPx: 29 } as const
|
||||||
|
|
@ -855,8 +873,20 @@ export class WorldPanelsHud {
|
||||||
identifyAllUiItems(playerInventory: InventoryPanel): number {
|
identifyAllUiItems(playerInventory: InventoryPanel): number {
|
||||||
let count = 0
|
let count = 0
|
||||||
for (const placed of playerInventory.gridItems) {
|
for (const placed of playerInventory.gridItems) {
|
||||||
if (placed.item.rawItem && placed.item.rawItem.identified === false) {
|
if (isUiItemUnidentified(placed.item)) {
|
||||||
placed.item.rawItem.identified = true
|
identifyUiItem(placed.item)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const placed of this.stashItems) {
|
||||||
|
if (isUiItemUnidentified(placed.item)) {
|
||||||
|
identifyUiItem(placed.item)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const placed of this.cubeItems) {
|
||||||
|
if (isUiItemUnidentified(placed.item)) {
|
||||||
|
identifyUiItem(placed.item)
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -946,6 +976,8 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
stashItems: GridPlacement[] = []
|
stashItems: GridPlacement[] = []
|
||||||
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||||
|
cubeItems: GridPlacement[] = []
|
||||||
|
hoveredCubeItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||||
hoveredWaypointIdx: number | null = null
|
hoveredWaypointIdx: number | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1059,6 +1091,24 @@ export class WorldPanelsHud {
|
||||||
return { handled: true, newCursorItem: hit.item }
|
return { handled: true, newCursorItem: hit.item }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const directHit = this.stashItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (directHit && isScrollForTome(cursorItem.code, directHit.item.code)) {
|
||||||
|
const curQty = getTomeQuantity(directHit.item)
|
||||||
|
const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY
|
||||||
|
if (curQty < maxQty) {
|
||||||
|
const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
|
||||||
|
const addQty = Math.min(maxQty - curQty, scrollQty)
|
||||||
|
setTomeQuantity(directHit.item, curQty + addQty)
|
||||||
|
if (scrollQty > addQty) {
|
||||||
|
setTomeQuantity(cursorItem, scrollQty - addQty)
|
||||||
|
return { handled: true, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
return { handled: true, newCursorItem: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
col < 0 ||
|
col < 0 ||
|
||||||
row < 0 ||
|
row < 0 ||
|
||||||
|
|
@ -1075,6 +1125,20 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
if (overlaps.length === 1) {
|
if (overlaps.length === 1) {
|
||||||
const single = overlaps[0]!
|
const single = overlaps[0]!
|
||||||
|
if (isScrollForTome(cursorItem.code, single.item.code)) {
|
||||||
|
const curQty = getTomeQuantity(single.item)
|
||||||
|
const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY
|
||||||
|
if (curQty < maxQty) {
|
||||||
|
const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
|
||||||
|
const addQty = Math.min(maxQty - curQty, scrollQty)
|
||||||
|
setTomeQuantity(single.item, curQty + addQty)
|
||||||
|
if (scrollQty > addQty) {
|
||||||
|
setTomeQuantity(cursorItem, scrollQty - addQty)
|
||||||
|
return { handled: true, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
return { handled: true, newCursorItem: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
this.stashItems = this.stashItems.filter(p => p !== single)
|
this.stashItems = this.stashItems.filter(p => p !== single)
|
||||||
this.stashItems.push({ item: cursorItem, col, row })
|
this.stashItems.push({ item: cursorItem, col, row })
|
||||||
return { handled: true, newCursorItem: single.item }
|
return { handled: true, newCursorItem: single.item }
|
||||||
|
|
@ -1098,6 +1162,133 @@ export class WorldPanelsHud {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an item of (w, h) can be placed at (col, row) in the 3x4 Horadric Cube grid.
|
||||||
|
*/
|
||||||
|
getCubeOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] {
|
||||||
|
if (col < 0 || row < 0 || col + w > CUBE_GRID_ORIGIN.cols || row + h > CUBE_GRID_ORIGIN.rows) {
|
||||||
|
throw new RangeError('Out of 3x4 Horadric Cube grid bounds')
|
||||||
|
}
|
||||||
|
return this.cubeItems.filter(p => {
|
||||||
|
const ox = col < p.col + p.item.invWidth && col + w > p.col
|
||||||
|
const oy = row < p.row + p.item.invHeight && row + h > p.row
|
||||||
|
return ox && oy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
canPlaceInCube(col: number, row: number, w: number, h: number): boolean {
|
||||||
|
if (col < 0 || row < 0 || col + w > CUBE_GRID_ORIGIN.cols || row + h > CUBE_GRID_ORIGIN.rows) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.getCubeOverlaps(col, row, w, h).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
autoPlaceInCube(item: UiInventoryItem): boolean {
|
||||||
|
const code = (item.code ?? '').trim().toLowerCase()
|
||||||
|
if (code === 'box' || code === 'cube') return false
|
||||||
|
for (let col = 0; col <= CUBE_GRID_ORIGIN.cols - item.invWidth; col++) {
|
||||||
|
for (let row = 0; row <= CUBE_GRID_ORIGIN.rows - item.invHeight; row++) {
|
||||||
|
if (this.canPlaceInCube(col, row, item.invWidth, item.invHeight)) {
|
||||||
|
this.cubeItems.push({ item, col, row })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
clickCubeCell(
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
cursorItem: UiInventoryItem | null,
|
||||||
|
): { handled: boolean; newCursorItem: UiInventoryItem | null } {
|
||||||
|
if (!cursorItem) {
|
||||||
|
const hit = this.cubeItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (!hit) return { handled: false, newCursorItem: null }
|
||||||
|
this.cubeItems = this.cubeItems.filter(p => p !== hit)
|
||||||
|
return { handled: true, newCursorItem: hit.item }
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (cursorItem.code ?? '').trim().toLowerCase()
|
||||||
|
if (code === 'box' || code === 'cube') {
|
||||||
|
return { handled: false, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
const directHit = this.cubeItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (directHit && isScrollForTome(cursorItem.code, directHit.item.code)) {
|
||||||
|
const curQty = getTomeQuantity(directHit.item)
|
||||||
|
const maxQty = directHit.item.maxQuantity ?? TOME_MAX_QUANTITY
|
||||||
|
if (curQty < maxQty) {
|
||||||
|
const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
|
||||||
|
const addQty = Math.min(maxQty - curQty, scrollQty)
|
||||||
|
setTomeQuantity(directHit.item, curQty + addQty)
|
||||||
|
if (scrollQty > addQty) {
|
||||||
|
setTomeQuantity(cursorItem, scrollQty - addQty)
|
||||||
|
return { handled: true, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
return { handled: true, newCursorItem: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCol = Math.min(CUBE_GRID_ORIGIN.cols - cursorItem.invWidth, Math.max(0, col))
|
||||||
|
const targetRow = Math.min(CUBE_GRID_ORIGIN.rows - cursorItem.invHeight, Math.max(0, row))
|
||||||
|
if (
|
||||||
|
targetCol < 0 ||
|
||||||
|
targetRow < 0 ||
|
||||||
|
targetCol + cursorItem.invWidth > CUBE_GRID_ORIGIN.cols ||
|
||||||
|
targetRow + cursorItem.invHeight > CUBE_GRID_ORIGIN.rows
|
||||||
|
) {
|
||||||
|
return { handled: false, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlaps = this.getCubeOverlaps(targetCol, targetRow, cursorItem.invWidth, cursorItem.invHeight)
|
||||||
|
if (overlaps.length === 0) {
|
||||||
|
this.cubeItems.push({ item: cursorItem, col: targetCol, row: targetRow })
|
||||||
|
return { handled: true, newCursorItem: null }
|
||||||
|
}
|
||||||
|
if (overlaps.length === 1) {
|
||||||
|
const single = overlaps[0]!
|
||||||
|
if (isScrollForTome(cursorItem.code, single.item.code)) {
|
||||||
|
const curQty = getTomeQuantity(single.item)
|
||||||
|
const maxQty = single.item.maxQuantity ?? TOME_MAX_QUANTITY
|
||||||
|
if (curQty < maxQty) {
|
||||||
|
const scrollQty = cursorItem.quantity ?? cursorItem.rawItem?.quantity ?? 1
|
||||||
|
const addQty = Math.min(maxQty - curQty, scrollQty)
|
||||||
|
setTomeQuantity(single.item, curQty + addQty)
|
||||||
|
if (scrollQty > addQty) {
|
||||||
|
setTomeQuantity(cursorItem, scrollQty - addQty)
|
||||||
|
return { handled: true, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
return { handled: true, newCursorItem: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cubeItems = this.cubeItems.filter(p => p !== single)
|
||||||
|
this.cubeItems.push({ item: cursorItem, col: targetCol, row: targetRow })
|
||||||
|
return { handled: true, newCursorItem: single.item }
|
||||||
|
}
|
||||||
|
return { handled: false, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
quickTransferToCube(item: UiInventoryItem, fromInventory: InventoryPanel): boolean {
|
||||||
|
const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id)
|
||||||
|
if (hitIdx === -1) return false
|
||||||
|
if (!this.autoPlaceInCube(item)) return false
|
||||||
|
fromInventory.gridItems.splice(hitIdx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
quickTransferFromCube(item: UiInventoryItem, toInventory: InventoryPanel): boolean {
|
||||||
|
const hitIdx = this.cubeItems.findIndex(p => p.item.id === item.id)
|
||||||
|
if (hitIdx === -1) return false
|
||||||
|
if (!toInventory.autoPlaceInGrid(item)) return false
|
||||||
|
this.cubeItems.splice(hitIdx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
depositGold(amount: number, playerInventory: { gold: number }): number {
|
depositGold(amount: number, playerInventory: { gold: number }): number {
|
||||||
if (amount <= 0) return 0
|
if (amount <= 0) return 0
|
||||||
const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this.stashGold))
|
const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this.stashGold))
|
||||||
|
|
@ -1119,6 +1310,7 @@ export class WorldPanelsHud {
|
||||||
|
|
||||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||||
this.hoveredStashItem = null
|
this.hoveredStashItem = null
|
||||||
|
this.hoveredCubeItem = null
|
||||||
this.hoveredVendorItem = null
|
this.hoveredVendorItem = null
|
||||||
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
|
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
|
||||||
? this.hitTestVendorButtonSlot(logicalX, logicalY)
|
? this.hitTestVendorButtonSlot(logicalX, logicalY)
|
||||||
|
|
@ -1159,6 +1351,26 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
logicalX >= CUBE_GRID_ORIGIN.x &&
|
||||||
|
logicalX < CUBE_GRID_ORIGIN.x + CUBE_GRID_ORIGIN.cols * CUBE_GRID_ORIGIN.cellPx &&
|
||||||
|
logicalY >= CUBE_GRID_ORIGIN.y &&
|
||||||
|
logicalY < CUBE_GRID_ORIGIN.y + CUBE_GRID_ORIGIN.rows * CUBE_GRID_ORIGIN.cellPx
|
||||||
|
) {
|
||||||
|
const col = Math.floor((logicalX - CUBE_GRID_ORIGIN.x) / CUBE_GRID_ORIGIN.cellPx)
|
||||||
|
const row = Math.floor((logicalY - CUBE_GRID_ORIGIN.y) / CUBE_GRID_ORIGIN.cellPx)
|
||||||
|
const hit = this.cubeItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hit) {
|
||||||
|
this.hoveredCubeItem = {
|
||||||
|
item: hit.item,
|
||||||
|
x: CUBE_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * CUBE_GRID_ORIGIN.cellPx,
|
||||||
|
y: CUBE_GRID_ORIGIN.y + hit.row * CUBE_GRID_ORIGIN.cellPx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
logicalX >= VENDOR_GRID_ORIGIN.x &&
|
logicalX >= VENDOR_GRID_ORIGIN.x &&
|
||||||
logicalX < VENDOR_GRID_ORIGIN.x + VENDOR_GRID_ORIGIN.cols * VENDOR_GRID_ORIGIN.cellPx &&
|
logicalX < VENDOR_GRID_ORIGIN.x + VENDOR_GRID_ORIGIN.cols * VENDOR_GRID_ORIGIN.cellPx &&
|
||||||
|
|
@ -1264,6 +1476,8 @@ export class WorldPanelsHud {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
|
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
|
||||||
|
onOpenCube?: () => void
|
||||||
|
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||||
inventory?: InventoryPanel
|
inventory?: InventoryPanel
|
||||||
isShiftClick?: boolean
|
isShiftClick?: boolean
|
||||||
isRightClick?: boolean
|
isRightClick?: boolean
|
||||||
|
|
@ -1328,9 +1542,69 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (kind === 'cube') {
|
} else if (kind === 'cube') {
|
||||||
// Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`)
|
// 1. Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`)
|
||||||
if (logicalX >= ox + 120 && logicalX <= ox + 200 && logicalY >= oy + 330 && logicalY <= oy + 380) {
|
if (logicalX >= ox + 120 && logicalX <= ox + 200 && logicalY >= oy + 330 && logicalY <= oy + 380) {
|
||||||
this.cubeTransmuteCount += 1
|
if (!callbacks.isRightClick) {
|
||||||
|
this.cubeTransmuteCount += 1
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Horadric Cube 3x4 Grid (`CUBE_GRID_ORIGIN`)
|
||||||
|
if (
|
||||||
|
logicalX >= CUBE_GRID_ORIGIN.x &&
|
||||||
|
logicalX < CUBE_GRID_ORIGIN.x + CUBE_GRID_ORIGIN.cols * CUBE_GRID_ORIGIN.cellPx &&
|
||||||
|
logicalY >= CUBE_GRID_ORIGIN.y &&
|
||||||
|
logicalY < CUBE_GRID_ORIGIN.y + CUBE_GRID_ORIGIN.rows * CUBE_GRID_ORIGIN.cellPx
|
||||||
|
) {
|
||||||
|
const col = Math.floor((logicalX - CUBE_GRID_ORIGIN.x) / CUBE_GRID_ORIGIN.cellPx)
|
||||||
|
const row = Math.floor((logicalY - CUBE_GRID_ORIGIN.y) / CUBE_GRID_ORIGIN.cellPx)
|
||||||
|
const hit = this.cubeItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (callbacks.inventory?.isIdentifyMode()) {
|
||||||
|
if (callbacks.isRightClick) {
|
||||||
|
callbacks.inventory.cancelIdentifyMode()
|
||||||
|
} else if (hit && isUiItemUnidentified(hit.item)) {
|
||||||
|
callbacks.inventory.applyIdentifyToItem(hit.item, {
|
||||||
|
stashItems: this.stashItems,
|
||||||
|
cubeItems: this.cubeItems,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
callbacks.inventory.cancelIdentifyMode()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks.isRightClick && callbacks.inventory) {
|
||||||
|
if (hit && isUsableRightClickItem(hit.item.code)) {
|
||||||
|
callbacks.inventory.usePlacedItemByRightClick(hit, 'cube', this.cubeItems, {
|
||||||
|
onOpenCube: () => callbacks.onOpenCube?.(),
|
||||||
|
onCastTownPortal: (source, item) => callbacks.onCastTownPortal?.(source, item),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (hit) {
|
||||||
|
this.quickTransferFromCube(hit.item, callbacks.inventory)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks.isShiftClick && callbacks.inventory) {
|
||||||
|
if (hit) {
|
||||||
|
this.quickTransferFromCube(hit.item, callbacks.inventory)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks.inventory) {
|
||||||
|
const result = this.clickCubeCell(col, row, callbacks.inventory.cursorItem)
|
||||||
|
if (result.handled) {
|
||||||
|
callbacks.inventory.cursorItem = result.newCursorItem
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if (kind === 'stash') {
|
} else if (kind === 'stash') {
|
||||||
|
|
@ -1341,7 +1615,7 @@ export class WorldPanelsHud {
|
||||||
logicalY >= STASH_DEPOSIT_BTN_BOUNDS.y &&
|
logicalY >= STASH_DEPOSIT_BTN_BOUNDS.y &&
|
||||||
logicalY <= STASH_DEPOSIT_BTN_BOUNDS.y + STASH_DEPOSIT_BTN_BOUNDS.h
|
logicalY <= STASH_DEPOSIT_BTN_BOUNDS.y + STASH_DEPOSIT_BTN_BOUNDS.h
|
||||||
) {
|
) {
|
||||||
if (callbacks.inventory) {
|
if (!callbacks.isRightClick && callbacks.inventory) {
|
||||||
const depositAmt = Math.min(50_000, callbacks.inventory.gold, STASH_GOLD_CAP - this.stashGold)
|
const depositAmt = Math.min(50_000, callbacks.inventory.gold, STASH_GOLD_CAP - this.stashGold)
|
||||||
if (depositAmt > 0) {
|
if (depositAmt > 0) {
|
||||||
this.depositGold(depositAmt, callbacks.inventory)
|
this.depositGold(depositAmt, callbacks.inventory)
|
||||||
|
|
@ -1357,7 +1631,7 @@ export class WorldPanelsHud {
|
||||||
logicalY >= STASH_WITHDRAW_BTN_BOUNDS.y &&
|
logicalY >= STASH_WITHDRAW_BTN_BOUNDS.y &&
|
||||||
logicalY <= STASH_WITHDRAW_BTN_BOUNDS.y + STASH_WITHDRAW_BTN_BOUNDS.h
|
logicalY <= STASH_WITHDRAW_BTN_BOUNDS.y + STASH_WITHDRAW_BTN_BOUNDS.h
|
||||||
) {
|
) {
|
||||||
if (callbacks.inventory) {
|
if (!callbacks.isRightClick && callbacks.inventory) {
|
||||||
const withdrawAmt = Math.min(50_000, this.stashGold, PLAYER_GOLD_CAP - callbacks.inventory.gold)
|
const withdrawAmt = Math.min(50_000, this.stashGold, PLAYER_GOLD_CAP - callbacks.inventory.gold)
|
||||||
if (withdrawAmt > 0) {
|
if (withdrawAmt > 0) {
|
||||||
this.withdrawGold(withdrawAmt, callbacks.inventory)
|
this.withdrawGold(withdrawAmt, callbacks.inventory)
|
||||||
|
|
@ -1375,15 +1649,43 @@ export class WorldPanelsHud {
|
||||||
) {
|
) {
|
||||||
const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx)
|
const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx)
|
||||||
const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx)
|
const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx)
|
||||||
|
const hit = this.stashItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
|
||||||
if ((callbacks.isShiftClick || callbacks.isRightClick) && callbacks.inventory) {
|
if (callbacks.inventory?.isIdentifyMode()) {
|
||||||
const hit = this.stashItems.find(
|
if (callbacks.isRightClick) {
|
||||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
callbacks.inventory.cancelIdentifyMode()
|
||||||
)
|
} else if (hit && isUiItemUnidentified(hit.item)) {
|
||||||
if (hit) {
|
callbacks.inventory.applyIdentifyToItem(hit.item, {
|
||||||
this.quickTransferFromStash(hit.item, callbacks.inventory)
|
stashItems: this.stashItems,
|
||||||
|
cubeItems: this.cubeItems,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
callbacks.inventory.cancelIdentifyMode()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks.isRightClick && callbacks.inventory) {
|
||||||
|
if (hit && isUsableRightClickItem(hit.item.code)) {
|
||||||
|
callbacks.inventory.usePlacedItemByRightClick(hit, 'stash', this.stashItems, {
|
||||||
|
onOpenCube: () => callbacks.onOpenCube?.(),
|
||||||
|
onCastTownPortal: (source, item) => callbacks.onCastTownPortal?.(source, item),
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (hit) {
|
||||||
|
this.quickTransferFromStash(hit.item, callbacks.inventory)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks.isShiftClick && callbacks.inventory) {
|
||||||
|
if (hit) {
|
||||||
|
this.quickTransferFromStash(hit.item, callbacks.inventory)
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (callbacks.inventory) {
|
if (callbacks.inventory) {
|
||||||
|
|
@ -1643,8 +1945,9 @@ export class WorldPanelsHud {
|
||||||
const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx
|
const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx
|
||||||
const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx
|
const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx
|
||||||
|
|
||||||
ctx.fillStyle =
|
ctx.fillStyle = isUiItemUnidentified(placed.item)
|
||||||
placed.item.quality === 'unique'
|
? 'rgba(72, 18, 18, 0.50)'
|
||||||
|
: placed.item.quality === 'unique'
|
||||||
? 'rgba(58, 42, 16, 0.45)'
|
? 'rgba(58, 42, 16, 0.45)'
|
||||||
: placed.item.quality === 'set'
|
: placed.item.quality === 'set'
|
||||||
? 'rgba(16, 52, 22, 0.45)'
|
? 'rgba(16, 52, 22, 0.45)'
|
||||||
|
|
@ -1726,6 +2029,48 @@ export class WorldPanelsHud {
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 3x4 Horadric Cube grid cell borders (`Inventory.txt` Transmogrify Box1)
|
||||||
|
for (let r = 0; r < CUBE_GRID_ORIGIN.rows; r++) {
|
||||||
|
for (let c = 0; c < CUBE_GRID_ORIGIN.cols; c++) {
|
||||||
|
const cx = CUBE_GRID_ORIGIN.x + c * CUBE_GRID_ORIGIN.cellPx
|
||||||
|
const cy = CUBE_GRID_ORIGIN.y + r * CUBE_GRID_ORIGIN.cellPx
|
||||||
|
ctx.strokeStyle = 'rgba(64, 52, 38, 0.4)'
|
||||||
|
ctx.strokeRect(cx + 0.5, cy + 0.5, CUBE_GRID_ORIGIN.cellPx - 1, CUBE_GRID_ORIGIN.cellPx - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw items stored in Horadric Cube
|
||||||
|
const atlas = assets.itemsAtlasImg ?? null
|
||||||
|
for (const placed of this.cubeItems) {
|
||||||
|
const gx = CUBE_GRID_ORIGIN.x + placed.col * CUBE_GRID_ORIGIN.cellPx
|
||||||
|
const gy = CUBE_GRID_ORIGIN.y + placed.row * CUBE_GRID_ORIGIN.cellPx
|
||||||
|
const gw = placed.item.invWidth * CUBE_GRID_ORIGIN.cellPx
|
||||||
|
const gh = placed.item.invHeight * CUBE_GRID_ORIGIN.cellPx
|
||||||
|
|
||||||
|
ctx.fillStyle = isUiItemUnidentified(placed.item)
|
||||||
|
? 'rgba(72, 18, 18, 0.50)'
|
||||||
|
: 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)
|
||||||
|
|
||||||
|
const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects)
|
||||||
|
if (atlas && rect) {
|
||||||
|
const scale = Math.min(gw / rect.w, gh / rect.h, 1)
|
||||||
|
const dw = Math.round(rect.w * scale)
|
||||||
|
const dh = Math.round(rect.h * scale)
|
||||||
|
const dx = gx + Math.round((gw - dw) / 2)
|
||||||
|
const dy = gy + Math.round((gh - dh) / 2)
|
||||||
|
ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh)
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle =
|
||||||
|
placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8'
|
||||||
|
ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (kind === 'vendor') {
|
} else if (kind === 'vendor') {
|
||||||
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
||||||
throw new Error('Cannot draw vendor panel without an active vendor descriptor')
|
throw new Error('Cannot draw vendor panel without an active vendor descriptor')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
/**
|
||||||
|
* Unit & Parity Tests for Diablo II v1.13c Tome / Scroll of Town Portal & Town Portal Lifecycle (Issue #394).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import {
|
||||||
|
TownPortalSlot,
|
||||||
|
resolveTownPortalSubTile,
|
||||||
|
TOWN_PORTAL_SUBTILES_BY_VARIANT,
|
||||||
|
DEFAULT_TOWN_PORTAL_SUBTILES_BY_LEVEL,
|
||||||
|
} from '../src/game/portal.ts'
|
||||||
|
import {
|
||||||
|
InventoryPanel,
|
||||||
|
INV_GRID_ORIGIN,
|
||||||
|
STARTER_BAG_ITEMS,
|
||||||
|
getTomeQuantity,
|
||||||
|
withTomeQuantity,
|
||||||
|
} from '../src/ui/inventory.ts'
|
||||||
|
import { createStarterBagForClass } from '../src/game/class-starter-profiles.ts'
|
||||||
|
import { OVERLAY_METAS } from '../src/render/overlays-meta.ts'
|
||||||
|
|
||||||
|
describe('Issue #394: Diablo II v1.13c Town Portal & Tome/Scroll of Town Portal', () => {
|
||||||
|
it('resolves exact 1.13c DS1 Type-2 Object #33 (Objects.txt ID 59) sub-tiles for all 9 town presets across Acts 1..5', () => {
|
||||||
|
// Act 1 Rogue Encampment variants (TownN1, TownE1, TownS1, TownW1)
|
||||||
|
expect(resolveTownPortalSubTile(1, '1-act-1-town-townn1')).toEqual({ x: 153, y: 73 })
|
||||||
|
expect(resolveTownPortalSubTile(1, '1-act-1-town-towne1')).toEqual({ x: 173, y: 103 })
|
||||||
|
expect(resolveTownPortalSubTile(1, '1-act-1-town-towns1')).toEqual({ x: 153, y: 158 })
|
||||||
|
expect(resolveTownPortalSubTile(1, '1-act-1-town-townw1')).toEqual({ x: 83, y: 128 })
|
||||||
|
// Default Act 1 town (TownS1 south exit to Blood Moor)
|
||||||
|
expect(resolveTownPortalSubTile(1)).toEqual({ x: 153, y: 158 })
|
||||||
|
|
||||||
|
// Act 2 Lut Gholein (LutN, LutW)
|
||||||
|
expect(resolveTownPortalSubTile(40, '40-act-2-town-lutn')).toEqual({ x: 178, y: 53 })
|
||||||
|
expect(resolveTownPortalSubTile(40, '40-act-2-town-lutw')).toEqual({ x: 178, y: 53 })
|
||||||
|
expect(resolveTownPortalSubTile(40)).toEqual({ x: 178, y: 53 })
|
||||||
|
|
||||||
|
// Act 3 Kurast Docks (DockTown3)
|
||||||
|
expect(resolveTownPortalSubTile(75, '75-act-3-town-docktown3')).toEqual({ x: 158, y: 68 })
|
||||||
|
expect(resolveTownPortalSubTile(75)).toEqual({ x: 158, y: 68 })
|
||||||
|
|
||||||
|
// Act 4 The Pandemonium Fortress (Fortress)
|
||||||
|
expect(resolveTownPortalSubTile(103, '103-act-4-town-fortress')).toEqual({ x: 48, y: 43 })
|
||||||
|
expect(resolveTownPortalSubTile(103)).toEqual({ x: 48, y: 43 })
|
||||||
|
|
||||||
|
// Act 5 Harrogath (townWest)
|
||||||
|
expect(resolveTownPortalSubTile(109, '109-act-5-town-townwest')).toEqual({ x: 98, y: 23 })
|
||||||
|
expect(resolveTownPortalSubTile(109)).toEqual({ x: 98, y: 23 })
|
||||||
|
|
||||||
|
// Fail-fast on non-town level ID
|
||||||
|
expect(() => resolveTownPortalSubTile(2)).toThrow(/invalid town levelId/)
|
||||||
|
expect(Object.keys(TOWN_PORTAL_SUBTILES_BY_VARIANT)).toHaveLength(9)
|
||||||
|
expect(Object.keys(DEFAULT_TOWN_PORTAL_SUBTILES_BY_LEVEL)).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies all 9 town portal sub-tiles land inside valid bounds of baked town preset scenes', () => {
|
||||||
|
const variantPaths: Array<{ act: number; variant: string; expected: { x: number; y: number } }> = [
|
||||||
|
{ act: 1, variant: '1-act-1-town-townn1', expected: { x: 153, y: 73 } },
|
||||||
|
{ act: 1, variant: '1-act-1-town-towne1', expected: { x: 173, y: 103 } },
|
||||||
|
{ act: 1, variant: '1-act-1-town-towns1', expected: { x: 153, y: 158 } },
|
||||||
|
{ act: 1, variant: '1-act-1-town-townw1', expected: { x: 83, y: 128 } },
|
||||||
|
{ act: 2, variant: '40-act-2-town-lutn', expected: { x: 178, y: 53 } },
|
||||||
|
{ act: 2, variant: '40-act-2-town-lutw', expected: { x: 178, y: 53 } },
|
||||||
|
{ act: 3, variant: '75-act-3-town-docktown3', expected: { x: 158, y: 68 } },
|
||||||
|
{ act: 4, variant: '103-act-4-town-fortress', expected: { x: 48, y: 43 } },
|
||||||
|
{ act: 5, variant: '109-act-5-town-townwest', expected: { x: 98, y: 23 } },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { act, variant, expected } of variantPaths) {
|
||||||
|
const scenePath = join(process.cwd(), 'samples', 'd2-packs', `act${act}`, variant, 'scene.json')
|
||||||
|
if (!existsSync(scenePath)) continue
|
||||||
|
const scene = JSON.parse(readFileSync(scenePath, 'utf8')) as {
|
||||||
|
cellsX: number
|
||||||
|
cellsY: number
|
||||||
|
}
|
||||||
|
const gridW = scene.cellsX * 5
|
||||||
|
const gridH = scene.cellsY * 5
|
||||||
|
expect(expected.x).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(expected.x).toBeLessThan(gridW)
|
||||||
|
expect(expected.y).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(expected.y).toBeLessThan(gridH)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('manages TownPortalSlot two-way lifecycle (wilderness -> town keeps portal open; town -> wilderness closes portal per ObjMode.cpp:3245)', () => {
|
||||||
|
const slot = new TownPortalSlot()
|
||||||
|
expect(slot.current()).toBeNull()
|
||||||
|
expect(slot.mouthInLevel(2)).toBeNull()
|
||||||
|
expect(slot.mouthInLevel(1)).toBeNull()
|
||||||
|
|
||||||
|
slot.cast({
|
||||||
|
fromLevelId: 2,
|
||||||
|
fromX: 80,
|
||||||
|
fromY: 95,
|
||||||
|
townLevelId: 1,
|
||||||
|
townX: 153,
|
||||||
|
townY: 158,
|
||||||
|
createdAtMs: 1234,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Both wilderness (2) and town (1) mouths are visible
|
||||||
|
expect(slot.mouthInLevel(2)).toEqual({
|
||||||
|
x: 80,
|
||||||
|
y: 95,
|
||||||
|
toLevelId: 1,
|
||||||
|
isTownMouth: false,
|
||||||
|
createdAtMs: 1234,
|
||||||
|
})
|
||||||
|
expect(slot.mouthInLevel(1)).toEqual({
|
||||||
|
x: 153,
|
||||||
|
y: 158,
|
||||||
|
toLevelId: 2,
|
||||||
|
isTownMouth: true,
|
||||||
|
createdAtMs: 1234,
|
||||||
|
})
|
||||||
|
expect(slot.mouthInLevel(3)).toBeNull()
|
||||||
|
|
||||||
|
// 1. Entering from wilderness (level 2) transports to town (level 1) and keeps the portal open
|
||||||
|
const toTown = slot.enterFrom(2)
|
||||||
|
expect(toTown).toEqual({ levelId: 1, x: 153, y: 158 })
|
||||||
|
expect(slot.current()).not.toBeNull()
|
||||||
|
expect(slot.mouthInLevel(1)).not.toBeNull()
|
||||||
|
|
||||||
|
// 2. Entering from town (level 1) transports back to wilderness (level 2) and closes the portal
|
||||||
|
const backToField = slot.enterFrom(1)
|
||||||
|
expect(backToField).toEqual({ levelId: 2, x: 80, y: 95 })
|
||||||
|
expect(slot.current()).toBeNull()
|
||||||
|
expect(slot.mouthInLevel(1)).toBeNull()
|
||||||
|
expect(slot.mouthInLevel(2)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes starter Tome of Town Portal (tbk) and Tome of Identify (ibk) with 20 charges across all classes', () => {
|
||||||
|
const starterTbk = STARTER_BAG_ITEMS.find(p => p.item.code === 'tbk')
|
||||||
|
const starterIbk = STARTER_BAG_ITEMS.find(p => p.item.code === 'ibk')
|
||||||
|
expect(starterTbk?.item.quantity).toBe(20)
|
||||||
|
expect(starterIbk?.item.quantity).toBe(20)
|
||||||
|
expect(getTomeQuantity(starterTbk!.item)).toBe(20)
|
||||||
|
|
||||||
|
for (const cls of ['sor', 'nec', 'pal', 'bar', 'ama', 'dru', 'ass'] as const) {
|
||||||
|
const bag = createStarterBagForClass(cls)
|
||||||
|
const tbk = bag.find(p => p.item.code === 'tbk')
|
||||||
|
const ibk = bag.find(p => p.item.code === 'ibk')
|
||||||
|
expect(tbk?.item.quantity).toBe(20)
|
||||||
|
expect(ibk?.item.quantity).toBe(20)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles right-clicking tbk / tsc to cast Town Portal and left-clicking tsc onto tbk to refill charges', () => {
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
inv.visible = true
|
||||||
|
const tbkPlacement = inv.gridItems.find(p => p.item.code === 'tbk')!
|
||||||
|
expect(tbkPlacement).toBeDefined()
|
||||||
|
expect(getTomeQuantity(tbkPlacement.item)).toBe(20)
|
||||||
|
|
||||||
|
// Compute pixel coordinates inside the tbk cell at (col=6, row=2)
|
||||||
|
const tbkClickX = INV_GRID_ORIGIN.x + tbkPlacement.col * INV_GRID_ORIGIN.cellPx + 10
|
||||||
|
const tbkClickY = INV_GRID_ORIGIN.y + tbkPlacement.row * INV_GRID_ORIGIN.cellPx + 10
|
||||||
|
|
||||||
|
// 1. Right-click tbk in town (onCastTownPortal returns false) -> does NOT decrement quantity
|
||||||
|
const castBlocked = vi.fn(() => false)
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
tbkClickX,
|
||||||
|
tbkClickY,
|
||||||
|
{ onOpenCube: () => {}, onCastTownPortal: castBlocked },
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(castBlocked).toHaveBeenCalledTimes(1)
|
||||||
|
expect(getTomeQuantity(inv.gridItems.find(p => p.item.code === 'tbk')!.item)).toBe(20)
|
||||||
|
|
||||||
|
// 2. Right-click tbk in wilderness (onCastTownPortal returns true) -> decrements quantity from 20 to 19
|
||||||
|
const castOk = vi.fn(() => true)
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
tbkClickX,
|
||||||
|
tbkClickY,
|
||||||
|
{ onOpenCube: () => {}, onCastTownPortal: castOk },
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(castOk).toHaveBeenCalledWith('tbk', expect.objectContaining({ code: 'tbk' }))
|
||||||
|
expect(getTomeQuantity(inv.gridItems.find(p => p.item.code === 'tbk')!.item)).toBe(19)
|
||||||
|
|
||||||
|
// 3. Left-click a Scroll of Town Portal (tsc) onto tbk -> refills quantity from 19 back to 20
|
||||||
|
inv.cursorItem = {
|
||||||
|
id: 'scroll_tp_1',
|
||||||
|
code: 'tsc',
|
||||||
|
name: 'Scroll of Town Portal',
|
||||||
|
nameZh: '回城卷轴',
|
||||||
|
baseNameZh: '回城卷轴',
|
||||||
|
quality: 'normal',
|
||||||
|
invFile: 'invscrl',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [],
|
||||||
|
}
|
||||||
|
expect(inv.clickGridCell(tbkPlacement.col, tbkPlacement.row)).toBe(true)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
expect(getTomeQuantity(inv.gridItems.find(p => p.item.code === 'tbk')!.item)).toBe(20)
|
||||||
|
|
||||||
|
// 4. Place a standalone Scroll of Town Portal (tsc) into free cell (7, 0) and right-click it
|
||||||
|
inv.gridItems.push({
|
||||||
|
col: 7,
|
||||||
|
row: 0,
|
||||||
|
item: {
|
||||||
|
id: 'scroll_tp_2',
|
||||||
|
code: 'tsc',
|
||||||
|
name: 'Scroll of Town Portal',
|
||||||
|
nameZh: '回城卷轴',
|
||||||
|
baseNameZh: '回城卷轴',
|
||||||
|
quality: 'normal',
|
||||||
|
invFile: 'invscrl',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const tscClickX = INV_GRID_ORIGIN.x + 7 * INV_GRID_ORIGIN.cellPx + 10
|
||||||
|
const tscClickY = INV_GRID_ORIGIN.y + 0 * INV_GRID_ORIGIN.cellPx + 10
|
||||||
|
|
||||||
|
// Right-click tsc when blocked (in town) -> scroll stays in inventory
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
tscClickX,
|
||||||
|
tscClickY,
|
||||||
|
{ onOpenCube: () => {}, onCastTownPortal: () => false },
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(inv.gridItems.some(p => p.item.id === 'scroll_tp_2')).toBe(true)
|
||||||
|
|
||||||
|
// Right-click tsc in wilderness -> scroll is consumed and removed from inventory
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
tscClickX,
|
||||||
|
tscClickY,
|
||||||
|
{ onOpenCube: () => {}, onCastTownPortal: () => true },
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(inv.gridItems.some(p => p.item.id === 'scroll_tp_2')).toBe(false)
|
||||||
|
|
||||||
|
// 5. Empty tome (0 charges) cannot cast Town Portal, but stays in inventory
|
||||||
|
const tbkIdx = inv.gridItems.findIndex(p => p.item.code === 'tbk')
|
||||||
|
inv.gridItems[tbkIdx] = {
|
||||||
|
...inv.gridItems[tbkIdx]!,
|
||||||
|
item: withTomeQuantity(inv.gridItems[tbkIdx]!.item, 0),
|
||||||
|
}
|
||||||
|
const castWhenEmpty = vi.fn(() => true)
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
tbkClickX,
|
||||||
|
tbkClickY,
|
||||||
|
{ onOpenCube: () => {}, onCastTownPortal: castWhenEmpty },
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
expect(castWhenEmpty).not.toHaveBeenCalled()
|
||||||
|
expect(inv.gridItems.some(p => p.item.code === 'tbk')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bakes authentic Town Portal DCC overlays (OP 15-frame opening + ON 15-frame loop, TR + HD layers)', () => {
|
||||||
|
for (const key of ['town_portal_op_tr', 'town_portal_op_hd', 'town_portal_on_tr', 'town_portal_on_hd'] as const) {
|
||||||
|
const meta = OVERLAY_METAS[key]
|
||||||
|
expect(meta, `Missing OVERLAY_METAS[${key}]`).toBeDefined()
|
||||||
|
expect(meta!.framesPerDirection).toBe(15)
|
||||||
|
expect(existsSync(join(process.cwd(), 'public', 'overlays', `${key}.png`))).toBe(true)
|
||||||
|
expect(existsSync(join(process.cwd(), 'public', 'overlays', `${key}.json`))).toBe(true)
|
||||||
|
}
|
||||||
|
expect(OVERLAY_METAS.town_portal_op_tr!.trans).toBe(0)
|
||||||
|
expect(OVERLAY_METAS.town_portal_op_hd!.trans).toBe(3)
|
||||||
|
expect(OVERLAY_METAS.town_portal_on_tr!.trans).toBe(0)
|
||||||
|
expect(OVERLAY_METAS.town_portal_on_hd!.trans).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -26,7 +26,15 @@ import {
|
||||||
} from '../src/ui/inventory.ts'
|
} from '../src/ui/inventory.ts'
|
||||||
import { CharacterSheetPanel, CHAR_PANEL_ORIGIN, CHAR_CLOSE_BTN_BOUNDS, STAT_ALLOC_BUTTONS } from '../src/ui/character-sheet.ts'
|
import { CharacterSheetPanel, CHAR_PANEL_ORIGIN, CHAR_CLOSE_BTN_BOUNDS, STAT_ALLOC_BUTTONS } from '../src/ui/character-sheet.ts'
|
||||||
import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/ui/skill-tree-panel.ts'
|
import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/ui/skill-tree-panel.ts'
|
||||||
import { WorldPanelsHud, ACT_WAYPOINTS, ALL_QUESTS, ACT_QUESTS, findWaypointBySlug } from '../src/ui/world-panels.ts'
|
import {
|
||||||
|
WorldPanelsHud,
|
||||||
|
ACT_WAYPOINTS,
|
||||||
|
ALL_QUESTS,
|
||||||
|
ACT_QUESTS,
|
||||||
|
findWaypointBySlug,
|
||||||
|
CUBE_GRID_ORIGIN,
|
||||||
|
STASH_GRID_ORIGIN,
|
||||||
|
} from '../src/ui/world-panels.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||||
import { HudManager, computeHudLayout } from '../src/ui/hud-manager.ts'
|
import { HudManager, computeHudLayout } from '../src/ui/hud-manager.ts'
|
||||||
import { projectNpcLabel } from '../src/scene/act-scene.ts'
|
import { projectNpcLabel } from '../src/scene/act-scene.ts'
|
||||||
|
|
@ -779,5 +787,260 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
expect(placement.h).toBe(dummyLines.length * 17 + 24)
|
expect(placement.h).toBe(dummyLines.length * 17 + 24)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('separates Left-Click (Move/Stack/Identify-Target) from Right-Click (Use) for Tomes, Scrolls, and Horadric Cube (Issue #487)', () => {
|
||||||
|
const inv = new InventoryPanel()
|
||||||
|
inv.visible = true
|
||||||
|
const worldPanels = new WorldPanelsHud()
|
||||||
|
let cubeOpened = 0
|
||||||
|
let tpCastCount = 0
|
||||||
|
|
||||||
|
const cellCenter = (col: number, row: number) => ({
|
||||||
|
x: INV_GRID_ORIGIN.x + col * INV_GRID_ORIGIN.cellPx + 10,
|
||||||
|
y: INV_GRID_ORIGIN.y + row * INV_GRID_ORIGIN.cellPx + 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. Left-Click (button = 0) on Horadric Cube ('box' at 0,0) picks it up instead of opening Cube
|
||||||
|
const boxPt = cellCenter(0, 0)
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
boxPt.x,
|
||||||
|
boxPt.y,
|
||||||
|
{
|
||||||
|
onOpenCube: () => {
|
||||||
|
cubeOpened++
|
||||||
|
},
|
||||||
|
onCastTownPortal: () => {
|
||||||
|
tpCastCount++
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(cubeOpened).toBe(0)
|
||||||
|
expect(inv.cursorItem?.code).toBe('box')
|
||||||
|
|
||||||
|
// Put Horadric Cube back at (0, 0) via Left-Click
|
||||||
|
expect(inv.handleClick(boxPt.x, boxPt.y, undefined, 0)).toBe(true)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
|
||||||
|
// 2. Right-Click (button = 2) on Horadric Cube ('box' at 0,0) opens Cube and leaves 'box' in inventory
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
boxPt.x,
|
||||||
|
boxPt.y,
|
||||||
|
{
|
||||||
|
onOpenCube: () => {
|
||||||
|
cubeOpened++
|
||||||
|
},
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(cubeOpened).toBe(1)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
expect(inv.gridItems.some(p => p.item.code === 'box' && p.col === 0 && p.row === 0)).toBe(true)
|
||||||
|
|
||||||
|
// 3. Left-Click (button = 0) on Tome of Town Portal ('tbk' at 2,0) picks it up without casting TP
|
||||||
|
const tbkPt = cellCenter(2, 0)
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
tbkPt.x,
|
||||||
|
tbkPt.y,
|
||||||
|
{
|
||||||
|
onCastTownPortal: () => {
|
||||||
|
tpCastCount++
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(tpCastCount).toBe(0)
|
||||||
|
expect(inv.cursorItem?.code).toBe('tbk')
|
||||||
|
expect(inv.handleClick(tbkPt.x, tbkPt.y, undefined, 0)).toBe(true)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
|
||||||
|
// 4. Right-Click (button = 2) on Tome of Town Portal ('tbk' at 2,0) decrements quantity (20 -> 19) and casts TP
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
tbkPt.x,
|
||||||
|
tbkPt.y,
|
||||||
|
{
|
||||||
|
onCastTownPortal: () => {
|
||||||
|
tpCastCount++
|
||||||
|
},
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(tpCastCount).toBe(1)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
const tbkPlaced = inv.gridItems.find(p => p.item.code === 'tbk')!
|
||||||
|
expect(tbkPlaced.item.quantity).toBe(19)
|
||||||
|
expect(tbkPlaced.item.baseNameZh.includes('数量: 19')).toBe(true)
|
||||||
|
|
||||||
|
// 5. Left-Click Scroll of Town Portal ('tsc') onto Tome of Town Portal ('tbk') stacks quantity (19 -> 20)
|
||||||
|
inv.cursorItem = {
|
||||||
|
id: 'scroll_tp_1',
|
||||||
|
code: 'tsc',
|
||||||
|
name: 'Scroll of Town Portal',
|
||||||
|
nameZh: '回城卷轴',
|
||||||
|
baseNameZh: '回城卷轴',
|
||||||
|
quality: 'normal',
|
||||||
|
invFile: 'invscrol',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [{ text: '右键点击开启回城传送门', color: 'white' }],
|
||||||
|
}
|
||||||
|
expect(inv.handleClick(tbkPt.x, tbkPt.y, undefined, 0)).toBe(true)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
expect(tbkPlaced.item.quantity).toBe(20)
|
||||||
|
|
||||||
|
// Place another 'tsc' in empty slot (5, 0) and Right-Click to consume scroll and cast TP
|
||||||
|
const tscSlotPt = cellCenter(5, 0)
|
||||||
|
inv.cursorItem = {
|
||||||
|
id: 'scroll_tp_2',
|
||||||
|
code: 'tsc',
|
||||||
|
name: 'Scroll of Town Portal',
|
||||||
|
nameZh: '回城卷轴',
|
||||||
|
baseNameZh: '回城卷轴',
|
||||||
|
quality: 'normal',
|
||||||
|
invFile: 'invscrol',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [{ text: '右键点击开启回城传送门', color: 'white' }],
|
||||||
|
}
|
||||||
|
expect(inv.handleClick(tscSlotPt.x, tscSlotPt.y, undefined, 0)).toBe(true)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
expect(
|
||||||
|
inv.handleClick(
|
||||||
|
tscSlotPt.x,
|
||||||
|
tscSlotPt.y,
|
||||||
|
{
|
||||||
|
onCastTownPortal: () => {
|
||||||
|
tpCastCount++
|
||||||
|
},
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(tpCastCount).toBe(2)
|
||||||
|
expect(inv.gridItems.some(p => p.item.id === 'scroll_tp_2')).toBe(false)
|
||||||
|
|
||||||
|
// 6. Right-Click Tome of Identify ('ibk' at 2,2) enters Identify Mode, Left-Click unidentified item identifies it
|
||||||
|
const unidRingPt = cellCenter(5, 1)
|
||||||
|
inv.gridItems.push({
|
||||||
|
col: 5,
|
||||||
|
row: 1,
|
||||||
|
item: {
|
||||||
|
id: 'unid_ring_1',
|
||||||
|
code: 'rin',
|
||||||
|
name: 'The Stone of Jordan',
|
||||||
|
nameZh: '乔丹之石',
|
||||||
|
baseNameZh: '戒指',
|
||||||
|
quality: 'unique',
|
||||||
|
invFile: 'invrin',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: ['ring1', 'ring2'],
|
||||||
|
identified: false,
|
||||||
|
stats: [
|
||||||
|
{ text: '未辨识 (Unidentified)', color: 'red' },
|
||||||
|
{ text: '+1 所有技能', color: 'blue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Unidentified item cannot be equipped
|
||||||
|
inv.cursorItem = inv.gridItems.find(p => p.item.id === 'unid_ring_1')!.item
|
||||||
|
expect(inv.clickEquipSlot('ring1')).toBe(false)
|
||||||
|
inv.cursorItem = null
|
||||||
|
|
||||||
|
const ibkPt = cellCenter(2, 2)
|
||||||
|
expect(inv.isIdentifyMode()).toBe(false)
|
||||||
|
// Left-Click 'ibk' picks it up (does NOT enter Identify Mode)
|
||||||
|
expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true)
|
||||||
|
expect(inv.isIdentifyMode()).toBe(false)
|
||||||
|
expect((inv.cursorItem as { code?: string } | null)?.code).toBe('ibk')
|
||||||
|
expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true)
|
||||||
|
|
||||||
|
// Right-Click 'ibk' enters Identify Mode
|
||||||
|
expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 2)).toBe(true)
|
||||||
|
expect(inv.isIdentifyMode()).toBe(true)
|
||||||
|
|
||||||
|
// Left-Click unidentified ring in Inventory identifies it and decrements 'ibk' quantity (20 -> 19)
|
||||||
|
expect(inv.handleClick(unidRingPt.x, unidRingPt.y, undefined, 0)).toBe(true)
|
||||||
|
expect(inv.isIdentifyMode()).toBe(false)
|
||||||
|
const idRing = inv.gridItems.find(p => p.item.id === 'unid_ring_1')!.item
|
||||||
|
expect(idRing.identified).toBe(true)
|
||||||
|
expect(idRing.stats.some(s => s.text.includes('未辨识'))).toBe(false)
|
||||||
|
const ibkPlaced = inv.gridItems.find(p => p.item.code === 'ibk')!
|
||||||
|
expect(ibkPlaced.item.quantity).toBe(19)
|
||||||
|
|
||||||
|
// Left-Click Scroll of Identify ('isc') onto 'ibk' stacks quantity (19 -> 20)
|
||||||
|
inv.cursorItem = {
|
||||||
|
id: 'scroll_id_1',
|
||||||
|
code: 'isc',
|
||||||
|
name: 'Scroll of Identify',
|
||||||
|
nameZh: '辨识卷轴',
|
||||||
|
baseNameZh: '辨识卷轴',
|
||||||
|
quality: 'normal',
|
||||||
|
invFile: 'invscrol',
|
||||||
|
invWidth: 1,
|
||||||
|
invHeight: 1,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [{ text: '右键点击辨识未鉴定物品', color: 'white' }],
|
||||||
|
}
|
||||||
|
expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
expect(ibkPlaced.item.quantity).toBe(20)
|
||||||
|
|
||||||
|
// 7. Right-Click on normal equipment ('crs' at 8,0) does NOT pick it up onto cursorItem
|
||||||
|
const crsPt = cellCenter(8, 0)
|
||||||
|
expect(inv.handleClick(crsPt.x, crsPt.y, undefined, 2)).toBe(false)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
|
||||||
|
// 8. Horadric Cube 3x4 grid placement and pickup (`CUBE_GRID_ORIGIN`)
|
||||||
|
expect(CUBE_GRID_ORIGIN).toEqual({ x: 80 + 117, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 })
|
||||||
|
// Cannot place Horadric Cube ('box') inside Horadric Cube
|
||||||
|
const boxItem = inv.gridItems.find(p => p.item.code === 'box')!.item
|
||||||
|
expect(worldPanels.clickCubeCell(0, 0, boxItem).handled).toBe(false)
|
||||||
|
|
||||||
|
// Can place identified ring into Horadric Cube (0, 0) and pick it back up via Left-Click
|
||||||
|
inv.cursorItem = idRing
|
||||||
|
const cubeCell00 = {
|
||||||
|
x: CUBE_GRID_ORIGIN.x + 10,
|
||||||
|
y: CUBE_GRID_ORIGIN.y + 10,
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
worldPanels.handleLeftDockClick('cube', cubeCell00.x, cubeCell00.y, {
|
||||||
|
onClose: () => {},
|
||||||
|
onWaypointTeleport: () => {},
|
||||||
|
inventory: inv,
|
||||||
|
isRightClick: false,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(inv.cursorItem).toBeNull()
|
||||||
|
expect(worldPanels.cubeItems).toHaveLength(1)
|
||||||
|
expect(worldPanels.cubeItems[0]!.item.id).toBe('unid_ring_1')
|
||||||
|
|
||||||
|
// Left-Click again picks it back up from Cube
|
||||||
|
expect(
|
||||||
|
worldPanels.handleLeftDockClick('cube', cubeCell00.x, cubeCell00.y, {
|
||||||
|
onClose: () => {},
|
||||||
|
onWaypointTeleport: () => {},
|
||||||
|
inventory: inv,
|
||||||
|
isRightClick: false,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect((inv.cursorItem as { id?: string } | null)?.id).toBe('unid_ring_1')
|
||||||
|
expect(worldPanels.cubeItems).toHaveLength(0)
|
||||||
|
|
||||||
|
// Stash grid dimensions check
|
||||||
|
expect(STASH_GRID_ORIGIN.cols).toBe(6)
|
||||||
|
expect(STASH_GRID_ORIGIN.rows).toBe(8)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue