diff --git a/public/overlays/town_portal_on_hd.json b/public/overlays/town_portal_on_hd.json new file mode 100644 index 0000000..58d4ee1 --- /dev/null +++ b/public/overlays/town_portal_on_hd.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/overlays/town_portal_on_hd.png b/public/overlays/town_portal_on_hd.png new file mode 100644 index 0000000..cf4a78c Binary files /dev/null and b/public/overlays/town_portal_on_hd.png differ diff --git a/public/overlays/town_portal_on_tr.json b/public/overlays/town_portal_on_tr.json new file mode 100644 index 0000000..c262263 --- /dev/null +++ b/public/overlays/town_portal_on_tr.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/overlays/town_portal_on_tr.png b/public/overlays/town_portal_on_tr.png new file mode 100644 index 0000000..8b4ca97 Binary files /dev/null and b/public/overlays/town_portal_on_tr.png differ diff --git a/public/overlays/town_portal_op_hd.json b/public/overlays/town_portal_op_hd.json new file mode 100644 index 0000000..3103ff6 --- /dev/null +++ b/public/overlays/town_portal_op_hd.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/overlays/town_portal_op_hd.png b/public/overlays/town_portal_op_hd.png new file mode 100644 index 0000000..f5934a8 Binary files /dev/null and b/public/overlays/town_portal_op_hd.png differ diff --git a/public/overlays/town_portal_op_tr.json b/public/overlays/town_portal_op_tr.json new file mode 100644 index 0000000..b7a8ff2 --- /dev/null +++ b/public/overlays/town_portal_op_tr.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/overlays/town_portal_op_tr.png b/public/overlays/town_portal_op_tr.png new file mode 100644 index 0000000..f29c540 Binary files /dev/null and b/public/overlays/town_portal_op_tr.png differ diff --git a/scripts/pack-overlays.ts b/scripts/pack-overlays.ts index 6117777..5bce56b 100644 --- a/scripts/pack-overlays.ts +++ b/scripts/pack-overlays.ts @@ -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 = {} 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) diff --git a/src/game/class-starter-profiles.ts b/src/game/class-starter-profiles.ts index e01684b..be7d164 100644 --- a/src/game/class-starter-profiles.ts +++ b/src/game/class-starter-profiles.ts @@ -1376,6 +1376,7 @@ export function createStarterBagForClass(classCode: CharacterClassCode): GridPla invWidth: 1, invHeight: 2, allowedSlots: [], + quantity: 20, stats: [{ text: '右键施放城镇传送门', color: 'white' }], }, }, @@ -1393,6 +1394,7 @@ export function createStarterBagForClass(classCode: CharacterClassCode): GridPla invWidth: 1, invHeight: 2, allowedSlots: [], + quantity: 20, stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }], }, }, diff --git a/src/game/portal.ts b/src/game/portal.ts index f59db5c..d55688d 100644 --- a/src/game/portal.ts +++ b/src/game/portal.ts @@ -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> = { + '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> = { + 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' + diff --git a/src/render/overlays-meta.ts b/src/render/overlays-meta.ts index 122b343..290aa46 100644 --- a/src/render/overlays-meta.ts +++ b/src/render/overlays-meta.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> = { @@ -23223,4 +23823,12 @@ export const OVERLAY_METAS: Readonly> = { "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, } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 594cce7..12fcafc 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 + /** 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 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 { 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, +): 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 = { 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() + 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('#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 diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index d123a8c..e329bb1 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -295,7 +295,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 }, @@ -917,16 +917,21 @@ export class HudManager { } } - 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) - } + this.inventory.handleClick( + rightX, + pt.y, + { + onOpenCube: () => this.toggleLeftPanel('cube'), + onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), + onDropGold: (amt) => { + const dropped = this.inventory.dropGold(amt) + if (dropped > 0) { + this.callbacks.onDropGoldToGround?.(dropped, pt) + } + }, }, - }) + e.button === 2 ? 2 : 0, + ) if (!this.inventory.visible) this.rightPanel = 'none' this.syncPublishedState() return diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index dae28b2..7da95a5 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -67,6 +67,7 @@ export interface UiInventoryItem { readonly repairCost?: number | undefined readonly permStore?: boolean | undefined readonly isGambleMystery?: boolean | undefined + readonly quantity?: number | undefined } export interface GridPlacement { @@ -75,6 +76,29 @@ export interface GridPlacement { readonly row: number } +export function getTomeQuantity(item: UiInventoryItem): number { + if (typeof item.quantity === 'number' && Number.isFinite(item.quantity)) { + return Math.max(0, Math.min(20, Math.floor(item.quantity))) + } + const match = item.baseNameZh?.match(/数量:\s*(\d+)/) + if (match) { + return Math.max(0, Math.min(20, Number.parseInt(match[1]!, 10))) + } + return 20 +} + +export function withTomeQuantity(item: UiInventoryItem, quantity: number): UiInventoryItem { + const clamped = Math.max(0, Math.min(20, Math.floor(quantity))) + const baseNameZh = item.baseNameZh?.includes('数量:') + ? item.baseNameZh.replace(/数量:\s*\d+/, `数量: ${clamped}`) + : `法术书 (数量: ${clamped})` + return { + ...item, + quantity: clamped, + baseNameZh, + } +} + /** * Find the first available (col, row) slot for an item in a grid without overlaps. */ @@ -816,6 +840,7 @@ export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [ invWidth: 1, invHeight: 2, allowedSlots: [], + quantity: 20, stats: [{ text: '右键施放城镇传送门', color: 'white' }], }, }, @@ -833,6 +858,7 @@ export const STARTER_BAG_ITEMS: readonly GridPlacement[] = [ invWidth: 1, invHeight: 2, allowedSlots: [], + quantity: 20, stats: [{ text: '鉴定未辨识的魔法/暗金装备', color: 'white' }], }, }, @@ -1025,7 +1051,7 @@ export class InventoryPanel { /** * 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 held: place it if 0 overlaps, or swap if exactly 1 overlap! + * - If `cursorItem` is held: merge scroll into tome if applicable, place if 0 overlaps, or swap if 1 overlap. */ clickGridCell(col: number, row: number): boolean { if (!this.cursorItem) { @@ -1048,6 +1074,19 @@ export class InventoryPanel { } if (overlaps.length === 1) { const single = overlaps[0]! + // Authentic 1.13c Tome Refill: left-clicking a Scroll (tsc/isc) onto its matching Tome (tbk/ibk) with < 20 charges + if ( + (this.cursorItem.code === 'tsc' && single.item.code === 'tbk') || + (this.cursorItem.code === 'isc' && single.item.code === 'ibk') + ) { + const qty = getTomeQuantity(single.item) + if (qty < 20) { + const updated = withTomeQuantity(single.item, qty + 1) + this.gridItems = this.gridItems.map(p => (p === single ? { ...single, item: updated } : p)) + this.cursorItem = null + return true + } + } this.gridItems = this.gridItems.filter(p => p !== single) this.gridItems.push({ item: this.cursorItem, col: targetCol, row: targetRow }) this.cursorItem = single.item @@ -1117,9 +1156,10 @@ 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, ): boolean { if (!this.visible) return false if (logicalX < 400) return false @@ -1160,18 +1200,45 @@ export class InventoryPanel { ) { 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( + const hitIdx = this.gridItems.findIndex( 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() + const hit = hitIdx >= 0 ? this.gridItems[hitIdx] : undefined + if (button === 2 && !this.cursorItem && hit) { + if (hit.item.code === 'box') { + callbacks.onOpenCube() + return true + } + if (hit.item.code === 'tbk') { + const qty = getTomeQuantity(hit.item) + if (qty <= 0) { + return true + } + const castOk = callbacks.onCastTownPortal('tbk', hit.item) + if (castOk !== false) { + const updated = withTomeQuantity(hit.item, qty - 1) + this.gridItems[hitIdx] = { ...hit, item: updated } + if (this.hoveredItem?.item.id === hit.item.id) { + this.hoveredItem = { ...this.hoveredItem, item: updated } + } + } + return true + } + if (hit.item.code === 'tsc') { + const castOk = callbacks.onCastTownPortal('tsc', hit.item) + if (castOk !== false) { + this.gridItems.splice(hitIdx, 1) + if (this.hoveredItem?.item.id === hit.item.id) { + this.hoveredItem = null + } + } + return true + } return true } - if (!this.cursorItem && hit?.item.code === 'tbk') { - callbacks.onCastTownPortal() - return true + if (button === 0) { + this.clickGridCell(col, row) } - this.clickGridCell(col, row) return true } diff --git a/tests/town-portal.test.ts b/tests/town-portal.test.ts new file mode 100644 index 0000000..223dc4a --- /dev/null +++ b/tests/town-portal.test.ts @@ -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(true) + 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) + }) +})