Merge pull request 'feat(portal): 实装回城之书/卷轴、1.13c 城镇传送门坐标与双向传送 (#394)' (#488) from feat/issue-394-town-portal into main
This commit is contained in:
commit
e29d743f38
|
|
@ -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,
|
||||
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
|
||||
|
||||
export async function bakeOverlays(
|
||||
|
|
@ -1549,7 +1590,10 @@ export async function bakeOverlays(
|
|||
const metaRecord: Record<string, OverlayMeta> = {}
|
||||
|
||||
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`)
|
||||
|
||||
const dcc = decodeDcc(raw)
|
||||
|
|
|
|||
|
|
@ -156,6 +156,81 @@ export interface OpenPortal {
|
|||
/** The mouth in town, in sub-tiles. */
|
||||
readonly townX: 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.
|
||||
* @returns the other mouth, or null when this level has no mouth.
|
||||
|
|
@ -211,6 +316,31 @@ export class TownPortalSlot {
|
|||
}
|
||||
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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
export const MIGHT_AURA_META: OverlayMeta = MIGHT_FRONT_AURA_META
|
||||
|
|
@ -22613,6 +23209,10 @@ export const ALL_OVERLAY_NAMES: readonly string[] = [
|
|||
"direwolfcharged",
|
||||
"blade_shield",
|
||||
"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>> = {
|
||||
|
|
@ -23223,4 +23823,12 @@ export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
|
|||
"expansion/dethsentry01": DEATHSENTRY_EXPLODE_META,
|
||||
"deathsentry": 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 { SEAM_TRIGGER_SUBTILES, WARP_TRIGGER_SUBTILES } from '../game/level-links.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 { Minimap } from '../ui/minimap.ts'
|
||||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||
|
|
@ -360,6 +360,8 @@ export interface ActSceneState {
|
|||
eliteMultiplier?: EliteMultiplier | undefined
|
||||
/** Dynamic monster re-budget and re-stream method without page reload. */
|
||||
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
||||
/** Active player Town Portal (if open). */
|
||||
portal?: OpenPortal | null | undefined
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
@ -374,6 +376,9 @@ declare global {
|
|||
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
||||
setPlayers?: (count: number, partyCount?: number) => void
|
||||
executeCommand?: (cmd: string) => boolean
|
||||
castTownPortal?: () => boolean
|
||||
enterTownPortal?: () => void
|
||||
getPortal?: () => OpenPortal | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1521,6 +1526,8 @@ export function getSkillRange(
|
|||
case 62: return 450
|
||||
case 63: return 0
|
||||
case 64: return 480
|
||||
case 219:
|
||||
case 220: return 0
|
||||
default: return 450
|
||||
}
|
||||
}
|
||||
|
|
@ -1564,6 +1571,8 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
|||
case 57: return 19
|
||||
case 58: return 5
|
||||
case 63: return 0
|
||||
case 219:
|
||||
case 220: return 0
|
||||
default: return 10
|
||||
}
|
||||
}
|
||||
|
|
@ -1586,6 +1595,9 @@ export function castSkill(
|
|||
hasAmmo?: boolean | undefined
|
||||
waypointNetwork?: any | undefined
|
||||
openStash?: ((stash: any) => void) | undefined
|
||||
portalSlot?: TownPortalSlot | undefined
|
||||
onEnterPortal?: (() => void) | undefined
|
||||
onCastTownPortal?: (() => boolean) | undefined
|
||||
},
|
||||
): boolean {
|
||||
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
|
||||
player.cooldown = 0
|
||||
engine.metrics.casts += 1
|
||||
|
|
@ -2009,6 +2038,18 @@ export function castSkill(
|
|||
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) {
|
||||
// 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)
|
||||
|
|
@ -3277,6 +3318,7 @@ export type PendingInteraction =
|
|||
| { readonly kind: 'npc'; readonly npc: NpcEntity }
|
||||
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
|
||||
| { 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: 'ground-item'; readonly item: GroundItemEntity }
|
||||
|
||||
|
|
@ -3298,6 +3340,9 @@ export interface SceneMouseControllerOptions {
|
|||
readonly status: HTMLElement
|
||||
readonly playerAnimator?: ActorAnimator | 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 playerAnimator?: ActorAnimator | 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
|
||||
pendingInteraction: PendingInteraction | null = null
|
||||
activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'monster' = 'none'
|
||||
activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'portal' | 'monster' = 'none'
|
||||
isLeftMouseDown = false
|
||||
isRightMouseDown = false
|
||||
lastClientPos: { x: number; y: number } | null = null
|
||||
|
|
@ -3419,6 +3467,9 @@ export class SceneMouseController {
|
|||
this.status = options.status
|
||||
this.playerAnimator = options.playerAnimator
|
||||
this.getCharacter = options.getCharacter
|
||||
this.portalSlot = options.portalSlot
|
||||
this.onEnterPortal = options.onEnterPortal
|
||||
this.onCastTownPortal = options.onCastTownPortal
|
||||
;(this.engine as any).dialog = null
|
||||
}
|
||||
|
||||
|
|
@ -3847,6 +3898,30 @@ export class SceneMouseController {
|
|||
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
|
||||
let clickedMonster: Monster | null = null
|
||||
let bestMonDist = 36
|
||||
|
|
@ -3931,6 +4006,9 @@ export class SceneMouseController {
|
|||
hasAmmo: options?.hasAmmo,
|
||||
waypointNetwork: this.waypointNetwork,
|
||||
openStash: (s: any) => this.openStash(s),
|
||||
portalSlot: this.portalSlot,
|
||||
onEnterPortal: this.onEnterPortal,
|
||||
onCastTownPortal: this.onCastTownPortal,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -4053,6 +4131,17 @@ export class SceneMouseController {
|
|||
} else {
|
||||
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') {
|
||||
const monster = this.pendingInteraction.monster
|
||||
if (monster.state === 'dead' || monster.hp <= 0) {
|
||||
|
|
@ -4371,13 +4460,25 @@ async function loadRuntimeForLevel(
|
|||
viewport: { width: number; height: number },
|
||||
charBases: readonly string[],
|
||||
fromSide?: Side,
|
||||
preferredVariantLabel?: string,
|
||||
): Promise<MapRuntime | null> {
|
||||
const index = await getPackIndex(packBase)
|
||||
if (index === null) return null
|
||||
const candidates = index.levels.filter(entry => entry.levelId === levelId)
|
||||
if (candidates.length === 0) return null
|
||||
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
|
||||
return await buildPackRuntime(packBase, index, entry, viewport, charBases)
|
||||
}
|
||||
|
|
@ -5884,6 +5985,64 @@ export function drawCastOverlay(
|
|||
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. */
|
||||
export const GROUND_ITEM_QUALITY_COLORS: Record<string, readonly [number, number, number, number]> = {
|
||||
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 waypointNetwork = new WaypointNetwork()
|
||||
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')
|
||||
hudManager = hudCanvas !== null
|
||||
? new HudManager(hudCanvas, {
|
||||
|
|
@ -6644,9 +6880,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
onWaypointTeleport: (targetAct, slug, levelId) => {
|
||||
void teleportViaWaypoint(targetAct, slug, levelId)
|
||||
},
|
||||
onCastTownPortal: () => {
|
||||
status.textContent = `已通过回城之书开启返回 ${ACT_NAMES_ZH[runtime.act] ?? '营地'} 的传送门`
|
||||
},
|
||||
onCastTownPortal: () => castTownPortal(),
|
||||
onDropItemToGround: (item) => {
|
||||
const player = engine.world.player
|
||||
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({
|
||||
canvas,
|
||||
engine,
|
||||
|
|
@ -6688,6 +6950,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
status,
|
||||
playerAnimator,
|
||||
getCharacter: () => character,
|
||||
portalSlot,
|
||||
onEnterPortal: enterTownPortal,
|
||||
onCastTownPortal: castTownPortal,
|
||||
})
|
||||
mouseController.attach()
|
||||
if (groundLabelOverlay !== null) {
|
||||
|
|
@ -6702,11 +6967,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
(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.
|
||||
*
|
||||
|
|
@ -6726,20 +6986,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
}
|
||||
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.
|
||||
*
|
||||
|
|
@ -6760,6 +7006,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
monsterArtMap = new Map()
|
||||
|
||||
runtime = next
|
||||
if (isTownLevel(next.levelId) && next.variant) {
|
||||
townVariantByAct.set(next.act, next.variant)
|
||||
}
|
||||
floorBounds = buildBounds(next.floors)
|
||||
shadowBounds = buildBounds(next.shadows ?? [])
|
||||
wallBounds = buildBounds(next.walls)
|
||||
|
|
@ -6934,6 +7183,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
{ width: canvas.width, height: canvas.height },
|
||||
runtime.charBases,
|
||||
fromSide,
|
||||
isTownLevel(toLevelId) ? townVariantByAct.get(runtime.act) : undefined,
|
||||
)
|
||||
if (next === null) {
|
||||
status.textContent = `资源包里没有关卡 ${String(toLevelId)}。`
|
||||
|
|
@ -6947,13 +7197,25 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
// that no edge describes.
|
||||
const back = next.entrances.find(entrance => entrance.toLevelId === fromLevelId)
|
||||
?? next.warps.find(warp => warp.toLevelId === fromLevelId)
|
||||
const landing = override
|
||||
const rawLanding = override
|
||||
?? (back === undefined
|
||||
? {
|
||||
x: Math.floor(next.grid.cellsX * SUB_TILES_PER_TILE / 2),
|
||||
y: Math.floor(next.grid.cellsY * SUB_TILES_PER_TILE / 2),
|
||||
}
|
||||
: { 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)
|
||||
preloadRemaining(next, renderer)
|
||||
} catch (err) {
|
||||
|
|
@ -7058,14 +7320,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
|
||||
const wantsUse = input.takeUse()
|
||||
if (wantsUse) {
|
||||
const portalTarget = portalSlot.otherEnd(runtime.levelId)
|
||||
for (const warp of runtime.warps) {
|
||||
if (!near(warp.x, warp.y, WARP_TRIGGER_SUBTILES)) continue
|
||||
void travel(warp.toLevelId, WARP_FADE_MS)
|
||||
return
|
||||
}
|
||||
if (portalTarget !== null) {
|
||||
void travel(portalTarget.levelId, WARP_FADE_MS, { x: portalTarget.x, y: portalTarget.y })
|
||||
const activeMouth = portalSlot.mouthInLevel(runtime.levelId)
|
||||
if (activeMouth !== null && near(activeMouth.x, activeMouth.y, WARP_TRIGGER_SUBTILES)) {
|
||||
enterTownPortal()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -7103,19 +7365,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
}
|
||||
|
||||
if (input.takePortal()) {
|
||||
const townLevelId = townLevelForAct(runtime.act)
|
||||
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 返回。`
|
||||
}
|
||||
castTownPortal()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185)
|
||||
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
|
||||
for (const gItem of engine.groundItems.all) {
|
||||
pushEntity(gItem.x, gItem.y, () => {
|
||||
|
|
@ -7857,12 +8133,42 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
labelsContainer.appendChild(label)
|
||||
}
|
||||
if (label.textContent !== npc.def.name) label.textContent = npc.def.name
|
||||
label.style.color = '#c8a15a'
|
||||
label.style.left = `${domX}px`
|
||||
label.style.top = `${domY - 2}px`
|
||||
label.style.fontSize = fontSizePx
|
||||
label.hidden = false
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -7899,6 +8205,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
}
|
||||
}
|
||||
|
||||
state.portal = portalSlot.current()
|
||||
state.drawCalls = renderer.drawCalls
|
||||
state.quadsDrawn = renderer.quadsSubmitted
|
||||
state.culledDraws = culledDraws
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ export class HudManager {
|
|||
private readonly callbacks: {
|
||||
onToggleAutomap: () => 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
|
||||
onDropGoldToGround?: (amount: number, mousePos?: { x: number; y: number }) => void
|
||||
},
|
||||
|
|
@ -873,7 +873,7 @@ export class HudManager {
|
|||
this.callbacks.onWaypointTeleport(act, slug, levelId)
|
||||
},
|
||||
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
||||
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
|
||||
inventory: this.inventory,
|
||||
isShiftClick: e.shiftKey,
|
||||
isRightClick: e.button === 2,
|
||||
|
|
@ -973,7 +973,7 @@ export class HudManager {
|
|||
pt.y,
|
||||
{
|
||||
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
||||
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
|
||||
onDropGold: (amt) => {
|
||||
const dropped = this.inventory.dropGold(amt)
|
||||
if (dropped > 0) {
|
||||
|
|
@ -981,7 +981,7 @@ export class HudManager {
|
|||
}
|
||||
},
|
||||
},
|
||||
e.button as 0 | 2,
|
||||
e.button === 2 ? 2 : 0,
|
||||
{
|
||||
stashItems: this.worldPanels.stashItems,
|
||||
cubeItems: this.worldPanels.cubeItems,
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,11 @@ export function setTomeQuantity(item: UiInventoryItem, newQty: number): UiInvent
|
|||
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 ||
|
||||
|
|
@ -1357,7 +1362,7 @@ export class InventoryPanel {
|
|||
containerList: GridPlacement[],
|
||||
callbacks?: {
|
||||
onOpenCube?: () => void
|
||||
onCastTownPortal?: () => void
|
||||
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||
},
|
||||
): boolean {
|
||||
if (this.cursorItem !== null) return false
|
||||
|
|
@ -1369,19 +1374,26 @@ export class InventoryPanel {
|
|||
if (code === 'tbk') {
|
||||
const qty = getTomeQuantity(placed.item)
|
||||
if (qty <= 0) return false
|
||||
setTomeQuantity(placed.item, qty - 1)
|
||||
callbacks?.onCastTownPortal?.()
|
||||
const castOk = callbacks?.onCastTownPortal?.('tbk', placed.item)
|
||||
if (castOk !== false) {
|
||||
setTomeQuantity(placed.item, qty - 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (code === 'tsc') {
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
callbacks?.onCastTownPortal?.()
|
||||
return true
|
||||
}
|
||||
if (code === 'ibk' || code === 'isc') {
|
||||
|
|
@ -1443,7 +1455,7 @@ export class InventoryPanel {
|
|||
logicalY: number,
|
||||
callbacks?: {
|
||||
onOpenCube?: () => void
|
||||
onCastTownPortal?: () => void
|
||||
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||
onDropGold?: (amount: number) => void
|
||||
},
|
||||
button: 0 | 2 = 0,
|
||||
|
|
|
|||
|
|
@ -1477,7 +1477,7 @@ export class WorldPanelsHud {
|
|||
onClose: () => void
|
||||
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
|
||||
onOpenCube?: () => void
|
||||
onCastTownPortal?: () => void
|
||||
onCastTownPortal?: (source?: 'tbk' | 'tsc', item?: UiInventoryItem) => boolean | void
|
||||
inventory?: InventoryPanel
|
||||
isShiftClick?: boolean
|
||||
isRightClick?: boolean
|
||||
|
|
@ -1581,7 +1581,7 @@ export class WorldPanelsHud {
|
|||
if (hit && isUsableRightClickItem(hit.item.code)) {
|
||||
callbacks.inventory.usePlacedItemByRightClick(hit, 'cube', this.cubeItems, {
|
||||
onOpenCube: () => callbacks.onOpenCube?.(),
|
||||
onCastTownPortal: () => callbacks.onCastTownPortal?.(),
|
||||
onCastTownPortal: (source, item) => callbacks.onCastTownPortal?.(source, item),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
|
@ -1671,7 +1671,7 @@ export class WorldPanelsHud {
|
|||
if (hit && isUsableRightClickItem(hit.item.code)) {
|
||||
callbacks.inventory.usePlacedItemByRightClick(hit, 'stash', this.stashItems, {
|
||||
onOpenCube: () => callbacks.onOpenCube?.(),
|
||||
onCastTownPortal: () => callbacks.onCastTownPortal?.(),
|
||||
onCastTownPortal: (source, item) => callbacks.onCastTownPortal?.(source, item),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue