From 102d001de1ea0e87cb7f995b786d0c16f27018c8 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 08:02:56 +0000 Subject: [PATCH 01/15] fix(automap): sync minimap/automap active level with main map on area transitions (Fixes #549) --- src/client/automap/automap-view.ts | 105 ++++++-- src/client/input/command-mapper.ts | 17 +- src/client/scene/world-renderer.ts | 14 +- src/client/session/online-session.ts | 93 ++++++- src/client/ui/minimap.ts | 8 + tests/client/minimap-level-switch-549.test.ts | 244 ++++++++++++++++++ 6 files changed, 437 insertions(+), 44 deletions(-) create mode 100644 tests/client/minimap-level-switch-549.test.ts diff --git a/src/client/automap/automap-view.ts b/src/client/automap/automap-view.ts index 230aa59..50fe585 100644 --- a/src/client/automap/automap-view.ts +++ b/src/client/automap/automap-view.ts @@ -288,6 +288,25 @@ export class AutomapView { return this.levelViews.get(areaId)! } + /** + * Switch the active level (`currentAreaId` / `currentActId`) within the current game session + * without clearing `levelViews` or `serverRevealedRooms` (#549). + */ + syncActiveLevel(areaId: number, existingLevelView?: LevelView): LevelView { + if (areaId > 0) { + this.currentAreaId = areaId + this.currentActId = getActForArea(areaId) + } + const targetAreaId = this.currentAreaId + if (existingLevelView && existingLevelView.areaId === targetAreaId) { + this.levelViews.set(targetAreaId, existingLevelView) + } else if (!this.levelViews.has(targetAreaId)) { + this.ensureLevelView(targetAreaId) + } + this.applyRevealModeToViews() + return this.levelViews.get(targetAreaId)! + } + /** * Record a server `0x07 AddMapRoom` event. */ @@ -498,36 +517,62 @@ export class AutomapView { /** * Build a `MinimapLevel` descriptor suitable for `Minimap.draw(...)` (`src/ui/minimap.ts`). */ - buildMinimapLevel(lang: TblLang = getTblLang()): MinimapLevel { + buildMinimapLevel(lang: TblLang = getTblLang(), areaId?: number): MinimapLevel { + if (areaId !== undefined && areaId > 0 && areaId !== this.currentAreaId) { + this.syncActiveLevel(areaId) + } const snap = this.buildSnapshot([], lang) const view = this.ensureLevelView(this.currentAreaId) const allRooms = view.rooms let minCellX = 0 let minCellY = 0 - let maxCellX = 40 - let maxCellY = 40 - if (allRooms.length > 0) { - minCellX = Math.min(...allRooms.map(r => r.tileX)) - minCellY = Math.min(...allRooms.map(r => r.tileY)) - maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW)) - maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH)) - } + let cellsX = 40 + let cellsY = 40 + let subW = 200 + let blocked: Uint8Array + let originX = 0 + let originY = 0 - const cellsX = Math.max(1, maxCellX - minCellX) - const cellsY = Math.max(1, maxCellY - minCellY) - const subW = cellsX * 5 - const subH = cellsY * 5 - const blocked = new Uint8Array(subW * subH) - - for (let sy = 0; sy < subH; sy++) { - for (let sx = 0; sx < subW; sx++) { - const absSubX = minCellX * 5 + sx - const absSubY = minCellY * 5 + sy - if (!isWalkableWorldSubTile(view, absSubX, absSubY)) { - blocked[sy * subW + sx] = 1 + if ( + view.cellsX > 0 && + view.cellsY > 0 && + view.blocked && + view.blocked.length === view.cellsX * 5 * view.cellsY * 5 + ) { + minCellX = view.tileOriginX ?? Math.floor((view.subOriginX ?? 0) / 5) + minCellY = view.tileOriginY ?? Math.floor((view.subOriginY ?? 0) / 5) + cellsX = view.cellsX + cellsY = view.cellsY + subW = view.gridWidth || cellsX * 5 + blocked = view.blocked + originX = view.isoOriginX ?? (minCellX - minCellY) * 80 + originY = view.isoOriginY ?? (minCellX + minCellY) * 40 + } else { + let maxCellX = 40 + let maxCellY = 40 + if (allRooms.length > 0) { + minCellX = Math.min(...allRooms.map(r => r.tileX)) + minCellY = Math.min(...allRooms.map(r => r.tileY)) + maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW)) + maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH)) + } + cellsX = Math.max(1, maxCellX - minCellX) + cellsY = Math.max(1, maxCellY - minCellY) + subW = cellsX * 5 + const subH = cellsY * 5 + blocked = new Uint8Array(subW * subH) + for (let sy = 0; sy < subH; sy++) { + for (let sx = 0; sx < subW; sx++) { + const absSubX = minCellX * 5 + sx + const absSubY = minCellY * 5 + sy + if (!isWalkableWorldSubTile(view, absSubX, absSubY)) { + blocked[sy * subW + sx] = 1 + } } } + originX = (minCellX - minCellY) * 80 + originY = (minCellX + minCellY) * 40 } const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({ @@ -538,6 +583,20 @@ export class AutomapView { targetName: wm.label, })) + if (Array.isArray(view.entrances)) { + for (const ent of view.entrances) { + if (ent.toLevelId > 0 && ent.toLevelId !== this.currentAreaId) { + markers.push({ + cellX: ent.x, + cellY: ent.y, + kind: 'exit', + toLevelId: ent.toLevelId, + targetName: levelDisplayName(ent.toLevelId, lang), + }) + } + } + } + return { key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`, cellsX, @@ -545,8 +604,8 @@ export class AutomapView { blocked, gridWidth: subW, markers, - originX: (minCellX - minCellY) * 80, - originY: (minCellX + minCellY) * 40, + originX, + originY, levelId: this.currentAreaId, levelName: levelDisplayName(this.currentAreaId, lang), ...(view.automap !== undefined ? { automap: view.automap } : {}), diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 8ef5dda..b021064 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -897,13 +897,22 @@ export class CommandMapper { return this.lastPath } + const snap = this.predictor.getSnapshot() + const sx = Math.round(snap.logicalX) + const sy = Math.round(snap.logicalY) const levelView = - this.mapService?.getActiveLevelView() ?? this.automapView?.getLevelView() ?? null + (this.mapService && this.world.areaId > 0 && (sx !== 0 || sy !== 0) + ? this.mapService.findLevelViewAtWorldSubTile(sx, sy, [ + this.world.areaId, + ...this.world.getRevealedAreaIds(), + ]) + : null) ?? + (this.world.areaId > 0 ? this.automapView?.getLevelView(this.world.areaId) : undefined) ?? + this.mapService?.getActiveLevelView() ?? + this.automapView?.getLevelView() ?? + null if (levelView && levelView.walkable.length > 0) { - const snap = this.predictor.getSnapshot() - const sx = Math.round(snap.logicalX) - const sy = Math.round(snap.logicalY) const ox = levelView.subOriginX ?? 0 const oy = levelView.subOriginY ?? 0 const inBounds = diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index e7680d2..20b45b0 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -927,11 +927,19 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram ctx.minimap.visible = false } else { ctx.minimap.visible = true - const baseMinimapLevel = ctx.automapView.buildMinimapLevel() + if (levelView && levelView.areaId > 0) { + ctx.automapView.syncActiveLevel(levelView.areaId, levelView) + } else if (snapshot.levelId > 0 && ctx.automapView.getCurrentAreaId() !== snapshot.levelId) { + ctx.automapView.syncActiveLevel(snapshot.levelId) + } + const baseMinimapLevel = ctx.automapView.buildMinimapLevel( + undefined, + levelView?.areaId ?? snapshot.levelId, + ) const effectiveMinimapLevel: MinimapLevel = { ...baseMinimapLevel, - originX: levelView?.isoOriginX ?? 0, - originY: levelView?.isoOriginY ?? 0, + originX: levelView?.isoOriginX ?? baseMinimapLevel.originX ?? 0, + originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0, preRevealTown: isTownLevel(snapshot.levelId), } const localSub = snapshot.playerLocalSubTile diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..abc30c7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -270,6 +270,7 @@ export class OnlineSession { private disconnectReasonValue: string | null = null private pingMsValue = 0 private tickCounter = 0 + private readonly knownAreaIds = new Set() private readonly outboundCommandsLog: ClientCommand[] = [] private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>() @@ -373,17 +374,11 @@ export class OnlineSession { // such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG // canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a // level exists the scene reports not-ready. + // #549: resolving the active levelView also keeps `world.areaId` and `automapView` in lockstep. const predSnap = this.predictor.getSnapshot() const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y - const levelView = - this.levelView ?? - (this.mapService && this.world.areaId > 0 && (playerSubX !== 0 || playerSubY !== 0) - ? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, [ - this.world.areaId, - ...this.world.getRevealedAreaIds(), - ]) - : null) + const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0) return { tick: this.tickCounter, @@ -694,6 +689,7 @@ export class OnlineSession { const savedName = this.world.self.name const savedClass = this.world.self.charClass this.world.reset() + this.knownAreaIds.clear() this.world.self.name = savedName this.world.self.charClass = savedClass this.predictor.setPosition(0, 0) @@ -921,6 +917,66 @@ export class OnlineSession { } } + private getCandidateAreaIds(): number[] { + const out: number[] = [] + if (this.world.areaId > 0) { + out.push(this.world.areaId) + } + for (const id of this.world.getRevealedAreaIds()) { + if (id > 0 && !out.includes(id)) { + out.push(id) + } + } + for (const id of this.knownAreaIds) { + if (id > 0 && !out.includes(id)) { + out.push(id) + } + } + return out + } + + /** + * Resolve the `LevelView` containing the local player's current world sub-tile and synchronize + * `world.areaId` and `automapView` whenever the player crosses into a different area (#549). + */ + private syncActiveAreaFromPlayerPosition(options?: { + readonly emitHudSync?: boolean + }): LevelView | null { + const predSnap = this.predictor.getSnapshot() + const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x + const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y + const candidateIds = this.getCandidateAreaIds() + const levelView = + this.levelView ?? + (this.mapService && candidateIds.length > 0 && (playerSubX !== 0 || playerSubY !== 0) + ? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, candidateIds) + : null) + + if (levelView && levelView.areaId > 0) { + if (this.world.areaId > 0) { + this.knownAreaIds.add(this.world.areaId) + } + this.knownAreaIds.add(levelView.areaId) + const areaChanged = this.world.areaId !== levelView.areaId + const automapOutOfSync = + this.automapView.getCurrentAreaId() !== levelView.areaId || + this.automapView.getLevelView(levelView.areaId) === undefined + if (areaChanged) { + this.world.areaId = levelView.areaId + } + if (areaChanged || automapOutOfSync) { + this.automapView.syncActiveLevel(levelView.areaId, levelView) + } + if (areaChanged) { + void this.soundService.onEnterArea(levelView.areaId) + if (options?.emitHudSync) { + this.hudModel.syncFromWorld() + } + } + } + return levelView + } + /** * `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a * town level, or `null` while no loaded level contains it. The sub-tile is the one the gate @@ -932,7 +988,7 @@ export class OnlineSession { const levelView = this.mapService.findLevelViewAtWorldSubTile( Math.round(snap.logicalX), Math.round(snap.logicalY), - [this.world.areaId, ...this.world.getRevealedAreaIds()], + this.getCandidateAreaIds(), ) return levelView === null ? null : isTownLevel(levelView.areaId) } @@ -1070,7 +1126,11 @@ export class OnlineSession { break } } - const activeView = this.mapService?.getActiveLevelView() ?? undefined + this.knownAreaIds.clear() + this.knownAreaIds.add(ev.areaId) + const activeView = + this.mapService?.getActiveLevelView() ?? + (this.levelView && this.levelView.areaId === ev.areaId ? this.levelView : undefined) this.automapView.setContext( ev.mapSeed, this.world.difficulty, @@ -1082,6 +1142,9 @@ export class OnlineSession { } case 'RoomReveal': { + if (ev.areaId > 0) { + this.knownAreaIds.add(ev.areaId) + } if (this.mapService) { try { this.mapService.onAddRoomDataPacket({ @@ -1271,6 +1334,9 @@ export class OnlineSession { } } + // Synchronize active area and AutomapView from current player position (#549) + this.syncActiveAreaFromPlayerPosition({ emitHudSync: false }) + // Synchronize HudManager via HudModel this.hudModel.syncFromWorld() @@ -1305,6 +1371,8 @@ export class OnlineSession { unit.direction = state.dir64 } } + + this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) } /** @@ -1359,10 +1427,7 @@ export class OnlineSession { const activeLevelView = this.levelView ?? (this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0) - ? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [ - this.world.areaId, - ...this.world.getRevealedAreaIds(), - ]) + ? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, this.getCandidateAreaIds()) : null) ?? this.mapService?.getActiveLevelView() ?? this.automapView.getLevelView() diff --git a/src/client/ui/minimap.ts b/src/client/ui/minimap.ts index 2d7f3a2..71e38c6 100644 --- a/src/client/ui/minimap.ts +++ b/src/client/ui/minimap.ts @@ -498,6 +498,9 @@ export class Minimap { /** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */ private readonly lastRevealSubPos = new Map() + /** Last drawn level key so manual pan resets when switching levels (#549). */ + private lastLevelKey: string | null = null + /** Diagnostic telemetry from the most recent `draw()` invocation. */ readonly lastRenderStats: AutomapRenderTelemetry = { mode: 'minimap', @@ -869,6 +872,11 @@ export class Minimap { viewport: { readonly width: number; readonly height: number }, options?: MinimapDrawOptions, ): void { + if (this.lastLevelKey !== null && this.lastLevelKey !== level.key) { + this.resetPan() + } + this.lastLevelKey = level.key + const screenW = Math.max(1, Math.trunc(viewport.width)) const screenH = Math.max(1, Math.trunc(viewport.height)) const vp = computeAutomapViewport( diff --git a/tests/client/minimap-level-switch-549.test.ts b/tests/client/minimap-level-switch-549.test.ts new file mode 100644 index 0000000..01bdc0d --- /dev/null +++ b/tests/client/minimap-level-switch-549.test.ts @@ -0,0 +1,244 @@ +/** + * Issue #549 regression: Minimap / Automap must switch synchronously with the main map + * when the player transitions between levels/areas within an Act (outdoor border crossing, + * dungeon warp, town portal, or waypoint) without a new `0x03 LoadAct` packet. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import '../../tools/lib/node-map-loader.ts' +import { AutomapView } from '../../src/client/automap/automap-view.ts' +import { MapService } from '../../src/client/map/map-service.ts' +import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' +import { OnlineSession } from '../../src/client/session/online-session.ts' +import { createSettingsStore } from '../../src/client/settings/settings-store.ts' +import { Minimap } from '../../src/client/ui/minimap.ts' +import { levelDisplayName } from '../../src/common/world/level-names-zh.ts' + +const FIXTURE_DIR = resolve('tests/fixtures/netproto') +const ROGUE_ENCAMPMENT = 1 +const BLOOD_MOOR = 2 +const DEN_OF_EVIL = 8 + +function createNoopSpriteRenderer() { + return { + begin() {}, + draw() {}, + drawSolidQuad() {}, + setLighting() {}, + setPalette() {}, + flush() {}, + getGl() { + return null + }, + } +} + +function createFakeTileGpuCache() { + return { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return { index: 1, width: 2048, height: 1932 } + }, + } +} + +describe('Issue #549: Minimap / Automap switches synchronously with main map on area transitions', () => { + let mapService: MapService + + beforeAll(async () => { + mapService = await MapService.fromPackedDir(resolve('public/act-packs/tiles')) + }) + + it('AutomapView.syncActiveLevel switches currentAreaId without wiping serverRevealedRooms across the Act', () => { + const automap = new AutomapView({ mapService, initialMode: 'off' }) + const townView = automap.setContext(0x52e4e6ff, 0, ROGUE_ENCAMPMENT) + const townRoom = townView.rooms[0]! + automap.onServerAddRoom(townRoom.tileX, townRoom.tileY, ROGUE_ENCAMPMENT) + expect(automap.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT) + expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1) + + const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all') + const bmRoom = bloodMoorView.rooms[0]! + automap.onServerAddRoom(bmRoom.tileX, bmRoom.tileY, BLOOD_MOOR) + + // Switch to Blood Moor via syncActiveLevel: preserves revealed rooms in both Area 1 and Area 2 + automap.syncActiveLevel(BLOOD_MOOR, bloodMoorView) + expect(automap.getCurrentAreaId()).toBe(BLOOD_MOOR) + expect(automap.buildSnapshot().currentAreaId).toBe(BLOOD_MOOR) + expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1) + + const bmMinimapLevel = automap.buildMinimapLevel('ENG') + expect(bmMinimapLevel.levelId).toBe(BLOOD_MOOR) + expect(bmMinimapLevel.levelName).toBe(levelDisplayName(BLOOD_MOOR, 'ENG')) + expect(bmMinimapLevel.cellsX).toBe(bloodMoorView.cellsX) + expect(bmMinimapLevel.cellsY).toBe(bloodMoorView.cellsY) + expect(bmMinimapLevel.markers.some((m) => m.kind === 'exit')).toBe(true) + + // Switch back to Rogue Encampment: Area 1 revealed rooms are still intact + automap.syncActiveLevel(ROGUE_ENCAMPMENT, townView) + expect(automap.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT) + expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1) + }) + + it('OnlineSession keeps automapView and world.areaId in lockstep across Area 1 -> Area 2 -> Area 8 -> Area 1 transitions', () => { + const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8') + const settingsStore = createSettingsStore() + settingsStore.set({ automapReveal: 'level' }) + const session = new OnlineSession({ settingsStore, mapService }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + + // Initial state: standing in Rogue Encampment (Area 1), even though Area 2 rooms were pre-revealed by 0x07 + expect(session.world.getRevealedAreaIds()).toContain(BLOOD_MOOR) + expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT) + expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT) + expect(session.automapView.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT) + expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(ROGUE_ENCAMPMENT) + + // 1. Player walks/moves across border into Blood Moor (Area 2) without a new LoadAct packet + const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all') + const bmSubX = bloodMoorView.subOriginX + bloodMoorView.spawnX * 5 + 2 + const bmSubY = bloodMoorView.subOriginY + bloodMoorView.spawnY * 5 + 2 + session.handleServerEvent({ + type: 'UnitReassign', + unitType: 'player', + id: session.world.selfId, + at: { x: bmSubX, y: bmSubY }, + snap: true, + }) + + expect(session.sceneSource.getLevelView()?.areaId).toBe(BLOOD_MOOR) + expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(BLOOD_MOOR) + expect(session.world.areaId).toBe(BLOOD_MOOR) + expect(session.automapView.getCurrentAreaId()).toBe(BLOOD_MOOR) + expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(BLOOD_MOOR) + expect(session.automapView.buildMinimapLevel('ENG').levelName).toBe( + levelDisplayName(BLOOD_MOOR, 'ENG'), + ) + + // 2. Player enters Den of Evil (Area 8): server reveals Area 8 room via 0x07 and reassigns player + const denView = mapService.getLevelView(DEN_OF_EVIL, 'all') + const denRoom = denView.rooms[0]! + session.handleServerEvent({ + type: 'RoomReveal', + areaId: DEN_OF_EVIL, + tileX: denRoom.tileX, + tileY: denRoom.tileY, + }) + const denSubX = denView.subOriginX + denView.spawnX * 5 + 2 + const denSubY = denView.subOriginY + denView.spawnY * 5 + 2 + session.handleServerEvent({ + type: 'UnitReassign', + unitType: 'player', + id: session.world.selfId, + at: { x: denSubX, y: denSubY }, + snap: true, + }) + + expect(session.sceneSource.getLevelView()?.areaId).toBe(DEN_OF_EVIL) + expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(DEN_OF_EVIL) + expect(session.world.areaId).toBe(DEN_OF_EVIL) + expect(session.automapView.getCurrentAreaId()).toBe(DEN_OF_EVIL) + expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(DEN_OF_EVIL) + expect(session.automapView.buildMinimapLevel('ENG').levelName).toBe( + levelDisplayName(DEN_OF_EVIL, 'ENG'), + ) + + // 3. Player returns to Rogue Encampment (Area 1) via Town Portal / walk back + const townView = mapService.getLevelView(ROGUE_ENCAMPMENT, 'all') + const townSubX = townView.subOriginX + townView.spawnX * 5 + 2 + const townSubY = townView.subOriginY + townView.spawnY * 5 + 2 + session.handleServerEvent({ + type: 'UnitReassign', + unitType: 'player', + id: session.world.selfId, + at: { x: townSubX, y: townSubY }, + snap: true, + }) + + expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT) + expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(ROGUE_ENCAMPMENT) + expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT) + expect(session.automapView.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT) + expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(ROGUE_ENCAMPMENT) + }) + + it('renderSceneSourceFrame + Minimap switches level geometry, HUD title, and resets manual pan when moving across areas', () => { + const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8') + const settingsStore = createSettingsStore() + settingsStore.set({ automapReveal: 'level' }) + const session = new OnlineSession({ settingsStore, mapService }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + + const minimap = new Minimap() + minimap.mode = 'full' + const renderer = createNoopSpriteRenderer() + const tileGpuCache = createFakeTileGpuCache() + + // Frame 1: in Rogue Encampment (Area 1) + renderSceneSourceFrame({ + source: session.sceneSource, + lightingPreset: 'fullbright', + renderer: renderer as never, + tileGpuCache: tileGpuCache as never, + minimap, + automapView: session.automapView, + }) + expect(minimap.hudTextLastFrame?.levelName).toBe(levelDisplayName(ROGUE_ENCAMPMENT)) + + // User manually pans the full automap in Area 1 + minimap.pan(32, -24) + expect(minimap.panX).toBe(32) + expect(minimap.panY).toBe(-24) + + // Local predictor moves player into Blood Moor (Area 2) via session.tick() + const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all') + const bmSubX = bloodMoorView.subOriginX + bloodMoorView.spawnX * 5 + 2 + const bmSubY = bloodMoorView.subOriginY + bloodMoorView.spawnY * 5 + 2 + session.predictor.setPosition(bmSubX, bmSubY) + session.tick(40, 2000) + + // Frame 2: in Blood Moor (Area 2) + const snap2 = renderSceneSourceFrame({ + source: session.sceneSource, + lightingPreset: 'fullbright', + renderer: renderer as never, + tileGpuCache: tileGpuCache as never, + minimap, + automapView: session.automapView, + }) + expect(snap2.levelId).toBe(BLOOD_MOOR) + expect(session.automapView.getCurrentAreaId()).toBe(BLOOD_MOOR) + expect(minimap.hudTextLastFrame?.levelName).toBe(levelDisplayName(BLOOD_MOOR)) + // Manual pan from Area 1 is automatically reset to (0, 0) upon switching to Area 2 + expect(minimap.panX).toBe(0) + expect(minimap.panY).toBe(0) + expect(minimap.revealedRoomsCount).toBeGreaterThan(0) + }) + + it('CommandMapper resolves local pathfinding against the level containing the player even when 0x07 revealed a neighbouring level', () => { + const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8') + const settingsStore = createSettingsStore() + settingsStore.set({ automapReveal: 'level' }) + const session = new OnlineSession({ settingsStore, mapService }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + + // Precondition: MapService.getActiveLevelView() was switched to Area 2 by 0x07 RoomReveal, + // while player is standing in Area 1 (Rogue Encampment). + expect(mapService.getActiveLevelView()?.areaId).toBe(BLOOD_MOOR) + expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT) + + const playerX = Math.round(session.predictor.getSnapshot().logicalX) + const playerY = Math.round(session.predictor.getSnapshot().logicalY) + const targetScreen = session.camera.subtileToScreen(playerX + 3, playerY) + session.commandMapper.handlePointerDown(targetScreen.x, targetScreen.y, 0, 5000) + session.commandMapper.handlePointerUp(0) + + const predAfterClick = session.predictor.getSnapshot() + expect(predAfterClick.moving).toBe(true) + }) +}) From 71610311fe450fdcc279b1317e0044379d2ccfb4 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 08:15:00 +0000 Subject: [PATCH 02/15] fix(client): isolate per-unit player anim modes (TN/TW/RN/DD) and use 1.13c AnimData.d2 25Hz frame speed (Fixes #544) --- src/client/main.ts | 1 - src/client/motion/interpolate.ts | 2 +- src/client/scene/world-renderer.ts | 167 +++++--- src/client/session/online-session.ts | 110 +++++- src/client/world/client-unit.ts | 2 +- src/client/world/client-world.ts | 95 ++++- src/netproto/d2gs/s2c/movement.ts | 10 +- tests/client/player-anim-modes-544.test.ts | 421 +++++++++++++++++++++ 8 files changed, 738 insertions(+), 70 deletions(-) create mode 100644 tests/client/player-anim-modes-544.test.ts diff --git a/src/client/main.ts b/src/client/main.ts index 49700ac..66238f5 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -958,7 +958,6 @@ async function bootPlayApp(): Promise { automapView: session.automapView, hudManager: session.hudManager, nowMs, - playerMoving: session.predictor.getSnapshot().moving, }) } diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 614d085..3a67336 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -88,7 +88,7 @@ export function resolveRemoteMoveSpeed(options: { const running = options.running ?? (options.moveType !== undefined - ? options.moveType === 0x17 || (options.moveType & 0x02) !== 0 + ? options.moveType === 0x17 || options.moveType === 0x18 || (options.moveType & 0x02) !== 0 : false) if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) { return { running, speedSubTilesPerSec: options.speedSubTilesPerSec } diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index e7680d2..4051900 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1,4 +1,5 @@ import { dir64ToClipDirection } from '../../common/units/direction.ts' +import { PLR_MODES } from '../../common/units/plr-mode.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' import { npcDisplayName } from '../../common/units/npc-table.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' @@ -175,19 +176,41 @@ const COMMON_HCIDX_TO_MONSTER_ID: Readonly> = { 520: 'cain5', } -const PLAYER_MODE_TO_CLIP: Readonly> = { - 0: 'dt', - 1: 'nu', - 2: 'wl', - 3: 'rn', - 4: 'gh', - 5: 'tn', - 6: 'tw', - 7: 'a1', - 8: 'a2', - 9: 'bl', - 10: 'sc', - 17: 'dd', +/** + * Secondary `PlrMode.txt` tokens that share HTH DCC sequences in `d2char.mpq` when no dedicated + * HTH DCC file exists for that mode (`KB`/`BL` -> `gh`, `TH`/`KK` -> `a1`, `S1..S4`/`SQ` -> `sc`). + * Primary modes (`dt`, `nu`, `wl`, `rn`, `gh`, `tn`, `tw`, `a1`, `a2`, `sc`, `dd`) never alias. + */ +const PLAYER_HTH_FALLBACK_CLIP: Readonly> = { + bl: 'gh', + th: 'a1', + kk: 'a1', + s1: 'sc', + s2: 'sc', + s3: 'sc', + s4: 'sc', + sq: 'sc', + kb: 'gh', +} + +/** + * Compute 0-based animation frame index from elapsed time `nowMs` and 1.13c 8.8 fixed-point + * animation speed (`speed256`, where `256 = 1.0` frame per 25Hz / 40ms game tick; + * `D2Common.dll` `0x6FDBE510` / `0x6FDBEFF0`). + */ +export function computeAnimDataFrameIndex( + nowMs: number, + speed256: number, + frameCount: number, + loop = true, +): number { + if (frameCount <= 1) return 0 + const safeTicks = Math.max(0, nowMs) / 40 + const rawFrame = Math.floor((safeTicks * speed256) / 256) + if (!loop) { + return Math.min(frameCount - 1, Math.max(0, rawFrame)) + } + return ((rawFrame % frameCount) + frameCount) % frameCount } const MONSTER_MODE_TO_CLIP: Readonly> = { @@ -345,7 +368,6 @@ export interface SceneSourceRenderContext { readonly automapView?: AutomapView | null | undefined readonly hudManager?: HudManager | null | undefined readonly nowMs?: number | undefined - readonly playerMoving?: boolean | undefined readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined } @@ -407,32 +429,42 @@ function drawSceneUnitDrawable( ctx.characterArt ?? null if (charArt !== null) { - const isMoving = - Boolean(ctx.playerMoving) || unit.mode === 2 || unit.mode === 3 || unit.mode === 6 - const requestedClipKey = - Boolean(ctx.playerMoving) && (unit.mode === 1 || unit.mode === 5) - ? unit.mode === 5 && charArt.clips?.tw - ? 'tw' - : 'wl' - : (PLAYER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu')) + const modeToken = PLR_MODES[unit.mode]?.token.toLowerCase() + const requestedClipKey = unit.isCorpse && modeToken !== 'dt' ? 'dd' : modeToken + if (!requestedClipKey) { + reportMissingArt( + 'player-mode', + `${unit.token}:${unit.mode}`, + `Invalid player mode=${unit.mode} for token=${unit.token}`, + ) + return + } + const fallbackHthKey = PLAYER_HTH_FALLBACK_CLIP[requestedClipKey] const clip = charArt.clips?.[requestedClipKey] ?? - (isMoving ? charArt.clips?.wl : charArt.clips?.nu) - const dirCount = Math.max(1, clip?.directions ?? charArt.directions) + (fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) + if (!clip || !(clip.speed > 0)) { + reportMissingArt( + 'player-clip', + `${unit.token}:${requestedClipKey}`, + `Missing player clip or AnimData.d2 speed for token=${unit.token} clip=${requestedClipKey} mode=${unit.mode}`, + ) + return + } + const dirCount = Math.max(1, clip.directions || charArt.directions) const direction = dir64ToClipDirection(unit.direction, dirCount) - const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand - const groupIndex = baseGroup + direction - const group = - charArt.groups[groupIndex] ?? - charArt.groups[charArt.stand + direction] ?? - charArt.groups[0] + const groupIndex = clip.group + direction + const group = charArt.groups[groupIndex] const frameCount = Math.max(1, group?.length ?? 1) + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + clip.loop !== false const frameIndex = - unit.isCorpse || requestedClipKey === 'dd' + unit.isCorpse && requestedClipKey !== 'dt' ? frameCount - 1 - : isMoving - ? Math.floor(nowMs / 65) % frameCount - : Math.floor(nowMs / 140) % frameCount + : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -485,19 +517,29 @@ function drawSceneUnitDrawable( art.clips?.[requestedClipKey] ?? (isMoving ? art.clips?.wl : undefined) ?? art.clips?.nu - const dirCount = Math.max(1, clip?.directions ?? art.directions) + if (!clip || !(clip.speed > 0)) { + reportMissingArt( + 'monster-clip', + `${unit.classId}:${requestedClipKey}`, + `Missing monster clip or AnimData.d2 speed for classId=${unit.classId} token=${unit.token} clip=${requestedClipKey}`, + ) + return + } + const dirCount = Math.max(1, clip.directions || art.directions) const direction = dir64ToClipDirection(unit.direction, dirCount) - const baseGroup = - clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset + const baseGroup = clip.group const groupIndex = baseGroup + direction const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0] const frameCount = Math.max(1, group?.length ?? 1) + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + clip.loop !== false const frameIndex = - unit.isCorpse || requestedClipKey === 'dd' + unit.isCorpse && requestedClipKey !== 'dt' ? frameCount - 1 - : isMoving - ? Math.floor(nowMs / 75) % frameCount - : Math.floor(nowMs / 130) % frameCount + : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -682,9 +724,16 @@ function drawSceneUnitDrawable( if (!shouldLoop) { frameIndex = frameCount - 1 } else { - const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 128 - const frameDurationMs = speed > 0 ? Math.max(40, Math.round((256 * 40) / speed)) : 100 - frameIndex = Math.abs(Math.floor(nowMs / frameDurationMs)) % frameCount + const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0 + if (!(speed > 0)) { + reportMissingArt( + 'object-clip', + `${unit.classId}:${clipKey}`, + `Missing frameDelta/speed for object classId=${unit.classId} mode=${modeIdx}`, + ) + return + } + frameIndex = computeAnimDataFrameIndex(nowMs, speed, frameCount, true) } } const frame = group?.[frameIndex] ?? group?.[0] @@ -738,11 +787,22 @@ function drawSceneMissileDrawable( ctx.missileArtMap?.get(missile.missileKey) ?? ctx.missileArtMap?.get(missile.celFile.toLowerCase()) if (art !== undefined) { + const animSpeed = art.meta.animSpeed + if (!(animSpeed > 0)) { + reportMissingArt( + 'missile-speed', + missile.missileKey || missile.celFile || String(missile.missileClassId), + `Missing animSpeed for missile key=${missile.missileKey}`, + ) + return + } const dirCount = Math.max(1, art.meta.directions) const dir = ((missile.direction % dirCount) + dirCount) % dirCount const group = art.frames[dir] ?? art.frames[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIdx = (missile.frame + Math.floor(nowMs / 50)) % frameCount + const frameIdx = + (missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) % + frameCount const frame = group?.[frameIdx] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -777,9 +837,20 @@ function drawSceneOverlayDrawable( ctx.overlayArtMap?.get(overlay.token) ?? ctx.overlayArtMap?.get(overlay.token.toLowerCase()) if (art !== undefined) { + const animRate = art.meta.animRate + if (!(animRate > 0)) { + reportMissingArt( + 'overlay-speed', + overlay.token || String(overlay.overlayId), + `Missing animRate for overlay token=${overlay.token}`, + ) + return + } const group = art.frames[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIdx = (overlay.frame + Math.floor(nowMs / 60)) % frameCount + const frameIdx = + (overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) % + frameCount const frame = group?.[frameIdx] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -826,7 +897,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram renderer.flush() } if (ctx.hudManager) { - ctx.hudManager.render(nowMs, ctx.playerMoving ?? false) + ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) return snapshot @@ -1011,7 +1082,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram } if (ctx.hudManager) { - ctx.hudManager.render(nowMs, ctx.playerMoving ?? false) + ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) return snapshot diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..d229d08 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -937,6 +937,32 @@ export class OnlineSession { return levelView === null ? null : isTownLevel(levelView.areaId) } + /** + * Resolve whether `(subX, subY)` is in a town level (`DUNGEON_IsRoomInTown` equivalent): + * prefers the exact `LevelView` containing `(subX, subY)` when `mapService` is loaded, + * and falls back to the active `world.areaId` / `levelView.areaId`. + */ + private isSubTileInTown(subX: number, subY: number): boolean { + if (this.mapService && this.world.areaId > 0 && (subX !== 0 || subY !== 0)) { + const levelView = this.mapService.findLevelViewAtWorldSubTile( + Math.round(subX), + Math.round(subY), + [this.world.areaId, ...this.world.getRevealedAreaIds()], + ) + if (levelView !== null) { + return isTownLevel(levelView.areaId) + } + } + const activeAreaId = + this.world.areaId > 0 + ? this.world.areaId + : (this.levelView?.areaId ?? + this.mapService?.getActiveLevelView()?.areaId ?? + this.automapView.getLevelView()?.areaId ?? + 0) + return isTownLevel(activeAreaId) + } + // ─── Outbound ClientCommand Dispatch ───────────────────────── /** @@ -1186,12 +1212,29 @@ export class OnlineSession { const evAny = ev as any const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType) const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1) - const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) - const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0) + const unit = this.world.getUnit(evTypeId as UnitType, evUnitId) + const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) + const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0) + const toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX + const toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y const isLocalPlayer = evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0 + if (unit && unit.unitType === 'player') { + const posForTownX = ev.mode === 'stop' ? toX : Number(fromX ?? unit.x ?? toX) + const posForTownY = ev.mode === 'stop' ? toY : Number(fromY ?? unit.y ?? toY) + const inTown = this.isSubTileInTown(posForTownX, posForTownY) + if (ev.mode === 'stop') { + unit.mode = inTown ? 5 : 1 + } else if (ev.mode === 'run') { + unit.mode = 3 + } else if (ev.mode === 'knockback') { + unit.mode = 19 + } else { + unit.mode = inTown ? 6 : 2 + } + } if (isLocalPlayer) { if (ev.mode === 'stop') { this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' }) @@ -1303,6 +1346,24 @@ export class OnlineSession { unit.x = state.x unit.y = state.y unit.direction = state.dir64 + if (unit.unitType === 'player' && unit.id !== this.world.selfId) { + const unitInTown = this.isSubTileInTown(state.x, state.y) + if (state.moving && unit.moveMode !== 'stop') { + if (state.running || unit.mode === 3) { + unit.mode = 3 + } else if (unit.mode === 2 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { + unit.mode = unitInTown ? 6 : 2 + } + } else if (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { + unit.mode = unitInTown ? 5 : 1 + unit.moveMode = 'stop' + } + } else if (unit.unitType === 'monster' && !state.moving) { + if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { + unit.mode = 1 + unit.moveMode = 'stop' + } + } } } } @@ -1322,17 +1383,31 @@ export class OnlineSession { const selfUnit = this.world.getUnit('player', this.world.selfId) const isSelfDead = this.world.isSelfDead || this.world.isDead const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined + const selfInTown = this.isSubTileInTown(selfX, selfY) + const neutralMode = selfInTown ? 5 : 1 + const walkMode = selfInTown ? 6 : 2 + const unitExplicitMode = + selfUnit?.mode !== undefined && + selfUnit.mode !== 0 && + selfUnit.mode !== 1 && + selfUnit.mode !== 2 && + selfUnit.mode !== 3 && + selfUnit.mode !== 5 && + selfUnit.mode !== 6 + ? selfUnit.mode + : undefined const selfMode = isSelfDead - ? 12 + ? (selfUnit?.mode === 0 ? 0 : 17) : selfCastState ? selfCastState.mode || 10 : predSnap.moving ? predSnap.running ? 3 - : 2 - : selfUnit?.mode && selfUnit.mode > 1 - ? selfUnit.mode - : 1 + : walkMode + : (unitExplicitMode ?? neutralMode) + if (selfUnit !== undefined && !selfCastState) { + selfUnit.mode = selfMode + } const selfDirection = selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64 out.push({ @@ -1435,9 +1510,26 @@ export class OnlineSession { continue } - const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0) + const isCorpse = + (u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)) || + (u.unitType === 'player' && (u.mode === 0 || u.mode === 17 || u.lifePct <= 0)) const castState = !isCorpse ? u.castState : undefined - const mode = castState ? castState.mode || u.mode : u.mode + let mode = castState ? castState.mode || u.mode : u.mode + if (u.unitType === 'player' && !isCorpse && !castState) { + const unitInTown = this.isSubTileInTown(ux, uy) + if (interp?.moving && u.moveMode !== 'stop') { + mode = interp.running || u.mode === 3 ? 3 : unitInTown ? 6 : 2 + } else if ( + u.mode === 1 || + u.mode === 5 || + (interp !== null && interp !== undefined && !interp.moving && (u.mode === 2 || u.mode === 3 || u.mode === 6)) + ) { + mode = unitInTown ? 5 : 1 + } else if (u.mode === 2 || u.mode === 6) { + mode = unitInTown ? 6 : 2 + } + u.mode = mode + } const targetX = castState?.targetX ?? u.targetX const targetY = castState?.targetY ?? u.targetY diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 26e927c..7ea2a28 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -73,7 +73,7 @@ export class ClientUnit { this.name = snapshot.name this.x = snapshot.x this.y = snapshot.y - this.mode = snapshot.mode ?? 0 + this.mode = snapshot.mode ?? (this.unitType === 'player' || this.unitType === 'monster' ? 1 : 0) this.direction = snapshot.direction ?? 0 this.lifePct = snapshot.lifePct ?? 128 this.flags = snapshot.flags ?? 0 diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index e1e2a12..1c70e00 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -19,6 +19,7 @@ import { } from '../../netproto/index.ts' import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts' import { dir64FromPoints } from '../../common/units/direction.ts' +import { isTownLevel } from '../../common/world/scene-population.ts' import { ClientUnit, makeUnitKey, type UnitCastState } from './client-unit.ts' import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts' import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts' @@ -430,9 +431,22 @@ export class ClientWorld { return this._soundQueue.splice(0, this._soundQueue.length) } + private syncPlayerTownModes(): void { + const inTown = isTownLevel(this.areaId) + for (const unit of this._units.values()) { + if (unit.unitType !== 'player') continue + if (unit.mode === 1 || unit.mode === 5) { + unit.mode = inTown ? 5 : 1 + } else if (unit.mode === 2 || unit.mode === 6) { + unit.mode = inTown ? 6 : 2 + } + } + } + tick(dtMs = 40): void { this.frameCount++ const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10) + const inTown = isTownLevel(this.areaId) for (const unit of this._units.values()) { if (unit.dead) continue if (unit.targetX === undefined || unit.targetY === undefined) continue @@ -443,6 +457,13 @@ export class ClientWorld { if (dist <= step) { unit.x = unit.targetX unit.y = unit.targetY + if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6)) { + unit.mode = inTown ? 5 : 1 + unit.moveMode = 'stop' + } else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 13 || unit.mode === 15)) { + unit.mode = 1 + unit.moveMode = 'stop' + } } else { unit.x += (dx / dist) * step unit.y += (dy / dist) * step @@ -469,12 +490,14 @@ export class ClientWorld { const key = makeUnitKey(unitType, id) let unit = this._units.get(key) if (!unit) { + const typeName = unitTypeFromId(unitType) unit = new ClientUnit({ - unitType: unitTypeFromId(unitType), + unitType: typeName, id, classId: 0, x: 0, y: 0, + ...(typeName === 'player' ? { mode: isTownLevel(this.areaId) ? 5 : 1 } : {}), }) this._units.set(key, unit) } @@ -527,6 +550,7 @@ export class ClientWorld { type: 'UnitMove', unitType: unitTypeFromId(ev.unitType ?? 0), id: Number(ev.unitId ?? ev.id ?? 1), + mode: ev.mode ?? 'walk', from: { x: Number(ev.currentX ?? ev.x ?? 0), y: Number(ev.currentY ?? ev.y ?? 0), @@ -567,11 +591,12 @@ export class ClientWorld { value: Number(ev.value ?? 0), } } else if (ev.type === 'Damage') { + const isPlayerTarget = unitTypeFromId(ev.unitType ?? 1) === 'player' ev = { type: 'UnitMode', unitType: ev.unitType ?? 1, id: Number(ev.unitId ?? ev.id ?? 0), - mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 5)), + mode: Number(ev.mode ?? (ev.lifePct === 0 ? (isPlayerTarget ? 17 : 12) : (isPlayerTarget ? 4 : 3))), lifePct: Number(ev.lifePct ?? 0), } } @@ -595,6 +620,7 @@ export class ClientWorld { if (ev.objectSeed !== undefined) { this.objectSeed = ev.objectSeed >>> 0 } + this.syncPlayerTownModes() break case 'LoadComplete': @@ -613,6 +639,7 @@ export class ClientWorld { this._revealedRooms.add(`${ev.areaId}:${ev.tileX}:${ev.tileY}`) if (this.areaId === 0 && ev.areaId > 0) { this.areaId = ev.areaId + this.syncPlayerTownModes() } break @@ -629,6 +656,13 @@ export class ClientWorld { } else { unit.updateFromSnapshot(ev.unit) } + if (ev.unit.mode === undefined) { + if (unit.unitType === 'player') { + unit.mode = isTownLevel(this.areaId) ? 5 : 1 + } else if (unit.unitType === 'monster') { + unit.mode = unit.lifePct <= 0 ? 12 : 1 + } + } if (unit.unitType === 'monster') { this._monsters.set(unit.id, unit) if (this._merc && unit.id === this._merc.mercId) { @@ -691,9 +725,56 @@ export class ClientWorld { unit.x = ev.to.x unit.y = ev.to.y } - unit.targetX = ev.to.x - unit.targetY = ev.to.y + let targetX = ev.to.x + let targetY = ev.to.y + if ( + ev.target && + ((targetX === 0 && targetY === 0) || + (ev.from !== undefined && targetX === ev.from.x && targetY === ev.from.y)) + ) { + const targetUnit = this.getUnit(ev.target.unitType, ev.target.id) + if (targetUnit && (targetUnit.x !== 0 || targetUnit.y !== 0)) { + targetX = targetUnit.x + targetY = targetUnit.y + } else if ( + unitTypeFromId(ev.target.unitType) === 'player' && + (ev.target.id === this.selfId || this.selfId === 0) && + (this._self.x !== 0 || this._self.y !== 0) + ) { + targetX = this._self.x + targetY = this._self.y + } + } + unit.targetX = targetX + unit.targetY = targetY unit.targetUnit = ev.target + const moveMode = ev.mode ?? 'walk' + unit.moveMode = moveMode + if (targetX !== unit.x || targetY !== unit.y) { + unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) + } + if (unit.unitType === 'player') { + const inTown = isTownLevel(this.areaId) + if (moveMode === 'stop') { + unit.mode = inTown ? 5 : 1 + } else if (moveMode === 'run') { + unit.mode = 3 + } else if (moveMode === 'knockback') { + unit.mode = 19 + } else { + unit.mode = inTown ? 6 : 2 + } + } else if (unit.unitType === 'monster') { + if (moveMode === 'stop') { + if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { + unit.mode = 1 + } + } else if (moveMode === 'run') { + unit.mode = 15 + } else { + unit.mode = 2 + } + } if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { this._self.x = ev.from.x @@ -702,9 +783,9 @@ export class ClientWorld { this._self.x = ev.to.x this._self.y = ev.to.y } - if (ev.to.x !== 0 || ev.to.y !== 0) { - this._self.targetX = ev.to.x - this._self.targetY = ev.to.y + if (targetX !== 0 || targetY !== 0) { + this._self.targetX = targetX + this._self.targetY = targetY } } break diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index cdc03e0..dbc74dc 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -14,9 +14,13 @@ function clampUnitType(raw: number): UnitType { } function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' { - // In D2Client.dll: 0x17 / 0x02 / 0x04 / bit 0x10 = run, 0x20 = knockback - if (rawMoveType === 0x20 || rawMoveType === 0x09) return 'knockback' - if (rawMoveType === 0x17 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) return 'run' + // In D2Client.dll 0x6fac9830: 0x17 (0x0F run) / 0x18 (0x10 run) / 0x02 = run, 0x13/0x14/0x20/0x09 = knockback + if (rawMoveType === 0x20 || rawMoveType === 0x09 || rawMoveType === 0x13 || rawMoveType === 0x14) { + return 'knockback' + } + if (rawMoveType === 0x17 || rawMoveType === 0x18 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) { + return 'run' + } return 'walk' } diff --git a/tests/client/player-anim-modes-544.test.ts b/tests/client/player-anim-modes-544.test.ts new file mode 100644 index 0000000..1f215e7 --- /dev/null +++ b/tests/client/player-anim-modes-544.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from 'vitest' +import { AutomapView } from '../../src/client/automap/automap-view.ts' +import { + clearMissingArtEntries, + computeAnimDataFrameIndex, + getMissingArtEntries, + renderSceneSourceFrame, +} from '../../src/client/scene/world-renderer.ts' +import type { LoadedCharacter } from '../../src/client/scene/entity-art-loader.ts' +import { OnlineSession } from '../../src/client/session/online-session.ts' +import { ClientWorldSceneSource, type ClientSceneWorldAdapter } from '../../src/client/view/scene-source.ts' +import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts' +import { PLR_MODES } from '../../src/common/units/plr-mode.ts' + +function makeTaggedCharArt(): LoadedCharacter { + const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [ + { key: 'dt', group: 0, frames: 4, speed: 256, loop: false }, + { key: 'nu', group: 1, frames: 8, speed: 128, loop: true }, + { key: 'wl', group: 2, frames: 8, speed: 256, loop: true }, + { key: 'rn', group: 3, frames: 8, speed: 256, loop: true }, + { key: 'gh', group: 4, frames: 4, speed: 256, loop: false }, + { key: 'tn', group: 5, frames: 16, speed: 80, loop: true }, + { key: 'tw', group: 6, frames: 8, speed: 256, loop: true }, + { key: 'a1', group: 7, frames: 8, speed: 256, loop: false }, + { key: 'a2', group: 8, frames: 8, speed: 256, loop: false }, + { key: 'sc', group: 9, frames: 8, speed: 256, loop: false }, + { key: 'dd', group: 10, frames: 1, speed: 256, loop: false }, + ] + const groups = modes.map((m) => + Array.from({ length: m.frames }, (_, fIdx) => ({ + x: m.group * 100 + fIdx, + y: 0, + width: 32, + height: 64, + anchorX: -16, + anchorY: -56, + tag: `${m.key}_${fIdx}`, + })), + ) + const clips = Object.fromEntries( + modes.map((m) => [ + m.key, + { + group: m.group, + directions: 1, + frames: m.frames, + speed: m.speed, + loop: m.loop, + events: [], + }, + ]), + ) + return { + handle: { index: 1, width: 512, height: 512 } as never, + groups: groups as never, + directions: 1, + walk: 2, + stand: 1, + members: 1, + clips, + } +} + +describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isolation & AnimData.d2 Speed', () => { + it('1. isolates local player movement from remote standing players in the same scene', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Rogue Encampment (Town) + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 0, + id: 1, + classId: 1, // Sorceress ('so') + name: 'LocalSorc', + x: 5100, + y: 5050, + }, + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: false, + unit: { + unitType: 0, + id: 2, + classId: 1, // Remote Sorceress ('so') + name: 'RemoteSorc', + x: 5104, + y: 5050, + }, + }) + + // Start local player walking in town (mode = 6 TW) + session.predictor.setPath([{ x: 5120, y: 5050 }], false) + session.tick(40, 100) + + const renderables = session.buildRenderableUnits() + const localPlayer = renderables.find((u) => u.id === 1)! + const remotePlayer = renderables.find((u) => u.id === 2)! + expect(localPlayer.mode).toBe(6) // PLRMODE_TOWNWALK (TW) + expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN) + + // Render scene frame and inspect drawn sprite tags for both players + const automap = new AutomapView({ initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1) + const baseX = levelView.subOriginX + 50 + const baseY = levelView.subOriginY + 50 + + const drawnTags: string[] = [] + const fakeRenderer = { + begin() {}, + draw(frame: { tag?: string }) { + if (frame.tag) drawnTags.push(frame.tag) + }, + setLighting() {}, + setPalette() {}, + flush() {}, + } + const fakeTileGpuCache = { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return undefined + }, + } + + const source = new ClientWorldSceneSource( + (): ClientSceneWorldAdapter => ({ + tick: 1, + actId: 1, + levelId: 1, + playerSubX: baseX, + playerSubY: baseY, + units: [ + { + kind: 'player', + id: 1, + classId: 1, + x: baseX, + y: baseY, + mode: localPlayer.mode, + direction: 0, + isLocalPlayer: true, + }, + { + kind: 'player', + id: 2, + classId: 1, + x: baseX + 4, + y: baseY, + mode: remotePlayer.mode, + direction: 0, + isLocalPlayer: false, + }, + ], + levelView, + }), + '800x600', + ) + + const charArt = makeTaggedCharArt() + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + tileGpuCache: fakeTileGpuCache as never, + characterArtByToken: new Map([['so', charArt]]), + nowMs: 80, + }) + + // Local player drew TW frame (`tw_2`), while remote standing player drew TN frame (`tn_0`) + expect(drawnTags).toContain('tw_2') + expect(drawnTags).toContain('tn_0') + expect(drawnTags.some((t) => t.startsWith('wl_'))).toBe(false) + }) + + it('2. transitions remote player mode on 0x0F PlayerMove, 0x10 PlayerMoveToTarget, 0x0D PlayerStop, and segment completion', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Town + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 }, + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: false, + unit: { unitType: 0, id: 2, classId: 3, name: 'RemotePal', x: 5105, y: 5050 }, + }) + + // Initially standing in town -> mode 5 (TN) + expect(session.world.getUnit('player', 2)?.mode).toBe(5) + + // 0x0F PlayerMove (walk: moveType = 0x01) from (5105, 5050) to (5115, 5050) + const pkt0x0FWalk = new Uint8Array(16) + const dvWalk = new DataView(pkt0x0FWalk.buffer) + dvWalk.setUint8(0, 0x0f) + dvWalk.setUint8(1, 0) // unitType = player + dvWalk.setUint32(2, 2, true) // unitId = 2 + dvWalk.setUint8(6, 0x01) // moveType = walk + dvWalk.setUint16(7, 5115, true) // targetX + dvWalk.setUint16(9, 5050, true) // targetY + dvWalk.setUint8(11, 0) + dvWalk.setUint16(12, 5105, true) // currentX + dvWalk.setUint16(14, 5050, true) // currentY + session.handleServerEvent(decode0x0FPlayerMove(pkt0x0FWalk)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(6) // TW in town + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(6) + + // 0x10 PlayerMoveToTarget (run: moveType = 0x18) targeting local player id = 1 at (5100, 5050) + const pkt0x10Run = new Uint8Array(16) + const dvRun = new DataView(pkt0x10Run.buffer) + dvRun.setUint8(0, 0x10) + dvRun.setUint8(1, 0) // unitType = player + dvRun.setUint32(2, 2, true) // unitId = 2 + dvRun.setUint8(6, 0x18) // moveType = 0x18 (run to target in 1.13c D2Client 0x6fac9830) + dvRun.setUint8(7, 0) // targetType = player + dvRun.setUint32(8, 1, true) // targetId = 1 + dvRun.setUint16(12, 5110, true) // currentX + dvRun.setUint16(14, 5050, true) // currentY + session.handleServerEvent(decode0x10PlayerMoveToTarget(pkt0x10Run)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(3) // RN (even in town) + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(3) + + // Advance interpolation until remote player reaches target (5100, 5050) -> returns to TN (5) + for (let i = 0; i < 30; i++) { + session.tick(100, i * 100) + } + expect(session.world.getUnit('player', 2)?.mode).toBe(5) + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5) + + // Start another run, then stop immediately via 0x0D PlayerStop + session.handleServerEvent({ + type: 'UnitMove', + unitType: 0, + id: 2, + mode: 'run', + from: { x: 5100, y: 5050 }, + to: { x: 5120, y: 5050 }, + }) + expect(session.world.getUnit('player', 2)?.mode).toBe(3) + + const pkt0x0DStop = new Uint8Array(13) + const dvStop = new DataView(pkt0x0DStop.buffer) + dvStop.setUint8(0, 0x0d) + dvStop.setUint8(1, 0) + dvStop.setUint32(2, 2, true) + dvStop.setUint8(6, 0x07) // actionCode = neutral + dvStop.setUint16(7, 5102, true) + dvStop.setUint16(9, 5050, true) + dvStop.setUint8(11, 0) + dvStop.setUint8(12, 128) + session.handleServerEvent(decode0x0DPlayerStop(pkt0x0DStop)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(5) // Back to TN in town + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5) + }) + + it('3. switches local player between town (TN=5, TW=6, RN=3) and wilderness (NU=1, WL=2, RN=3) and uses DD=17 (never KK=12) on death', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Rogue Encampment (Town) + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 }, + }) + + // Town Idle -> TN (5) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(5) + expect(PLR_MODES[5]?.token).toBe('TN') + + // Town Walk -> TW (6) + session.predictor.setPath([{ x: 5120, y: 5050 }], false) + session.tick(40, 40) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(6) + expect(PLR_MODES[6]?.token).toBe('TW') + + // Town Run -> RN (3) (1.13c D2Game sub_6FC7F600: running in town still uses PLRMODE_RUN=3) + session.predictor.setPath([{ x: 5140, y: 5050 }], true) + session.tick(40, 80) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3) + expect(PLR_MODES[3]?.token).toBe('RN') + + // Enter Blood Moor (areaId = 2, non-town) while running -> stays RN (3) + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 2, + }) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3) + + // Switch to walking in Blood Moor -> WL (2) + session.predictor.setPath([{ x: 5160, y: 5050 }], false) + session.tick(40, 120) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(2) + expect(PLR_MODES[2]?.token).toBe('WL') + + // Stop in Blood Moor -> NU (1) + session.predictor.setPosition(5160, 5050) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(1) + expect(PLR_MODES[1]?.token).toBe('NU') + + // Local player dies -> mode must be 17 (DD = Dead/Corpse), never 12 (KK = Kick) + expect(PLR_MODES[12]?.token).toBe('KK') + expect(PLR_MODES[17]?.token).toBe('DD') + session.handleServerEvent({ + type: 'LifeMana', + life: 0, + mana: 0, + stamina: 0, + at: { x: 5160, y: 5050 }, + vx: 0, + vy: 0, + }) + const deadSelf = session.buildRenderableUnits().find((u) => u.id === 1)! + expect(deadSelf.isCorpse).toBe(true) + expect(deadSelf.mode).toBe(17) + expect(deadSelf.mode).not.toBe(12) + }) + + it('4. advances frames at 1.13c 25Hz (40ms tick) 8.8 fixed-point AnimData.d2 speed and reports missing clip speed', () => { + // speed = 256 -> 1 frame per 40ms tick (25 fps) + expect(computeAnimDataFrameIndex(0, 256, 8)).toBe(0) + expect(computeAnimDataFrameIndex(39, 256, 8)).toBe(0) + expect(computeAnimDataFrameIndex(40, 256, 8)).toBe(1) + expect(computeAnimDataFrameIndex(80, 256, 8)).toBe(2) + expect(computeAnimDataFrameIndex(320, 256, 8)).toBe(0) // loops at 8 frames + + // speed = 128 (SONUHTH) -> 1 frame per 80ms (12.5 fps) + expect(computeAnimDataFrameIndex(0, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(40, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(79, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(80, 128, 8)).toBe(1) + expect(computeAnimDataFrameIndex(160, 128, 8)).toBe(2) + + // speed = 80 (SOTNHTH) -> 1 frame per 128ms (7.8125 fps) + expect(computeAnimDataFrameIndex(0, 80, 16)).toBe(0) + expect(computeAnimDataFrameIndex(127, 80, 16)).toBe(0) + expect(computeAnimDataFrameIndex(128, 80, 16)).toBe(1) + expect(computeAnimDataFrameIndex(256, 80, 16)).toBe(2) + + // Non-looping death animation (DT, loop = false) clamps to frameCount - 1 + expect(computeAnimDataFrameIndex(10000, 256, 4, false)).toBe(3) + + // Fail-fast diagnostic when clip speed metadata is missing + clearMissingArtEntries() + const automap = new AutomapView({ initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1) + const baseX = levelView.subOriginX + 50 + const baseY = levelView.subOriginY + 50 + const source = new ClientWorldSceneSource( + (): ClientSceneWorldAdapter => ({ + tick: 1, + actId: 1, + levelId: 1, + playerSubX: baseX, + playerSubY: baseY, + units: [ + { + kind: 'player', + id: 1, + classId: 1, + x: baseX, + y: baseY, + mode: 5, // TN + direction: 0, + isLocalPlayer: true, + }, + ], + levelView, + }), + '800x600', + ) + const brokenCharArt: LoadedCharacter = { + handle: { index: 1, width: 64, height: 64 } as never, + groups: [[{ x: 0, y: 0, width: 32, height: 64 }]], + directions: 1, + walk: 0, + stand: 0, + members: 1, + // Missing 'tn' clip! + clips: {}, + } + const fakeRenderer = { + begin() {}, + draw() {}, + setLighting() {}, + setPalette() {}, + flush() {}, + } + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + characterArtByToken: new Map([['so', brokenCharArt]]), + nowMs: 0, + }) + expect( + getMissingArtEntries().some((e) => e.category === 'player-clip' && e.id.toLowerCase() === 'so:tn'), + ).toBe(true) + clearMissingArtEntries() + }) +}) From 45aa95ce2db9cebb37ae59fc827626b1714c1899 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 08:19:02 +0000 Subject: [PATCH 03/15] fix(client/netproto): align 1.13c monster/NPC mode decoding and prevent initial death state (#551) - Decode the initial 4-bit bitstream in 0xAC AssignNPC as monster mode nMode (D2Game.dll 0x6fcacac9, D2Client.dll 0x6fb5e462) and seed initial 1.13c facing via 0x6ac690c5 (D2Client.dll 0x6fafd512) - Map 0x69/0x6A/0x6B state command byte pkt[5] via 1.13c D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE, inverse of D2Game.dll 0x6fd1a718) and only apply pkt[10]/pkt[11] as lifePct on 0x06 (GH) or 0x08/0x09 (DT/DD) so non-death commands (e.g. Charsi 0x6B anvil sequence 0x0C) never zero out lifePct - Default alive monsters and players to mode=1 (NU) instead of mode=0 (DT) across ClientUnit, ClientWorld, OnlineSession, buildSceneDrawables, and drawUnitSprite - Transition monster/player modes across UnitMove and OnlineSession interpolator ticks (walk=2, run=15/3, knockback=9/19, stop=1) - Add 1.13c unit and live .d2cap regression tests Closes #551 --- src/client/motion/interpolate.ts | 4 +- src/client/scene/world-renderer.ts | 14 ++- src/client/session/online-session.ts | 50 +++++++-- src/client/view/unit-drawables.ts | 2 +- src/client/world/client-unit.ts | 30 ++++- src/client/world/client-world.ts | 44 ++++++-- src/netproto/d2gs/s2c/movement.ts | 20 +++- src/netproto/d2gs/s2c/units.ts | 158 +++++++++++++++++++++------ tests/client/client-world.test.ts | 94 ++++++++++++++++ tests/netproto/d2gs-s2c.test.ts | 112 +++++++++++++++++++ 10 files changed, 466 insertions(+), 62 deletions(-) diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 3a67336..ed187bf 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -88,7 +88,9 @@ export function resolveRemoteMoveSpeed(options: { const running = options.running ?? (options.moveType !== undefined - ? options.moveType === 0x17 || options.moveType === 0x18 || (options.moveType & 0x02) !== 0 + ? options.moveType === 0x17 || + options.moveType === 0x18 || + (options.moveType & 0x02) !== 0 : false) if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) { return { running, speedSubTilesPerSec: options.speedSubTilesPerSec } diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index b7f51be..f441603 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -505,13 +505,15 @@ function drawSceneUnitDrawable( art = resolveOnlineMonsterArt(unit, ctx.monsterArtMap) } if (art !== undefined) { - const isMoving = unit.mode === 2 || unit.mode === 13 || unit.mode === 15 + const isMoving = unit.mode === 2 || unit.mode === 15 const requestedClipKey = unit.isCorpse - ? art.clips?.dd - ? 'dd' - : art.clips?.dt - ? 'dt' - : 'nu' + ? unit.mode === 0 && art.clips?.dt + ? 'dt' + : art.clips?.dd + ? 'dd' + : art.clips?.dt + ? 'dt' + : 'nu' : (MONSTER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu')) const clip = art.clips?.[requestedClipKey] ?? diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 654bfe6..46e54a5 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1415,20 +1415,32 @@ export class OnlineSession { if (unit.unitType === 'player' && unit.id !== this.world.selfId) { const unitInTown = this.isSubTileInTown(state.x, state.y) if (state.moving && unit.moveMode !== 'stop') { - if (state.running || unit.mode === 3) { + if (unit.moveMode === 'knockback') { + unit.mode = 19 + } else if (state.running || unit.mode === 3) { unit.mode = 3 } else if (unit.mode === 2 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { unit.mode = unitInTown ? 6 : 2 } - } else if (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { + } else if ( + unit.mode === 2 || + unit.mode === 3 || + unit.mode === 6 || + unit.mode === 19 || + unit.mode === 1 || + unit.mode === 5 + ) { unit.mode = unitInTown ? 5 : 1 unit.moveMode = 'stop' } - } else if (unit.unitType === 'monster' && !state.moving) { - if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { - unit.mode = 1 - unit.moveMode = 'stop' - } + } else if ( + unit.unitType === 'monster' && + !state.moving && + unit.lifePct > 0 && + (unit.mode === 2 || unit.mode === 9 || unit.mode === 15) + ) { + unit.mode = 1 + unit.moveMode = 'stop' } } } @@ -1583,17 +1595,37 @@ export class OnlineSession { if (u.unitType === 'player' && !isCorpse && !castState) { const unitInTown = this.isSubTileInTown(ux, uy) if (interp?.moving && u.moveMode !== 'stop') { - mode = interp.running || u.mode === 3 ? 3 : unitInTown ? 6 : 2 + mode = + u.moveMode === 'knockback' + ? 19 + : interp.running || u.mode === 3 + ? 3 + : unitInTown + ? 6 + : 2 } else if ( u.mode === 1 || u.mode === 5 || - (interp !== null && interp !== undefined && !interp.moving && (u.mode === 2 || u.mode === 3 || u.mode === 6)) + (interp !== null && + interp !== undefined && + !interp.moving && + (u.mode === 2 || u.mode === 3 || u.mode === 6 || u.mode === 19)) ) { mode = unitInTown ? 5 : 1 } else if (u.mode === 2 || u.mode === 6) { mode = unitInTown ? 6 : 2 } u.mode = mode + } else if (u.unitType === 'monster') { + if (isCorpse) { + mode = u.mode === 0 ? 0 : 12 + } else if (castState) { + mode = castState.mode || u.mode || 1 + } else if (interp?.moving && u.moveMode !== 'stop') { + mode = u.moveMode === 'knockback' ? 9 : interp.running ? 15 : 2 + } else { + mode = u.mode || 1 + } } const targetX = castState?.targetX ?? u.targetX const targetY = castState?.targetY ?? u.targetY diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 0650ee8..2920138 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -487,7 +487,7 @@ export function buildSceneDrawables( const effectiveTargetY = unit.targetY ?? activeCastState?.targetY const effectiveMode = activeCastState ? activeCastState.mode || unit.mode || 10 - : (unit.mode ?? 0) + : (unit.mode ?? (unit.kind === 'player' || unit.kind === 'monster' ? (isCorpse ? 12 : 1) : 0)) const animMode = unit.animMode ?? (!isCorpse && (effectiveMode === 7 || effectiveMode === 4) diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 7ea2a28..4ace995 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -73,9 +73,17 @@ export class ClientUnit { this.name = snapshot.name this.x = snapshot.x this.y = snapshot.y - this.mode = snapshot.mode ?? (this.unitType === 'player' || this.unitType === 'monster' ? 1 : 0) - this.direction = snapshot.direction ?? 0 this.lifePct = snapshot.lifePct ?? 128 + const defaultMode = + this.unitType === 'monster' + ? this.lifePct <= 0 + ? 12 + : 1 + : this.unitType === 'player' + ? 1 + : 0 + this.mode = snapshot.mode ?? defaultMode + this.direction = snapshot.direction ?? 0 this.flags = snapshot.flags ?? 0 this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined) this.objectState = snapshot.objectState @@ -131,6 +139,13 @@ export class ClientUnit { } if (snapshot.lifePct !== undefined) { this.lifePct = snapshot.lifePct + if (snapshot.mode === undefined && this.unitType === 'monster') { + if (this.lifePct <= 0) { + this.mode = 12 + } else if (this.mode === 0 || this.mode === 12) { + this.mode = 1 + } + } } if (snapshot.flags !== undefined) { this.flags = snapshot.flags @@ -183,6 +198,17 @@ export class ClientUnit { this.y = from.y } this.moveMode = mode + if (!this.dead && (this.unitType === 'monster' || this.unitType === 'player')) { + if (mode === 'stop') { + this.mode = 1 + } else if (mode === 'run') { + this.mode = this.unitType === 'monster' ? 15 : 3 + } else if (mode === 'knockback') { + this.mode = this.unitType === 'monster' ? 9 : 19 + } else { + this.mode = 2 + } + } this.targetX = to.x this.targetY = to.y this.targetUnit = target diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 1c70e00..534ca6d 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -457,10 +457,10 @@ export class ClientWorld { if (dist <= step) { unit.x = unit.targetX unit.y = unit.targetY - if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6)) { + if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19)) { unit.mode = inTown ? 5 : 1 unit.moveMode = 'stop' - } else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 13 || unit.mode === 15)) { + } else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 9 || unit.mode === 15)) { unit.mode = 1 unit.moveMode = 'stop' } @@ -526,6 +526,17 @@ export class ClientWorld { } else if (ev.type === 'PlayerAssign' || ev.type === 'EntitySpawn') { const uType = ev.type === 'PlayerAssign' ? 'player' : unitTypeFromId(ev.unitType ?? 1) const uId = Number(ev.unitId ?? ev.id ?? 1) + const lifePct = ev.maxHp + ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) + : 128 + const defaultMode = + uType === 'monster' + ? lifePct <= 0 + ? 12 + : 1 + : uType === 'player' + ? 1 + : 0 ev = { type: 'UnitAssign', isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId === 0 || this.selfId === uId)), @@ -536,7 +547,8 @@ export class ClientWorld { name: ev.name ?? ev.code, x: Number(ev.x ?? 0), y: Number(ev.y ?? 0), - lifePct: ev.maxHp ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) : 128, + lifePct, + mode: ev.mode !== undefined ? Number(ev.mode) : defaultMode, }, } } else if (ev.type === 'EntityRemove') { @@ -753,7 +765,7 @@ export class ClientWorld { if (targetX !== unit.x || targetY !== unit.y) { unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) } - if (unit.unitType === 'player') { + if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) { const inTown = isTownLevel(this.areaId) if (moveMode === 'stop') { unit.mode = inTown ? 5 : 1 @@ -764,16 +776,22 @@ export class ClientWorld { } else { unit.mode = inTown ? 6 : 2 } - } else if (unit.unitType === 'monster') { + if (moveMode !== 'stop') { + unit.castState = undefined + } + } else if (unit.unitType === 'monster' && unit.lifePct > 0 && unit.mode !== 0 && unit.mode !== 12) { if (moveMode === 'stop') { - if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { - unit.mode = 1 - } + unit.mode = 1 } else if (moveMode === 'run') { unit.mode = 15 + } else if (moveMode === 'knockback') { + unit.mode = 9 } else { unit.mode = 2 } + if (moveMode !== 'stop') { + unit.castState = undefined + } } if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { @@ -816,6 +834,16 @@ export class ClientWorld { const uId = Number(ev.id ?? ev.unitId ?? 0) const unit = this.ensureUnit(ev.unitType, uId) unit.mode = ev.mode + if ( + ev.mode === 0 || + ev.mode === 1 || + ev.mode === 3 || + ev.mode === 12 || + ev.mode === 16 || + ev.mode === 17 + ) { + unit.castState = undefined + } if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) { unit.x = ev.at.x unit.y = ev.at.y diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index dbc74dc..0208243 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -14,11 +14,25 @@ function clampUnitType(raw: number): UnitType { } function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' { - // In D2Client.dll 0x6fac9830: 0x17 (0x0F run) / 0x18 (0x10 run) / 0x02 = run, 0x13/0x14/0x20/0x09 = knockback - if (rawMoveType === 0x20 || rawMoveType === 0x09 || rawMoveType === 0x13 || rawMoveType === 0x14) { + // In D2Client.dll 0x6fac9830 (player) & 0x6fb82e68 (monster): + // - 0x17 (0x0F/0x67 run) / 0x18 (0x10/0x68 run) / 0x02 = run + // - 0x13/0x14 (player KB), 0x0e/0x0f (monster KB), 0x20/0x09 = knockback + if ( + rawMoveType === 0x20 || + rawMoveType === 0x09 || + rawMoveType === 0x0e || + rawMoveType === 0x0f || + rawMoveType === 0x13 || + rawMoveType === 0x14 + ) { return 'knockback' } - if (rawMoveType === 0x17 || rawMoveType === 0x18 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) { + if ( + rawMoveType === 0x17 || + rawMoveType === 0x18 || + rawMoveType === 0x02 || + (rawMoveType & 0x02) !== 0 + ) { return 'run' } return 'walk' diff --git a/src/netproto/d2gs/s2c/units.ts b/src/netproto/d2gs/s2c/units.ts index a99476c..2ed0d15 100644 --- a/src/netproto/d2gs/s2c/units.ts +++ b/src/netproto/d2gs/s2c/units.ts @@ -124,7 +124,68 @@ export function decode0x51AssignObject(pkt: Uint8Array): ServerEvent { } } -/** 0x59 AssignPlayer (26B, D2Client.dll 0x6fb5df20) */ +/** + * 1.13c D2Client.dll `0x6fb82e68` (`30` entries) mapping monster state-command byte (`pkt[5]` in + * `0x67`, `0x68`, `0x69`, `0x6A`, `0x6B`) to canonical `MonMode.txt` ID (`0..16`). + * Inverse of D2Game.dll `0x6fd1a718` (`g_aMonModePacketTable`). + */ +export const MONSTER_CMD_TO_MODE: readonly number[] = [ + 2, // 0x00: WL (0x68 walk) + 2, // 0x01: WL (0x67 walk) + 16, // 0x02: special + 16, // 0x03: special + 7, // 0x04: SC (Skill1 / Cast no target) + 7, // 0x05: SC (Skill1 / Cast with target) + 3, // 0x06: GH (Get Hit) + 1, // 0x07: NU (Neutral / 0x6D stop) + 0, // 0x08: DT (Death animation) + 12, // 0x09: DD (Dead corpse) + 4, // 0x0A: A1 (Attack 1 with target) + 4, // 0x0B: A1 (Attack 1 no target) + 8, // 0x0C: SQ (Sequence no target) + 8, // 0x0D: SQ (Sequence with target) + 9, // 0x0E: KB (Knockback no target) + 9, // 0x0F: KB (Knockback with target) + 5, // 0x10: A2 (Attack 2 with target) + 5, // 0x11: A2 (Attack 2 no target) + 6, // 0x12: BL (Block) + 16, // 0x13: special + 13, // 0x14: S2 (Skill 2) + 14, // 0x15: S3 (Skill 3) + 14, // 0x16: S3 (Skill 3) + 15, // 0x17: RN (0x67 run) + 15, // 0x18: RN (0x68 run) + 8, // 0x19: SQ (Sequence) + 10, // 0x1A: S1 (no target) + 10, // 0x1B: S1 (with target) + 11, // 0x1C: S2 (no target) + 11, // 0x1D: S2 (with target) +] + +function resolveMonsterCmdMode(rawCmd: number): number { + const mapped = MONSTER_CMD_TO_MODE[rawCmd] + return mapped !== undefined ? mapped : rawCmd +} + +/** + * In 1.13c (`D2Game.dll 0x6fd13620` & `D2Client.dll 0x6fafdc70`): + * - `pkt[10]` (`0x69`/`0x6B`) / `pkt[11]` (`0x6A`) carries `lifePct & 0x7f` ONLY when `rawCmd === 0x06` (`MONMODE_GETHIT`, `0x6fd13829` / `0x6fafe16c`). + * - When `rawCmd === 0x08` (`MONMODE_DEATH`) or `0x09` (`MONMODE_DEAD`), `D2Client.dll` (`0x6fafe298`) sets HP to `0`. + * - For all other commands (e.g. `0x0C` `MONMODE_SEQUENCE` when Charsi hammers the anvil), the byte is `0` on the wire and must NOT overwrite `unit.lifePct` with `0`. + */ +function resolveMonsterCmdLifePct(rawCmd: number, paramByte: number): number | undefined { + if (rawCmd === 0x08 || rawCmd === 0x09) { + return 0 + } + if (rawCmd === 0x06) { + if (paramByte === 0) return undefined + const masked = paramByte & 0x7f + return masked > 0 ? masked : 128 + } + return paramByte > 0 && paramByte <= 0x80 ? paramByte : undefined +} + +/** 0x59 AssignPlayer (26B, D2Client.dll 0x6fb5df20 -> 0x6fac9275) */ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x59 @@ -142,64 +203,72 @@ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent { name, x, y, + mode: 1, }, isSelf: x !== 0 || y !== 0, } } -/** 0x69 SetNPCMode (12B, D2Client.dll 0x6fb5f390) */ +/** 0x69 SetNPCMode (12B, D2Client.dll 0x6fb5f390 -> 0x6fafdc70) */ export function decode0x69SetNpcMode(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x69 const id = r.u32LE() - const mode = r.u8() + const rawCmd = r.u8() const x = r.u16LE() const y = r.u16LE() - const lifePct = r.u8() - r.u8() + const paramByte = r.u8() + r.u8() // animSpeed + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), at: { x, y }, - lifePct, + ...(lifePct !== undefined ? { lifePct } : {}), } } -/** 0x6A NPCAction (12B, D2Client.dll 0x6fb5f340) */ +/** 0x6A NPCAction (12B, D2Client.dll 0x6fb5f340 -> 0x6fafdc70) */ export function decode0x6ANpcAction(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x6A const id = r.u32LE() - const mode = r.u8() - r.u8() - r.u32LE() - r.u8() + const rawCmd = r.u8() + r.u8() // targetType + r.u32LE() // targetId + const paramByte = r.u8() + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), + ...(lifePct !== undefined ? { lifePct } : {}), } } -/** 0x6B NPCAction2 (16B, D2Client.dll 0x6fb5f7c0) */ +/** 0x6B NPCAction2 (16B, D2Client.dll 0x6fb5f7c0 -> 0x6fb5f390 -> 0x6fafdc70) */ export function decode0x6BNpcAction2(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x6B const id = r.u32LE() - const mode = r.u8() - r.u32LE() + const rawCmd = r.u8() + r.u16LE() // targetX + r.u16LE() // targetY + const paramByte = r.u8() + r.u8() // animSpeed const x = r.u16LE() const y = r.u16LE() - r.u16LE() + const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte) return { type: 'UnitMode', unitType: 1, id, - mode, + mode: resolveMonsterCmdMode(rawCmd), at: { x, y }, + ...(lifePct !== undefined ? { lifePct } : {}), } } @@ -217,7 +286,15 @@ export function decode0x76UnitInSight(pkt: Uint8Array): ServerEvent { } } -/** 0xAC AssignNPC (variable pkt[12], D2Client.dll 0x6fb5e420) */ +/** + * 0xAC AssignNPC (variable pkt[12], D2Game.dll 0x6fcaca40 -> D2Client.dll 0x6fb5e420). + * + * Bitstream layout starting at offset 13 (`0x6fb5e462`): + * - 4 bits: initial monster mode `nMode` (`D2Game.dll 0x6fcacac9`: `0=DT`, `8=SQ`, `9=KB`, `12=DD`, + * or `1=NU` for all other alive modes; applied via `D2Common_11090` at `0x6fb5787c`/`0x6fafd345`) + * - 1 bit: `hasAnimComp` (`0x6fb5e47f`) + * - 1 bit: `hasMonsterFlags` (`0x6fb5e4fc`, followed by 5 boss flag bits when `hasAnimComp === 0`) + */ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0xAC @@ -228,27 +305,42 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { const lifePct = r.u8() const packetLen = r.u8() + let rawMode: number | undefined let flags = 0 if (packetLen > 13 && pkt.byteLength >= 13) { const bitReader = new BitReader(pkt.subarray(13, Math.min(pkt.byteLength, packetLen))) - if (bitReader.remainingBits >= 6) { - bitReader.readBits(4) // initial facing direction - const hasAnimComp = bitReader.readBit() - // Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2). - // If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc: - if (hasAnimComp === 0 && bitReader.remainingBits >= 1) { - const hasMonsterFlags = bitReader.readBit() - if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) { - if (bitReader.readBit()) flags |= 0x04 // champion - if (bitReader.readBit()) flags |= 0x08 // unique - if (bitReader.readBit()) flags |= 0x02 // superunique - if (bitReader.readBit()) flags |= 0x10 // minion - if (bitReader.readBit()) flags |= 0x40 // ghostly + if (bitReader.remainingBits >= 4) { + rawMode = bitReader.readBits(4) // 4-bit initial monster mode (D2Client.dll 0x6fb5e462) + if (bitReader.remainingBits >= 1) { + const hasAnimComp = bitReader.readBit() + // Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2). + // If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc: + if (hasAnimComp === 0 && bitReader.remainingBits >= 1) { + const hasMonsterFlags = bitReader.readBit() + if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) { + if (bitReader.readBit()) flags |= 0x04 // champion + if (bitReader.readBit()) flags |= 0x08 // unique + if (bitReader.readBit()) flags |= 0x02 // superunique + if (bitReader.readBit()) flags |= 0x10 // minion + if (bitReader.readBit()) flags |= 0x40 // ghostly + } } } } } + const mode = + lifePct <= 0 + ? rawMode === 0 || rawMode === 12 + ? rawMode + : 12 + : rawMode !== undefined && rawMode > 0 && rawMode !== 12 + ? rawMode + : 1 + + // D2Client.dll 0x6fafd512..0x6fafd59c (D2CLIENT_InitMonsterUnit): initial facing seeded by unit id + const direction = Math.imul(id, 0x6ac690c5) & 0x3f + return { type: 'UnitAssign', unit: { @@ -257,6 +349,8 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent { classId, x, y, + mode, + direction, lifePct, flags, }, diff --git a/tests/client/client-world.test.ts b/tests/client/client-world.test.ts index 3c2f423..82581b7 100644 --- a/tests/client/client-world.test.ts +++ b/tests/client/client-world.test.ts @@ -678,4 +678,98 @@ describe('ClientWorld authoritative state mirror', () => { expect(world.containers.tradeGrid).toHaveLength(0) expect(world.containers.partnerTradeGrid).toHaveLength(0) }) + + it('Issue #551: initializes alive monsters/NPCs (e.g. Rogue Scout) in MONMODE_NEUTRAL (1) instead of MONMODE_DEATH (0) and transitions mode on UnitMove', () => { + const world = new ClientWorld() + + // Assign an alive camp Rogue Scout without an explicit mode field + world.apply({ + type: 'UnitAssign', + unit: { + unitType: 1, + id: 42, + classId: 564, // act1hire (Rogue Scout) + x: 5110, + y: 5060, + lifePct: 128, + }, + }) + const rogue = world.getUnit(1, 42)! + expect(rogue).toBeDefined() + expect(rogue.mode).toBe(1) // MONMODE_NEUTRAL (1), never MONMODE_DEATH (0) + + // Patrol walk (0x67 NPCMove) transitions mode to MONMODE_WALK (2) + world.apply({ + type: 'UnitMove', + unitType: 1, + id: 42, + mode: 'walk', + from: { x: 5110, y: 5060 }, + to: { x: 5116, y: 5064 }, + }) + expect(rogue.mode).toBe(2) + + // Patrol stop (0x6D NPCStop) transitions mode back to MONMODE_NEUTRAL (1) + world.apply({ + type: 'UnitMove', + unitType: 1, + id: 42, + mode: 'stop', + to: { x: 5116, y: 5064 }, + }) + expect(rogue.mode).toBe(1) + + // Dead monster spawned with lifePct=0 defaults to MONMODE_DEAD (12) + world.apply({ + type: 'UnitAssign', + unit: { + unitType: 1, + id: 99, + classId: 1, + x: 5200, + y: 5200, + lifePct: 0, + }, + }) + expect(world.getUnit(1, 99)?.mode).toBe(12) + }) + + it('Issue #551: live .d2cap Rogue Encampment captures never render alive camp NPCs (Rogue Scout, Akara, Charsi, Kashya, Warriv, Gheed) as corpses or mode=0 (DT)', async () => { + const { readFileSync } = await import('node:fs') + const { resolve } = await import('node:path') + const { OnlineSession } = await import('../../src/client/session/online-session.ts') + const { createSettingsStore } = await import('../../src/client/settings/settings-store.ts') + const { buildSceneDrawables } = await import('../../src/client/view/unit-drawables.ts') + + for (const fixture of [ + '02-d2gs-town-walk-chat.d2cap', + '03-d2gs-items-npc-skills.d2cap', + '05-d2gs-vendor-open.d2cap', + ]) { + const text = readFileSync(resolve('tests/fixtures/netproto', fixture), 'utf8') + const session = new OnlineSession({ settingsStore: createSettingsStore() }) + session.loadD2CapReplay(text) + session.replayAllD2Cap() + for (let i = 0; i < 30; i++) session.tick(40, 1000 + i * 40) + + const renderUnits = session.buildRenderableUnits() + const monsterUnits = renderUnits.filter(u => u.kind === 'monster') + expect(monsterUnits.length, `${fixture} should have camp NPCs`).toBeGreaterThan(0) + + for (const npc of monsterUnits) { + expect(npc.isCorpse, `${fixture} npc id=${npc.id} classId=${npc.classId}`).toBe(false) + expect(npc.mode, `${fixture} npc id=${npc.id} classId=${npc.classId} mode`).not.toBe(0) + expect(npc.mode, `${fixture} npc id=${npc.id} classId=${npc.classId} mode`).not.toBe(12) + } + + const scene = buildSceneDrawables(renderUnits) + const monsterDrawables = scene.unitDrawables.filter(u => u.unitKind === 'monster') + expect(monsterDrawables.length).toBe(monsterUnits.length) + for (const d of monsterDrawables) { + expect(d.isCorpse, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).toBe(false) + expect(d.mode, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).toBeGreaterThan(0) + expect(d.mode, `${fixture} drawable id=${d.unitId} classId=${d.classId}`).not.toBe(12) + } + } + }) }) diff --git a/tests/netproto/d2gs-s2c.test.ts b/tests/netproto/d2gs-s2c.test.ts index ece8d34..0690eba 100644 --- a/tests/netproto/d2gs-s2c.test.ts +++ b/tests/netproto/d2gs-s2c.test.ts @@ -141,4 +141,116 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => { partnerId: 7, }) }) + + it('decodes 0xAC AssignNPC 4-bit bitstream mode (D2Game.dll 0x6fcacac9 / D2Client.dll 0x6fb5e462)', () => { + // Alive Rogue Scout (classId=564) with bitstream byte 0x01 (low 4 bits = 1 MONMODE_NEUTRAL) + const alivePkt = new Uint8Array([ + 0xac, + 0x2a, 0x00, 0x00, 0x00, // id = 42 + 0x34, 0x02, // classId = 564 (act1hire / Rogue Scout) + 0xf6, 0x13, // x = 5110 + 0xc4, 0x13, // y = 5060 + 0x80, // lifePct = 128 (full HP) + 0x0e, // packet length = 14 + 0x01, // bitstream: low 4 bits = 1 (MONMODE_NEUTRAL) + ]) + const aliveEv = decodeD2gsServerPacket(alivePkt) + expect(aliveEv.type).toBe('UnitAssign') + if (aliveEv.type === 'UnitAssign') { + expect(aliveEv.unit.unitType).toBe(1) + expect(aliveEv.unit.id).toBe(42) + expect(aliveEv.unit.classId).toBe(564) + expect(aliveEv.unit.lifePct).toBe(128) + expect(aliveEv.unit.mode).toBe(1) // MONMODE_NEUTRAL, never 0 (MONMODE_DEATH) + expect(aliveEv.unit.direction).toBeGreaterThanOrEqual(0) + expect(aliveEv.unit.direction).toBeLessThan(64) + } + + // Dead monster corpse (lifePct = 0, bitstream low 4 bits = 12 MONMODE_DEAD) + const corpsePkt = new Uint8Array([ + 0xac, + 0x99, 0x00, 0x00, 0x00, + 0x01, 0x00, + 0x00, 0x14, + 0x00, 0x14, + 0x00, // lifePct = 0 + 0x0e, + 0x0c, // low 4 bits = 12 (MONMODE_DEAD) + ]) + const corpseEv = decodeD2gsServerPacket(corpsePkt) + expect(corpseEv.type).toBe('UnitAssign') + if (corpseEv.type === 'UnitAssign') { + expect(corpseEv.unit.lifePct).toBe(0) + expect(corpseEv.unit.mode).toBe(12) + } + }) + + it('maps 0x69/0x6A/0x6B state command index via D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE)', () => { + // 0x69 SetNPCMode: [0x69, id:u32, cmd:u8, x:u16, y:u16, lifePct:u8, unk:u8] + const make69 = (cmd: number, lifePct: number) => { + const w = new ByteWriter(12) + w.u8(0x69) + w.u32LE(77) + w.u8(cmd) + w.u16LE(5100) + w.u16LE(5200) + w.u8(lifePct) + w.u8(0) + return decodeD2gsServerPacket(w.toUint8Array()) + } + + // cmd 0x07 -> mode 1 (MONMODE_NEUTRAL) + expect(make69(0x07, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 1, lifePct: 128 }) + // cmd 0x06 -> mode 3 (MONMODE_GETHIT) + expect(make69(0x06, 96)).toMatchObject({ type: 'UnitMode', id: 77, mode: 3, lifePct: 96 }) + // cmd 0x08 -> mode 0 (MONMODE_DEATH) + expect(make69(0x08, 0)).toMatchObject({ type: 'UnitMode', id: 77, mode: 0, lifePct: 0 }) + // cmd 0x09 -> mode 12 (MONMODE_DEAD) + expect(make69(0x09, 0)).toMatchObject({ type: 'UnitMode', id: 77, mode: 12, lifePct: 0 }) + // cmd 0x0A -> mode 4 (MONMODE_ATTACK1) + expect(make69(0x0a, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 4, lifePct: 128 }) + // cmd 0x10 -> mode 5 (MONMODE_ATTACK2) + expect(make69(0x10, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 5, lifePct: 128 }) + // cmd 0x04 -> mode 7 (MONMODE_CAST) + expect(make69(0x04, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 7, lifePct: 128 }) + + // Real 1.13c Charsi 0x6B NPCAction2 packet from 02-d2gs-town-walk-chat.d2cap: + // 6b 08000000 0c 000000000000 3f16 9a11 (rawCmd=0x0C Sequence, paramByte=0x00 must NOT zero out lifePct) + const charsi6b = new Uint8Array([ + 0x6b, + 0x08, 0x00, 0x00, 0x00, // id = 8 (Charsi) + 0x0c, // rawCmd = 0x0C -> mode 8 (MONMODE_SEQUENCE) + 0x00, 0x00, 0x00, 0x00, // targetX=0, targetY=0 + 0x00, 0x00, // paramByte=0, animSpeed=0 + 0x3f, 0x16, // x = 5695 + 0x9a, 0x11, // y = 4506 + ]) + const charsiEv = decodeD2gsServerPacket(charsi6b) + expect(charsiEv).toEqual({ + type: 'UnitMode', + unitType: 1, + id: 8, + mode: 8, + at: { x: 5695, y: 4506 }, + }) + }) + + it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType (0x01/0x17 and 0x00/0x18)', () => { + const w68Run = new ByteWriter(21) + w68Run.u8(0x68) + w68Run.u32LE(88) + w68Run.u8(0x18) // 0x18 = run in 0x68 NPCMoveToTarget + w68Run.u16LE(5100) + w68Run.u16LE(5200) + w68Run.u8(0) // targetType = player + w68Run.u32LE(1) // targetId = 1 + w68Run.bytes(new Uint8Array(6)) + const ev68 = decodeD2gsServerPacket(w68Run.toUint8Array()) + expect(ev68).toMatchObject({ + type: 'UnitMove', + unitType: 1, + id: 88, + mode: 'run', + }) + }) }) From e6347a9ac6a245731310471cfca3c4b1b45769ab Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 09:11:56 +0000 Subject: [PATCH 04/15] fix(client): restore canonical 1.13c spell visual effects (Closes #550) --- src/client/scene/world-renderer.ts | 30 +- src/client/session/online-session.ts | 123 +++- src/client/view/unit-drawables.ts | 210 ++++-- src/client/world/client-unit.ts | 46 ++ src/client/world/client-world.ts | 902 +++++++++++++++++++++++++- src/common/skills/missiles-data.ts | 575 ++++++++++++++++ src/netproto/d2gs/s2c/misc.ts | 7 +- src/netproto/domain/server-event.ts | 2 + tests/client/client-world.test.ts | 286 ++++++++ tests/client/hud-session-play.test.ts | 81 +++ 10 files changed, 2189 insertions(+), 73 deletions(-) diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index e7680d2..c88d4e7 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1,3 +1,4 @@ +import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts' import { dir64ToClipDirection } from '../../common/units/direction.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' import { npcDisplayName } from '../../common/units/npc-table.ts' @@ -734,6 +735,9 @@ function drawSceneMissileDrawable( ctx: SceneSourceRenderContext, nowMs: number, ): void { + if (missile.celFile.toLowerCase() === 'sqr') { + return + } const art = ctx.missileArtMap?.get(missile.missileKey) ?? ctx.missileArtMap?.get(missile.celFile.toLowerCase()) @@ -742,17 +746,21 @@ function drawSceneMissileDrawable( const dir = ((missile.direction % dirCount) + dirCount) % dirCount const group = art.frames[dir] ?? art.frames[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIdx = (missile.frame + Math.floor(nowMs / 50)) % frameCount + const frameIdx = + missile.loopAnim === false + ? Math.min(frameCount - 1, Math.max(0, Math.floor(missile.frame))) + : (missile.frame + Math.floor(nowMs / 50)) % frameCount const frame = group?.[frameIdx] ?? group?.[0] if (frame !== undefined) { + const altitude = missile.altitude ?? 0 const drawX = frame.anchorX !== undefined ? missile.worldX + frame.anchorX : missile.worldX - frame.width / 2 const drawY = - frame.anchorY !== undefined + (frame.anchorY !== undefined ? missile.worldY + frame.anchorY - : missile.worldY - frame.height / 2 + : missile.worldY - frame.height / 2) - altitude renderer.draw(frame, drawX, drawY, { atlas: art.handle, blendMode: missile.blendMode, @@ -773,13 +781,25 @@ function drawSceneOverlayDrawable( ctx: SceneSourceRenderContext, nowMs: number, ): void { + const lowerToken = overlay.token.toLowerCase() + const celFile = CANONICAL_113C_OVERLAYS[lowerToken]?.filename + const lowerCel = celFile?.toLowerCase() const art = ctx.overlayArtMap?.get(overlay.token) ?? - ctx.overlayArtMap?.get(overlay.token.toLowerCase()) + ctx.overlayArtMap?.get(lowerToken) ?? + (celFile ? ctx.overlayArtMap?.get(celFile) : undefined) ?? + (lowerCel ? ctx.overlayArtMap?.get(lowerCel) : undefined) ?? + ctx.missileArtMap?.get(overlay.token) ?? + ctx.missileArtMap?.get(lowerToken) ?? + (celFile ? ctx.missileArtMap?.get(celFile) : undefined) ?? + (lowerCel ? ctx.missileArtMap?.get(lowerCel) : undefined) if (art !== undefined) { const group = art.frames[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIdx = (overlay.frame + Math.floor(nowMs / 60)) % frameCount + const frameIdx = + overlay.loop === false + ? Math.min(frameCount - 1, Math.max(0, Math.floor(overlay.frame))) + : (overlay.frame + Math.floor(nowMs / 60)) % frameCount const frame = group?.[frameIdx] ?? group?.[0] if (frame !== undefined) { const drawX = diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..ce9d6e6 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -298,6 +298,40 @@ export class OnlineSession { this.predictor = options.predictor ?? new LocalMovementPredictor() this.interpolator = options.interpolator ?? new RemoteEntityInterpolator() + + this.world.setCollisionChecker((subX, subY) => { + const predSnap = this.predictor.getSnapshot() + const px = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x + const py = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y + const activeView = + this.levelView ?? + (this.mapService && this.world.areaId > 0 && (px !== 0 || py !== 0) + ? this.mapService.findLevelViewAtWorldSubTile(px, py, [ + this.world.areaId, + ...this.world.getRevealedAreaIds(), + ]) + : null) ?? + this.mapService?.getActiveLevelView() ?? + this.automapView.getLevelView() + if (!activeView) return false + if (typeof (activeView as any).isWalkableWorldSubTile === 'function') { + return !(activeView as any).isWalkableWorldSubTile(subX, subY) + } + const localX = Math.floor(subX - (activeView.subOriginX ?? 0)) + const localY = Math.floor(subY - (activeView.subOriginY ?? 0)) + if ( + localX < 0 || + localY < 0 || + localX >= activeView.subWidth || + localY >= activeView.subHeight + ) { + return false + } + const idx = localY * activeView.subWidth + localX + if (activeView.blocked && activeView.blocked[idx] === 1) return true + if (activeView.walkable) return activeView.walkable[idx] === 0 + return false + }) this.rateLimiter = options.rateLimiter ?? new OutboundRateLimiter() this.soundService = options.soundService ?? @@ -955,12 +989,65 @@ export class OnlineSession { : (cmd as ClientCommand) const res = this.rateLimiter.tryAcquire(normalized, nowMs) if (res.allowed) { + this.world.noteOutgoingCommand(normalized) this.forwardCommandToAdapter(normalized) return true } return false } + /** + * Alias for `dispatchCommand` used by input/HUD callers. + */ + sendCommand(cmd: ClientCommand | Record, nowMs = performance.now()): boolean { + return this.dispatchCommand(cmd, nowMs) + } + + /** + * Casts the active left or right skill at world sub-tile `(x, y)`. + */ + useSkillAt( + x: number, + y: number, + hand: 'left' | 'right' = 'right', + shift = false, + ): boolean { + const cmd: ClientCommand = { + type: 'SkillAt', + hand, + at: { x: Math.round(x), y: Math.round(y) }, + shift, + } + this.world.noteOutgoingCommand(cmd) + return this.dispatchCommand(cmd) + } + + /** + * Casts the active left or right skill on target unit `(unitKind, unitId)`. + */ + useSkillOn( + unitKind: UnitType, + unitId: number, + hand: 'left' | 'right' = 'right', + shift = false, + ): boolean { + const cmd: ClientCommand = { + type: 'SkillOn', + hand, + target: { unitType: unitKind, id: unitId }, + shift, + } + this.world.noteOutgoingCommand(cmd) + return this.dispatchCommand(cmd) + } + + /** + * Attacks target unit `(unitKind, unitId)` using the active left-hand skill. + */ + attackUnit(unitKind: UnitType, unitId: number, shift = false): boolean { + return this.useSkillOn(unitKind, unitId, 'left', shift) + } + private forwardCommandToAdapter(cmd: ClientCommand): void { this.outboundCommandsLog.push(cmd) if (this.outboundCommandsLog.length > 512) { @@ -1305,6 +1392,8 @@ export class OnlineSession { unit.direction = state.dir64 } } + + this.world.tick(dtMs, nowMs) } /** @@ -1353,6 +1442,7 @@ export class OnlineSession { isLocalPlayer: true, isCorpse: isSelfDead, ...(selfCastState !== undefined ? { castState: selfCastState } : {}), + ...(selfUnit && selfUnit.overlays.length > 0 ? { overlays: selfUnit.overlays } : {}), }) } @@ -1371,6 +1461,30 @@ export class OnlineSession { if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) { continue } + if (u.unitType === 'missile') { + out.push({ + id: u.id, + kind: 'missile', + classId: u.classId, + ...(u.name !== undefined ? { name: u.name } : {}), + ...(u.missileKey !== undefined ? { missileKey: u.missileKey } : {}), + x: u.x, + y: u.y, + ...(u.targetX !== undefined ? { targetX: u.targetX } : {}), + ...(u.targetY !== undefined ? { targetY: u.targetY } : {}), + ...(u.vx !== undefined ? { vx: u.vx } : {}), + ...(u.vy !== undefined ? { vy: u.vy } : {}), + ...(u.frame !== undefined ? { frame: u.frame } : {}), + ...(u.totalFrames !== undefined ? { totalFrames: u.totalFrames } : {}), + ...(u.loopAnim !== undefined ? { loopAnim: u.loopAnim } : {}), + ...(u.altitude !== undefined ? { altitude: u.altitude } : {}), + mode: u.mode, + direction: u.direction, + hpRatio: 1, + isCorpse: false, + }) + continue + } const interp = this.interpolator.getEntityState( u.unitType === 'player' ? 0 @@ -1378,11 +1492,9 @@ export class OnlineSession { ? 1 : u.unitType === 'object' ? 2 - : u.unitType === 'missile' - ? 3 - : u.unitType === 'item' - ? 4 - : 5, + : u.unitType === 'item' + ? 4 + : 5, u.id, ) const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined @@ -1543,6 +1655,7 @@ export class OnlineSession { isSuperUnique: u.isSuperUnique, states: Array.from(u.states.list()), ...(castState !== undefined ? { castState } : {}), + ...(u.overlays.length > 0 ? { overlays: u.overlays } : {}), ...(u.unitType === 'player' ? { isPartyMember } : {}), }) } diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 0650ee8..91ee19b 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -1,6 +1,12 @@ -import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts' +import { + CANONICAL_113C_MISSILES, + CANONICAL_113C_OVERLAYS, + MISSILE_CLASS_ID_TO_KEY, + getMissileTxtData, + getSkillCastOverlay, +} from '../../common/skills/missiles-data.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' -import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts' +import { assertDir64, dir64FromPoints, dir64ToClipDirection } from '../../common/units/direction.ts' import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts' import type { PointLightInput } from '../scene/lighting-presets.ts' import type { UnitCastState } from '../world/client-unit.ts' @@ -16,8 +22,11 @@ export interface UnitOverlayState { readonly token?: string | undefined readonly frame?: number | undefined readonly totalFrames?: number | undefined - readonly blendMode?: DrawableBlendMode | undefined + readonly blendMode?: DrawableBlendMode | 0 | 1 | 2 | 3 | undefined readonly yOffset?: number | undefined + readonly loop?: boolean | undefined + readonly lightRadius?: number | undefined + readonly lightColor?: readonly [number, number, number] | undefined } /** @@ -38,11 +47,17 @@ export interface RenderableUnitInput { readonly mode?: number | undefined /** * Facing. Players, monsters, objects and items: a dir64 (`common/units/direction.ts`), kept - * by the motion layer; 0 (south) when absent. Missiles: the 16-direction index used when no + * by the motion layer; 0 (south) when absent. Missiles: the 16-direction index or dir64 used when no * target is given. */ readonly direction?: number | undefined readonly frame?: number | undefined + readonly totalFrames?: number | undefined + readonly loopAnim?: boolean | undefined + readonly missileKey?: string | undefined + readonly vx?: number | undefined + readonly vy?: number | undefined + readonly altitude?: number | undefined readonly lifePct?: number | undefined readonly name?: string | undefined readonly code?: string | undefined @@ -121,6 +136,11 @@ export interface SceneMissileDrawable { readonly celFile: string readonly direction: number readonly frame: number + readonly totalFrames?: number | undefined + readonly loopAnim?: boolean | undefined + readonly vx?: number | undefined + readonly vy?: number | undefined + readonly altitude?: number | undefined readonly subX: number readonly subY: number readonly worldX: number @@ -139,6 +159,7 @@ export interface SceneOverlayDrawable { readonly token: string readonly frame: number readonly totalFrames: number + readonly loop?: boolean | undefined readonly worldX: number readonly worldY: number readonly sortY: number @@ -208,7 +229,10 @@ export function resolveUnitToken(unit: RenderableUnitInput): string { return token } if (unit.kind === 'missile') { - const key = MISSILE_ID_TO_KEY[unit.classId] + const key = + unit.missileKey && CANONICAL_113C_MISSILES[unit.missileKey.toLowerCase()] + ? unit.missileKey.toLowerCase() + : (MISSILE_ID_TO_KEY[unit.classId] ?? MISSILE_CLASS_ID_TO_KEY[unit.classId]) // Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile. return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}` } @@ -237,36 +261,40 @@ export function resolveMissileMetadata( classId: number, explicitToken?: string, ): { readonly key: string; readonly entry: MissileTxtEntry } | null { + const tokenKey = + explicitToken && explicitToken.trim() !== '' ? explicitToken.trim().toLowerCase() : undefined const key = - explicitToken && explicitToken.trim() !== '' - ? explicitToken.trim().toLowerCase() - : MISSILE_ID_TO_KEY[classId] - if (!key) return null + tokenKey && CANONICAL_113C_MISSILES[tokenKey] + ? tokenKey + : (MISSILE_ID_TO_KEY[classId] ?? MISSILE_CLASS_ID_TO_KEY[classId]) + if (!key || !CANONICAL_113C_MISSILES[key]) return null return { key, entry: getMissileTxtData(key), } } -function computeDirection8Or16( - fromX: number, - fromY: number, - toX: number | undefined, - toY: number | undefined, - fallbackDir = 0, - numDirs = 16, +function computeMissileClipDirection( + unit: RenderableUnitInput, + numDirs: number, ): number { - if (toX === undefined || toY === undefined) { - return ((fallbackDir % numDirs) + numDirs) % numDirs + const validDirs = [1, 2, 4, 8, 16, 32, 64].includes(numDirs) ? numDirs : 16 + if (validDirs === 1) return 0 + const fallbackDir64 = Number.isInteger(unit.direction) && (unit.direction ?? 0) >= 0 && (unit.direction ?? 0) < 64 + ? (unit.direction as number) + : 0 + if (unit.vx !== undefined && unit.vy !== undefined && (Math.abs(unit.vx) > 1e-5 || Math.abs(unit.vy) > 1e-5)) { + const dir64 = dir64FromPoints(0, 0, unit.vx, unit.vy, fallbackDir64) + return dir64ToClipDirection(dir64, validDirs) } - const dx = toX - fromX - const dy = toY - fromY - if (Math.abs(dx) < 1e-4 && Math.abs(dy) < 1e-4) { - return ((fallbackDir % numDirs) + numDirs) % numDirs + if (unit.targetX !== undefined && unit.targetY !== undefined) { + const dir64 = dir64FromPoints(unit.x, unit.y, unit.targetX, unit.targetY, fallbackDir64) + return dir64ToClipDirection(dir64, validDirs) } - const angle = Math.atan2(dy, dx) - const normalized = ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2) - return Math.floor(normalized * numDirs) % numDirs + if (Number.isInteger(unit.direction) && (unit.direction ?? 0) >= 0 && (unit.direction ?? 0) < 64) { + return dir64ToClipDirection(unit.direction as number, validDirs) + } + return (((unit.direction ?? 0) % validDirs) + validDirs) % validDirs } /** @@ -345,19 +373,17 @@ export function buildSceneDrawables( const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY) if (unit.kind === 'missile') { - const resolved = resolveMissileMetadata(unit.classId, unit.token) + const resolved = resolveMissileMetadata(unit.classId, unit.missileKey ?? unit.token ?? unit.name) if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art). const { key, entry } = resolved const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal' - const dir = computeDirection8Or16( - unit.x, - unit.y, - unit.targetX, - unit.targetY, - unit.direction ?? 0, - 16, - ) - const frame = unit.frame ?? (tick % 8) + const dir = computeMissileClipDirection(unit, entry.numDirections || 16) + const totalFrames = Math.max(1, unit.totalFrames ?? entry.animLen ?? 8) + const loopAnim = unit.loopAnim !== undefined ? unit.loopAnim : entry.loopAnim !== 0 + const rawFrame = unit.frame ?? tick + const frame = loopAnim + ? ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames + : Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame))) const lightUnits = entry.light > 0 ? entry.light : entry.trans === 1 ? 6 : 0 const isCold = entry.blue > entry.red const lightColor: readonly [number, number, number] = [ @@ -374,6 +400,11 @@ export function buildSceneDrawables( celFile: entry.celFile, direction: dir, frame, + totalFrames, + loopAnim, + ...(unit.vx !== undefined ? { vx: unit.vx } : {}), + ...(unit.vy !== undefined ? { vy: unit.vy } : {}), + ...(unit.altitude !== undefined ? { altitude: unit.altitude } : {}), subX: unit.x, subY: unit.y, worldX, @@ -404,11 +435,31 @@ export function buildSceneDrawables( const unitOverlays: SceneOverlayDrawable[] = [] if (unit.overlays) { for (const ov of unit.overlays) { + const ovTxt = ov.token ? CANONICAL_113C_OVERLAYS[ov.token.trim().toLowerCase()] : undefined const meta = OVERLAY_METADATA[ov.overlayId] - const totalFrames = ov.totalFrames ?? meta?.totalFrames ?? 16 - const frame = ov.frame ?? (tick % totalFrames) - const yOffset = ov.yOffset ?? meta?.yOffset ?? 0 - const blendMode = ov.blendMode ?? meta?.blendMode ?? 'additive' + const totalFrames = Math.max(1, ov.totalFrames ?? ovTxt?.frames ?? meta?.totalFrames ?? 16) + const isOneShot = ov.loop === false || (ovTxt !== undefined && ov.loop !== true) + const rawFrame = ov.frame ?? tick + const frame = isOneShot + ? Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame))) + : ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames + const yOffset = + ov.yOffset ?? + (ovTxt?.yOffset !== undefined ? -ovTxt.yOffset : undefined) ?? + meta?.yOffset ?? + 0 + const blendMode: DrawableBlendMode = + typeof ov.blendMode === 'string' + ? ov.blendMode + : typeof ov.blendMode === 'number' + ? ov.blendMode === 0 + ? 'normal' + : 'additive' + : ovTxt + ? ovTxt.trans === 0 + ? 'normal' + : 'additive' + : (meta?.blendMode ?? 'additive') const ovDrawable: SceneOverlayDrawable = { drawableKind: 'overlay', ownerUnitId: unit.id, @@ -416,6 +467,7 @@ export function buildSceneDrawables( token: ov.token ?? meta?.token ?? `OVL_${ov.overlayId}`, frame, totalFrames, + loop: !isOneShot, worldX, worldY: worldY + yOffset, sortY: worldY + 1, @@ -424,15 +476,23 @@ export function buildSceneDrawables( unitOverlays.push(ovDrawable) overlayDrawables.push(ovDrawable) - if (meta?.lightRadius && meta.lightColor) { + const lightRadius = + ov.lightRadius ?? + (ovTxt && ovTxt.radius > 0 ? Math.max(12, ovTxt.radius) : meta?.lightRadius) + const lightColor: readonly [number, number, number] | undefined = + ov.lightColor ?? + (ovTxt && (ovTxt.red > 0 || ovTxt.green > 0 || ovTxt.blue > 0) + ? [ovTxt.red, ovTxt.green, ovTxt.blue] + : meta?.lightColor) + if (lightRadius && lightColor) { pointLights.push({ subTileX: Math.round(unit.x), subTileY: Math.round(unit.y), - radius: meta.lightRadius, - intensity: 190, - r: meta.lightColor[0], - g: meta.lightColor[1], - b: meta.lightColor[2], + radius: lightRadius, + intensity: 210, + r: lightColor[0], + g: lightColor[1], + b: lightColor[2], }) } } @@ -457,6 +517,7 @@ export function buildSceneDrawables( token: meta.token, frame: tick % meta.totalFrames, totalFrames: meta.totalFrames, + loop: true, worldX, worldY: worldY + meta.yOffset, sortY: worldY + 1, @@ -496,16 +557,55 @@ export function buildSceneDrawables( ? 'SC' : undefined) - if (activeCastState && effectiveMode === 10) { - pointLights.push({ - subTileX: Math.round(unit.x), - subTileY: Math.round(unit.y), - radius: 10, - intensity: 210, - r: 255, - g: 195, - b: 110, - }) + if (activeCastState) { + const castOverlayKey = + activeCastState.castOverlayToken ?? getSkillCastOverlay(activeCastState.skillId) + if ( + castOverlayKey && + !unitOverlays.some((o) => o.token.toLowerCase() === castOverlayKey.toLowerCase()) + ) { + const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()] + if (ovTxt) { + const totalFrames = Math.max(1, ovTxt.frames) + const frame = Math.min(totalFrames - 1, Math.max(0, Math.floor(tick))) + const ovDrawable: SceneOverlayDrawable = { + drawableKind: 'overlay', + ownerUnitId: unit.id, + overlayId: activeCastState.skillId || 1000, + token: castOverlayKey, + frame, + totalFrames, + loop: false, + worldX, + worldY: worldY - (ovTxt.yOffset ?? 0), + sortY: worldY + 1, + blendMode: ovTxt.trans === 0 ? 'normal' : 'additive', + } + unitOverlays.push(ovDrawable) + overlayDrawables.push(ovDrawable) + if (ovTxt.radius > 0) { + pointLights.push({ + subTileX: Math.round(unit.x), + subTileY: Math.round(unit.y), + radius: Math.max(12, ovTxt.radius), + intensity: 215, + r: ovTxt.red, + g: ovTxt.green, + b: ovTxt.blue, + }) + } + } + } else if (effectiveMode === 10) { + pointLights.push({ + subTileX: Math.round(unit.x), + subTileY: Math.round(unit.y), + radius: 10, + intensity: 210, + r: 255, + g: 195, + b: 110, + }) + } } const dir = unitFacingDir64(unit, effectiveTargetX, effectiveTargetY) diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 26e927c..c2ca9ed 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -17,6 +17,22 @@ import { import { StatList } from './stat-list.ts' import { UnitStateSet } from './states.ts' +export interface ClientUnitOverlay { + readonly overlayId: number + readonly token: string + frame: number + readonly totalFrames: number + readonly animRate: number + readonly loop: boolean + readonly blendMode: 0 | 1 | 2 | 3 + readonly yOffset: number + readonly startedAtMs: number + ageTicks: number + readonly maxTicks: number + readonly lightRadius: number + readonly lightColor: readonly [number, number, number] +} + export interface UnitCastState { readonly skillId: number readonly targetX: number @@ -24,6 +40,7 @@ export interface UnitCastState { readonly mode: number readonly startedAtMs: number readonly untilMs: number + readonly castOverlayToken?: string | undefined } export function makeUnitKey(unitType: UnitType, id: number): string { @@ -62,6 +79,35 @@ export class ClientUnit { overheadText?: string | undefined itemData?: ItemData | undefined castState?: UnitCastState | undefined + lastSkillId?: number | undefined + overlays: ClientUnitOverlay[] = [] + // Missile simulation fields (populated when unitType === 'missile') + missileKey?: string | undefined + vx?: number | undefined + vy?: number | undefined + ageTicks?: number | undefined + maxTicks?: number | undefined + frame?: number | undefined + totalFrames?: number | undefined + animSpeed?: number | undefined + loopAnim?: boolean | undefined + numDirections?: number | undefined + explosionMissile?: string | undefined + alwaysExplode?: boolean | undefined + pierce?: boolean | undefined + hitUnitIds?: Set | undefined + altitude?: number | undefined + originX?: number | undefined + originY?: number | undefined + spiralAngle?: number | undefined + spiralRadius?: number | undefined + jitterIndex?: number | undefined + emitterKind?: 'frozenorb' | 'meteor_controller' | 'blizzard_controller' | undefined + subMissileIdx?: number | undefined + startedAtMs?: number | undefined + startedAtTick?: number | undefined + sourceSkillId?: number | undefined + skillLevel?: number | undefined readonly stats = new StatList() readonly states = new UnitStateSet() diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index e1e2a12..2c48c92 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -16,10 +16,23 @@ import { type SkillLevel, type UnitRef, type UnitType, + type UnitTypeName, } from '../../netproto/index.ts' import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts' +import { + CANONICAL_113C_MISSILES, + MISSILE_CLASS_ID_TO_KEY, + getMissileTxtData, + getOverlayTxtData, + getSkillCastOverlay, +} from '../../common/skills/missiles-data.ts' import { dir64FromPoints } from '../../common/units/direction.ts' -import { ClientUnit, makeUnitKey, type UnitCastState } from './client-unit.ts' +import { + ClientUnit, + makeUnitKey, + type ClientUnitOverlay, + type UnitCastState, +} from './client-unit.ts' import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts' import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts' @@ -143,9 +156,74 @@ const MAX_D2_STAT_ID = 0x1ff /** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */ const PHYSICAL_ATTACK_SKILL_IDS = new Set([ - 0, 2, 6, 7, 10, 19, 20, 24, 96, 97, 106, 107, 116, 126, 133, 139, 140, 144, 147, 151, 152, + 0, 2, 6, 7, 10, 11, 12, 16, 19, 20, 21, 22, 24, 26, 27, 31, 96, 97, 106, 107, 116, 126, 133, 139, + 140, 144, 147, 151, 152, ]) +/** Single-bolt linear projectile skills (1.13c Skills.txt srvmissile / cltmissilea). */ +const LINEAR_SKILL_MISSILES: Readonly> = + Object.freeze({ + 6: { key: 'magicarrow' }, + 7: { key: 'firearrow' }, + 11: { key: 'coldarrow' }, + 16: { key: 'explodingarrow' }, + 21: { key: 'icearrow' }, + 22: { key: 'guidedarrow' }, + 26: { key: 'strafearrow' }, + 27: { key: 'immolationarrow' }, + 31: { key: 'freezingarrow' }, + 36: { key: 'firebolt' }, + 39: { key: 'icebolt' }, + 45: { key: 'iceblast' }, + 47: { key: 'fireball' }, + 49: { key: 'lightning', pierce: true }, + 53: { key: 'chainlightning', pierce: true }, + 55: { key: 'glacialspike' }, + 84: { key: 'bonespear', pierce: true }, + 93: { key: 'bonespirit' }, + 101: { key: 'holybolt' }, + }) + +/** Radial ring skills (32 bolts in 360 degrees around caster). */ +const RADIAL_RING_SKILLS: Readonly> = Object.freeze({ + 44: 'frostnova', + 48: 'nova', + 92: 'poisonnova', +}) + +/** Necromancer curse target overlays (1.13c Skills.txt / Overlay.txt). */ +const CURSE_TARGET_OVERLAYS: Readonly> = Object.freeze({ + 66: 'curseampplydamage', + 71: 'cursedimvision', + 72: 'curseweaken', + 76: 'curseironmaiden', + 77: 'curseterror', + 81: 'curseconfuse', + 82: 'curselifetap', + 86: 'curseattract', + 87: 'cursedecrepify', + 91: 'curselowerresist', +}) + +export interface SpawnMissileOptions { + readonly id?: number | undefined + readonly classId?: number | undefined + readonly ownerType?: UnitTypeName | undefined + readonly ownerId?: number | undefined + readonly sourceSkillId?: number | undefined + readonly skillLevel?: number | undefined + readonly pierce?: boolean | undefined + readonly loopAnim?: boolean | undefined + readonly maxTicks?: number | undefined + readonly altitude?: number | undefined + readonly jitterIndex?: number | undefined + readonly emitterKind?: 'frozenorb' | 'meteor_controller' | 'blizzard_controller' | undefined + readonly originX?: number | undefined + readonly originY?: number | undefined + readonly spiralAngle?: number | undefined + readonly spiralRadius?: number | undefined +} + export class ClientWorld { act = 0 mapSeed = 0 @@ -195,6 +273,16 @@ export class ClientWorld { private _npcTransactionSeq = 0 private readonly _changeListeners = new Set<(ev: ServerEvent) => void>() + private _nextMissileId = 0x70000000 + private _nextOverlayId = 1 + private _lastLocalCastSkillId = -1 + private _lastLocalCastAtMs = -1000 + private _simTimeMs = 0 + private readonly _castSimStartMs = new Map() + private _notedCommands = new WeakSet() + private readonly _blockedSubTiles = new Set() + private _collisionChecker: ((subX: number, subY: number) => boolean) | null = null + get npcInteraction(): NpcInteractionState { return this._npcInteraction } @@ -216,15 +304,435 @@ export class ClientWorld { return this._mercHireList } - private applySkillCastToUnit(unit: ClientUnit, skillId: number, targetX: number, targetY: number): void { + /** + * Registers an optional world sub-tile collision callback (`true` = blocked wall/obstacle). + */ + setCollisionChecker(checker: ((subX: number, subY: number) => boolean) | null): void { + this._collisionChecker = checker + } + + /** + * Marks or unmarks an individual sub-tile `(subX, subY)` as blocked for missile collision. + */ + setSubTileBlocked(subX: number, subY: number, blocked = true): void { + const key = `${Math.floor(subX)},${Math.floor(subY)}` + if (blocked) { + this._blockedSubTiles.add(key) + } else { + this._blockedSubTiles.delete(key) + } + } + + /** + * Returns `true` when sub-tile `(subX, subY)` is blocked by a map wall/obstacle. + */ + isSubTileBlocked(subX: number, subY: number): boolean { + const ix = Math.floor(subX) + const iy = Math.floor(subY) + if (this._blockedSubTiles.has(`${ix},${iy}`)) return true + if (this._collisionChecker) { + return Boolean(this._collisionChecker(ix, iy)) + } + return false + } + + /** + * Attaches a 1.13c Overlay.txt visual onto `unit.overlays`. + */ + attachUnitOverlay(unit: ClientUnit, overlayToken: string, loop = false): ClientUnitOverlay { + const cleanToken = overlayToken.trim().toLowerCase() + const ovData = getOverlayTxtData(cleanToken) + const totalFrames = Math.max(1, ovData.frames || 14) + const animRate = Math.max(1, ovData.animRate || 16) + const maxTicks = Math.max(1, Math.ceil((totalFrames * 16) / animRate)) + const blendMode: 0 | 1 | 2 | 3 = + ovData.trans === 0 ? 0 : ovData.trans === 1 ? 1 : ovData.trans === 2 ? 2 : 3 + const entry: ClientUnitOverlay = { + overlayId: this._nextOverlayId++, + token: cleanToken, + frame: 0, + totalFrames, + animRate, + loop, + blendMode, + yOffset: ovData.yOffset ?? 0, + startedAtMs: Date.now(), + ageTicks: 0, + maxTicks, + lightRadius: ovData.radius ?? 0, + lightColor: [ovData.red ?? 255, ovData.green ?? 255, ovData.blue ?? 255] as const, + } + unit.overlays = unit.overlays.filter(o => o.token !== cleanToken || o.loop !== loop) + unit.overlays.push(entry) + return entry + } + + /** + * Spawns a client-side missile unit (`unitType = 'missile'`) initialized from 1.13c `Missiles.txt`. + */ + spawnMissileByKey( + missileKey: string, + startX: number, + startY: number, + targetX: number, + targetY: number, + opts?: SpawnMissileOptions, + ): ClientUnit { + const cleanKey = missileKey.trim().toLowerCase() + const data = getMissileTxtData(cleanKey) + const canonical = CANONICAL_113C_MISSILES[cleanKey] + const classId = opts?.classId ?? (canonical?.id && canonical.id > 0 ? canonical.id : data.id || 1) + const id = opts?.id ?? this._nextMissileId++ + const level = Math.max(1, opts?.skillLevel ?? 1) + + const dx = targetX - startX + const dy = targetY - startY + const dist = Math.hypot(dx, dy) + const dir64 = dir64FromPoints(startX, startY, targetX, targetY, 0) + + const speedSubTilesPerTick = data.vel > 0 ? data.vel / 8 : 0 + let vx = 0 + let vy = 0 + if (speedSubTilesPerTick > 0) { + if (dist > 1e-5) { + vx = (dx / dist) * speedSubTilesPerTick + vy = (dy / dist) * speedSubTilesPerTick + } else { + vx = speedSubTilesPerTick + vy = 0 + } + } + + const totalFrames = Math.max(1, data.animLen || 1) + const animSpeed = Math.max(1, data.animSpeed || 16) + const loopAnim = opts?.loopAnim ?? (data.loopAnim !== 0) + const defaultMaxTicks = + !loopAnim && data.vel === 0 + ? Math.max(1, Math.ceil((totalFrames * 16) / animSpeed)) + : Math.max(1, (data.range || 20) + Math.max(0, level - 1) * (data.levRange ?? 0)) + const maxTicks = opts?.maxTicks ?? defaultMaxTicks + + const unit = new ClientUnit({ + unitType: 'missile', + id, + classId, + name: cleanKey, + x: startX, + y: startY, + mode: 1, + direction: dir64, + ownerType: opts?.ownerType, + ownerId: opts?.ownerId, + }) + unit.missileKey = cleanKey unit.targetX = targetX unit.targetY = targetY + unit.vx = vx + unit.vy = vy + unit.ageTicks = 0 + unit.maxTicks = maxTicks + unit.frame = 0 + unit.totalFrames = totalFrames + unit.animSpeed = animSpeed + unit.loopAnim = loopAnim + unit.numDirections = data.numDirections || 1 + const rawRow = CANONICAL_113C_MISSILES[cleanKey] + unit.explosionMissile = data.explosionMissile || '' + unit.alwaysExplode = Boolean(rawRow?.alwaysExplode) + unit.pierce = opts?.pierce ?? Boolean(rawRow?.pierce) + unit.hitUnitIds = new Set() + unit.altitude = opts?.altitude + unit.originX = opts?.originX ?? startX + unit.originY = opts?.originY ?? startY + unit.spiralAngle = opts?.spiralAngle + unit.spiralRadius = opts?.spiralRadius + unit.jitterIndex = opts?.jitterIndex + unit.emitterKind = opts?.emitterKind + unit.startedAtMs = Date.now() + unit.startedAtTick = this.frameCount + unit.sourceSkillId = opts?.sourceSkillId + unit.skillLevel = level + + this._units.set(unit.key, unit) + return unit + } + + /** + * Spawns canonical 1.13c projectile/ground/radial/spiral/emitter missiles for a skill cast. + */ + private spawnMissilesForSkill( + caster: ClientUnit, + skillId: number, + targetX: number, + targetY: number, + skillLevel = 1, + ): void { + const ownerType = caster.unitType + const ownerId = caster.id + const startX = caster.x + const startY = caster.y + const dx = targetX - startX + const dy = targetY - startY + const baseAngle = dx === 0 && dy === 0 ? 0 : Math.atan2(dy, dx) + + // 1. Single-bolt linear projectiles + const linear = LINEAR_SKILL_MISSILES[skillId] + if (linear) { + const destX = dx === 0 && dy === 0 ? startX + 10 : targetX + const destY = dx === 0 && dy === 0 ? startY : targetY + this.spawnMissileByKey(linear.key, startX, startY, destX, destY, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + pierce: linear.pierce, + }) + return + } + + // 2. Multi-projectile fan spells: Charged Bolt (38), Teeth (67), Multiple Shot (12) + if (skillId === 38 || skillId === 67 || skillId === 12) { + const missileKey = + skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multiple shot' + const count = + skillId === 38 + ? Math.min(24, 2 + Math.max(1, skillLevel)) + : Math.min(24, Math.max(2, 1 + Math.max(1, skillLevel))) + const totalSpread = Math.min(Math.PI * 0.65, 0.14 * Math.max(1, count - 1)) + for (let i = 0; i < count; i++) { + const offset = count === 1 ? 0 : -totalSpread / 2 + (totalSpread * i) / (count - 1) + const angle = baseAngle + offset + const tx = startX + Math.cos(angle) * 20 + const ty = startY + Math.sin(angle) * 20 + this.spawnMissileByKey(missileKey, startX, startY, tx, ty, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + jitterIndex: skillId === 38 ? i + 1 : undefined, + }) + } + return + } + + // 3. Radial ring spells: Frost Nova (44), Nova (48), Poison Nova (92) + const radialKey = RADIAL_RING_SKILLS[skillId] + if (radialKey) { + const boltCount = 32 + for (let i = 0; i < boltCount; i++) { + const angle = (2 * Math.PI * i) / boltCount + const tx = startX + Math.cos(angle) * 16 + const ty = startY + Math.sin(angle) * 16 + this.spawnMissileByKey(radialKey, startX, startY, tx, ty, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + }) + } + return + } + + // 4. Spiral trajectory: Blessed Hammer (112) + if (skillId === 112) { + const tx = dx === 0 && dy === 0 ? startX + 8 : targetX + const ty = dx === 0 && dy === 0 ? startY : targetY + this.spawnMissileByKey('blessedhammer', startX, startY, tx, ty, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + pierce: true, + originX: startX, + originY: startY, + spiralAngle: baseAngle, + spiralRadius: 0, + }) + return + } + + // 5. Sub-missile emitter: Frozen Orb (64) + if (skillId === 64) { + const tx = dx === 0 && dy === 0 ? startX + 16 : targetX + const ty = dx === 0 && dy === 0 ? startY : targetY + this.spawnMissileByKey('frozenorb', startX, startY, tx, ty, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + pierce: true, + emitterKind: 'frozenorb', + }) + return + } + + // 6. Ground / Skyfall / Instant-target spells + if (skillId === 42) { + // Static Field: spawn lightninghit on nearby alive monsters (or at caster/target) + let hitAny = false + for (const m of this._monsters.values()) { + if (m.dead || (this._merc && m.id === this._merc.mercId)) continue + if (Math.hypot(m.x - startX, m.y - startY) <= 15) { + this.spawnMissileByKey('lightninghit', m.x, m.y, m.x, m.y, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + loopAnim: false, + }) + hitAny = true + } + } + if (!hitAny) { + this.spawnMissileByKey('lightninghit', targetX, targetY, targetX, targetY, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + loopAnim: false, + }) + } + return + } + + if (skillId === 43) { + // Telekinesis: lightninghit at target + this.spawnMissileByKey('lightninghit', targetX, targetY, targetX, targetY, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + loopAnim: false, + }) + return + } + + if (skillId === 46) { + // Blaze: stationary fire patch at caster's feet + this.spawnMissileByKey('blaze', startX, startY, startX, startY, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + }) + return + } + + if (skillId === 51) { + // Fire Wall: line of 5 fire segments perpendicular to caster->target direction + const perpAngle = baseAngle + Math.PI / 2 + const perpX = Math.cos(perpAngle) + const perpY = Math.sin(perpAngle) + for (let i = -2; i <= 2; i++) { + const fx = targetX + perpX * i * 1.5 + const fy = targetY + perpY * i * 1.5 + this.spawnMissileByKey('firewall', fx, fy, fx, fy, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + }) + } + return + } + + if (skillId === 56) { + // Meteor: target marker + descending skyfall meteor + this.spawnMissileByKey('meteorcenter', targetX, targetY, targetX, targetY, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + maxTicks: 25, + }) + const fallTicks = 25 + const mUnit = this.spawnMissileByKey( + 'meteor', + targetX - 10, + targetY - 10, + targetX, + targetY, + { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + maxTicks: fallTicks, + altitude: 240, + pierce: true, + }, + ) + mUnit.vx = 10 / fallTicks + mUnit.vy = 10 / fallTicks + mUnit.explosionMissile = 'meteorexplode' + mUnit.alwaysExplode = true + return + } + + if (skillId === 59) { + // Blizzard: controller + initial falling shard + this.spawnMissileByKey('blizzardcenter', targetX, targetY, targetX, targetY, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + emitterKind: 'blizzard_controller', + maxTicks: 100, + }) + const shard = this.spawnMissileByKey('blizzard1', targetX, targetY, targetX, targetY, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + maxTicks: 8, + }) + shard.explosionMissile = 'freezeexplode' + shard.alwaysExplode = true + return + } + + if (skillId === 121) { + // Fist of the Heavens + this.spawnMissileByKey('foa', targetX, targetY, targetX, targetY, { + ownerType, + ownerId, + sourceSkillId: skillId, + skillLevel, + maxTicks: 6, + }) + return + } + + // 7. Necromancer curses: attach target overlay to monsters near target + const curseOverlay = CURSE_TARGET_OVERLAYS[skillId] + if (curseOverlay) { + for (const m of this._monsters.values()) { + if (m.dead) continue + if (Math.hypot(m.x - targetX, m.y - targetY) <= 6) { + this.attachUnitOverlay(m, curseOverlay, false) + } + } + } + } + + private applySkillCastToUnit( + unit: ClientUnit, + skillId: number, + targetX: number, + targetY: number, + skillLevel = 1, + spawnVisuals = true, + ): void { + unit.targetX = targetX + unit.targetY = targetY + unit.lastSkillId = skillId // `ClientUnit.direction` is a dir64 (`common/units/direction.ts`): face the target through the // same 1.13c direction table as movement; a target on the unit's own position keeps the facing. unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId) const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10 unit.mode = mode + const castOverlayToken = getSkillCastOverlay(skillId) const now = Date.now() const castState: UnitCastState = { skillId, @@ -233,11 +741,24 @@ export class ClientWorld { mode, startedAtMs: now, untilMs: now + 400, + castOverlayToken, } unit.castState = castState + this._castSimStartMs.set(unit.key, this._simTimeMs) if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { this._self.castState = castState } + + if (spawnVisuals) { + if (castOverlayToken) { + this.attachUnitOverlay(unit, castOverlayToken, false) + } + const effectiveLevel = + unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0) + ? Math.max(1, this._self.skills.get(skillId) ?? skillLevel) + : Math.max(1, skillLevel) + this.spawnMissilesForSkill(unit, skillId, targetX, targetY, effectiveLevel) + } } /** @@ -247,6 +768,10 @@ export class ClientWorld { * sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08). */ noteOutgoingCommand(cmd: ClientCommand): void { + if (cmd && typeof cmd === 'object') { + if (this._notedCommands.has(cmd)) return + this._notedCommands.add(cmd) + } const cur = this._npcInteraction switch (cmd.type) { case 'NpcInit': @@ -316,6 +841,8 @@ export class ClientWorld { const skillId = isRight ? this._self.rightSkill : this._self.leftSkill const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y + this._lastLocalCastSkillId = skillId + this._lastLocalCastAtMs = Date.now() if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY) @@ -333,6 +860,8 @@ export class ClientWorld { const targetUnit = this.getUnit(targetRef.unitType, targetRef.id) const targetX = targetUnit?.x ?? this._self.x const targetY = targetUnit?.y ?? this._self.y + this._lastLocalCastSkillId = skillId + this._lastLocalCastAtMs = Date.now() if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) selfUnit.targetUnit = targetRef @@ -430,10 +959,322 @@ export class ClientWorld { return this._soundQueue.splice(0, this._soundQueue.length) } - tick(dtMs = 40): void { + private triggerMissileImpact( + missile: ClientUnit, + hitX: number, + hitY: number, + isWallHit: boolean, + ): void { + const expKey = + missile.explosionMissile || + (!isWallHit && + (missile.missileKey === 'lightning' || + missile.missileKey === 'chainlightning' || + missile.missileKey === 'chargedbolt' || + missile.missileKey === 'nova') + ? 'lightninghit' + : '') + + if (expKey) { + const exp = this.spawnMissileByKey(expKey, hitX, hitY, hitX, hitY, { + ownerType: missile.ownerType, + ownerId: missile.ownerId, + sourceSkillId: missile.sourceSkillId, + skillLevel: missile.skillLevel, + loopAnim: false, + }) + exp.vx = 0 + exp.vy = 0 + } + + // Chain Lightning bounce to nearest unhit alive hostile monster within 10 sub-tiles + if (!isWallHit && missile.missileKey === 'chainlightning') { + let nearest: ClientUnit | null = null + let bestDist = 10 + for (const m of this._monsters.values()) { + if (m.dead || (this._merc && m.id === this._merc.mercId)) continue + if (missile.hitUnitIds?.has(m.id)) continue + const d = Math.hypot(m.x - hitX, m.y - hitY) + if (d < bestDist) { + bestDist = d + nearest = m + } + } + if (nearest) { + const speed = Math.hypot(missile.vx ?? 0, missile.vy ?? 0) || 1.5 + const dx = nearest.x - hitX + const dy = nearest.y - hitY + const dist = Math.hypot(dx, dy) || 1 + missile.vx = (dx / dist) * speed + missile.vy = (dy / dist) * speed + missile.direction = dir64FromPoints(hitX, hitY, nearest.x, nearest.y, missile.direction) + } + } + } + + private stepMissileUnit(unit: ClientUnit): void { + const prevX = unit.x + const prevY = unit.y + const vx = unit.vx ?? 0 + const vy = unit.vy ?? 0 + const isMoving = vx !== 0 || vy !== 0 || unit.missileKey === 'blessedhammer' + + if (isMoving) { + if (unit.missileKey === 'blessedhammer') { + unit.spiralAngle = (unit.spiralAngle ?? 0) + 0.28 + unit.spiralRadius = (unit.spiralRadius ?? 0) + 0.45 + const nextX = (unit.originX ?? unit.x) + Math.cos(unit.spiralAngle) * unit.spiralRadius + const nextY = (unit.originY ?? unit.y) + Math.sin(unit.spiralAngle) * unit.spiralRadius + unit.vx = nextX - unit.x + unit.vy = nextY - unit.y + unit.x = nextX + unit.y = nextY + } else if (unit.missileKey === 'chargedbolt') { + const speed = Math.hypot(vx, vy) || 1 + const perpX = -vy / speed + const perpY = vx / speed + const phase = ((unit.ageTicks ?? 0) + (unit.jitterIndex ?? 0) * 3) % 4 + const lateral = phase === 0 ? 0.35 : phase === 2 ? -0.35 : 0 + unit.x += vx + perpX * lateral + unit.y += vy + perpY * lateral + } else { + unit.x += vx + unit.y += vy + if (unit.altitude !== undefined && unit.altitude > 0) { + const progress = Math.min( + 1, + ((unit.ageTicks ?? 0) + 1) / Math.max(1, unit.maxTicks ?? 1), + ) + unit.altitude = Math.max(0, Math.round(240 * (1 - progress))) + } + } + unit.direction = dir64FromPoints(0, 0, unit.vx ?? 0, unit.vy ?? 0, unit.direction) + } + + unit.ageTicks = (unit.ageTicks ?? 0) + 1 + const animSpeed = Math.max(1, unit.animSpeed ?? 16) + const totalFrames = Math.max(1, unit.totalFrames ?? 1) + const rawFrame = Math.floor((unit.ageTicks * animSpeed) / 16) + unit.frame = + unit.loopAnim === false ? Math.min(totalFrames - 1, rawFrame) : rawFrame % totalFrames + + // Sub-missile emitter logic + if (unit.emitterKind === 'frozenorb' && unit.ageTicks % 2 === 0) { + for (let b = 0; b < 2; b++) { + const angle = unit.ageTicks * 0.65 + b * Math.PI + const tx = unit.x + Math.cos(angle) * 16 + const ty = unit.y + Math.sin(angle) * 16 + this.spawnMissileByKey('frozenorbbolt', unit.x, unit.y, tx, ty, { + ownerType: unit.ownerType, + ownerId: unit.ownerId, + sourceSkillId: unit.sourceSkillId, + skillLevel: unit.skillLevel, + }) + } + } else if (unit.emitterKind === 'blizzard_controller' && unit.ageTicks % 5 === 0) { + const idx = Math.floor(unit.ageTicks / 5) + const angle = idx * 2.39996 // golden angle distribution + const rad = (idx % 4) + 0.5 + const sx = unit.x + Math.cos(angle) * rad + const sy = unit.y + Math.sin(angle) * rad + const shard = this.spawnMissileByKey('blizzard1', sx, sy, sx, sy, { + ownerType: unit.ownerType, + ownerId: unit.ownerId, + sourceSkillId: unit.sourceSkillId, + skillLevel: unit.skillLevel, + maxTicks: 8, + }) + shard.explosionMissile = 'freezeexplode' + shard.alwaysExplode = true + } + + // Wall / obstacle collision check along the step segment (only for ground-level flying projectiles) + if (isMoving && (unit.altitude ?? 0) <= 0) { + const stepDist = Math.hypot(unit.x - prevX, unit.y - prevY) + const samples = Math.max(1, Math.ceil(stepDist * 2)) + for (let s = 1; s <= samples; s++) { + const t = s / samples + const sx = prevX + (unit.x - prevX) * t + const sy = prevY + (unit.y - prevY) * t + if (this.isSubTileBlocked(sx, sy)) { + unit.x = sx + unit.y = sy + if (unit.emitterKind === 'frozenorb') { + for (let i = 0; i < 16; i++) { + const angle = (2 * Math.PI * i) / 16 + this.spawnMissileByKey( + 'frozenorbbolt', + unit.x, + unit.y, + unit.x + Math.cos(angle) * 16, + unit.y + Math.sin(angle) * 16, + { + ownerType: unit.ownerType, + ownerId: unit.ownerId, + sourceSkillId: unit.sourceSkillId, + skillLevel: unit.skillLevel, + }, + ) + } + } + this.triggerMissileImpact(unit, unit.x, unit.y, true) + this._units.delete(unit.key) + return + } + } + } + + // Hostile unit collision check along the step segment (only for ground-level flying projectiles, excluding Frozen Orb carrier) + if (isMoving && (unit.altitude ?? 0) <= 0 && unit.emitterKind !== 'frozenorb') { + const hitRadius = 1.75 + const segDx = unit.x - prevX + const segDy = unit.y - prevY + const segLenSq = segDx * segDx + segDy * segDy + const distToSeg = (tx: number, ty: number): number => { + if (segLenSq <= 1e-6) return Math.hypot(tx - unit.x, ty - unit.y) + const t = Math.max(0, Math.min(1, ((tx - prevX) * segDx + (ty - prevY) * segDy) / segLenSq)) + return Math.hypot(tx - (prevX + segDx * t), ty - (prevY + segDy * t)) + } + if (unit.ownerType === 'monster') { + for (const target of this._units.values()) { + if (target.unitType !== 'player' && !(this._merc && target.id === this._merc.mercId)) { + continue + } + if (target.dead) continue + if (unit.hitUnitIds?.has(target.id)) continue + if (distToSeg(target.x, target.y) <= hitRadius) { + unit.hitUnitIds?.add(target.id) + this.triggerMissileImpact(unit, target.x, target.y, false) + if (!unit.pierce) { + this._units.delete(unit.key) + return + } + } + } + } else { + for (const target of this._monsters.values()) { + if (target.dead) continue + if (this._merc && target.id === this._merc.mercId) continue + if (unit.hitUnitIds?.has(target.id)) continue + if (distToSeg(target.x, target.y) <= hitRadius) { + unit.hitUnitIds?.add(target.id) + this.triggerMissileImpact(unit, target.x, target.y, false) + if (!unit.pierce) { + this._units.delete(unit.key) + return + } + } + } + } + } + + // Lifetime / range expiry check + if (unit.ageTicks >= (unit.maxTicks ?? 1)) { + if (unit.emitterKind === 'frozenorb') { + for (let i = 0; i < 16; i++) { + const angle = (2 * Math.PI * i) / 16 + this.spawnMissileByKey( + 'frozenorbbolt', + unit.x, + unit.y, + unit.x + Math.cos(angle) * 16, + unit.y + Math.sin(angle) * 16, + { + ownerType: unit.ownerType, + ownerId: unit.ownerId, + sourceSkillId: unit.sourceSkillId, + skillLevel: unit.skillLevel, + }, + ) + } + this.triggerMissileImpact(unit, unit.x, unit.y, false) + } else if (unit.missileKey === 'meteor') { + this.triggerMissileImpact(unit, unit.x, unit.y, false) + for (let i = -1; i <= 1; i++) { + this.spawnMissileByKey('meteorfire', unit.x + i, unit.y, unit.x + i, unit.y, { + ownerType: unit.ownerType, + ownerId: unit.ownerId, + sourceSkillId: unit.sourceSkillId, + skillLevel: unit.skillLevel, + }) + } + } else if (unit.missileKey === 'foa') { + for (const m of this._monsters.values()) { + if (m.dead || (this._merc && m.id === this._merc.mercId)) continue + if (Math.hypot(m.x - unit.x, m.y - unit.y) <= 12) { + this.spawnMissileByKey('fistoftheheavensbolt', unit.x, unit.y, m.x, m.y, { + ownerType: unit.ownerType, + ownerId: unit.ownerId, + sourceSkillId: unit.sourceSkillId, + skillLevel: unit.skillLevel, + }) + } + } + } else if (unit.alwaysExplode && unit.explosionMissile) { + this.triggerMissileImpact(unit, unit.x, unit.y, false) + } + this._units.delete(unit.key) + } + } + + tick(dtMs = 40, nowMs?: number): void { this.frameCount++ - const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10) - for (const unit of this._units.values()) { + const safeDt = Math.max(0, dtMs) + this._simTimeMs += safeDt + const step = Math.max(1, (safeDt / 1000) * 10) + const ticksToStep = Math.max(1, Math.round(safeDt / 40)) + + const unitsSnapshot = Array.from(this._units.values()) + for (const unit of unitsSnapshot) { + if (!this._units.has(unit.key)) continue + + // 1. Step missile units + if (unit.unitType === 'missile') { + for (let t = 0; t < ticksToStep; t++) { + if (!this._units.has(unit.key)) break + this.stepMissileUnit(unit) + } + continue + } + + // 2. Step unit overlays (one-shot cast overlays & state overlays) + if (unit.overlays.length > 0) { + const remainingOverlays: ClientUnitOverlay[] = [] + for (const ov of unit.overlays) { + ov.ageTicks += ticksToStep + const rawFrame = Math.floor((ov.ageTicks * ov.animRate) / 16) + if (ov.loop) { + ov.frame = rawFrame % Math.max(1, ov.totalFrames) + remainingOverlays.push(ov) + } else if (ov.ageTicks < ov.maxTicks) { + ov.frame = Math.min(Math.max(0, ov.totalFrames - 1), rawFrame) + remainingOverlays.push(ov) + } + } + unit.overlays = remainingOverlays + } + + // 3. Expire castState when cast window completes + if (unit.castState) { + const simStart = this._castSimStartMs.get(unit.key) ?? this._simTimeMs + const simElapsed = this._simTimeMs - simStart + const wallExpired = nowMs !== undefined && nowMs >= unit.castState.untilMs + if (simElapsed >= 400 || wallExpired) { + unit.castState = undefined + this._castSimStartMs.delete(unit.key) + if (!unit.dead) { + unit.mode = 1 // return to NU (neutral) + unit.targetX = unit.x + unit.targetY = unit.y + } + if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { + this._self.castState = undefined + } + } + continue + } + + // 4. Interpolate moving non-casting units if (unit.dead) continue if (unit.targetX === undefined || unit.targetY === undefined) continue const dx = unit.targetX - unit.x @@ -768,6 +1609,33 @@ export class ClientWorld { ev.caster?.id ?? ev.monsterId ?? ev.unitId ?? ev.attackerId ?? 0, ) const skillId = Number(ev.skillId ?? ev.skill ?? ev.attackType ?? 0) + const level = Number(ev.level ?? 1) || 1 + + // 0x73 CreateMissile: caster.unitType === 3 ('missile') + if (unitTypeFromId(casterType) === 'missile') { + const missileClassId = skillId + const missileKey = MISSILE_CLASS_ID_TO_KEY[missileClassId] ?? 'arrow' + const ownerType = ev.owner ? unitTypeFromId(ev.owner.unitType) : 'player' + const ownerId = ev.owner?.id ?? 0 + const ownerUnit = ev.owner ? this.getUnit(ev.owner.unitType, ev.owner.id) : undefined + const startX = ev.at ? Number(ev.at.x) : (ownerUnit?.x ?? this._self.x) + const startY = ev.at ? Number(ev.at.y) : (ownerUnit?.y ?? this._self.y) + let targetX = startX + let targetY = startY + if (ev.target && typeof ev.target === 'object' && 'x' in ev.target) { + targetX = Number(ev.target.x ?? startX) + targetY = Number(ev.target.y ?? startY) + } + this.spawnMissileByKey(missileKey, startX, startY, targetX, targetY, { + id: casterId > 0 ? casterId : undefined, + classId: missileClassId, + ownerType, + ownerId, + skillLevel: level, + }) + break + } + let unit = this.getUnit(casterType, casterId) if (!unit && unitTypeFromId(casterType) === 'player' && (casterId === this.selfId || this.selfId === 0)) { unit = this.ensureUnit('player', casterId || this.selfId || 1) @@ -798,7 +1666,19 @@ export class ClientWorld { } } } - this.applySkillCastToUnit(unit, skillId, targetX, targetY) + + const isSelfEcho = + unit.unitType === 'player' && + (unit.id === this.selfId || this.selfId === 0) && + skillId === this._lastLocalCastSkillId && + Date.now() - this._lastLocalCastAtMs >= 0 && + Date.now() - this._lastLocalCastAtMs <= 250 + if (isSelfEcho) { + this._lastLocalCastSkillId = -1 + this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false) + } else { + this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true) + } break } @@ -1352,6 +2232,14 @@ export class ClientWorld { this._chatLog.length = 0 this._soundQueue.length = 0 this._portals.clear() + this._nextMissileId = 0x70000000 + this._nextOverlayId = 1 + this._lastLocalCastSkillId = -1 + this._lastLocalCastAtMs = -1000 + this._simTimeMs = 0 + this._castSimStartMs.clear() + this._notedCommands = new WeakSet() + this._blockedSubTiles.clear() } clear(): void { diff --git a/src/common/skills/missiles-data.ts b/src/common/skills/missiles-data.ts index 0a2eb69..4dd0eed 100644 --- a/src/common/skills/missiles-data.ts +++ b/src/common/skills/missiles-data.ts @@ -216,6 +216,8 @@ export interface MissileTxtData { readonly trans?: number | undefined readonly subLoop?: number | undefined readonly subStop?: number | undefined + readonly alwaysExplode?: number | boolean | undefined + readonly pierce?: number | boolean | undefined } /** Canonical 1.13c Ground Truth Missiles.txt defaults. */ @@ -1518,8 +1520,248 @@ export const CANONICAL_113C_MISSILES: Readonly> = trans: 1, distancePx: 750, }, + bolt: { + name: 'bolt', + id: 1, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'Spike', + animLen: 1, + animSpeed: 16, + loopAnim: 1, + numDirections: 32, + explosionMissile: '', + light: 0, + red: 0, + green: 0, + blue: 0, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + coldarrow: { + name: 'coldarrow', + id: 13, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'ColdArrow', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: '', + light: 3, + red: 112, + green: 160, + blue: 255, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + guidedarrow: { + name: 'guidedarrow', + id: 31, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'Arrow', + animLen: 1, + animSpeed: 16, + loopAnim: 1, + numDirections: 32, + explosionMissile: '', + light: 0, + red: 255, + green: 255, + blue: 255, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + explodingarrow: { + name: 'explodingarrow', + id: 41, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'FireArrow', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: 'explodingarrowexp', + alwaysExplode: true, + light: 3, + red: 255, + green: 175, + blue: 55, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + immolationarrow: { + name: 'immolationarrow', + id: 63, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'FireArrow', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: 'explodingarrowexp', + alwaysExplode: true, + light: 5, + red: 255, + green: 175, + blue: 55, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + immolationfire: { + name: 'immolationfire', + id: 64, + vel: 0, + maxVel: 0, + range: 75, + levRange: 0, + celFile: 'FireSmall', + animLen: 21, + animSpeed: 16, + loopAnim: 1, + numDirections: 1, + explosionMissile: '', + light: 3, + red: 255, + green: 185, + blue: 75, + speedPxPerSec: 0, + trans: 1, + distancePx: 0, + clientAlias: true, + }, + freezingarrow: { + name: 'freezingarrow', + id: 87, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'ColdArrow', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: 'freezingarrowexp1', + alwaysExplode: true, + light: 3, + red: 112, + green: 160, + blue: 255, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + foa: { + name: 'foa', + id: 211, + vel: 0, + maxVel: 0, + range: 25, + levRange: 0, + celFile: 'FistOfTheHeavensDelay', + animLen: 5, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: 'fistoftheheavensbolt', + alwaysExplode: true, + light: 8, + red: 255, + green: 255, + blue: 255, + speedPxPerSec: 0, + trans: 1, + distancePx: 0, + clientAlias: true, + }, + fistoftheheavensbolt: { + name: 'fistoftheheavensbolt', + id: 212, + vel: 24, + maxVel: 24, + range: 25, + levRange: 0, + celFile: 'HolyBolt', + animLen: 6, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: '', + light: 3, + red: 255, + green: 255, + blue: 255, + speedPxPerSec: 600, + trans: 1, + distancePx: 600, + clientAlias: true, + }, + strafearrow: { + name: 'strafearrow', + id: 215, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'Arrow', + animLen: 1, + animSpeed: 16, + loopAnim: 1, + numDirections: 32, + explosionMissile: '', + light: 0, + red: 0, + green: 0, + blue: 0, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, }) +/** + * Lookup from 1.13c Missiles.txt numeric `Id` to canonical missile key. + */ +export const MISSILE_CLASS_ID_TO_KEY: Readonly> = Object.freeze( + (() => { + const map: Record = {} + for (const [key, data] of Object.entries(CANONICAL_113C_MISSILES)) { + if (!data.clientAlias || map[data.id] === undefined) { + map[data.id] = key + } + } + return map + })(), +) + + /** * Safely parse an RGB color channel (0..255) with fallback (default 255). * Preserves authentic 0 values while mapping empty/unspecified cells to fallback. @@ -3677,6 +3919,262 @@ export const CANONICAL_113C_OVERLAYS: Readonly> = xOffset: 0, yOffset: 0, }, + bonearmor_cast: { + overlay: 'bonearmor_cast', + filename: 'NecromancerSpellCast', + frames: 16, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 8, + red: 215, + green: 215, + blue: 235, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + bonecast: { + overlay: 'bonecast', + filename: 'NecromancerSpellCast', + frames: 16, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 8, + red: 215, + green: 215, + blue: 235, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + holy_bolt_cast: { + overlay: 'holy_bolt_cast', + filename: 'HolyBoltCast', + frames: 14, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + fist_of_the_heavens_cast: { + overlay: 'fist_of_the_heavens_cast', + filename: 'FistOfTheHeavensCast', + frames: 12, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + conversioncast: { + overlay: 'conversioncast', + filename: 'ConversionCast', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 7, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + cast_innersight: { + overlay: 'cast_innersight', + filename: 'CastInnerSight', + frames: 16, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 7, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + cast_slowmissiles: { + overlay: 'cast_slowmissiles', + filename: 'CastSlowMissiles', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 7, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + valkyriestart: { + overlay: 'valkyriestart', + filename: 'ValkyrieStart', + frames: 20, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 235, + blue: 155, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + shoutstart: { + overlay: 'shoutstart', + filename: 'ShoutCast', + frames: 20, + animRate: 16, + trans: 3, + preDraw: true, + initRadius: 1, + radius: 8, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + battleorderscast: { + overlay: 'battleorderscast', + filename: 'BattleOrdersCast', + frames: 20, + animRate: 16, + trans: 3, + preDraw: true, + initRadius: 1, + radius: 8, + red: 155, + green: 205, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + battlecommandcast: { + overlay: 'battlecommandcast', + filename: 'BattleCommandCast', + frames: 20, + animRate: 16, + trans: 3, + preDraw: true, + initRadius: 1, + radius: 8, + red: 255, + green: 185, + blue: 115, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + druid_fire_cast_1: { + overlay: 'druid_fire_cast_1', + filename: 'DruidFireCast1', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 8, + red: 255, + green: 175, + blue: 55, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + druid_fire_cast_2: { + overlay: 'druid_fire_cast_2', + filename: 'DruidFireCast2', + frames: 17, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 175, + blue: 55, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + wolf_into: { + overlay: 'wolf_into', + filename: 'WolfTransform', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 0, + radius: 0, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + bear_into: { + overlay: 'bear_into', + filename: 'BearTransform', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 0, + radius: 0, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + lightning: { + overlay: 'lightning', + filename: 'LightHit', + frames: 12, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 8, + red: 192, + green: 215, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, }) /** @@ -4085,12 +4583,87 @@ const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly> = Object 'bone wall': 'bonecast', boneprison: 'bonecast', 'bone prison': 'bonecast', + teeth: 'bonecast', + bonespear: 'bonecast', + 'bone spear': 'bonecast', + bonespirit: 'bonecast', + 'bone spirit': 'bonecast', + corpseexplosion: 'bonecast', + 'corpse explosion': 'bonecast', + poisonexplosion: 'bonecast', + 'poison explosion': 'bonecast', + poisonnova: 'bonecast', + 'poison nova': 'bonecast', + amplifydamage: 'cursecast', + 'amplify damage': 'cursecast', + dimvision: 'cursecast', + 'dim vision': 'cursecast', + weaken: 'cursecast', + ironmaiden: 'cursecast', + 'iron maiden': 'cursecast', + terror: 'cursecast', + confuse: 'cursecast', + lifetap: 'cursecast', + 'life tap': 'cursecast', + attract: 'cursecast', + decrepify: 'cursecast', + lowerresist: 'cursecast', + 'lower resist': 'cursecast', + holybolt: 'holy_bolt_cast', + 'holy bolt': 'holy_bolt_cast', + blessedhammer: 'holy_bolt_cast', + 'blessed hammer': 'holy_bolt_cast', + fistoftheheavens: 'fist_of_the_heavens_cast', + 'fist of the heavens': 'fist_of_the_heavens_cast', }) const NECROMANCER_SKILL_CAST_OVERLAYS: Readonly> = Object.freeze({ + 66: 'cursecast', // Amplify Damage + 67: 'bonecast', // Teeth 68: 'bonearmor_cast', // Bone Armor + 70: 'bonecast', // Raise Skeleton + 71: 'cursecast', // Dim Vision + 72: 'cursecast', // Weaken + 74: 'bonecast', // Corpse Explosion + 76: 'cursecast', // Iron Maiden + 77: 'cursecast', // Terror 78: 'bonecast', // Bone Wall + 80: 'bonecast', //Raise Skeletal Mage + 81: 'cursecast', // Confuse + 82: 'cursecast', // Life Tap + 83: 'bonecast', // Poison Explosion + 84: 'bonecast', // Bone Spear + 86: 'cursecast', // Attract + 87: 'cursecast', // Decrepify 88: 'bonecast', // Bone Prison + 91: 'cursecast', // Lower Resist + 92: 'bonecast', // Poison Nova + 93: 'bonecast', // Bone Spirit + 95: 'bonecast', // Revive +}) + +const OTHER_CLASS_SKILL_CAST_OVERLAYS: Readonly> = Object.freeze({ + // Amazon + 8: 'cast_innersight', // Inner Sight + 17: 'cast_slowmissiles', // Slow Missiles + 32: 'valkyriestart', // Valkyrie + // Paladin + 101: 'holy_bolt_cast', // Holy Bolt + 112: 'holy_bolt_cast', // Blessed Hammer + 116: 'conversioncast', // Conversion + 121: 'fist_of_the_heavens_cast', // Fist of the Heavens + // Barbarian + 138: 'shoutstart', // Shout + 149: 'battleorderscast', // Battle Orders + 155: 'battlecommandcast', // Battle Command + // Druid + 223: 'wolf_into', // Werewolf + 225: 'druid_fire_cast_1', // Firestorm + 228: 'bear_into', // Werebear + 229: 'druid_fire_cast_1', // Molten Boulder + 234: 'druid_fire_cast_1', // Fissure + 244: 'druid_fire_cast_2', // Volcano + 249: 'druid_fire_cast_2', // Armageddon }) /** @@ -4101,6 +4674,7 @@ export function getSkillCastOverlay(skillId: number | string): string | undefine return ( SORCERESS_SKILL_CAST_OVERLAYS[skillId] ?? NECROMANCER_SKILL_CAST_OVERLAYS[skillId] ?? + OTHER_CLASS_SKILL_CAST_OVERLAYS[skillId] ?? BATCH1_SKILLS[skillId]?.castOverlay ) } @@ -4109,6 +4683,7 @@ export function getSkillCastOverlay(skillId: number | string): string | undefine return ( SORCERESS_SKILL_CAST_OVERLAYS[num] ?? NECROMANCER_SKILL_CAST_OVERLAYS[num] ?? + OTHER_CLASS_SKILL_CAST_OVERLAYS[num] ?? BATCH1_SKILLS[num]?.castOverlay ) } diff --git a/src/netproto/d2gs/s2c/misc.ts b/src/netproto/d2gs/s2c/misc.ts index 21b452e..513341a 100644 --- a/src/netproto/d2gs/s2c/misc.ts +++ b/src/netproto/d2gs/s2c/misc.ts @@ -71,12 +71,17 @@ export function decode0x73CreateMissile(pkt: Uint8Array): ServerEvent { const y = r.u16LE() const targetX = r.u16LE() const targetY = r.u16LE() + const level = r.remaining >= 1 ? r.u8() : 1 + const ownerType = r.remaining >= 1 ? clampUnitType(r.u8()) : 0 + const ownerId = r.remaining >= 4 ? r.u32LE() : 0 return { type: 'SkillCast', caster: { unitType: 3, id: missileClassId }, skill: missileClassId, - level: 1, + level: level || 1, + at: { x, y }, target: { x: targetX || x, y: targetY || y }, + owner: { unitType: ownerType, id: ownerId }, } } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index 5e20476..9ab0098 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -86,6 +86,8 @@ export type ServerEvent = readonly skillId?: number | undefined readonly level: number readonly target: UnitRef | Pt + readonly at?: Pt | undefined + readonly owner?: UnitRef | undefined } | { readonly type: 'StatSet' diff --git a/tests/client/client-world.test.ts b/tests/client/client-world.test.ts index 3c2f423..5faeb32 100644 --- a/tests/client/client-world.test.ts +++ b/tests/client/client-world.test.ts @@ -678,4 +678,290 @@ describe('ClientWorld authoritative state mirror', () => { expect(world.containers.tradeGrid).toHaveLength(0) expect(world.containers.partnerTradeGrid).toHaveLength(0) }) + + it('R1: attaches canonical 1.13c cast overlays, deduplicates local cast echoes within 250ms, and expires castState to neutral mode 1', async () => { + const { getSkillCastOverlay } = await import('../../src/common/skills/missiles-data.ts') + expect(getSkillCastOverlay(36)).toBe('fire_cast_1') + expect(getSkillCastOverlay(39)).toBe('ice_cast_1') + expect(getSkillCastOverlay(44)).toBe('ice_cast_2') + expect(getSkillCastOverlay(47)).toBe('fire_cast_2') + expect(getSkillCastOverlay(49)).toBe('light_cast_1') + expect(getSkillCastOverlay(54)).toBe('teleport') + expect(getSkillCastOverlay(62)).toBe('fire_cast_2') + expect(getSkillCastOverlay(64)).toBe('ice_cast_3') + expect(getSkillCastOverlay(66)).toBe('cursecast') + expect(getSkillCastOverlay(67)).toBe('bonecast') + expect(getSkillCastOverlay(68)).toBe('bonearmor_cast') + expect(getSkillCastOverlay(84)).toBe('bonecast') + expect(getSkillCastOverlay(101)).toBe('holy_bolt_cast') + expect(getSkillCastOverlay(112)).toBe('holy_bolt_cast') + expect(getSkillCastOverlay(121)).toBe('fist_of_the_heavens_cast') + expect(getSkillCastOverlay(8)).toBe('cast_innersight') + expect(getSkillCastOverlay(32)).toBe('valkyriestart') + expect(getSkillCastOverlay(138)).toBe('shoutstart') + expect(getSkillCastOverlay(149)).toBe('battleorderscast') + expect(getSkillCastOverlay(155)).toBe('battlecommandcast') + expect(getSkillCastOverlay(225)).toBe('druid_fire_cast_1') + + const world = new ClientWorld() + world.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 }, + isSelf: true, + }) + world.apply({ type: 'ActiveSkill', unitId: 1, hand: 'right', skill: 36 }) + + // Local cast command attaches fire_cast_1 overlay and spawns 1 firebolt missile + const castCmd: ClientCommand = { + type: 'SkillAt', + hand: 'right', + at: { x: 5020, y: 5000 }, + shift: false, + } + world.noteOutgoingCommand(castCmd) + // Calling noteOutgoingCommand again with the same command object is idempotent + world.noteOutgoingCommand(castCmd) + + const selfUnit = world.getUnit(0, 1)! + expect(selfUnit.mode).toBe(10) + expect(selfUnit.castState?.skillId).toBe(36) + expect(selfUnit.castState?.castOverlayToken).toBe('fire_cast_1') + expect(selfUnit.overlays?.some(o => o.token === 'fire_cast_1' && o.loop === false)).toBe(true) + + const initialMissiles = Array.from(world.units.values()).filter(u => u.unitType === 'missile') + expect(initialMissiles).toHaveLength(1) + expect(initialMissiles[0]?.missileKey).toBe('firebolt') + + // Server echo within 250ms must not duplicate the cast overlay or spawn a second firebolt + world.apply({ + type: 'SkillCast', + caster: { unitType: 0, id: 1 }, + skill: 36, + skillId: 36, + level: 1, + target: { x: 5020, y: 5000 }, + }) + const afterEchoMissiles = Array.from(world.units.values()).filter(u => u.unitType === 'missile') + expect(afterEchoMissiles).toHaveLength(1) + expect(selfUnit.overlays?.filter(o => o.token === 'fire_cast_1')).toHaveLength(1) + + // Stepping ticks advances the one-shot overlay and expires castState back to mode 1 (Neutral) + const startMs = selfUnit.castState!.startedAtMs + for (let i = 1; i <= 15; i++) { + world.tick(40, startMs + i * 40) + } + expect(selfUnit.castState).toBeUndefined() + expect(world.self.castState).toBeUndefined() + expect(selfUnit.mode).toBe(1) + expect(selfUnit.overlays?.some(o => o.token === 'fire_cast_1')).toBe(false) + }) + + it('R2: decodes 0x73 CreateMissile and simulates 1.13c linear, fan, radial ring, spiral, emitter, and skyfall trajectories at 25Hz', async () => { + const { decodeD2gsS2cPacket } = await import('../../src/netproto/index.ts') + const world = new ClientWorld() + world.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 }, + isSelf: true, + }) + + // 1. 0x73 CreateMissile wire packet decoding & ClientWorld reduction (missileId=62 fireball at (5002,5004) -> (5022,5004)) + const raw0x73 = new Uint8Array(32) + const dv = new DataView(raw0x73.buffer) + raw0x73[0] = 0x73 + dv.setUint32(1, 0, true) // unk + dv.setUint16(5, 62, true) // missileClassId = 62 (fireball) + dv.setUint16(7, 5002, true) // x + dv.setUint16(9, 5004, true) // y + dv.setUint16(11, 5022, true) // targetX + dv.setUint16(13, 5004, true) // targetY + raw0x73[15] = 5 // level = 5 + raw0x73[16] = 0 // ownerType = player + dv.setUint32(17, 2, true) // ownerId = 2 (remote player) + const ev0x73 = decodeD2gsS2cPacket(raw0x73) + expect(ev0x73.type).toBe('SkillCast') + if (ev0x73.type === 'SkillCast') { + expect(ev0x73.caster).toEqual({ unitType: 3, id: 62 }) + expect(ev0x73.skill).toBe(62) + expect(ev0x73.at).toEqual({ x: 5002, y: 5004 }) + expect(ev0x73.target).toEqual({ x: 5022, y: 5004 }) + expect(ev0x73.level).toBe(5) + expect(ev0x73.owner).toEqual({ unitType: 0, id: 2 }) + } + world.apply(ev0x73) + const spawnedFireball = Array.from(world.units.values()).find( + u => u.unitType === 'missile' && u.missileKey === 'fireball', + ) + expect(spawnedFireball).toBeDefined() + expect(spawnedFireball?.x).toBe(5002) + expect(spawnedFireball?.y).toBe(5004) + world.tick(40, Date.now() + 40) + expect(spawnedFireball!.x).toBeGreaterThan(5002) + + // 2. Charged Bolt (skill 38, level 3 -> 5 bolts in a fan) + const cbWorld = new ClientWorld() + cbWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 2, classId: 1, name: 'RemoteSorc', x: 5000, y: 5000 }, + }) + cbWorld.apply({ + type: 'SkillCast', + caster: { unitType: 0, id: 2 }, + skill: 38, + skillId: 38, + level: 3, + target: { x: 5015, y: 5000 }, + }) + const chargedBolts = Array.from(cbWorld.units.values()).filter( + u => u.unitType === 'missile' && u.missileKey === 'chargedbolt', + ) + expect(chargedBolts).toHaveLength(5) + + // 3. Frost Nova (skill 44 -> 32 radial projectiles) + const fnWorld = new ClientWorld() + fnWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 2, classId: 1, name: 'RemoteSorc', x: 5000, y: 5000 }, + }) + fnWorld.apply({ + type: 'SkillCast', + caster: { unitType: 0, id: 2 }, + skill: 44, + skillId: 44, + level: 1, + target: { x: 5005, y: 5005 }, + }) + const frostNovas = Array.from(fnWorld.units.values()).filter( + u => u.unitType === 'missile' && u.missileKey === 'frostnova', + ) + expect(frostNovas).toHaveLength(32) + + // 4. Blessed Hammer (skill 112 -> spiral trajectory) + const bhWorld = new ClientWorld() + bhWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 3, classId: 3, name: 'Hammerdin', x: 5000, y: 5000 }, + }) + bhWorld.apply({ + type: 'SkillCast', + caster: { unitType: 0, id: 3 }, + skill: 112, + skillId: 112, + level: 1, + target: { x: 5005, y: 5000 }, + }) + const hammer = Array.from(bhWorld.units.values()).find( + u => u.unitType === 'missile' && u.missileKey === 'blessedhammer', + )! + expect(hammer).toBeDefined() + const r0 = hammer.spiralRadius ?? 0 + bhWorld.tick(120, Date.now() + 120) + expect(hammer.spiralRadius).toBeGreaterThan(r0) + expect(Math.hypot(hammer.x - 5000, hammer.y - 5000)).toBeGreaterThan(0.5) + + // 5. Frozen Orb (skill 64 -> emits frozenorbbolt sub-missiles during flight) + const foWorld = new ClientWorld() + foWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 2, classId: 1, name: 'OrbSorc', x: 5000, y: 5000 }, + }) + foWorld.apply({ + type: 'SkillCast', + caster: { unitType: 0, id: 2 }, + skill: 64, + skillId: 64, + level: 1, + target: { x: 5030, y: 5000 }, + }) + foWorld.tick(160, Date.now() + 160) // 4 ticks + const subBolts = Array.from(foWorld.units.values()).filter( + u => u.unitType === 'missile' && u.missileKey === 'frozenorbbolt', + ) + expect(subBolts.length).toBeGreaterThanOrEqual(4) + }) + + it('R3: detects wall and hostile monster collisions, respects pierce, and spawns one-shot 1.13c ExplosionMissile visuals', () => { + // 1. Blocked sub-tile collision -> spawns fireexplode and removes firebolt + const wallWorld = new ClientWorld() + wallWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 }, + isSelf: true, + }) + wallWorld.setSubTileBlocked(5005, 5000, true) + wallWorld.apply({ + type: 'SkillCast', + caster: { unitType: 0, id: 1 }, + skill: 36, + skillId: 36, // Fire Bolt (vel=20 -> 2.5 sub-tiles/tick) + level: 1, + target: { x: 5020, y: 5000 }, + }) + wallWorld.tick(80, Date.now() + 80) // 2 ticks -> reaches x=5005 + const afterWallUnits = Array.from(wallWorld.units.values()).filter(u => u.unitType === 'missile') + expect(afterWallUnits.some(u => u.missileKey === 'firebolt')).toBe(false) + const wallExplosion = afterWallUnits.find(u => u.missileKey === 'fireexplode') + expect(wallExplosion).toBeDefined() + expect(wallExplosion?.loopAnim).toBe(false) + + // Step explosion past its lifetime -> clamps frame and removes upon completion + for (let i = 1; i <= 15; i++) { + wallWorld.tick(40, Date.now() + 80 + i * 40) + } + expect( + Array.from(wallWorld.units.values()).some(u => u.missileKey === 'fireexplode'), + ).toBe(false) + + // 2. Hostile monster collision: non-piercing Ice Blast vs piercing Bone Spear + const monWorld = new ClientWorld() + monWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 }, + isSelf: true, + }) + monWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 1, id: 99, classId: 1, name: 'Fallen', x: 5003, y: 5000, lifePct: 100 }, + }) + // Ice Blast (skill 45) hits Fallen at (5003, 5000), spawns freezeexplode + glacialspikeejecta, and is removed + monWorld.apply({ + type: 'SkillCast', + caster: { unitType: 0, id: 1 }, + skill: 45, + skillId: 45, + level: 1, + target: { x: 5020, y: 5000 }, + }) + monWorld.tick(80, Date.now() + 80) + const monMissiles = Array.from(monWorld.units.values()).filter(u => u.unitType === 'missile') + expect(monMissiles.some(u => u.missileKey === 'iceblast')).toBe(false) + expect(monMissiles.some(u => u.missileKey === 'freezeexplode')).toBe(true) + + // Piercing Bone Spear (skill 84) passes through Fallen without being destroyed + const pierceWorld = new ClientWorld() + pierceWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 0, id: 2, classId: 2, name: 'Necro', x: 5000, y: 5000 }, + }) + pierceWorld.apply({ + type: 'UnitAssign', + unit: { unitType: 1, id: 100, classId: 1, name: 'Zombie', x: 5003, y: 5000, lifePct: 100 }, + }) + pierceWorld.apply({ + type: 'SkillCast', + caster: { unitType: 0, id: 2 }, + skill: 84, + skillId: 84, + level: 1, + target: { x: 5030, y: 5000 }, + }) + pierceWorld.tick(120, Date.now() + 120) + const boneSpear = Array.from(pierceWorld.units.values()).find( + u => u.unitType === 'missile' && u.missileKey === 'bonespear', + ) + expect(boneSpear).toBeDefined() + expect(boneSpear?.hitUnitIds?.has(100)).toBe(true) + expect(boneSpear!.x).toBeGreaterThan(5005) + }) }) + diff --git a/tests/client/hud-session-play.test.ts b/tests/client/hud-session-play.test.ts index 5e51ae5..3d4666a 100644 --- a/tests/client/hud-session-play.test.ts +++ b/tests/client/hud-session-play.test.ts @@ -1031,5 +1031,86 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => { session.destroy() } }) + + it('projects canonical 1.13c spell cast overlays, flying missiles, LevelView wall collisions, and one-shot impact explosions into SceneSource drawables (#550)', () => { + const session = new OnlineSession({ + settingsStore: createSettingsStore(), + }) + + const walkable = new Uint8Array(200 * 200).fill(1) + const blocked = new Uint8Array(200 * 200) + // Block sub-tile at world (5056, 5050) -> local (56, 50) + walkable[50 * 200 + 56] = 0 + blocked[50 * 200 + 56] = 1 + + session.levelView = { + act: 0, + areaId: 2, + subOriginX: 5000, + subOriginY: 5000, + subWidth: 200, + subHeight: 200, + isoOriginX: 0, + isoOriginY: 0, + cellsX: 40, + cellsY: 40, + gridWidth: 200, + blocked, + walkable, + collisionMasks: new Uint16Array(200 * 200), + rooms: [], + floors: [], + shadows: [], + walls: [], + roofs: [], + warps: [], + presets: [], + } as any + + session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x11223344, areaId: 2 }) + session.handleServerEvent({ + type: 'UnitAssign', + unit: { unitType: 0, id: 1, classId: 1, name: 'Sorceress', x: 5050, y: 5050 }, + isSelf: true, + }) + session.handleServerEvent({ + type: 'ActiveSkill', + unitId: 1, + hand: 'right', + skill: 47, // Fire Ball + }) + + // Cast Fire Ball toward (5070, 5050); wall is at (5056, 5050) + session.useSkillAt(5070, 5050, 'right', true) + + const snap0 = session.sceneSource.buildFrameSnapshot() + expect(snap0.entities.overlayDrawables.some((o) => o.token === 'fire_cast_2' && o.loop === false)).toBe(true) + const fireball0 = snap0.entities.missileDrawables.find((m) => m.missileKey === 'fireball') + expect(fireball0).toBeDefined() + expect(fireball0?.blendMode).toBe('additive') + expect(snap0.entities.pointLights.length).toBeGreaterThanOrEqual(2) + + // Advance 3 ticks (120ms) -> fireball collides with blocked LevelView sub-tile at (5056, 5050) + // and spawns one-shot explodingarrowexp + session.tick(120, 1120) + const snapImpact = session.sceneSource.buildFrameSnapshot() + expect(snapImpact.entities.missileDrawables.some((m) => m.missileKey === 'fireball')).toBe(false) + const expDrawable = snapImpact.entities.missileDrawables.find( + (m) => m.missileKey === 'explodingarrowexp', + ) + expect(expDrawable).toBeDefined() + expect(expDrawable?.loopAnim).toBe(false) + + // Advance past explosion and cast duration -> explosion removed and caster returns to neutral + for (let i = 1; i <= 20; i++) { + session.tick(40, 1120 + i * 40) + } + const snapAfter = session.sceneSource.buildFrameSnapshot() + expect(snapAfter.entities.missileDrawables).toHaveLength(0) + expect(snapAfter.entities.overlayDrawables).toHaveLength(0) + + session.destroy() + }) }) }) + From 7bd10903f63dc87c1449f89deb272af4dd3898da Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 09:13:09 +0000 Subject: [PATCH 05/15] fix(client): drive 1.13c character animation speed from FCR/IAS/WSM and stop one-shot cast/attack looping (#552) --- src/client/scene/world-renderer.ts | 165 +++++- src/client/session/online-session.ts | 53 +- src/client/view/unit-drawables.ts | 31 +- src/client/world/client-unit.ts | 16 +- src/client/world/client-world.ts | 348 ++++++++++-- src/client/world/inventory.ts | 22 +- src/common/units/char-anim-speed.ts | 578 +++++++++++++++++++ tests/client/char-anim-speed-loop.test.ts | 645 ++++++++++++++++++++++ 8 files changed, 1775 insertions(+), 83 deletions(-) create mode 100644 src/common/units/char-anim-speed.ts create mode 100644 tests/client/char-anim-speed-loop.test.ts diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index d16d5b2..3709d94 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1,4 +1,9 @@ import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts' +import { + computeAnimFrameIndex, + computeStepSpeed256, + getCharAnimClip113c, +} from '../../common/units/char-anim-speed.ts' import { dir64ToClipDirection } from '../../common/units/direction.ts' import { PLR_MODES } from '../../common/units/plr-mode.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' @@ -45,7 +50,12 @@ import { type LightingPassTargets, } from './lighting-presets.ts' -export { layoutGroundItemLabels } +export { + computeAnimFrameIndex, + computeStepSpeed256, + getCharAnimClip113c, + layoutGroundItemLabels, +} /** * Diagnostic entry recorded whenever an entity, item, missile, overlay, or tile @@ -64,9 +74,47 @@ declare global { } } +interface TrackedOneShotState { + readonly clipKey: string + readonly rawStartedAtMs: number | undefined + readonly effectiveStartMs: number +} + const missingArtRegistry = new Map() const missingArtList: MissingArtEntry[] = [] const dropStartMsByItemId = new Map() +const oneShotStartMsByUnitKey = new Map() + +function resolveOneShotStartMs( + unitKey: string, + clipKey: string, + rawStartedAtMs: number | undefined, + nowMs: number, +): number { + if (rawStartedAtMs !== undefined && Number.isFinite(rawStartedAtMs) && rawStartedAtMs <= nowMs) { + oneShotStartMsByUnitKey.set(unitKey, { + clipKey, + rawStartedAtMs, + effectiveStartMs: rawStartedAtMs, + }) + return rawStartedAtMs + } + const prev = oneShotStartMsByUnitKey.get(unitKey) + if ( + prev !== undefined && + prev.clipKey === clipKey && + prev.rawStartedAtMs === rawStartedAtMs && + nowMs >= prev.effectiveStartMs + ) { + return prev.effectiveStartMs + } + oneShotStartMsByUnitKey.set(unitKey, { + clipKey, + rawStartedAtMs, + effectiveStartMs: nowMs, + }) + return nowMs +} if (typeof window !== 'undefined' && !window.__d2MissingArt) { window.__d2MissingArt = missingArtList @@ -104,6 +152,7 @@ export function clearMissingArtEntries(): void { missingArtRegistry.clear() missingArtList.length = 0 dropStartMsByItemId.clear() + oneShotStartMsByUnitKey.clear() if (typeof window !== 'undefined') { window.__d2MissingArt = missingArtList } @@ -430,6 +479,7 @@ function drawSceneUnitDrawable( ctx.characterArt ?? null if (charArt !== null) { + const isMoving = unit.mode === 2 || unit.mode === 3 || unit.mode === 6 const modeToken = PLR_MODES[unit.mode]?.token.toLowerCase() const requestedClipKey = unit.isCorpse && modeToken !== 'dt' ? 'dd' : modeToken if (!requestedClipKey) { @@ -443,8 +493,12 @@ function drawSceneUnitDrawable( const fallbackHthKey = PLAYER_HTH_FALLBACK_CLIP[requestedClipKey] const clip = charArt.clips?.[requestedClipKey] ?? - (fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) - if (!clip || !(clip.speed > 0)) { + (fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) ?? + (isMoving ? charArt.clips?.wl : charArt.clips?.nu) + const canonicalClip = getCharAnimClip113c(unit.classId, requestedClipKey) + const baseSpeed = + (clip?.speed && clip.speed > 0 ? clip.speed : canonicalClip?.speed) ?? 0 + if (!clip || !(baseSpeed > 0)) { reportMissingArt( 'player-clip', `${unit.token}:${requestedClipKey}`, @@ -457,15 +511,52 @@ function drawSceneUnitDrawable( const groupIndex = clip.group + direction const group = charArt.groups[groupIndex] const frameCount = Math.max(1, group?.length ?? 1) - const shouldLoop = - !unit.isCorpse && - requestedClipKey !== 'dd' && - requestedClipKey !== 'dt' && - clip.loop !== false - const frameIndex = - unit.isCorpse && requestedClipKey !== 'dt' - ? frameCount - 1 - : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) + const unitKey = `player:${unit.unitId}` + let frameIndex = 0 + if ((unit.isCorpse && requestedClipKey !== 'dt') || requestedClipKey === 'dd') { + oneShotStartMsByUnitKey.delete(unitKey) + frameIndex = frameCount - 1 + } else { + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + (clip.loop !== undefined + ? clip.loop + : (canonicalClip?.loop ?? + (requestedClipKey === 'nu' || + requestedClipKey === 'tn' || + requestedClipKey === 'wl' || + requestedClipKey === 'tw' || + requestedClipKey === 'rn'))) + const stepSpeed256 = + unit.animStepSpeed256 ?? + unit.castState?.stepSpeed256 ?? + computeStepSpeed256(baseSpeed, unit.animEffRate ?? unit.castState?.effRate ?? 0) + if (shouldLoop) { + oneShotStartMsByUnitKey.delete(unitKey) + frameIndex = computeAnimFrameIndex({ + frameCount, + stepSpeed256, + loop: true, + nowMs, + }) + } else { + const startedAtMs = resolveOneShotStartMs( + unitKey, + requestedClipKey, + unit.animStartedAtMs ?? unit.castState?.startedAtMs, + nowMs, + ) + frameIndex = computeAnimFrameIndex({ + frameCount, + stepSpeed256, + loop: false, + nowMs, + startedAtMs, + }) + } + } const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -534,15 +625,47 @@ function drawSceneUnitDrawable( const groupIndex = baseGroup + direction const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0] const frameCount = Math.max(1, group?.length ?? 1) - const shouldLoop = - !unit.isCorpse && - requestedClipKey !== 'dd' && - requestedClipKey !== 'dt' && - clip.loop !== false - const frameIndex = - unit.isCorpse && requestedClipKey !== 'dt' - ? frameCount - 1 - : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) + const unitKey = `monster:${unit.unitId}` + let frameIndex = 0 + if ((unit.isCorpse && requestedClipKey !== 'dt') || requestedClipKey === 'dd') { + oneShotStartMsByUnitKey.delete(unitKey) + frameIndex = frameCount - 1 + } else { + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + (clip.loop !== undefined + ? clip.loop + : requestedClipKey === 'nu' || requestedClipKey === 'wl' || requestedClipKey === 'rn') + const stepSpeed256 = + unit.animStepSpeed256 ?? + unit.castState?.stepSpeed256 ?? + clip.speed + if (shouldLoop) { + oneShotStartMsByUnitKey.delete(unitKey) + frameIndex = computeAnimFrameIndex({ + frameCount, + stepSpeed256, + loop: true, + nowMs, + }) + } else { + const startedAtMs = resolveOneShotStartMs( + unitKey, + requestedClipKey, + unit.animStartedAtMs ?? unit.castState?.startedAtMs, + nowMs, + ) + frameIndex = computeAnimFrameIndex({ + frameCount, + stepSpeed256, + loop: false, + nowMs, + startedAtMs, + }) + } + } const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index b82d6e2..3af1e8c 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1139,6 +1139,12 @@ export class OnlineSession { try { this.gameAdapter.send(cmd) this.world.noteOutgoingCommand(cmd) + if (this.world.selfId !== 0) { + const selfUnit = this.world.getUnit('player', this.world.selfId) + if (selfUnit?.castState && !this.predictor.getSnapshot().moving) { + this.predictor.setDirection(selfUnit.direction) + } + } } catch (err) { const msg = err instanceof Error ? err.message : String(err) this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true) @@ -1410,6 +1416,16 @@ export class OnlineSession { break } + case 'SkillCast': { + if (this.world.selfId !== 0) { + const selfUnit = this.world.getUnit('player', this.world.selfId) + if (selfUnit?.castState && !this.predictor.getSnapshot().moving) { + this.predictor.setDirection(selfUnit.direction) + } + } + break + } + case 'UnitRemove': { this.interpolator.removeEntity(unitTypeToId(ev.unitType), ev.id) break @@ -1482,17 +1498,25 @@ export class OnlineSession { * - Flushes rate-limited movement commands in `CommandMapper` * - Steps `LocalMovementPredictor` and `Camera` * - Steps `RemoteEntityInterpolator` and syncs interpolated positions to `ClientWorld` units + * - Expires finished one-shot cast/attack animations in `ClientWorld` */ tick(dtMs = 40, nowMs = performance.now()): void { + const safeDt = Math.max(0, dtMs) this.tickCounter++ this.commandMapper.tick(nowMs) - const predSnap = this.predictor.update(dtMs) + const predSnap = this.predictor.update(safeDt) if (predSnap.x !== 0 || predSnap.y !== 0) { this.camera.update(predSnap.x, predSnap.y) } - this.interpolator.update(dtMs) + const selfUnit = + this.world.selfId !== 0 ? this.world.getUnit('player', this.world.selfId) : undefined + if (!predSnap.moving && selfUnit?.castState) { + this.predictor.setDirection(selfUnit.direction) + } + + this.interpolator.update(safeDt) for (const state of this.interpolator.getAllStates()) { const unit = this.world.getUnit(state.unitType as UnitType, state.unitId) if (unit) { @@ -1539,7 +1563,10 @@ export class OnlineSession { /** * Build `RenderableUnitInput[]` from `ClientWorld` + `LocalMovementPredictor` + `RemoteEntityInterpolator`. */ - buildRenderableUnits(): RenderableUnitInput[] { + buildRenderableUnits(nowMs?: number): RenderableUnitInput[] { + if (nowMs !== undefined) { + this.world.expireCastStates(nowMs) + } const out: RenderableUnitInput[] = [] const predSnap = this.predictor.getSnapshot() const selfX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x @@ -1550,6 +1577,9 @@ export class OnlineSession { if (this.world.selfId !== 0 && selfClass !== null) { const selfUnit = this.world.getUnit('player', this.world.selfId) const isSelfDead = this.world.isSelfDead || this.world.isDead + if (!predSnap.moving && selfUnit?.castState) { + this.predictor.setDirection(selfUnit.direction) + } const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined const selfInTown = this.isSubTileInTown(selfX, selfY) const neutralMode = selfInTown ? 5 : 1 @@ -1578,6 +1608,9 @@ export class OnlineSession { } const selfDirection = selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64 + const selfAnimStartedAtMs = selfCastState?.startedAtMs ?? selfUnit?.modeStartedAtMs + const selfAnimStepSpeed256 = selfCastState?.stepSpeed256 ?? selfUnit?.animStepSpeed256 + const selfAnimEffRate = selfCastState?.effRate ?? selfUnit?.animEffRate out.push({ id: this.world.selfId, kind: 'player', @@ -1597,6 +1630,9 @@ export class OnlineSession { isCorpse: isSelfDead, ...(selfCastState !== undefined ? { castState: selfCastState } : {}), ...(selfUnit && selfUnit.overlays.length > 0 ? { overlays: selfUnit.overlays } : {}), + ...(selfAnimStartedAtMs !== undefined ? { animStartedAtMs: selfAnimStartedAtMs } : {}), + ...(selfAnimStepSpeed256 !== undefined ? { animStepSpeed256: selfAnimStepSpeed256 } : {}), + ...(selfAnimEffRate !== undefined ? { animEffRate: selfAnimEffRate } : {}), }) } @@ -1701,6 +1737,9 @@ export class OnlineSession { const isCorpse = (u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)) || (u.unitType === 'player' && (u.mode === 0 || u.mode === 17 || u.lifePct <= 0)) + if (interp?.moving && u.castState) { + this.world.clearUnitCastState(u) + } const castState = !isCorpse ? u.castState : undefined let mode = castState ? castState.mode || u.mode : u.mode if (u.unitType === 'player' && !isCorpse && !castState) { @@ -1824,6 +1863,9 @@ export class OnlineSession { (partyMember.partyState === 1 || (partyMember.partyId !== undefined && partyMember.partyId !== 0xffff)), ) + const unitAnimStartedAtMs = castState?.startedAtMs ?? u.modeStartedAtMs + const unitAnimStepSpeed256 = castState?.stepSpeed256 ?? u.animStepSpeed256 + const unitAnimEffRate = castState?.effRate ?? u.animEffRate out.push({ id: u.id, @@ -1844,7 +1886,10 @@ export class OnlineSession { states: Array.from(u.states.list()), ...(castState !== undefined ? { castState } : {}), ...(u.overlays.length > 0 ? { overlays: u.overlays } : {}), - ...(u.unitType === 'player' ? { isPartyMember } : {}), + ...(unitAnimStartedAtMs !== undefined ? { animStartedAtMs: unitAnimStartedAtMs } : {}), + ...(unitAnimStepSpeed256 !== undefined ? { animStepSpeed256: unitAnimStepSpeed256 } : {}), + ...(unitAnimEffRate !== undefined ? { animEffRate: unitAnimEffRate } : {}), + ...(u.unitType === 'player' ? { isPartyMember, isLocalPlayer: false } : {}), }) } diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 60f7eda..fa4709d 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -82,6 +82,9 @@ export interface RenderableUnitInput { readonly itemCode?: string | undefined readonly castState?: UnitCastState | undefined readonly animMode?: string | undefined + readonly animStartedAtMs?: number | undefined + readonly animStepSpeed256?: number | undefined + readonly animEffRate?: number | undefined readonly portalOwnerName?: string | undefined readonly portalAreaId?: number | undefined readonly isPartyMember?: boolean | undefined @@ -123,6 +126,10 @@ export interface SceneUnitDrawable { readonly itemCode?: string | undefined readonly castState?: UnitCastState | undefined readonly animMode?: string | undefined + readonly animStartedAtMs?: number | undefined + readonly animStepSpeed256?: number | undefined + readonly animEffRate?: number | undefined + readonly isLocalPlayer?: boolean | undefined readonly portalOwnerName?: string | undefined readonly portalAreaId?: number | undefined readonly isPartyMember?: boolean | undefined @@ -553,9 +560,23 @@ export function buildSceneDrawables( unit.animMode ?? (!isCorpse && (effectiveMode === 7 || effectiveMode === 4) ? 'A1' - : !isCorpse && effectiveMode === 10 - ? 'SC' - : undefined) + : !isCorpse && effectiveMode === 8 + ? 'A2' + : !isCorpse && effectiveMode === 10 + ? 'SC' + : undefined) + const animStartedAtMs = + unit.animStartedAtMs ?? + activeCastState?.startedAtMs ?? + (rawAny.modeStartedAtMs as number | undefined) + const animStepSpeed256 = + unit.animStepSpeed256 ?? + activeCastState?.stepSpeed256 ?? + (rawAny.animStepSpeed256 as number | undefined) + const animEffRate = + unit.animEffRate ?? + activeCastState?.effRate ?? + (rawAny.animEffRate as number | undefined) if (activeCastState) { const castOverlayKey = @@ -659,6 +680,10 @@ export function buildSceneDrawables( : {}), ...(activeCastState !== undefined ? { castState: activeCastState } : {}), ...(animMode !== undefined ? { animMode } : {}), + ...(animStartedAtMs !== undefined ? { animStartedAtMs } : {}), + ...(animStepSpeed256 !== undefined ? { animStepSpeed256 } : {}), + ...(animEffRate !== undefined ? { animEffRate } : {}), + ...(unit.isLocalPlayer !== undefined ? { isLocalPlayer: unit.isLocalPlayer } : {}), ...(unit.portalOwnerName !== undefined ? { portalOwnerName: unit.portalOwnerName } : {}), ...(unit.portalAreaId !== undefined ? { portalAreaId: unit.portalAreaId } : {}), ...(unit.isPartyMember !== undefined ? { isPartyMember: unit.isPartyMember } : {}), diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 7163d47..0f95e80 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -38,9 +38,15 @@ export interface UnitCastState { readonly targetX: number readonly targetY: number readonly mode: number - readonly startedAtMs: number - readonly untilMs: number + startedAtMs: number + untilMs: number readonly castOverlayToken?: string | undefined + readonly durationMs?: number | undefined + elapsedMs?: number | undefined + readonly stepSpeed256?: number | undefined + readonly frames?: number | undefined + readonly baseSpeed?: number | undefined + readonly effRate?: number | undefined } export function makeUnitKey(unitType: UnitType, id: number): string { @@ -108,6 +114,12 @@ export class ClientUnit { startedAtTick?: number | undefined sourceSkillId?: number | undefined skillLevel?: number | undefined + modeStartedAtMs?: number | undefined + modeUntilMs?: number | undefined + modeDurationMs?: number | undefined + modeElapsedMs?: number | undefined + animStepSpeed256?: number | undefined + animEffRate?: number | undefined readonly stats = new StatList() readonly states = new UnitStateSet() diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 6c73350..ef343f8 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -26,6 +26,28 @@ import { getOverlayTxtData, getSkillCastOverlay, } from '../../common/skills/missiles-data.ts' +import { + CHAR_ANIM_CLIPS_113C, + computeAnimFrameIndex, + computeDiminishingRate113c, + computeEffectiveFcr, + computeEffectiveFhr, + computeEffectiveIas, + computeOneShotDurationMs, + computeOneShotTicks, + computeStepSpeed256, + getCharAnimClip113c, + resolveCharacterAnimSpeedParams, + resolveCharacterStatTotal, + resolveEquippedWeaponWsm, + resolveWeaponWsm, + STAT_FASTERATTACKRATE, + STAT_FASTERBLOCKRATE, + STAT_FASTERCASTRATE, + STAT_FASTERHITRECOVERY, + STAT_SKILL_IAS, + type CharacterAnimSpeedParams, +} from '../../common/units/char-anim-speed.ts' import { dir64FromPoints } from '../../common/units/direction.ts' import { isTownLevel } from '../../common/world/scene-population.ts' import { @@ -37,6 +59,29 @@ import { import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts' import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts' +export { + CHAR_ANIM_CLIPS_113C, + computeAnimFrameIndex, + computeDiminishingRate113c, + computeEffectiveFcr, + computeEffectiveFhr, + computeEffectiveIas, + computeOneShotDurationMs, + computeOneShotTicks, + computeStepSpeed256, + getCharAnimClip113c, + resolveCharacterAnimSpeedParams, + resolveCharacterStatTotal, + resolveEquippedWeaponWsm, + resolveWeaponWsm, + STAT_FASTERATTACKRATE, + STAT_FASTERBLOCKRATE, + STAT_FASTERCASTRATE, + STAT_FASTERHITRECOVERY, + STAT_SKILL_IAS, + type CharacterAnimSpeedParams, +} + export interface PartyMemberState { readonly unitId: number charName?: string | undefined @@ -272,6 +317,8 @@ export class ClientWorld { } private _lastNpcTransaction: NpcTransactionRecord | null = null private _npcTransactionSeq = 0 + private _clockMs: number | null = null + private _clockExplicit = false private readonly _changeListeners = new Set<(ev: ServerEvent) => void>() private _nextMissileId = 0x70000000 @@ -305,6 +352,53 @@ export class ClientWorld { return this._mercHireList } + private resolveEventNowMs(nowMs?: number): number { + if (nowMs !== undefined && Number.isFinite(nowMs)) { + this._clockMs = nowMs + this._clockExplicit = true + return nowMs + } + if (this._clockExplicit && this._clockMs !== null) { + return this._clockMs + } + const autoNow = typeof performance !== 'undefined' ? performance.now() : Date.now() + this._clockMs = autoNow + return autoNow + } + + /** + * Resolves 1.13c character animation speed parameters (`frames`, `baseSpeed`, `effRate`, + * `stepSpeed256`, `durationMs`) for `modeOrClipKey` from the unit's stats and equipped items. + */ + resolvePlayerAnimSpeed( + modeOrClipKey: number | string, + unitId = this.selfId, + ): CharacterAnimSpeedParams { + const unit = unitId !== 0 ? this.getUnit('player', unitId) : undefined + const isSelf = unitId === this.selfId || this.selfId === 0 + const classId = + unit && unit.classId >= 0 && unit.classId <= 6 ? unit.classId : (this._self.charClass ?? 1) + return resolveCharacterAnimSpeedParams(classId, modeOrClipKey, { + unitStats: unit?.stats, + selfStats: isSelf ? this._self.stats : undefined, + equipment: isSelf ? Array.from(this._containers.equipment.values()) : undefined, + sockets: isSelf ? this._containers.sockets : undefined, + }) + } + + clearUnitCastState(unit: ClientUnit): void { + unit.castState = undefined + unit.modeUntilMs = undefined + unit.modeDurationMs = undefined + unit.modeElapsedMs = undefined + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined + this._castSimStartMs.delete(unit.key) + if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { + this._self.castState = undefined + } + } + /** * Registers an optional world sub-tile collision callback (`true` = blocked wall/obstacle). */ @@ -723,6 +817,7 @@ export class ClientWorld { targetY: number, skillLevel = 1, spawnVisuals = true, + nowMs?: number, ): void { unit.targetX = targetX unit.targetY = targetY @@ -731,22 +826,50 @@ export class ClientWorld { // same 1.13c direction table as movement; a target on the unit's own position keeps the facing. unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId) + const isSelf = unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0) const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10 unit.mode = mode const castOverlayToken = getSkillCastOverlay(skillId) - const now = Date.now() + const now = this.resolveEventNowMs(nowMs) + let frames = 12 + let baseSpeed = 256 + let effRate = 0 + let stepSpeed256 = 256 + let durationMs = computeOneShotDurationMs(frames, stepSpeed256) + + if (unit.unitType === 'player') { + const animParams = this.resolvePlayerAnimSpeed(mode, unit.id) + frames = animParams.frames + baseSpeed = animParams.baseSpeed + effRate = animParams.effRate + stepSpeed256 = animParams.stepSpeed256 + durationMs = animParams.durationMs + } + const castState: UnitCastState = { skillId, targetX, targetY, mode, startedAtMs: now, - untilMs: now + 400, + untilMs: now + durationMs, castOverlayToken, + durationMs, + elapsedMs: 0, + stepSpeed256, + frames, + baseSpeed, + effRate, } unit.castState = castState this._castSimStartMs.set(unit.key, this._simTimeMs) - if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { + unit.modeStartedAtMs = now + unit.modeUntilMs = now + durationMs + unit.modeDurationMs = durationMs + unit.modeElapsedMs = 0 + unit.animStepSpeed256 = stepSpeed256 + unit.animEffRate = effRate + if (isSelf) { this._self.castState = castState } @@ -768,7 +891,7 @@ export class ClientWorld { * refilled by 0x9C AddToShop; 0x30 frees the store units (no RemoveFromShop is * sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08). */ - noteOutgoingCommand(cmd: ClientCommand): void { + noteOutgoingCommand(cmd: ClientCommand, nowMs?: number): void { if (cmd && typeof cmd === 'object') { if (this._notedCommands.has(cmd)) return this._notedCommands.add(cmd) @@ -834,6 +957,30 @@ export class ClientWorld { this._isSelfDead = false this._self.isDead = false break + case 'Move': + case 'WalkToLocation': + case 'RunToLocation': + case 'MoveToUnit': + case 'WalkToTarget': + case 'RunToTarget': { + if (this.selfId !== 0) { + const selfUnit = this.getUnit('player', this.selfId) + if (selfUnit) { + this.clearUnitCastState(selfUnit) + const isWalk = + cmd.type === 'WalkToLocation' || + cmd.type === 'WalkToTarget' || + (cmd.type === 'Move' && cmd.run === false) || + (cmd.type === 'MoveToUnit' && cmd.run === false) + selfUnit.moveMode = isWalk ? 'walk' : 'run' + if (!selfUnit.dead) { + selfUnit.mode = isWalk ? (isTownLevel(this.areaId) ? 6 : 2) : 3 + } + } + } + this._self.castState = undefined + break + } case 'SkillAt': case 'CastLeftSkillOnLocation': case 'CastRightSkillOnLocation': { @@ -846,7 +993,7 @@ export class ClientWorld { this._lastLocalCastAtMs = Date.now() if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) - this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY) + this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) } break } @@ -866,7 +1013,7 @@ export class ClientWorld { if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) selfUnit.targetUnit = targetRef - this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY) + this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) } break } @@ -1230,6 +1377,113 @@ export class ClientWorld { } } + /** + * Expires any completed one-shot `castState` or `GH` recovery windows on units, + * returning `unit.mode` to `1` (`NU` neutral) and clearing `castState`. + */ + expireCastStates(nowMs?: number, dtMs = 0): void { + const safeDt = Math.max(0, Number.isFinite(dtMs) ? dtMs : 0) + if (nowMs !== undefined && Number.isFinite(nowMs)) { + if (!this._clockExplicit) { + for (const unit of this._units.values()) { + if (unit.castState && nowMs < unit.castState.startedAtMs) { + const elapsed = unit.castState.elapsedMs ?? 0 + const duration = + unit.castState.durationMs ?? + Math.max(40, unit.castState.untilMs - unit.castState.startedAtMs) + unit.castState.startedAtMs = nowMs - elapsed + unit.castState.untilMs = unit.castState.startedAtMs + duration + } + if (unit.modeStartedAtMs !== undefined && nowMs < unit.modeStartedAtMs) { + const elapsed = unit.modeElapsedMs ?? 0 + const duration = + unit.modeDurationMs ?? + (unit.modeUntilMs !== undefined ? Math.max(40, unit.modeUntilMs - unit.modeStartedAtMs) : 0) + unit.modeStartedAtMs = nowMs - elapsed + if (unit.modeUntilMs !== undefined) { + unit.modeUntilMs = unit.modeStartedAtMs + duration + } + } + } + } + this._clockMs = nowMs + this._clockExplicit = true + } else if (safeDt > 0 && this._clockMs !== null) { + this._clockMs += safeDt + } + + const effNow = nowMs !== undefined && Number.isFinite(nowMs) ? nowMs : this._clockMs + + for (const unit of this._units.values()) { + if (unit.castState) { + if (safeDt > 0) { + unit.castState.elapsedMs = (unit.castState.elapsedMs ?? 0) + safeDt + } + const durationMs = + unit.castState.durationMs ?? + Math.max(40, unit.castState.untilMs - unit.castState.startedAtMs) + const expiredByClock = + effNow !== null && + effNow >= unit.castState.untilMs && + effNow >= unit.castState.startedAtMs + const expiredByElapsed = (unit.castState.elapsedMs ?? 0) >= durationMs + + if (expiredByClock || expiredByElapsed) { + const castMode = unit.castState.mode + this.clearUnitCastState(unit) + if ( + !unit.dead && + (unit.mode === castMode || + unit.mode === 7 || + unit.mode === 8 || + unit.mode === 10 || + (unit.unitType === 'monster' && (unit.mode === 4 || unit.mode === 5 || unit.mode === 7))) + ) { + unit.mode = unit.unitType === 'player' && isTownLevel(this.areaId) ? 5 : 1 + if (!unit.moveMode || unit.moveMode === 'stop') { + unit.targetX = undefined + unit.targetY = undefined + } + } + } + } else if (unit.modeUntilMs !== undefined) { + if (safeDt > 0) { + unit.modeElapsedMs = (unit.modeElapsedMs ?? 0) + safeDt + } + const durationMs = + unit.modeDurationMs ?? + Math.max(40, unit.modeUntilMs - (unit.modeStartedAtMs ?? 0)) + const expiredByClock = + effNow !== null && + effNow >= unit.modeUntilMs && + effNow >= (unit.modeStartedAtMs ?? 0) + const expiredByElapsed = (unit.modeElapsedMs ?? 0) >= durationMs + + if (expiredByClock || expiredByElapsed) { + unit.modeUntilMs = undefined + unit.modeDurationMs = undefined + unit.modeElapsedMs = undefined + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined + if ( + !unit.dead && + ((unit.unitType === 'player' && unit.mode === 4) || + (unit.unitType === 'monster' && unit.mode === 3)) + ) { + unit.mode = unit.unitType === 'player' && isTownLevel(this.areaId) ? 5 : 1 + } + } + } + } + + if (this._self.castState) { + const selfUnit = this.selfId !== 0 ? this.getUnit('player', this.selfId) : undefined + if (!selfUnit || !selfUnit.castState) { + this._self.castState = undefined + } + } + } + tick(dtMs = 40, nowMs?: number): void { this.frameCount++ const safeDt = Math.max(0, dtMs) @@ -1238,6 +1492,8 @@ export class ClientWorld { const ticksToStep = Math.max(1, Math.round(safeDt / 40)) const inTown = isTownLevel(this.areaId) + this.expireCastStates(nowMs, safeDt) + const unitsSnapshot = Array.from(this._units.values()) for (const unit of unitsSnapshot) { if (!this._units.has(unit.key)) continue @@ -1268,28 +1524,8 @@ export class ClientWorld { unit.overlays = remainingOverlays } - // 3. Expire castState when cast window completes - if (unit.castState) { - const simStart = this._castSimStartMs.get(unit.key) ?? this._simTimeMs - const simElapsed = this._simTimeMs - simStart - const wallExpired = nowMs !== undefined && nowMs >= unit.castState.untilMs - if (simElapsed >= 400 || wallExpired) { - unit.castState = undefined - this._castSimStartMs.delete(unit.key) - if (!unit.dead) { - unit.mode = unit.unitType === 'player' && inTown ? 5 : 1 // return to TN or NU (neutral) - unit.targetX = unit.x - unit.targetY = unit.y - } - if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { - this._self.castState = undefined - } - } - continue - } - - // 4. Interpolate moving non-casting units - if (unit.dead) continue + // 3. Interpolate moving non-casting units + if (unit.dead || unit.castState) continue if (unit.targetX === undefined || unit.targetY === undefined) continue const dx = unit.targetX - unit.x const dy = unit.targetY - unit.y @@ -1345,15 +1581,15 @@ export class ClientWorld { return unit } - applyEvent(ev: ServerEvent | Record): void { - this.apply(ev as ServerEvent) + applyEvent(ev: ServerEvent | Record, nowMs?: number): void { + this.apply(ev as ServerEvent, nowMs) } - applyServerEvent(ev: ServerEvent | Record): void { - this.apply(ev as ServerEvent) + applyServerEvent(ev: ServerEvent | Record, nowMs?: number): void { + this.apply(ev as ServerEvent, nowMs) } - apply(rawEv: ServerEvent | Record): void { + apply(rawEv: ServerEvent | Record, nowMs?: number): void { if (!rawEv || typeof rawEv !== 'object') return let ev: any = rawEv if (ev.type === 'ActDataLoaded') { @@ -1571,6 +1807,9 @@ export class ClientWorld { if (unit.unitType === 'monster') { this._monsters.set(unit.id, unit) } + this.clearUnitCastState(unit) + const prevX = ev.from && (ev.from.x !== 0 || ev.from.y !== 0) ? ev.from.x : unit.x + const prevY = ev.from && (ev.from.x !== 0 || ev.from.y !== 0) ? ev.from.y : unit.y if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { unit.x = ev.from.x unit.y = ev.from.y @@ -1603,8 +1842,8 @@ export class ClientWorld { unit.targetUnit = ev.target const moveMode = ev.mode ?? 'walk' unit.moveMode = moveMode - if (targetX !== unit.x || targetY !== unit.y) { - unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) + if (prevX !== targetX || prevY !== targetY) { + unit.direction = dir64FromPoints(prevX, prevY, targetX, targetY, unit.direction) } if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) { const inTown = isTownLevel(this.areaId) @@ -1652,6 +1891,7 @@ export class ClientWorld { case 'UnitReassign': { const unit = this.ensureUnit(ev.unitType, ev.id) + this.clearUnitCastState(unit) unit.x = ev.at.x unit.y = ev.at.y unit.targetX = ev.at.x @@ -1674,16 +1914,27 @@ export class ClientWorld { case 'UnitMode': { const uId = Number(ev.id ?? ev.unitId ?? 0) const unit = this.ensureUnit(ev.unitType, uId) + this.clearUnitCastState(unit) + const now = this.resolveEventNowMs(nowMs) unit.mode = ev.mode - if ( - ev.mode === 0 || - ev.mode === 1 || - ev.mode === 3 || - ev.mode === 12 || - ev.mode === 16 || - ev.mode === 17 - ) { - unit.castState = undefined + unit.modeStartedAtMs = now + unit.modeElapsedMs = 0 + if (unit.unitType === 'player' && ev.mode === 4) { + const speedInfo = this.resolvePlayerAnimSpeed(4, unit.id) + unit.modeDurationMs = speedInfo.durationMs + unit.modeUntilMs = now + speedInfo.durationMs + unit.animStepSpeed256 = speedInfo.stepSpeed256 + unit.animEffRate = speedInfo.effRate + } else if (unit.unitType === 'monster' && ev.mode === 3) { + unit.modeDurationMs = 240 + unit.modeUntilMs = now + 240 + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined + } else { + unit.modeDurationMs = undefined + unit.modeUntilMs = undefined + unit.animStepSpeed256 = undefined + unit.animEffRate = undefined } if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) { unit.x = ev.at.x @@ -1784,9 +2035,9 @@ export class ClientWorld { Date.now() - this._lastLocalCastAtMs <= 250 if (isSelfEcho) { this._lastLocalCastSkillId = -1 - this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false) + this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs) } else { - this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true) + this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true, nowMs) } break } @@ -1865,7 +2116,8 @@ export class ClientWorld { } case 'Item': - case 'ItemEvent': { + case 'ItemEvent': + case 'ItemAction': { // 0x17 WeaponSwitch if (ev.action === 0x17) { this._self.weaponSet = this._self.weaponSet === 0 ? 1 : 0 @@ -2349,6 +2601,8 @@ export class ClientWorld { this._castSimStartMs.clear() this._notedCommands = new WeakSet() this._blockedSubTiles.clear() + this._clockMs = null + this._clockExplicit = false } clear(): void { diff --git a/src/client/world/inventory.ts b/src/client/world/inventory.ts index 8859b6c..e66841a 100644 --- a/src/client/world/inventory.ts +++ b/src/client/world/inventory.ts @@ -82,7 +82,7 @@ export class ClientInventoryManager implements ClientItemContainers { if (this.equipment.has(itemId)) { const eq = this.equipment.get(itemId)! this.equipment.delete(itemId) - const slot = eq.bodyLoc || eq.equippedSlot || 0 + const slot = eq.bodyLoc || eq.equippedSlot || (eq as { equipSlot?: number }).equipSlot || 0 if (this.equipmentBySlot.get(slot)?.id === itemId) { this.equipmentBySlot.delete(slot) } @@ -111,7 +111,7 @@ export class ClientInventoryManager implements ClientItemContainers { const m = this.merc.get(itemId) this.merc.delete(itemId) if (m) { - const slot = m.bodyLoc || m.equippedSlot || 0 + const slot = m.bodyLoc || m.equippedSlot || (m as { equipSlot?: number }).equipSlot || 0 if (this.mercBySlot.get(slot)?.id === itemId) { this.mercBySlot.delete(slot) } @@ -205,11 +205,16 @@ export class ClientInventoryManager implements ClientItemContainers { this.cube.set(stored.id, stored) break case 'equipment': { - this.equipment.set(stored.id, stored) - const slot = stored.bodyLoc || stored.equippedSlot || 0 + const slot = stored.bodyLoc || stored.equippedSlot || (stored as { equipSlot?: number }).equipSlot || 0 if (slot > 0) { + const prevInSlot = this.equipmentBySlot.get(slot) + if (prevInSlot && prevInSlot.id !== stored.id) { + this.equipment.delete(prevInSlot.id) + this.allById.delete(prevInSlot.id) + } this.equipmentBySlot.set(slot, stored) } + this.equipment.set(stored.id, stored) break } case 'belt': { @@ -224,11 +229,16 @@ export class ClientInventoryManager implements ClientItemContainers { break } case 'merc': { - this.merc.set(stored.id, stored) - const mSlot = stored.bodyLoc || stored.equippedSlot || 0 + const mSlot = stored.bodyLoc || stored.equippedSlot || (stored as { equipSlot?: number }).equipSlot || 0 if (mSlot > 0) { + const prevInSlot = this.mercBySlot.get(mSlot) + if (prevInSlot && prevInSlot.id !== stored.id) { + this.merc.delete(prevInSlot.id) + this.allById.delete(prevInSlot.id) + } this.mercBySlot.set(mSlot, stored) } + this.merc.set(stored.id, stored) break } case 'shop': diff --git a/src/common/units/char-anim-speed.ts b/src/common/units/char-anim-speed.ts new file mode 100644 index 0000000..d500341 --- /dev/null +++ b/src/common/units/char-anim-speed.ts @@ -0,0 +1,578 @@ +/** + * Diablo II v1.13c Character Animation Speed & One-Shot Lifecycle Math. + * + * Ground Truth (`D2Common.dll` / `D2Client.dll` + `data/global/AnimData.d2` + `Weapons.txt`): + * - Engine simulation rate is 25Hz (40ms per tick). + * - `AnimData.d2` stores per-clip `frames` (frames per direction) and `speed` in 8.8 fixed-point + * where `256` = 1.0 frame per 40ms tick. + * - Diminishing returns on item FCR / IAS / FHR: + * `diminished = floor(120 * rate / (120 + rate))` (for `rate > 0`; clamped to `[-85, 75]`). + * - Spell casting (`SC`, mode 10): + * `EFCR = clamp(diminished(fcr), -85, 75)` + * - Weapon attacks (`A1` / `A2`, modes 7 / 8): + * `EIAS = clamp(diminished(itemIas) + skillIas - weaponWsm, -85, 75)` + * where `weaponWsm` is the equipped weapon's `speed` column from 1.13c `Weapons.txt`. + * - Get Hit recovery (`GH`, mode 4): + * `EFHR = clamp(diminished(fhr), -85, 75)` + * - Effective 8.8 fixed-point step speed: + * `stepSpeed256 = max(32, floor(baseSpeed * (100 + effRate) / 100))` + * - One-shot animation duration: + * `ticks = ceil(frames * 256 / stepSpeed256)` + * `durationMs = ticks * 40` + */ + +import { RAW_WEAPONS } from '../data/canonical-drop-data.ts' +import { parseWeaponsTable, type WeaponBase, type WeaponTable } from '../items/weapons.ts' + +/** 1.13c simulation tick duration in milliseconds (25Hz). */ +export const D2_TICK_MS = 40 + +/** 1.13c `ItemStatCost.txt` stat IDs for animation rate modifiers. */ +export const STAT_SKILL_IAS = 68 +export const STAT_FASTERATTACKRATE = 93 +export const STAT_FASTERMOVEVELOCITY = 96 +export const STAT_FASTERHITRECOVERY = 99 +export const STAT_FASTERBLOCKRATE = 102 +export const STAT_FASTERCASTRATE = 105 + +export type CharAnimClipKey = + | 'wl' + | 'nu' + | 'rn' + | 'a1' + | 'a2' + | 'sc' + | 'gh' + | 'dt' + | 'dd' + | 'tn' + | 'tw' + +export interface CharAnimClip113c { + readonly frames: number + readonly speed: number + readonly loop: boolean + readonly events: readonly { readonly f: number; readonly c: number }[] +} + +/** + * Canonical 1.13c `AnimData.d2` clip metadata for all 7 playable classes (`0..6`: + * `am, so, ne, pa, ba, dz, ai`), matching `public/assets/characters/char-*.json`. + */ +export const CHAR_ANIM_CLIPS_113C: Readonly< + Record>> +> = { + // 0: Amazon (am) + 0: { + wl: { frames: 8, speed: 256, loop: true, events: [] }, + nu: { frames: 8, speed: 128, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 13, speed: 256, loop: false, events: [{ f: 6, c: 1 }] }, + a2: { frames: 13, speed: 256, loop: false, events: [{ f: 6, c: 1 }] }, + sc: { frames: 20, speed: 256, loop: false, events: [{ f: 13, c: 1 }] }, + gh: { frames: 6, speed: 256, loop: false, events: [] }, + dt: { frames: 23, speed: 256, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 80, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, + // 1: Sorceress (so) + 1: { + wl: { frames: 8, speed: 256, loop: true, events: [] }, + nu: { frames: 8, speed: 128, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 16, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + a2: { frames: 16, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + sc: { frames: 14, speed: 256, loop: false, events: [{ f: 7, c: 1 }] }, + gh: { frames: 8, speed: 256, loop: false, events: [] }, + dt: { frames: 24, speed: 256, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 80, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, + // 2: Necromancer (ne) + 2: { + wl: { frames: 8, speed: 256, loop: true, events: [] }, + nu: { frames: 8, speed: 128, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 15, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + a2: { frames: 15, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + sc: { frames: 16, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + gh: { frames: 7, speed: 256, loop: false, events: [] }, + dt: { frames: 27, speed: 176, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 80, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, + // 3: Paladin (pa) + 3: { + wl: { frames: 10, speed: 288, loop: true, events: [] }, + nu: { frames: 8, speed: 184, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 14, speed: 256, loop: false, events: [{ f: 7, c: 1 }] }, + a2: { frames: 14, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + sc: { frames: 16, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + gh: { frames: 5, speed: 256, loop: false, events: [] }, + dt: { frames: 28, speed: 256, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 128, loop: true, events: [] }, + tw: { frames: 10, speed: 288, loop: true, events: [] }, + }, + // 4: Barbarian (ba) + 4: { + wl: { frames: 8, speed: 168, loop: true, events: [] }, + nu: { frames: 8, speed: 96, loop: true, events: [] }, + rn: { frames: 8, speed: 216, loop: true, events: [] }, + a1: { frames: 12, speed: 256, loop: false, events: [{ f: 7, c: 1 }] }, + a2: { frames: 12, speed: 256, loop: false, events: [{ f: 7, c: 1 }] }, + sc: { frames: 14, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + gh: { frames: 5, speed: 256, loop: false, events: [] }, + dt: { frames: 27, speed: 240, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 80, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, + // 5: Druid (dz) + 5: { + wl: { frames: 8, speed: 136, loop: true, events: [] }, + nu: { frames: 6, speed: 120, loop: true, events: [] }, + rn: { frames: 8, speed: 168, loop: true, events: [] }, + a1: { frames: 16, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + a2: { frames: 16, speed: 256, loop: false, events: [{ f: 8, c: 1 }] }, + sc: { frames: 15, speed: 208, loop: false, events: [{ f: 8, c: 1 }] }, + gh: { frames: 7, speed: 256, loop: false, events: [] }, + dt: { frames: 25, speed: 176, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 16, speed: 96, loop: true, events: [] }, + tw: { frames: 8, speed: 128, loop: true, events: [] }, + }, + // 6: Assassin (ai) + 6: { + wl: { frames: 8, speed: 256, loop: true, events: [] }, + nu: { frames: 8, speed: 128, loop: true, events: [] }, + rn: { frames: 8, speed: 256, loop: true, events: [] }, + a1: { frames: 11, speed: 256, loop: false, events: [{ f: 6, c: 1 }] }, + a2: { frames: 12, speed: 256, loop: false, events: [{ f: 6, c: 1 }] }, + sc: { frames: 17, speed: 256, loop: false, events: [{ f: 9, c: 1 }] }, + gh: { frames: 5, speed: 256, loop: false, events: [] }, + dt: { frames: 20, speed: 208, loop: false, events: [] }, + dd: { frames: 1, speed: 256, loop: false, events: [] }, + tn: { frames: 12, speed: 64, loop: true, events: [] }, + tw: { frames: 8, speed: 256, loop: true, events: [] }, + }, +} + +const TOKEN_TO_CLASS_ID: Readonly> = { + am: 0, + ama: 0, + amazon: 0, + so: 1, + sor: 1, + sorceress: 1, + ne: 2, + nec: 2, + necromancer: 2, + pa: 3, + pal: 3, + paladin: 3, + ba: 4, + bar: 4, + barbarian: 4, + dz: 5, + dru: 5, + druid: 5, + ai: 6, + ass: 6, + asn: 6, + assassin: 6, +} + +const PLAYER_MODE_NUM_TO_CLIP: Readonly> = { + 0: 'dt', + 1: 'nu', + 2: 'wl', + 3: 'rn', + 4: 'gh', + 5: 'tn', + 6: 'tw', + 7: 'a1', + 8: 'a2', + 9: 'a1', + 10: 'sc', + 17: 'dd', +} + +/** + * Resolves the canonical 1.13c `AnimData.d2` clip metadata for a given character class + * (`0..6` or token `'am'|'so'|'ne'|'pa'|'ba'|'dz'|'ai'`) and mode (`0..17` or clip key). + */ +export function getCharAnimClip113c( + classIdOrToken: number | string, + modeOrClipKey: number | string, +): CharAnimClip113c { + const classId = + typeof classIdOrToken === 'number' + ? classIdOrToken >= 0 && classIdOrToken <= 6 + ? Math.trunc(classIdOrToken) + : 1 + : (TOKEN_TO_CLASS_ID[classIdOrToken.trim().toLowerCase()] ?? 1) + const classClips = CHAR_ANIM_CLIPS_113C[classId] ?? CHAR_ANIM_CLIPS_113C[1]! + + const clipKey: CharAnimClipKey = + typeof modeOrClipKey === 'number' + ? (PLAYER_MODE_NUM_TO_CLIP[Math.trunc(modeOrClipKey)] ?? 'nu') + : ((modeOrClipKey.trim().toLowerCase() as CharAnimClipKey) in classClips + ? (modeOrClipKey.trim().toLowerCase() as CharAnimClipKey) + : 'nu') + + return classClips[clipKey] ?? classClips.nu +} + +/** + * 1.13c diminishing returns formula (`D2Common.dll`): + * `floor(120 * rate / (120 + rate))` for `rate > 0`. + */ +export function computeDiminishingRate113c(rate: number): number { + if (!Number.isFinite(rate)) return 0 + if (rate <= 0) return Math.max(-85, Math.trunc(rate)) + return Math.trunc((120 * rate) / (120 + rate)) +} + +/** + * Effective Faster Cast Rate (`EFCR = clamp(diminished(fcr), -85, 75)`). + */ +export function computeEffectiveFcr(fcr: number): number { + return Math.max(-85, Math.min(75, computeDiminishingRate113c(fcr))) +} + +/** + * Effective Increased Attack Speed (`EIAS = clamp(diminished(itemIas) + skillIas - weaponWsm, -85, 75)`). + * Accepts either an options object `{ itemIas, skillIas, weaponWsm }` or positional `(itemIas, skillIas, weaponWsm)`. + */ +export function computeEffectiveIas( + input: + | { + readonly itemIas: number + readonly skillIas?: number | undefined + readonly weaponWsm?: number | undefined + } + | number, + skillIasArg = 0, + weaponWsmArg = 0, +): number { + const itemIas = typeof input === 'number' ? input : input.itemIas + const skillIas = typeof input === 'number' ? skillIasArg : (input.skillIas ?? 0) + const weaponWsm = typeof input === 'number' ? weaponWsmArg : (input.weaponWsm ?? 0) + const dimIas = computeDiminishingRate113c(itemIas) + const safeSkillIas = Number.isFinite(skillIas) ? Math.trunc(skillIas) : 0 + const safeWsm = Number.isFinite(weaponWsm) ? Math.trunc(weaponWsm) : 0 + return Math.max(-85, Math.min(75, dimIas + safeSkillIas - safeWsm)) +} + +/** + * Effective Faster Hit Recovery (`EFHR = clamp(diminished(fhr), -85, 75)`). + */ +export function computeEffectiveFhr(fhr: number): number { + return Math.max(-85, Math.min(75, computeDiminishingRate113c(fhr))) +} + +/** + * Effective 8.8 fixed-point animation step speed: + * `stepSpeed256 = max(32, floor(baseSpeed * (100 + effRate) / 100))`. + */ +export function computeStepSpeed256(baseSpeed: number, effRate = 0): number { + const safeBase = Number.isFinite(baseSpeed) && baseSpeed > 0 ? baseSpeed : 256 + const safeRate = Number.isFinite(effRate) ? effRate : 0 + return Math.max(32, Math.floor((safeBase * (100 + safeRate)) / 100)) +} + +/** + * Total 25Hz simulation ticks for a one-shot animation: + * `ceil(frames * 256 / stepSpeed256)`. + */ +export function computeOneShotTicks(frames: number, stepSpeed256: number): number { + const safeFrames = Math.max(1, Math.trunc(Number.isFinite(frames) ? frames : 1)) + const safeSpeed = Math.max(32, Math.trunc(Number.isFinite(stepSpeed256) ? stepSpeed256 : 256)) + return Math.max(1, Math.ceil((safeFrames * 256) / safeSpeed)) +} + +/** + * Total duration in milliseconds for a one-shot animation at 25Hz (40ms per tick): + * `ceil(frames * 256 / stepSpeed256) * 40`. + */ +export function computeOneShotDurationMs(frames: number, stepSpeed256: number): number { + return computeOneShotTicks(frames, stepSpeed256) * D2_TICK_MS +} + +let cachedWeaponsTable: WeaponTable | null = null + +/** + * Lazily parses and memoizes the canonical 1.13c `Weapons.txt` table (`RAW_WEAPONS`). + */ +export function getCanonicalWeaponsTable(): WeaponTable { + if (!cachedWeaponsTable) { + cachedWeaponsTable = parseWeaponsTable(RAW_WEAPONS) + } + return cachedWeaponsTable +} + +/** + * Resolves a weapon record from canonical 1.13c `Weapons.txt` by item code. + */ +export function resolveWeaponBase113c(code: string | undefined | null): WeaponBase | undefined { + if (!code) return undefined + const trimmed = code.trim() + if (!trimmed) return undefined + return getCanonicalWeaponsTable().get(trimmed) +} + +/** + * Resolves the 1.13c Weapon Speed Modifier (`WSM`, `speed` column in `Weapons.txt`) for an item code. + * Unarmed (`hth`) or non-weapon codes return `0`. + */ +export function resolveWeaponWsm(code: string | undefined | null): number { + const weapon = resolveWeaponBase113c(code) + return weapon ? weapon.speed : 0 +} + +export interface EquippedItemStatLike { + readonly statId?: number | undefined + readonly id?: number | undefined + readonly param?: number | undefined + readonly value: number +} + +export interface EquippedItemLike { + readonly id?: number | undefined + readonly code?: string | undefined + readonly equippedSlot?: number | undefined + readonly equipSlot?: number | undefined + readonly bodyLoc?: number | undefined + readonly broken?: boolean | undefined + readonly inSocket?: boolean | undefined + readonly stats?: readonly EquippedItemStatLike[] | undefined + readonly runewordStats?: readonly EquippedItemStatLike[] | undefined + readonly setBonusStats?: readonly (readonly EquippedItemStatLike[])[] | undefined +} + +export interface StatListReaderLike { + get(statId: number, param?: number): number + has?(statId: number, param?: number): boolean +} + +/** + * Resolves the effective Weapon Speed Modifier (`WSM`) across equipped items. + * Prefers primary/secondary weapon arm slots (4 = right arm, 5 = left arm); if dual-wielding two + * weapons in slots 4 and 5, averages their WSM (`trunc((wsm4 + wsm5) / 2)`). + */ +export function resolveEquippedWeaponWsm( + equipment?: Iterable | null, +): number { + if (!equipment) return 0 + let slot4Wsm: number | undefined + let slot5Wsm: number | undefined + let fallbackWsm: number | undefined + + for (const item of equipment) { + if (!item || item.broken || item.inSocket) continue + const weapon = resolveWeaponBase113c(item.code) + if (!weapon) continue + const slot = item.equippedSlot ?? item.equipSlot ?? item.bodyLoc ?? 0 + if (slot === 4 && slot4Wsm === undefined) { + slot4Wsm = weapon.speed + } else if (slot === 5 && slot5Wsm === undefined) { + slot5Wsm = weapon.speed + } else if (fallbackWsm === undefined) { + fallbackWsm = weapon.speed + } + } + + if (slot4Wsm !== undefined && slot5Wsm !== undefined) { + return Math.trunc((slot4Wsm + slot5Wsm) / 2) + } + if (slot4Wsm !== undefined) return slot4Wsm + if (slot5Wsm !== undefined) return slot5Wsm + return fallbackWsm ?? 0 +} + +function sumItemStatList( + stats: readonly EquippedItemStatLike[] | undefined, + targetStatId: number, +): number { + if (!stats || stats.length === 0) return 0 + let total = 0 + for (const s of stats) { + const id = s.statId ?? s.id + if (id === targetStatId && Number.isFinite(s.value)) { + total += s.value + } + } + return total +} + +/** + * Resolves the total value of `statId` for a character from both `StatList` (`unitStats` / `selfStats`) + * and equipped items (`equipment` + socketed children in `sockets`). + */ +export function resolveCharacterStatTotal( + statId: number, + sources: { + readonly unitStats?: StatListReaderLike | null | undefined + readonly selfStats?: StatListReaderLike | null | undefined + readonly equipment?: Iterable | null | undefined + readonly sockets?: ReadonlyMap | null | undefined + }, +): number { + const unitVal = sources.unitStats ? sources.unitStats.get(statId) : 0 + const selfVal = sources.selfStats ? sources.selfStats.get(statId) : 0 + // `StatSet` writes the same wire value to both `self.stats` and `selfUnit.stats`. + // Use `unitVal` if non-zero, otherwise `selfVal` (e.g., if `StatSet` arrived before `UnitAssign`). + const baseStatVal = unitVal !== 0 ? unitVal : selfVal + + let equipTotal = 0 + const visitedIds = new Set() + + const addFromItem = (item: EquippedItemLike, includeRunewordAndSet: boolean): void => { + if (!item || item.broken) return + if (item.id !== undefined) { + if (visitedIds.has(item.id)) return + visitedIds.add(item.id) + } + equipTotal += sumItemStatList(item.stats, statId) + if (includeRunewordAndSet) { + equipTotal += sumItemStatList(item.runewordStats, statId) + if (item.setBonusStats) { + for (const group of item.setBonusStats) { + equipTotal += sumItemStatList(group, statId) + } + } + } + } + + if (sources.equipment) { + for (const item of sources.equipment) { + if (!item || item.broken || item.inSocket) continue + addFromItem(item, true) + if (item.id !== undefined && sources.sockets) { + const children = sources.sockets.get(item.id) + if (children) { + for (const child of children) { + addFromItem(child, false) + } + } + } + } + } + + return baseStatVal + equipTotal +} + +export interface CharacterAnimSpeedParams { + readonly clipKey: CharAnimClipKey + readonly frames: number + readonly baseSpeed: number + readonly loop: boolean + readonly effRate: number + readonly stepSpeed256: number + readonly durationMs: number + readonly fcr: number + readonly itemIas: number + readonly skillIas: number + readonly weaponWsm: number + readonly fhr: number +} + +/** + * Resolves full 1.13c animation speed parameters (`frames`, `baseSpeed`, `effRate`, `stepSpeed256`, + * `durationMs`) for a character class and animation mode given their stats and equipped items. + */ +export function resolveCharacterAnimSpeedParams( + classIdOrToken: number | string, + modeOrClipKey: number | string, + sources: { + readonly unitStats?: StatListReaderLike | null | undefined + readonly selfStats?: StatListReaderLike | null | undefined + readonly equipment?: Iterable | null | undefined + readonly sockets?: ReadonlyMap | null | undefined + readonly weaponCode?: string | null | undefined + readonly weaponWsmOverride?: number | null | undefined + } = {}, +): CharacterAnimSpeedParams { + const clip = getCharAnimClip113c(classIdOrToken, modeOrClipKey) + const clipKey: CharAnimClipKey = + typeof modeOrClipKey === 'number' + ? (PLAYER_MODE_NUM_TO_CLIP[Math.trunc(modeOrClipKey)] ?? 'nu') + : ((modeOrClipKey.trim().toLowerCase() as CharAnimClipKey) || 'nu') + + const equipmentList = sources.equipment ? Array.from(sources.equipment) : undefined + const normalizedSources = { + ...sources, + equipment: equipmentList, + } + + const fcr = resolveCharacterStatTotal(STAT_FASTERCASTRATE, normalizedSources) + const itemIas = resolveCharacterStatTotal(STAT_FASTERATTACKRATE, normalizedSources) + const skillIas = resolveCharacterStatTotal(STAT_SKILL_IAS, normalizedSources) + const fhr = resolveCharacterStatTotal(STAT_FASTERHITRECOVERY, normalizedSources) + const weaponWsm = + sources.weaponWsmOverride !== undefined && sources.weaponWsmOverride !== null + ? sources.weaponWsmOverride + : sources.weaponCode + ? resolveWeaponWsm(sources.weaponCode) + : resolveEquippedWeaponWsm(equipmentList) + + let effRate = 0 + if (clipKey === 'sc') { + effRate = computeEffectiveFcr(fcr) + } else if (clipKey === 'a1' || clipKey === 'a2') { + effRate = computeEffectiveIas({ itemIas, skillIas, weaponWsm }) + } else if (clipKey === 'gh') { + effRate = computeEffectiveFhr(fhr) + } + + const stepSpeed256 = computeStepSpeed256(clip.speed, effRate) + const durationMs = computeOneShotDurationMs(clip.frames, stepSpeed256) + + return { + clipKey, + frames: clip.frames, + baseSpeed: clip.speed, + loop: clip.loop, + effRate, + stepSpeed256, + durationMs, + fcr, + itemIas, + skillIas, + weaponWsm, + fhr, + } +} + +/** + * Computes the 0-based animation frame index at `nowMs` according to 1.13c 25Hz (40ms) fixed-point + * progression (`stepSpeed256` where `256` = 1 frame per 40ms tick): + * - For non-looping clips (`loop === false`, e.g. `sc`, `a1`, `a2`, `gh`, `dt`, `dd`), starts at + * frame `0` at `startedAtMs` and clamps at `frameCount - 1` (never wraps via `% frameCount`). + * - For looping clips (`loop === true`, e.g. `nu`, `wl`, `rn`, `tn`, `tw`), wraps cleanly via `% frameCount`. + */ +export function computeAnimFrameIndex(params: { + readonly frameCount: number + readonly stepSpeed256: number + readonly loop: boolean + readonly nowMs: number + readonly startedAtMs?: number | undefined +}): number { + const frameCount = Math.max(1, Math.trunc(params.frameCount || 1)) + if (frameCount <= 1) return 0 + const stepSpeed256 = Math.max(32, Math.trunc(params.stepSpeed256 || 256)) + const elapsedMs = + params.startedAtMs !== undefined && Number.isFinite(params.startedAtMs) + ? Math.max(0, params.nowMs - params.startedAtMs) + : Math.max(0, params.nowMs) + const rawFrame = Math.floor(((elapsedMs / D2_TICK_MS) * stepSpeed256) / 256) + if (!params.loop) { + return Math.max(0, Math.min(frameCount - 1, rawFrame)) + } + return ((rawFrame % frameCount) + frameCount) % frameCount +} diff --git a/tests/client/char-anim-speed-loop.test.ts b/tests/client/char-anim-speed-loop.test.ts new file mode 100644 index 0000000..1093195 --- /dev/null +++ b/tests/client/char-anim-speed-loop.test.ts @@ -0,0 +1,645 @@ +import { describe, expect, it } from 'vitest' +import { + CHAR_ANIM_CLIPS_113C, + computeAnimFrameIndex, + computeDiminishingRate113c, + computeEffectiveFcr, + computeEffectiveFhr, + computeEffectiveIas, + computeOneShotDurationMs, + computeOneShotTicks, + computeStepSpeed256, + getCharAnimClip113c, + resolveWeaponWsm, +} from '../../src/common/units/char-anim-speed.ts' +import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts' +import type { AnimClipMeta } from '../../src/client/render/actor-animator.ts' +import type { LoadedCharacter } from '../../src/client/scene/entity-art-loader.ts' +import { + clearMissingArtEntries, + renderSceneSourceFrame, +} from '../../src/client/scene/world-renderer.ts' +import { OnlineSession } from '../../src/client/session/online-session.ts' +import type { SpriteRenderer } from '../../src/client/render/renderer.ts' +import type { SceneFrameSnapshot, SceneSource } from '../../src/client/view/scene-source.ts' +import { buildSceneDrawables, type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts' +import { getViewportProfile } from '../../src/client/view/viewport-profile.ts' +import type { ClientUnit } from '../../src/client/world/client-unit.ts' +import { ClientWorld } from '../../src/client/world/client-world.ts' + +type MockFrame = LoadedCharacter['groups'][number][number] + +function toRenderableUnit(unit: ClientUnit): RenderableUnitInput { + return { + id: unit.id, + kind: unit.unitType, + classId: unit.classId, + x: unit.x, + y: unit.y, + ...(unit.targetX !== undefined ? { targetX: unit.targetX } : {}), + ...(unit.targetY !== undefined ? { targetY: unit.targetY } : {}), + mode: unit.mode, + direction: unit.direction, + lifePct: unit.lifePct, + ...(unit.castState !== undefined ? { castState: unit.castState } : {}), + ...(unit.modeStartedAtMs !== undefined ? { animStartedAtMs: unit.modeStartedAtMs } : {}), + ...(unit.animStepSpeed256 !== undefined ? { animStepSpeed256: unit.animStepSpeed256 } : {}), + ...(unit.animEffRate !== undefined ? { animEffRate: unit.animEffRate } : {}), + states: Array.from(unit.states.list()), + } +} + +function createMockCharacterArt(classId = 1): LoadedCharacter { + const canonical = CHAR_ANIM_CLIPS_113C[classId] ?? CHAR_ANIM_CLIPS_113C[1]! + const groups: MockFrame[][] = [] + const clips: Record = {} + const numDirs = 16 + + let groupCursor = 0 + for (const [clipKey, spec] of Object.entries(canonical)) { + clips[clipKey] = { + group: groupCursor, + frames: spec.frames, + directions: numDirs, + speed: spec.speed, + loop: spec.loop, + events: [], + } + for (let dir = 0; dir < numDirs; dir++) { + const dirFrames: MockFrame[] = [] + for (let f = 0; f < spec.frames; f++) { + dirFrames.push({ + x: (groupCursor + dir) * 1000 + f, + y: f, + width: 48, + height: 72, + anchorX: -24, + anchorY: -64, + }) + } + groups.push(dirFrames) + } + groupCursor += numDirs + } + + return { + handle: 1 as unknown as LoadedCharacter['handle'], + stand: clips.nu!.group, + walk: clips.wl!.group, + directions: numDirs, + members: 1, + clips, + groups, + } +} + +function createSpyRenderer(): { + readonly renderer: SpriteRenderer + readonly drawnFrames: MockFrame[] +} { + const drawnFrames: MockFrame[] = [] + const renderer = { + begin: () => {}, + flush: () => {}, + setPalette: () => {}, + setLighting: () => {}, + addIndexedAtlas: () => 1 as any, + draw: (frame: MockFrame) => { + drawnFrames.push(frame) + }, + } as unknown as SpriteRenderer + return { renderer, drawnFrames } +} + +describe('1.13c character animation speed & one-shot lifecycle (#552)', () => { + it('computes 1.13c AnimData.d2 diminishing returns, EFCR/EIAS/EFHR, stepSpeed256, and durationMs', () => { + expect(computeDiminishingRate113c(0)).toBe(0) + expect(computeDiminishingRate113c(20)).toBe(17) + expect(computeDiminishingRate113c(40)).toBe(30) + expect(computeDiminishingRate113c(63)).toBe(41) + expect(computeDiminishingRate113c(105)).toBe(56) + expect(computeDiminishingRate113c(120)).toBe(60) + expect(computeDiminishingRate113c(200)).toBe(75) + expect(computeDiminishingRate113c(-30)).toBe(-30) + + expect(computeEffectiveFcr(0)).toBe(0) + expect(computeEffectiveFcr(105)).toBe(56) + expect(computeEffectiveFcr(500)).toBe(75) + + // Phase Blade (WSM -30) + 40% item IAS (diminished = 30) + 20% Fanaticism -> min(75, 30 + 20 - (-30)) = 75 + expect(computeEffectiveIas(40, 20, -30)).toBe(75) + // Great Maul (WSM +20) + 0% IAS -> -20 + expect(computeEffectiveIas(0, 0, 20)).toBe(-20) + + expect(computeEffectiveFhr(0)).toBe(0) + expect(computeEffectiveFhr(86)).toBe(50) + + // Sorceress SC: 14 frames, baseSpeed = 256 + const sorcSc = getCharAnimClip113c(1, 'sc')! + expect(sorcSc.frames).toBe(14) + expect(sorcSc.speed).toBe(256) + expect(sorcSc.loop).toBe(false) + + const step0 = computeStepSpeed256(sorcSc.speed, computeEffectiveFcr(0)) + expect(step0).toBe(256) + expect(computeOneShotTicks(sorcSc.frames, step0)).toBe(14) + expect(computeOneShotDurationMs(sorcSc.frames, step0)).toBe(560) + + const step105 = computeStepSpeed256(sorcSc.speed, computeEffectiveFcr(105)) + expect(step105).toBe(399) + expect(computeOneShotTicks(sorcSc.frames, step105)).toBe(9) + expect(computeOneShotDurationMs(sorcSc.frames, step105)).toBe(360) + }) + + it('resolves 1.13c weapon WSM from Weapons.txt codes', () => { + expect(resolveWeaponWsm('7cr')).toBe(-30) // Phase Blade + expect(resolveWeaponWsm('hax')).toBe(0) // Hand Axe + expect(resolveWeaponWsm('axe')).toBe(10) // Axe + expect(resolveWeaponWsm('mau')).toBe(10) // Maul + expect(resolveWeaponWsm('gma')).toBe(20) // Great Maul + }) + + it('drives spell cast speed and castState duration from FCR in StatList and equipped items', () => { + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, // Sorceress + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + + // Cast Fireball (skillId = 47) at 0% FCR + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5020, y: 5000 }, + }, + 1000, + ) + + const unit = world.getUnit('player', 1)! + expect(unit.mode).toBe(10) + expect(unit.castState).toBeDefined() + expect(unit.castState!.stepSpeed256).toBe(256) + expect(unit.castState!.durationMs).toBe(560) + expect(unit.castState!.untilMs).toBe(1560) + + // Equip a weapon + shield totaling 105% FCR (statId = 105) + world.apply({ + type: 'ItemAction', + action: 0x06, + item: { + id: 101, + code: 'obf', + container: 'equipment', + equipSlot: 4, + x: 0, + y: 0, + stats: [{ id: 105, value: 40 }], + runewordStats: [{ id: 105, value: 35 }], + } as any, + }) + world.apply({ + type: 'ItemAction', + action: 0x06, + item: { + id: 102, + code: 'uit', + container: 'equipment', + equipSlot: 5, + x: 0, + y: 0, + stats: [{ id: 105, value: 30 }], + } as any, + }) + + // Cast Fireball at 105% FCR + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5020, y: 5000 }, + }, + 2000, + ) + + expect(unit.castState).toBeDefined() + expect(unit.castState!.effRate).toBe(56) + expect(unit.castState!.stepSpeed256).toBe(399) + expect(unit.castState!.durationMs).toBe(360) + expect(unit.castState!.untilMs).toBe(2360) + }) + + it('drives melee attack speed and duration from IAS, skill IAS, and equipped weapon WSM', () => { + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 3, // Paladin (A1: 14 frames, baseSpeed = 256) + name: 'Pala', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + + // 1) Equip slow Great Maul ('gma', WSM = +20) with 0% IAS + world.apply({ + type: 'ItemAction', + action: 0x06, + item: { + id: 201, + code: 'gma', + container: 'equipment', + equipSlot: 4, + x: 0, + y: 0, + stats: [], + } as any, + }) + + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 0, // Normal Attack -> mode 7 (A1) + level: 1, + target: { x: 5005, y: 5000 }, + }, + 1000, + ) + + const unit = world.getUnit('player', 1)! + expect(unit.mode).toBe(7) + expect(unit.castState!.effRate).toBe(-20) + expect(unit.castState!.stepSpeed256).toBe(204) + expect(unit.castState!.durationMs).toBe(720) + + // 2) Replace with Phase Blade ('7cr', WSM = -30, +40% IAS) + 20% skill IAS (statId 68) + world.apply({ + type: 'ItemAction', + action: 0x06, + item: { + id: 202, + code: '7cr', + container: 'equipment', + equipSlot: 4, + x: 0, + y: 0, + stats: [{ id: 93, value: 40 }], + } as any, + }) + world.apply({ type: 'StatSet', stat: 68, value: 20, param: 0 }, 2000) + + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 0, + level: 1, + target: { x: 5005, y: 5000 }, + }, + 2000, + ) + + expect(unit.castState!.effRate).toBe(75) + expect(unit.castState!.stepSpeed256).toBe(448) + expect(unit.castState!.durationMs).toBe(320) + expect(unit.castState!.untilMs).toBe(2320) + }) + + it('clamps non-looping one-shot clips at frameCount - 1 without modulo wrapping and wraps looping clips', () => { + // Non-looping 14-frame clip at stepSpeed256 = 256 (1 frame per 40ms) + const framesVisited: number[] = [] + for (let t = 0; t <= 1200; t += 40) { + framesVisited.push( + computeAnimFrameIndex({ + frameCount: 14, + stepSpeed256: 256, + loop: false, + nowMs: 1000 + t, + startedAtMs: 1000, + }), + ) + } + expect(framesVisited[0]).toBe(0) + expect(framesVisited[1]).toBe(1) + expect(framesVisited[13]).toBe(13) + // After 13 * 40 = 520ms, all subsequent samples stay clamped at 13 (never wrap to 0) + for (let i = 13; i < framesVisited.length; i++) { + expect(framesVisited[i]).toBe(13) + } + + // Looping 8-frame clip wraps around + expect( + computeAnimFrameIndex({ + frameCount: 8, + stepSpeed256: 256, + loop: true, + nowMs: 8 * 40, + }), + ).toBe(0) + expect( + computeAnimFrameIndex({ + frameCount: 8, + stepSpeed256: 256, + loop: true, + nowMs: 9 * 40, + }), + ).toBe(1) + }) + + it('expires castState back to mode 1 (NU) without moving caster position, and interrupts cast on UnitMove', () => { + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, + name: 'Sorc', + x: 5100, + y: 5100, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + + // Cast ranged spell toward (5200, 5100) + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5200, y: 5100 }, + }, + 1000, + ) + + const unit = world.getUnit('player', 1)! + const castDir = dir64FromPoints(5100, 5100, 5200, 5100, 0) + expect(unit.mode).toBe(10) + expect(unit.direction).toBe(castDir) + + // Mid-cast tick at t = 1280ms (280ms elapsed < 560ms duration) + world.tick(280, 1280) + expect(unit.x).toBe(5100) + expect(unit.y).toBe(5100) + expect(unit.castState).toBeDefined() + expect(unit.mode).toBe(10) + + const midDrawables = buildSceneDrawables([toRenderableUnit(unit)]).unitDrawables + expect(midDrawables[0]!.mode).toBe(10) + expect(midDrawables[0]!.animMode).toBe('SC') + expect(midDrawables[0]!.direction).toBe(castDir) + + // Tick past cast completion at t = 1560ms (560ms elapsed) + world.tick(280, 1560) + expect(unit.castState).toBeUndefined() + expect(world.self.castState).toBeUndefined() + expect(unit.mode).toBe(1) + expect(unit.direction).toBe(castDir) + + const afterDrawables = buildSceneDrawables([toRenderableUnit(unit)]).unitDrawables + expect(afterDrawables[0]!.mode).toBe(1) + expect(afterDrawables[0]!.animMode).toBeUndefined() + expect(afterDrawables[0]!.direction).toBe(castDir) + + // Start another cast at t = 2000ms and interrupt it with UnitMove at t = 2100ms + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5200, y: 5100 }, + }, + 2000, + ) + expect(unit.castState).toBeDefined() + expect(unit.mode).toBe(10) + + world.apply( + { + type: 'UnitMove', + unitType: 'player', + id: 1, + mode: 'run', + from: { x: 5100, y: 5100 }, + to: { x: 5120, y: 5120 }, + }, + 2100, + ) + expect(unit.castState).toBeUndefined() + expect(world.self.castState).toBeUndefined() + expect(unit.mode).toBe(3) + }) + + it('preserves local player cast facing direction on OnlineSession after cast expires into NU and clears cast on movement', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }) + + session.handleServerEvent({ + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5040, y: 5020 }, + }) + + const expectedDir = dir64FromPoints(5000, 5000, 5040, 5020, 0) + const duringUnits = session.buildRenderableUnits() + const selfDuring = duringUnits.find((u) => u.isLocalPlayer)! + expect(selfDuring.mode).toBe(10) + expect(selfDuring.direction).toBe(expectedDir) + expect(selfDuring.castState).toBeDefined() + + // Advance 600ms via session.tick so the 560ms Sorceress cast finishes + for (let i = 1; i <= 15; i++) { + session.tick(40, i * 40) + } + + const afterUnits = session.buildRenderableUnits() + const selfAfter = afterUnits.find((u) => u.isLocalPlayer)! + expect(selfAfter.mode).toBe(1) // Returned to NU (idle) + expect(selfAfter.castState).toBeUndefined() + expect(selfAfter.direction).toBe(expectedDir) // Still facing cast target! + + session.destroy() + }) + + it('renders non-looping one-shot clip frames once (clamped at last frame) and switches back to NU clip after castState expires', () => { + clearMissingArtEntries() + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, // Sorceress + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5030, y: 5000 }, + }, + 1000, + ) + + const charArt = createMockCharacterArt(1) + const characterArtByToken = new Map([['so', charArt]]) + const { renderer, drawnFrames } = createSpyRenderer() + const vp = getViewportProfile('800x600') + + const makeSource = (): SceneSource => ({ + isWorldReady: () => true, + getLevelView: () => null, + getViewportProfile: () => vp, + setViewportProfile: () => vp, + buildLightingInputs: () => ({}) as any, + buildFrameSnapshot: (): SceneFrameSnapshot => { + const unit = world.getUnit('player', 1)! + const entities = buildSceneDrawables([ + { + id: unit.id, + kind: 'player', + classId: unit.classId, + x: unit.x, + y: unit.y, + mode: unit.mode, + direction: unit.direction, + castState: unit.castState, + animStartedAtMs: unit.castState?.startedAtMs ?? unit.modeStartedAtMs, + animStepSpeed256: unit.castState?.stepSpeed256 ?? unit.animStepSpeed256, + animEffRate: unit.castState?.effRate ?? unit.animEffRate, + isLocalPlayer: true, + }, + ]) + return { + tick: 0, + worldReady: true, + actId: 1, + levelId: 1, + viewportProfile: vp, + playerSubTile: { x: 5000, y: 5000 }, + playerLocalSubTile: { x: 5000, y: 5000 }, + playerWorld: { x: 0, y: 0 }, + cameraTopLeft: { x: -400, y: -300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities, + } + }, + }) as unknown as SceneSource + + const source = makeSource() + const scClip = charArt.clips!.sc! + const nuClip = charArt.clips!.nu! + const unit = world.getUnit('player', 1)! + const clipDir = dir64ToClipDirection(unit.direction, scClip.directions ?? 16) + const expectedScGroup = scClip.group + clipDir + const expectedNuGroup = nuClip.group + clipDir + + // At t = 1000ms (cast start), renders SC frame 0 + renderSceneSourceFrame({ + source, + lightingPreset: 'day' as any, + renderer, + characterArtByToken, + nowMs: 1000, + }) + expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 0) + expect(drawnFrames.at(-1)!.y).toBe(0) + + // At t = 1200ms (5 ticks into cast), renders SC frame 5 + renderSceneSourceFrame({ + source, + lightingPreset: 'day' as any, + renderer, + characterArtByToken, + nowMs: 1200, + }) + expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 5) + expect(drawnFrames.at(-1)!.y).toBe(5) + + // Even if world.tick hasn't been called yet at t = 1800ms, the one-shot SC clip clamps at frame 13 (never wraps to 0) + renderSceneSourceFrame({ + source, + lightingPreset: 'day' as any, + renderer, + characterArtByToken, + nowMs: 1800, + }) + expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 13) + expect(drawnFrames.at(-1)!.y).toBe(13) + + // Once world.tick(600, 1600) expires castState, unit returns to NU clip while preserving clipDir + world.tick(600, 1600) + renderSceneSourceFrame({ + source, + lightingPreset: 'day' as any, + renderer, + characterArtByToken, + nowMs: 1600, + }) + const finalFrame = drawnFrames.at(-1)! + expect(Math.floor(finalFrame.x / 100)).toBe(expectedNuGroup * 10) + }) +}) From b839301aae406309d8e57986ad11e062af363edf Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 10:26:12 +0000 Subject: [PATCH 06/15] fix(client): ground items cannot be picked up and holding Alt does not display ground item labels (#555) Closes #555 --- src/client/input/command-mapper.ts | 133 ++++++- src/client/main.ts | 8 +- src/client/scene/world-renderer.ts | 61 ++- src/client/session/online-session.ts | 18 + src/client/ui/hud-manager.ts | 55 ++- src/client/view/unit-drawables.ts | 6 + src/client/world/client-unit.ts | 2 + src/client/world/client-world.ts | 13 +- src/netproto/d2gs/s2c/units.ts | 2 +- ...und-item-pickup-and-alt-labels-555.test.ts | 373 ++++++++++++++++++ 10 files changed, 656 insertions(+), 15 deletions(-) create mode 100644 tests/client/ground-item-pickup-and-alt-labels-555.test.ts diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index b021064..4a32e04 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -161,7 +161,11 @@ export class CommandMapper { private rightMouseDown = false private lastPointerX = 0 private lastPointerY = 0 + private pointerDownX = 0 + private pointerDownY = 0 private hoveredUnit: ClientUnit | null = null + private lockedPointerUnit: ClientUnit | null = null + private pendingItemPickupId: number | null = null private lastPath: readonly SubTilePoint[] = [] /** Default F1..F8 skill hotkey bindings (`key.toUpperCase()` -> binding). */ @@ -232,7 +236,39 @@ export class CommandMapper { this.camera.update(this.world.self.x, this.world.self.y) } const subtile = this.camera.screenToSubtile(screenX, screenY) - const unit = this.pickUnitAtSubtile(subtile.x, subtile.y, hitRadiusSubtiles) + + // 1. Hit-test visible ground item label plaques (`Alt` or hovered item label boxes) + const labelBoxes = this.hudManager.groundItemLabelBoxes + if (labelBoxes && labelBoxes.length > 0) { + for (let i = labelBoxes.length - 1; i >= 0; i--) { + const box = labelBoxes[i]! + if ( + screenX >= box.x && + screenX <= box.x + box.width && + screenY >= box.y && + screenY <= box.y + box.height + ) { + const itemUnit = this.world.getUnit('item', box.id) + if (itemUnit) { + return { + subtileX: itemUnit.x, + subtileY: itemUnit.y, + hoveredUnit: itemUnit, + } + } + } + } + } + + // 2. Hit-test world units, compensating for `world-renderer.ts` `camY = playerWorld.y - 16` + const renderAlignedSubtile = this.camera.screenToSubtile(screenX, screenY - 16) + const unit = this.pickUnitAtSubtile( + subtile.x, + subtile.y, + hitRadiusSubtiles, + renderAlignedSubtile.x, + renderAlignedSubtile.y, + ) return { subtileX: subtile.x, subtileY: subtile.y, @@ -244,17 +280,27 @@ export class CommandMapper { * Pick the closest interactive unit within `radiusSubtiles` of `(subtileX, subtileY)`. * Excludes the local player unit and dead/corpse monsters unless interactable. */ - pickUnitAtSubtile(subtileX: number, subtileY: number, radiusSubtiles = 2.5): ClientUnit | null { + pickUnitAtSubtile( + subtileX: number, + subtileY: number, + radiusSubtiles = 2.5, + altSubtileX?: number, + altSubtileY?: number, + ): ClientUnit | null { const localPlayerId = this.world.selfId const radiusSq = radiusSubtiles * radiusSubtiles let bestUnit: ClientUnit | null = null let bestDistSq = radiusSq + const hasAlt = altSubtileX !== undefined && altSubtileY !== undefined // 1. Check ground items first (unitType 4 = 'item') for (const unit of this.world.getUnitsByType(4)) { const dx = unit.x - subtileX const dy = unit.y - subtileY - const d2 = dx * dx + dy * dy + const d2Primary = dx * dx + dy * dy + const d2 = hasAlt + ? Math.min(d2Primary, (unit.x - altSubtileX!) ** 2 + (unit.y - altSubtileY!) ** 2) + : d2Primary if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) { bestDistSq = d2 bestUnit = unit @@ -269,13 +315,16 @@ export class CommandMapper { if (unitType === 0 && localPlayerId !== 0 && unit.id === localPlayerId) { continue } - // Skip dead monsters (mode 12 = Dead, or lifePct <= 0) - if (unitType === 1 && (unit.mode === 12 || unit.lifePct <= 0)) { + // Skip dead monsters (mode 0 = Death, mode 12 = Dead, or lifePct <= 0) + if (unitType === 1 && (unit.mode === 0 || unit.mode === 12 || unit.lifePct <= 0)) { continue } const dx = unit.x - subtileX const dy = unit.y - subtileY - const d2 = dx * dx + dy * dy + const d2Primary = dx * dx + dy * dy + const d2 = hasAlt + ? Math.min(d2Primary, (unit.x - altSubtileX!) ** 2 + (unit.y - altSubtileY!) ** 2) + : d2Primary if (d2 <= radiusSq && (bestUnit === null || d2 < bestDistSq)) { bestDistSq = d2 bestUnit = unit @@ -323,6 +372,12 @@ export class CommandMapper { // If holding left mouse button in world space, continuously steer toward cursor if (this.leftMouseDown) { + if (this.lockedPointerUnit?.unitType === 'item') { + if (Math.hypot(screenX - this.pointerDownX, screenY - this.pointerDownY) <= 12) { + return + } + this.lockedPointerUnit = null + } this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } else if (this.rightMouseDown) { this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) @@ -341,6 +396,8 @@ export class CommandMapper { ): DispatchedCommandRecord | null { this.lastPointerX = screenX this.lastPointerY = screenY + this.pointerDownX = screenX + this.pointerDownY = screenY this.hudManager.handleMouseMove(screenX, screenY) // 1. Check external UI interception first @@ -362,11 +419,14 @@ export class CommandMapper { if (button === 0) { this.leftMouseDown = true + this.lockedPointerUnit = + target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } if (button === 2) { this.rightMouseDown = true + this.lockedPointerUnit = null return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } @@ -379,6 +439,7 @@ export class CommandMapper { handlePointerUp(button: number): void { if (button === 0) { this.leftMouseDown = false + this.lockedPointerUnit = null } else if (button === 2) { this.rightMouseDown = false } @@ -400,6 +461,7 @@ export class CommandMapper { // Stand-still modifier (Shift): cast left skill on location/target instead of moving if (this.shiftHeld) { + this.pendingItemPickupId = null if (hoveredUnit && hoveredUnit.unitType === 'monster') { return this.dispatchRateLimited( { @@ -434,11 +496,14 @@ export class CommandMapper { const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning) const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY) if (dist > 4) { + this.pendingItemPickupId = hoveredUnit.id this.sendCommand({ type: 'MoveToUnit', run: isRunning, target: unitToRef(hoveredUnit), }) + } else { + this.pendingItemPickupId = null } return this.dispatchRateLimited( { @@ -451,6 +516,8 @@ export class CommandMapper { ) } + this.pendingItemPickupId = null + if (hoveredUnit.unitType === 'tile' || (hoveredUnit as any).kind === 'warp') { const path = this.startLocalPathPrediction(hoveredUnit.x, hoveredUnit.y, isRunning) const moveRec = this.dispatchRateLimited( @@ -567,6 +634,7 @@ export class CommandMapper { } // Ground walk / run + this.pendingItemPickupId = null const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning) const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY } return this.dispatchRateLimited( @@ -589,6 +657,7 @@ export class CommandMapper { hoveredUnit: ClientUnit | null = null, nowMs = performance.now(), ): DispatchedCommandRecord | null { + this.pendingItemPickupId = null if (hoveredUnit) { return this.dispatchRateLimited( { @@ -635,6 +704,8 @@ export class CommandMapper { } if (event.key === 'Alt' || event.altKey) { this.altHeld = true + this.hudManager.showGroundLabels = true + this.hudManager.syncPublishedState() event.preventDefault?.() if (event.key === 'Alt') return true } @@ -843,6 +914,23 @@ export class CommandMapper { } if (event.key === 'Alt' || event.altKey === false) { this.altHeld = false + this.hudManager.showGroundLabels = false + this.hudManager.syncPublishedState() + } + } + + /** + * Reset transient modifier and mouse down states (e.g. on window blur). + */ + resetModifiers(): void { + this.shiftHeld = false + this.altHeld = false + this.leftMouseDown = false + this.rightMouseDown = false + this.lockedPointerUnit = null + if (this.hudManager.showGroundLabels) { + this.hudManager.showGroundLabels = false + this.hudManager.syncPublishedState() } } @@ -851,6 +939,7 @@ export class CommandMapper { /** * Advance periodic input state: * - Flushes any coalesced movement command when its 120ms cooldown expires. + * - Completes deferred arrival pickup when player walks within <= 4 subtiles of a distant clicked item. * - Repeats held mouse actions at rate-limited intervals. */ tick(nowMs = performance.now()): ClientCommand | null { @@ -860,7 +949,39 @@ export class CommandMapper { return flushed } + // Check deferred ground item pickup upon arrival within reach (<= 4 subtiles) + if (this.pendingItemPickupId !== null) { + const pendingItem = this.world.getUnit('item', this.pendingItemPickupId) + if (!pendingItem) { + this.pendingItemPickupId = null + } else { + const predSnap = this.predictor?.getSnapshot() + const selfX = + predSnap && (predSnap.x !== 0 || predSnap.y !== 0) ? predSnap.x : this.world.self.x + const selfY = + predSnap && (predSnap.x !== 0 || predSnap.y !== 0) ? predSnap.y : this.world.self.y + const dist = Math.hypot(pendingItem.x - selfX, pendingItem.y - selfY) + if (dist <= 4) { + const record = this.dispatchRateLimited( + { + type: 'ItemPickup', + itemId: pendingItem.id, + toCursor: false, + }, + nowMs, + ) + if (record.rateLimit.allowed) { + this.pendingItemPickupId = null + return record.command + } + } + } + } + if (this.leftMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) { + if (this.lockedPointerUnit?.unitType === 'item') { + return null + } const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY) const record = this.triggerPrimaryWorldAction( target.subtileX, diff --git a/src/client/main.ts b/src/client/main.ts index 66238f5..bfe3d9f 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -843,7 +843,7 @@ async function bootPlayApp(): Promise { } return } - if (e.target === hudCanvas) return + if (e.defaultPrevented) return const pt = toStageCoords(e) session.commandMapper.handlePointerDown(pt.x, pt.y, e.button, performance.now()) }) @@ -912,6 +912,12 @@ async function bootPlayApp(): Promise { true, ) + window.addEventListener('blur', () => { + if (inGameView) { + session.commandMapper.resetModifiers() + } + }) + let lastFrameMs = performance.now() const renderFrame = (nowMs = performance.now()): void => { const dtMs = Math.max(0, Math.min(100, nowMs - lastFrameMs)) diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 3709d94..c948c5d 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -43,7 +43,11 @@ import type { import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts' import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts' import { BAKED_UI_MANIFEST, type SpriteRect } from '../../common/items/baked-ui-meta.ts' -import { layoutGroundItemLabels } from '../../common/items/ground-items.ts' +import { + layoutGroundItemLabels, + resolveGroundItemMetadata, + type GroundItemLabelInput, +} from '../../common/items/ground-items.ts' import { resolveGroundItemSpriteRect } from '../ui/inventory.ts' import { applyLightingInputs, @@ -1040,16 +1044,18 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram renderer.flush() } if (ctx.hudManager) { + ctx.hudManager.groundItemLabelBoxes = [] ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) return snapshot } + const camX = snapshot.playerWorld.x + const camY = snapshot.playerWorld.y - 16 + if (renderer !== null) { ctx.tileGpuCache?.ensureActPalette(snapshot.actId) - const camX = snapshot.playerWorld.x - const camY = snapshot.playerWorld.y - 16 renderer.begin({ x: camX, y: camY, zoom: 1 }, [0.02, 0.02, 0.03]) if (ctx.lightingTargets) { @@ -1233,6 +1239,55 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram } if (ctx.hudManager) { + const vpW = snapshot.viewportProfile.width + const vpH = snapshot.viewportProfile.height + const showAll = ctx.hudManager.showGroundLabels + const isEn = getTblLang() === 'ENG' + const labelInputs: GroundItemLabelInput[] = [] + for (const u of snapshot.entities.unitDrawables) { + if (u.unitKind !== 'item') continue + if (!showAll && !u.isHovered) continue + const screenX = Math.round(u.worldX - camX + vpW / 2) + const screenY = Math.round(u.worldY - 14 - camY + vpH / 2) + if (screenX < -60 || screenX > vpW + 60 || screenY < -10 || screenY > vpH + 20) { + continue + } + const rawCode = + u.code?.trim().toLowerCase() || + u.itemCode?.trim().toLowerCase() || + (u.token && !u.token.startsWith('ITM_') ? u.token.trim().toLowerCase() : '') || + BAKED_UI_MANIFEST.itemClassIdToCode[u.classId] || + '' + const meta = + !u.itemColor || (!u.name && !u.nameZh) + ? resolveGroundItemMetadata( + { + code: rawCode, + ...(u.itemQuality !== undefined ? { quality: u.itemQuality } : {}), + ...(u.goldAmount !== undefined ? { goldAmount: u.goldAmount } : {}), + ...(u.name !== undefined ? { name: u.name } : {}), + }, + u.goldAmount, + ) + : undefined + const labelText = isEn + ? (u.name || meta?.labelEn || rawCode || `Item #${u.unitId}`) + : (u.nameZh || meta?.labelZh || u.name || meta?.labelEn || rawCode || `Item #${u.unitId}`) + const color = u.itemColor || meta?.color || '#ffffff' + const measuredW = Math.max(40, ctx.hudManager.font.measureText(labelText, 'font16') + 14) + labelInputs.push({ + id: u.unitId, + text: labelText, + label: labelText, + color, + screenX, + screenY, + width: measuredW, + height: 18, + }) + } + ctx.hudManager.groundItemLabelBoxes = + labelInputs.length > 0 ? layoutGroundItemLabels(labelInputs, vpW, vpH) : [] ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 3af1e8c..e60f2d1 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -1644,6 +1644,8 @@ export class OnlineSession { this.mapService?.getActiveLevelView() ?? this.automapView.getLevelView() + const hoveredUnit = this.commandMapper.currentHoveredUnit + for (const u of this.world.units.values()) { if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) { continue @@ -1672,6 +1674,9 @@ export class OnlineSession { }) continue } + const isHovered = Boolean( + hoveredUnit && hoveredUnit.unitType === u.unitType && hoveredUnit.id === u.id, + ) const interp = this.interpolator.getEntityState( u.unitType === 'player' ? 0 @@ -1730,6 +1735,7 @@ export class OnlineSession { direction: interp ? interp.dir64 : u.direction, radiusX: 28, radiusY: 20, + isHovered, }) continue } @@ -1809,6 +1815,7 @@ export class OnlineSession { direction: interp ? interp.dir64 : u.direction, hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)), isCorpse: false, + isHovered, states: Array.from(u.states.list()), ...(portal?.ownerName !== undefined ? { portalOwnerName: portal.ownerName } : {}), ...(portal?.areaId !== undefined ? { portalAreaId: portal.areaId } : {}), @@ -1839,11 +1846,20 @@ export class OnlineSession { : u.name && u.name.trim() !== '' ? u.name : meta?.name + const itemNameZh = + meta?.isGold && goldAmount !== undefined + ? `${goldAmount} 金币` + : u.nameZh && u.nameZh.trim() !== '' + ? u.nameZh + : meta?.nameZh + const itemColor = u.itemColor ?? meta?.color out.push({ id: u.id, kind: 'item', classId: u.classId, ...(itemName !== undefined ? { name: itemName } : {}), + ...(itemNameZh !== undefined ? { nameZh: itemNameZh } : {}), + ...(itemColor !== undefined ? { itemColor } : {}), ...(itemCode !== undefined ? { code: itemCode, itemCode } : {}), x: ux, y: uy, @@ -1851,6 +1867,7 @@ export class OnlineSession { direction: interp ? interp.dir64 : u.direction, hpRatio: 1, isCorpse: false, + isHovered, ...(goldAmount !== undefined ? { goldAmount } : {}), ...(itemQuality !== undefined ? { itemQuality, quality: itemQuality } : {}), }) @@ -1880,6 +1897,7 @@ export class OnlineSession { direction: interp ? interp.dir64 : u.direction, hpRatio: Math.max(0, Math.min(1, u.lifePct / 128)), isCorpse, + isHovered, isChampion: u.isChampion, isUnique: u.isUnique, isSuperUnique: u.isSuperUnique, diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index f1b3271..8fe8355 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -13,7 +13,8 @@ * and Authentic Hand Cursor (`CURSOR\ohand.dc6`) / Held `cursorItem` Sprite. */ -import { D2FontRenderer } from './font.ts' +import { D2FontRenderer, type D2ColorCode } from './font.ts' +import type { GroundItemLabelBox } from '../../common/items/ground-items.ts' import { GlobesHud } from './globes.ts' import { BeltHud } from './belt.ts' import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts' @@ -406,6 +407,31 @@ export function serverItemIdOf(item: UiInventoryItem): number { return id } +function groundLabelHexToD2Color(hex: string | undefined): D2ColorCode { + switch ((hex ?? '').toLowerCase()) { + case '#c4a060': + case '#d8b864': + case '#c8a15a': + return 'gold' + case '#00fc00': + return 'green' + case '#ffff64': + return 'yellow' + case '#ff8000': + case '#ffa500': + case '#ff9c18': + return 'orange' + case '#4880ff': + case '#6868ff': + return 'blue' + case '#a0a0a0': + case '#696969': + return 'gray' + default: + return 'white' + } +} + export class HudManager { readonly font = new D2FontRenderer() readonly globes = new GlobesHud() @@ -422,6 +448,8 @@ export class HudManager { rightPanel: 'none' | 'inv' | 'skill' = 'none' isRunning = true showGroundLabels = false + /** Screen-space (800x600 viewport) ground item label boxes laid out for the current frame. */ + groundItemLabelBoxes: readonly GroundItemLabelBox[] = [] /** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */ stamina = 0 maxStamina = 0 @@ -2355,6 +2383,31 @@ export class HudManager { } } + // 0c. Ground Item Labels (`D2Client.dll` Alt / hover item label plaques) + if (this.groundItemLabelBoxes.length > 0) { + ctx.save() + for (const box of this.groundItemLabelBoxes) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.78)' + ctx.fillRect(box.x, box.y, box.width, box.height) + ctx.strokeStyle = 'rgba(96, 84, 56, 0.75)' + ctx.strokeRect( + box.x + 0.5, + box.y + 0.5, + Math.max(0, box.width - 1), + Math.max(0, box.height - 1), + ) + const d2Color = groundLabelHexToD2Color(box.color) + const textCenterX = Math.round(box.x + box.width / 2) + const textBaselineY = Math.round(box.y + box.height - 4) + this.font.drawText(ctx, box.text, textCenterX, textBaselineY, { + font: 'font16', + color: d2Color, + align: 'center', + }) + } + ctx.restore() + } + // 1. Left-Half Dock Panel (`0..400, panelOffsetY..panelOffsetY+553`) if (this.leftPanel === 'char') { ctx.save() diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index fa4709d..3f2b664 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -60,8 +60,10 @@ export interface RenderableUnitInput { readonly altitude?: number | undefined readonly lifePct?: number | undefined readonly name?: string | undefined + readonly nameZh?: string | undefined readonly code?: string | undefined readonly itemQuality?: number | undefined + readonly itemColor?: string | undefined readonly goldAmount?: number | undefined readonly token?: string | undefined readonly compositeArmor?: Readonly> | undefined @@ -101,6 +103,7 @@ export interface SceneUnitDrawable { readonly token: string readonly code?: string | undefined readonly itemQuality?: number | undefined + readonly itemColor?: string | undefined readonly goldAmount?: number | undefined readonly mode: number /** Facing as a dir64; the renderer picks the clip direction with `dir64ToClipDirection`. */ @@ -114,6 +117,7 @@ export interface SceneUnitDrawable { readonly blendMode: DrawableBlendMode readonly lifePct: number readonly name: string | undefined + readonly nameZh?: string | undefined readonly isHovered: boolean readonly isCorpse: boolean readonly tintColor: readonly [number, number, number] | null @@ -662,6 +666,8 @@ export function buildSceneDrawables( blendMode: 'normal', lifePct: unit.lifePct ?? 100, name: resolvedName, + ...(unit.nameZh !== undefined ? { nameZh: unit.nameZh } : {}), + ...(unit.itemColor !== undefined ? { itemColor: unit.itemColor } : {}), isHovered: Boolean(unit.isHovered), isCorpse, tintColor, diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 0f95e80..66c0370 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -59,8 +59,10 @@ export class ClientUnit { readonly id: number classId: number name?: string | undefined + nameZh?: string | undefined itemCode?: string | undefined itemQuality?: number | undefined + itemColor?: string | undefined goldAmount?: number | undefined x: number y: number diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index ef343f8..ab4ac53 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -2128,8 +2128,8 @@ export class ClientWorld { this._containers.applyItemEvent(effectiveAction, ev.item, effectiveContainer, this.selfId) if (effectiveContainer === 'ground') { const itemUnit = this.ensureUnit('item', ev.item.id) - itemUnit.x = ev.item.worldX - itemUnit.y = ev.item.worldY + itemUnit.x = ev.item.worldX || ev.item.x || 0 + itemUnit.y = ev.item.worldY || ev.item.y || 0 const trimmedCode = ev.item.code.trim() const goldStatVal = ev.item.stats?.find((s: { statId: number; value: number }) => s.statId === 14)?.value const goldAmt = @@ -2141,7 +2141,14 @@ export class ClientWorld { itemUnit.itemData = ev.item itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3 const uiItem = onlineItemDataToUiInventoryItem(ev.item) - itemUnit.name = uiItem.name || ev.item.code + itemUnit.name = + trimmedCode === 'gld' && goldAmt !== undefined + ? `${goldAmt} Gold` + : (uiItem.name || ev.item.code) + itemUnit.nameZh = + trimmedCode === 'gld' && goldAmt !== undefined + ? `${goldAmt} 金币` + : (uiItem.nameZh || uiItem.baseNameZh || itemUnit.name) } else { this._units.delete(makeUnitKey('item', ev.item.id)) } diff --git a/src/netproto/d2gs/s2c/units.ts b/src/netproto/d2gs/s2c/units.ts index 2ed0d15..ef24e83 100644 --- a/src/netproto/d2gs/s2c/units.ts +++ b/src/netproto/d2gs/s2c/units.ts @@ -94,7 +94,7 @@ export function decode0x11ReportKill(pkt: Uint8Array): ServerEvent { type: 'UnitMode', unitType, id, - mode: 0x09, + mode: unitType === 0 ? 17 : resolveMonsterCmdMode(0x09), lifePct: 0, } } diff --git a/tests/client/ground-item-pickup-and-alt-labels-555.test.ts b/tests/client/ground-item-pickup-and-alt-labels-555.test.ts new file mode 100644 index 0000000..36cb1fa --- /dev/null +++ b/tests/client/ground-item-pickup-and-alt-labels-555.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it, vi } from 'vitest' +import { decode0x11ReportKill } from '../../src/netproto/d2gs/s2c/units.ts' +import { ClientWorld } from '../../src/client/world/client-world.ts' +import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { CommandMapper } from '../../src/client/input/command-mapper.ts' +import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts' +import type { SceneSource, SceneFrameSnapshot } from '../../src/client/view/scene-source.ts' +import { VIEWPORT_800x600 } from '../../src/client/view/viewport-profile.ts' +import { buildSceneDrawables } from '../../src/client/view/unit-drawables.ts' +import { setTblLang } from '../../src/client/i18n/lang.ts' + +describe('Issue #555: Ground items pickup, arrival trigger, and Alt labels', () => { + it('0x11 ReportKill maps monster kills to mode 12 (MONMODE_DEAD) and player kills to mode 17 (PLRMODE_DEAD)', () => { + // Monster kill: unitType 1, id 100, killer 0x59 + const monsterKillBytes = new Uint8Array([ + 0x11, + 0x01, + 0x64, 0x00, 0x00, 0x00, // unitId 100 + 0x09, // raw mode in packet + 0x59, 0x00, 0x00, 0x00, // killerId + ]) + const monsterEv = decode0x11ReportKill(monsterKillBytes) + expect(monsterEv.type).toBe('UnitMode') + if (monsterEv.type === 'UnitMode') { + expect(monsterEv.unitType).toBe(1) + expect(monsterEv.id).toBe(100) + expect(monsterEv.mode).toBe(12) // MONMODE_DEAD + } + + // Player kill: unitType 0, id 200, killer 0x10 + const playerKillBytes = new Uint8Array([ + 0x11, + 0x00, + 0xc8, 0x00, 0x00, 0x00, // unitId 200 + 0x09, // raw mode in packet + 0x10, 0x00, 0x00, 0x00, // killerId + ]) + const playerEv = decode0x11ReportKill(playerKillBytes) + expect(playerEv.type).toBe('UnitMode') + if (playerEv.type === 'UnitMode') { + expect(playerEv.unitType).toBe(0) + expect(playerEv.id).toBe(200) + expect(playerEv.mode).toBe(17) // PLRMODE_DEAD + } + }) + + it('Alt keydown/keyup synchronizes HudManager.showGroundLabels and published state', () => { + const world = new ClientWorld() + const hudManager = new HudManager() + const sentCommands: any[] = [] + const commandMapper = new CommandMapper({ + world, + hudManager, + sendCommand: (cmd) => sentCommands.push(cmd), + }) + + expect(hudManager.showGroundLabels).toBe(false) + expect(commandMapper.isAltHeld).toBe(false) + + // Press Alt + const handledDown = commandMapper.handleKeyDown({ key: 'Alt', preventDefault: vi.fn() }) + expect(handledDown).toBe(true) + expect(commandMapper.isAltHeld).toBe(true) + expect(hudManager.showGroundLabels).toBe(true) + expect(hudManager.state.showGroundLabels).toBe(true) + + // Release Alt + commandMapper.handleKeyUp({ key: 'Alt' }) + expect(commandMapper.isAltHeld).toBe(false) + expect(hudManager.showGroundLabels).toBe(false) + expect(hudManager.state.showGroundLabels).toBe(false) + + // Press Alt again and blur window + commandMapper.handleKeyDown({ key: 'Alt' }) + expect(commandMapper.isAltHeld).toBe(true) + expect(hudManager.showGroundLabels).toBe(true) + + commandMapper.resetModifiers() + expect(commandMapper.isAltHeld).toBe(false) + expect(hudManager.showGroundLabels).toBe(false) + expect(hudManager.state.showGroundLabels).toBe(false) + }) + + it('renderSceneSourceFrame computes groundItemLabelBoxes with localized text and 1.13c colors when Alt is held or item is hovered', () => { + const hudManager = new HudManager() + setTblLang('ENG') + const mockCtx = { + save: vi.fn(), + restore: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + fillText: vi.fn(), + scale: vi.fn(), + translate: vi.fn(), + measureText: (text: string) => ({ width: text.length * 8 }), + } as unknown as CanvasRenderingContext2D + hudManager.ctx = mockCtx + + // Create scene units: gold pile, a unique short sword, and a normal potion + const entities = buildSceneDrawables([ + { + id: 101, + kind: 'item', + classId: 0, + x: 100, + y: 100, + code: 'gld', + goldAmount: 250, + name: '250 Gold', + nameZh: '250 金币', + itemColor: '#d8b864', + mode: 3, + }, + { + id: 102, + kind: 'item', + classId: 1, + x: 102, + y: 101, + code: 'ssd', + name: 'Rixot\'s Keen', + nameZh: '瑞克希特的开胃甜点', + itemQuality: 7, + itemColor: '#c4a060', + mode: 3, + isHovered: true, // Hovered item + }, + { + id: 103, + kind: 'item', + classId: 2, + x: 104, + y: 102, + code: 'hp1', + name: 'Minor Healing Potion', + nameZh: '微型治疗药剂', + itemQuality: 2, + itemColor: '#ffffff', + mode: 3, + }, + ]) + + const mockSnapshot: SceneFrameSnapshot = { + tick: 1, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 100, y: 100 }, + playerLocalSubTile: { x: 100, y: 100 }, + playerWorld: { x: 0, y: 1600 }, + cameraTopLeft: { x: -400, y: 1300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities, + } + + const mockSource: SceneSource = { + getViewportProfile: () => VIEWPORT_800x600, + setViewportProfile: () => VIEWPORT_800x600, + getActId: () => 1, + getLevelId: () => 1, + getTick: () => 1, + getPlayerSubTile: () => ({ x: 100, y: 100 }), + isWorldReady: () => true, + getPlayerLocalSubTile: () => ({ x: 100, y: 100 }), + getPlayerWorldOrtho: () => ({ x: 0, y: 1600 }), + getCameraTopLeft: () => ({ x: -400, y: 1300 }), + getLevelView: () => null, + isSubTileWalkable: () => true, + buildFrameSnapshot: () => mockSnapshot, + buildLightingInputs: () => ({} as any), + pickUnitAtScreen: () => null, + } + + // 1. Without Alt: only hovered item (id 102) gets a label box + hudManager.showGroundLabels = false + renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' }) + + expect(hudManager.groundItemLabelBoxes.length).toBe(1) + expect(hudManager.groundItemLabelBoxes[0]!.id).toBe(102) + expect(hudManager.groundItemLabelBoxes[0]!.text).toBe('Rixot\'s Keen') + expect(hudManager.groundItemLabelBoxes[0]!.color).toBe('#c4a060') + + // 2. With Alt: all 3 ground items get label boxes + hudManager.showGroundLabels = true + renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' }) + + expect(hudManager.groundItemLabelBoxes.length).toBe(3) + const boxIds = hudManager.groundItemLabelBoxes.map((b) => b.id) + expect(boxIds).toContain(101) + expect(boxIds).toContain(102) + expect(boxIds).toContain(103) + + const goldBox = hudManager.groundItemLabelBoxes.find((b) => b.id === 101)! + expect(goldBox.text).toBe('250 Gold') + expect(goldBox.color).toBe('#d8b864') + + // 3. In Chinese locale + setTblLang('CHI') + renderSceneSourceFrame({ source: mockSource, hudManager, lightingPreset: 'noon' }) + const swordBoxZh = hudManager.groundItemLabelBoxes.find((b) => b.id === 102)! + expect(swordBoxZh.text).toBe('瑞克希特的开胃甜点') + }) + + it('CommandMapper.resolvePointerTarget resolves ground item when clicking directly on its label box', () => { + const world = new ClientWorld() + const hudManager = new HudManager() + const commandMapper = new CommandMapper({ + world, + hudManager, + sendCommand: () => {}, + }) + + // Simulate an item on the ground via ItemEvent + world.applyServerEvent({ + type: 'ItemEvent', + action: 0x03, + container: 'ground', + item: { + id: 777, + action: 0x03, + code: 'rin', + worldX: 105, + worldY: 105, + x: 105, + y: 105, + } as any, + }) + + // Position label box on screen at x: 380..460, y: 220..238 + hudManager.groundItemLabelBoxes = [ + { + id: 777, + text: 'Bronze Ring', + label: 'Bronze Ring', + color: '#4880ff', + x: 380, + y: 220, + width: 80, + height: 18, + }, + ] + + // Click inside the label box at (400, 228) + const target = commandMapper.resolvePointerTarget(400, 228) + expect(target.hoveredUnit).not.toBeNull() + expect(target.hoveredUnit!.id).toBe(777) + expect(target.hoveredUnit!.unitType).toBe('item') + expect(target.subtileX).toBe(105) + expect(target.subtileY).toBe(105) + }) + + it('CommandMapper handles distant item pickup (dist > 4) and dispatches ItemPickup upon arrival via tick', () => { + const world = new ClientWorld() + const hudManager = new HudManager() + const sentCommands: any[] = [] + const commandMapper = new CommandMapper({ + world, + hudManager, + sendCommand: (cmd) => sentCommands.push(cmd), + }) + + // Player starts at (100, 100) + world.self.x = 100 + world.self.y = 100 + commandMapper.getCamera().snapTo(100, 100) + + // Distant item at (108, 100) -> distance = 8 (> 4) + world.applyServerEvent({ + type: 'ItemEvent', + action: 0x03, + container: 'ground', + item: { + id: 888, + action: 0x03, + code: 'bsd', + worldX: 108, + worldY: 100, + x: 108, + y: 100, + } as any, + }) + const itemUnit = world.getUnit('item', 888)! + + // Click on distant item + const rec = commandMapper.triggerPrimaryWorldAction(108, 100, itemUnit, 1000) + expect(rec).not.toBeNull() + // Initial click sends MoveToUnit and attempts ItemPickup + expect(sentCommands.some((c) => c.type === 'MoveToUnit' && c.target.id === 888)).toBe(true) + expect(sentCommands.some((c) => c.type === 'ItemPickup' && c.itemId === 888)).toBe(true) + + // Player moves toward the item: at (105, 100), distance is 3 (<= 4) + world.self.x = 105 + world.self.y = 100 + + // Clear sent commands to observe arrival pickup + sentCommands.length = 0 + + // Advance tick at 1150ms (> 80ms interaction cooldown) + const tickResult = commandMapper.tick(1150) + expect(tickResult).not.toBeNull() + expect(tickResult!.type).toBe('ItemPickup') + expect((tickResult as any).itemId).toBe(888) + expect(sentCommands.some((c) => c.type === 'ItemPickup' && c.itemId === 888)).toBe(true) + + // Subsequent tick does not re-emit once picked up + sentCommands.length = 0 + const nextTick = commandMapper.tick(1200) + expect(nextTick).toBeNull() + expect(sentCommands.length).toBe(0) + }) + + it('pickUnitAtSubtile skips dead monsters with mode 0 (Death) or mode 12 (Dead)', () => { + const world = new ClientWorld() + const hudManager = new HudManager() + const commandMapper = new CommandMapper({ + world, + hudManager, + sendCommand: () => {}, + }) + + // Dead monster at (100, 100) with mode 12 + world.applyServerEvent({ + type: 'UnitAssign', + unit: { + unitType: 'monster', + id: 301, + classId: 1, + x: 100, + y: 100, + mode: 12, + lifePct: 0, + }, + }) + + // Dying monster at (100, 100) with mode 0 + world.applyServerEvent({ + type: 'UnitAssign', + unit: { + unitType: 'monster', + id: 302, + classId: 1, + x: 100, + y: 100, + mode: 0, + lifePct: 0, + }, + }) + + // Alive monster at (101, 100) with mode 1 + world.applyServerEvent({ + type: 'UnitAssign', + unit: { + unitType: 'monster', + id: 303, + classId: 1, + x: 101, + y: 100, + mode: 1, + lifePct: 100, + }, + }) + + const picked = commandMapper.pickUnitAtSubtile(100, 100, 2.5) + expect(picked).not.toBeNull() + expect(picked!.id).toBe(303) // Picked the alive monster, ignored 301 and 302 + }) +}) From 7873ffc2270e9d1e40c05978f2268d428af46d66 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 10:41:26 +0000 Subject: [PATCH 07/15] fix(ui): restore authentic 1.13c mercenary hire stone dialog and packet protocol (#558) Closes #558 --- public/assets/ui/textslid.png | Bin 0 -> 1531 bytes public/ui/textslid.png | Bin 0 -> 1531 bytes src/baker/packers/pack-canonical-tbl.ts | 22 + src/baker/packers/pack-ui-assets.ts | 8 + src/baker/packers/pack-ui.ts | 10 + src/client/main.ts | 4 + src/client/ui-model/hud-model.ts | 61 +- src/client/ui/hud-manager.ts | 35 +- src/client/ui/world-panels.ts | 588 ++- src/client/world/client-world.ts | 2 +- src/common/data/canonical-tbl-strings.ts | 451 ++- src/common/items/baked-ui-meta.ts | 1 + src/common/units/hireling-table.ts | 4520 ++++++++++++++++++++++ tests/client/hire-dialog.test.ts | 191 + tests/common/hireling-table.test.ts | 162 + 15 files changed, 5968 insertions(+), 87 deletions(-) create mode 100644 public/assets/ui/textslid.png create mode 100644 public/ui/textslid.png create mode 100644 src/common/units/hireling-table.ts create mode 100644 tests/client/hire-dialog.test.ts create mode 100644 tests/common/hireling-table.test.ts diff --git a/public/assets/ui/textslid.png b/public/assets/ui/textslid.png new file mode 100644 index 0000000000000000000000000000000000000000..691c7dd8f1850df4a6af8c75921300aa30b89be7 GIT binary patch literal 1531 zcmXApZE(~C8pk6n7~55Y6VF_`Lck0SHqsywV;eMJDk%oYVT6F`xFeaedJD#xm0O0@ zE|MulqAs%F7FKQ65Q+f+ z1!Z}hmjECD@df}SP`%ro%8Ncj4M#z{9fDwo1%?v@LDCFO)4YW!Q?e?{GNXD_Rn z0*2feaix;_JON90DzGEps6lh7k(~hN38j zVK|PXIB9{CG(}O26=;rO7?KfKmStGZg5gD)lLbLw1g9cXq9{s|q$rB0xSUR>N8?1* z=W@B+Znx9z)illH@%Vf`)gSce0j)k72n6(CI3DyzB9Txu7L7*Z@pw9&&SWxXI-Seq z{D29<5a@tG6t-iCoj?%^bQieK+o0U4*@J+K-oSE-b=c>QG0WU?+Q%}W&eYLctQ>pIzyn}-)q z%Ee|+Z8_KT+TuGCW-J+Q+3=5tzlfcB`q6@9)s70fbm}Gj&fOX356&FR{BdP=&A(3l zsWlnyP#z!cj7>N{mTvf^J$Ss^nRA!FRB`%6ryWl;`2SWmb9CL%cD#7cO}X$^{XI|q z&@=XwtMILBy?tZZy}z`=wHqH_+A;Xyw*Jlo*Up^XxUKD6*}iGbgL}G)$8HqXPU$)w z>Z@1IT$$dt9l6**ma1QMs(r2Pt3Jsf&sR^VI8`*ia_C6z!S@gRWl|5_+yCc-U+&%b z`jnbtpsZ&1jwQ2proVgS=AGBHSC&2WaR4j1Vq(wq>|AxCM*6wyth=sq#jan!9@zB4 zJZ1Iv>zQ>ks?#N7U8CQe2yGhtp=axfW4}GN_Ewu?bW?ut*otW@KOWg~e)o=&bH}#S zZg269V=4aCfxE6m{gvljCz<#gu~GALz)1E0Q zHdh+=jxIa2=fH4T@SoMhg2cPMTj{#$+P2Y4yH?#80%xPwiSFU*6Gumtf0u7;S{9o# zx4p0VYj0h4YOON&jsNa%=xoR@SpE8wpM6?5{#bTD@%HzC@a+?IH_G2!{%zCl+3lrH rW{dXGUyn6xSmzxmDF$2m)33JKms~r%>*x8uKk=%{rCifO=X?JHpe=%= literal 0 HcmV?d00001 diff --git a/public/ui/textslid.png b/public/ui/textslid.png new file mode 100644 index 0000000000000000000000000000000000000000..691c7dd8f1850df4a6af8c75921300aa30b89be7 GIT binary patch literal 1531 zcmXApZE(~C8pk6n7~55Y6VF_`Lck0SHqsywV;eMJDk%oYVT6F`xFeaedJD#xm0O0@ zE|MulqAs%F7FKQ65Q+f+ z1!Z}hmjECD@df}SP`%ro%8Ncj4M#z{9fDwo1%?v@LDCFO)4YW!Q?e?{GNXD_Rn z0*2feaix;_JON90DzGEps6lh7k(~hN38j zVK|PXIB9{CG(}O26=;rO7?KfKmStGZg5gD)lLbLw1g9cXq9{s|q$rB0xSUR>N8?1* z=W@B+Znx9z)illH@%Vf`)gSce0j)k72n6(CI3DyzB9Txu7L7*Z@pw9&&SWxXI-Seq z{D29<5a@tG6t-iCoj?%^bQieK+o0U4*@J+K-oSE-b=c>QG0WU?+Q%}W&eYLctQ>pIzyn}-)q z%Ee|+Z8_KT+TuGCW-J+Q+3=5tzlfcB`q6@9)s70fbm}Gj&fOX356&FR{BdP=&A(3l zsWlnyP#z!cj7>N{mTvf^J$Ss^nRA!FRB`%6ryWl;`2SWmb9CL%cD#7cO}X$^{XI|q z&@=XwtMILBy?tZZy}z`=wHqH_+A;Xyw*Jlo*Up^XxUKD6*}iGbgL}G)$8HqXPU$)w z>Z@1IT$$dt9l6**ma1QMs(r2Pt3Jsf&sR^VI8`*ia_C6z!S@gRWl|5_+yCc-U+&%b z`jnbtpsZ&1jwQ2proVgS=AGBHSC&2WaR4j1Vq(wq>|AxCM*6wyth=sq#jan!9@zB4 zJZ1Iv>zQ>ks?#N7U8CQe2yGhtp=axfW4}GN_Ewu?bW?ut*otW@KOWg~e)o=&bH}#S zZg269V=4aCfxE6m{gvljCz<#gu~GALz)1E0Q zHdh+=jxIa2=fH4T@SoMhg2cPMTj{#$+P2Y4yH?#80%xPwiSFU*6Gumtf0u7;S{9o# zx4p0VYj0h4YOON&jsNa%=xoR@SpE8wpM6?5{#bTD@%HzC@a+?IH_G2!{%zCl+3lrH rW{dXGUyn6xSmzxmDF$2m)33JKms~r%>*x8uKk=%{rCifO=X?JHpe=%= literal 0 HcmV?d00001 diff --git a/src/baker/packers/pack-canonical-tbl.ts b/src/baker/packers/pack-canonical-tbl.ts index 54c4a4e..b309bf3 100644 --- a/src/baker/packers/pack-canonical-tbl.ts +++ b/src/baker/packers/pack-canonical-tbl.ts @@ -33,6 +33,7 @@ import { const EXPANSION_CHI_FALLBACKS: Readonly> = { strethereal: '无形的 (无法修复)', + PalMercEXST3X: '進攻', ModStre9s: '无法破坏', ModStre9t: '每秒修复 %d 点耐久度', ModStre9u: '每 %d 秒修复 1 点耐久度', @@ -419,6 +420,21 @@ async function main() { 'DemonID', 'Champion', 'minion', + 'HP', + 'AC', + 'Level', + 'Cost', + 'ItemDesc1s', + 'ItemDesc1t', + 'Back', + 'NPCMenuHire', + 'NPCHire1', + 'space', + 'dash', + 'colon', + 'strMerc', + 'merca239', + 'NotUsed150', ]) for (const [k] of eng.dict.entries()) { @@ -438,6 +454,12 @@ async function main() { k.startsWith('strScaryMonDesc') || k.startsWith('champ') || k.startsWith('Eskill') || + k.startsWith('merc') || + k.startsWith('Merc') || + k.startsWith('strhire') || + k.startsWith('PalMerc') || + k.startsWith('ItemDesc1') || + k.startsWith('VerifyTransaction') || explicitUiAndMonsterKeys.has(k) ) { keys.add(k) diff --git a/src/baker/packers/pack-ui-assets.ts b/src/baker/packers/pack-ui-assets.ts index 2971af8..6caa64e 100644 --- a/src/baker/packers/pack-ui-assets.ts +++ b/src/baker/packers/pack-ui-assets.ts @@ -442,6 +442,14 @@ async function main(): Promise { const f16Strip = stitchHorizontalStrip(f16Frames) savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels) + const textslidDc6 = await decodeUiDc6('data\\global\\ui\\menu\\textslid.dc6') + const tsFrames = textslidDc6.groups[0]?.frames + if (!tsFrames || tsFrames.length !== 17 || tsFrames.some(f => f.width !== 12 || f.height !== 13)) { + throw new Error('Invalid textslid.dc6 frame geometry: expected 17 frames of 12x13') + } + const tsStrip = stitchHorizontalStrip(tsFrames) + savePng('textslid.png', tsStrip.width, tsStrip.height, tsStrip.pixels) + const levelBtn = await decodeUiDc6('data\\global\\ui\\Panel\\Level.dc6') const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames) savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels) diff --git a/src/baker/packers/pack-ui.ts b/src/baker/packers/pack-ui.ts index bd3d636..bc110f3 100644 --- a/src/baker/packers/pack-ui.ts +++ b/src/baker/packers/pack-ui.ts @@ -534,6 +534,15 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom const f16Strip = stitchHorizontalStrip(f16Frames) savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels) + // Dialog scrollbar slider control (`menu/textslid.dc6` per D2Client.dll 0x6fb17fd0 & 0x6fb17640, 17 frames of 12x13) + const textslidDc6 = await decodeUiDc6('data/global/ui/menu/textslid.dc6') + const tsFrames = textslidDc6.groups[0]?.frames + if (!tsFrames || tsFrames.length !== 17 || tsFrames.some(f => f.width !== 12 || f.height !== 13)) { + throw new Error('Invalid textslid.dc6 frame geometry: expected 17 frames of 12x13') + } + const tsStrip = stitchHorizontalStrip(tsFrames) + savePng('textslid.png', tsStrip.width, tsStrip.height, tsStrip.pixels) + const levelBtn = await decodeUiDc6('data/global/ui/Panel/Level.dc6') const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames) savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels) @@ -1473,6 +1482,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom vendorTabs: '/ui/vendor-tabs.png', boxPieces: '/ui/boxpieces.png', focus16: '/ui/focus16.png', + textslid: '/ui/textslid.png', popbelt: '/ui/popbelt.png', minipanel: '/ui/minipanel.png', minipanelBtns: '/ui/minipanel-btns.png', diff --git a/src/client/main.ts b/src/client/main.ts index bfe3d9f..d6136fd 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -870,6 +870,10 @@ async function bootPlayApp(): Promise { e.preventDefault() frontendRenderer.handleWheel(e.deltaY) frontendRenderer.render(performance.now()) + } else if (inGameView && session?.hudManager) { + if (session.hudManager.handleWheel(e.deltaY)) { + e.preventDefault() + } } }, { passive: false }, diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 11d0123..91a4c23 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -41,6 +41,7 @@ import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts' import { D2StatId } from '../world/stat-list.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { computeCharacterSheetDerived } from './derived-stats.ts' +import { resolveHirelingCandidates, type HirelingCandidateDesc } from '../../common/units/hireling-table.ts' export const CLASS_ID_TO_CODE: Readonly> = { 0: 'ama', @@ -841,30 +842,25 @@ export class HudModel implements HudCommandSink { (this.world.mercHireList && this.world.mercHireList.length > 0 ? this.world.mercHireList : (this.world.merc as any)?.hireList) ?? [] - const hireCandidates = rawHireList.map((entry: any, idx: number) => { - const nameId = entry.nameId ?? entry.mercId ?? idx + 1 - const mercId = entry.mercId ?? entry.nameId ?? idx + 1 - const seed = entry.seed ?? 0 - const level = entry.level ?? Math.max(1, (seed % 15) + 5) - return { - ...entry, - mercId, - nameId, - seed, - name: entry.name || `Mercenary #${nameId}`, - className: entry.className ?? `Type #${entry.mercClass ?? 0}`, - level, - cost: entry.cost ?? level * 150 + 100, - life: entry.life ?? 40 + level * 12, - strength: entry.strength ?? 35 + level * 2, - dexterity: entry.dexterity ?? 30 + level * 2, - defense: entry.defense ?? 20 + level * 6, - } - }) + const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1) + const difficulty = this.world.difficulty ?? 0 + const activeNpcDesc = (hud.worldPanels as any).npcMenu?.descriptor ?? (this.world.npcInteraction.npcId ? this.tryNpcDescriptor(this.world.npcInteraction.npcId) : undefined) + const actIndex = activeNpcDesc ? activeNpcDesc.actIndex : this.world.act ?? 0 + const hireLang = (hud.lang === 'ENG' || (hud as any).language === 'en') ? 'ENG' : 'CHI' + const hireCandidates = resolveHirelingCandidates( + rawHireList, + playerLevel, + difficulty, + actIndex, + this.world.expansion ?? true, + hireLang, + ) ;(hud as any).hireList = hireCandidates ;(hud as any).mercHireList = hireCandidates ;(hud.worldPanels as any).hireList = hireCandidates ;(hud.worldPanels as any).mercHireList = hireCandidates + ;(hud.worldPanels as any).playerGold = this.world.self.gold ?? 0 + ;(hud.worldPanels as any).stashGold = this.world.self.stashGold ?? 0 ;(hud.worldPanels as any).hireMercPanel = { open: (hud.leftPanel as string) === 'hire' || (hud.worldPanels as any).activePanel === 'hire', candidates: hireCandidates, @@ -973,10 +969,14 @@ export class HudModel implements HudCommandSink { this.prevNpc = npc const wp = hud.worldPanels + const activeWin = npc.activeWindow ?? npc.window + const isHireOpen = (hud.leftPanel as string) === 'hire' || (wp as any).activePanel === 'hire' + // a. NpcInit noted without a window: open the NPC menu for service NPCs (npc-table descriptors). if ( npc.npcId !== null && - npc.window === 'none' && + activeWin === 'none' && + !isHireOpen && (npc.npcId !== prev.npcId || !wp.npcMenu || (wp.npcMenu.npcId === npc.npcId && !wp.npcMenu.talking)) ) { const desc = this.tryNpcDescriptor(npc.npcId) @@ -990,7 +990,11 @@ export class HudModel implements HudCommandSink { }) } } - if (npc.npcId === null && wp.npcMenu && wp.npcMenu.npcId !== null) wp.npcMenu = null + if ((npc.npcId === null || isHireOpen) && wp.npcMenu && wp.npcMenu.npcId !== null) { + if (npc.npcId === null || isHireOpen) { + wp.npcMenu = null + } + } // b. Trade / gamble window: store items come from 0x9C AddToShop (`containers.shop`). const npcWin = npc.window as string @@ -1016,13 +1020,18 @@ export class HudModel implements HudCommandSink { if (prev.window !== npc.window || prev.npcId !== npcId) hud.showVendorPanel() } else if ( npcWin === 'hire' || + activeWin === 'hire' || npcWin === 'imbue' || npcWin === 'add_sockets' || npcWin === 'personalize' ) { - hud.leftPanel = npcWin as any - ;(wp as any).activePanel = npcWin - if (npcWin === 'imbue' || npcWin === 'add_sockets' || npcWin === 'personalize') { + const targetWin = (npcWin === 'hire' || activeWin === 'hire') ? 'hire' : npcWin + hud.leftPanel = targetWin as any + ;(wp as any).activePanel = targetWin + if (targetWin === 'hire') { + wp.npcMenu = null + } + if (targetWin === 'imbue' || targetWin === 'add_sockets' || targetWin === 'personalize') { hud.rightPanel = 'inv' hud.inventory.visible = true } @@ -1567,9 +1576,11 @@ export class HudModel implements HudCommandSink { case 'hire': { this.npcEntityAction(3, resolvedNpcId, 0) ;(this.world.npcInteraction as any).window = 'hire' + ;(this.world.npcInteraction as any).activeWindow = 'hire' if (this.hudManager) { this.hudManager.leftPanel = 'hire' as any ;(this.hudManager.worldPanels as any).activePanel = 'hire' + ;(this.hudManager.worldPanels as any).npcMenu = null } break } diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 8fe8355..62eaddd 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -831,6 +831,7 @@ export class HudManager { cursorAtlas: `${baseUrl}/cursor.png`, boxPieces: `${baseUrl}/boxpieces.png`, focus16: `${baseUrl}/focus16.png`, + textSlid: `${baseUrl}/textslid.png`, escOptions: `${baseUrl}/esc-options.png`, escExit: `${baseUrl}/esc-exit.png`, escReturn: `${baseUrl}/esc-return.png`, @@ -1172,6 +1173,13 @@ export class HudManager { return this.activateEscapeMenuIndex(hit) } + handleWheel(deltaY: number): boolean { + if (this.leftPanel === 'hire' || (this.worldPanels as any).activePanel === 'hire') { + return this.worldPanels.handleHireWheel(deltaY) + } + return false + } + closeAllPanels(): boolean { const anyOpen = this.leftPanel !== 'none' || @@ -1277,6 +1285,7 @@ export class HudManager { return false } if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true + if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY, profile.width, profile.height)) return true const barX = logicalX - profile.barOffsetX const barY = logicalY - profile.barOffsetY if (barY >= 540 && barY <= 600 && barX >= 0 && barX <= 800) return true @@ -1298,6 +1307,7 @@ export class HudManager { if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true + if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY)) return true if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true @@ -1575,7 +1585,11 @@ export class HudManager { if (e.button === 0) this.handleEscapeMenuClick(pt.x, pt.y) return } - if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) { + if ( + this.worldPanels.npcMenu && + !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font) && + !this.worldPanels.isPointInHireMercDialog(pt.x, pt.y) + ) { this.dismissNpcMenu() } if (!this.isPointInterceptedByHud(pt.x, pt.y)) { @@ -2468,7 +2482,18 @@ export class HudManager { this.font, ) } else if (typeof (this.worldPanels as any).renderActiveLeftPanel === 'function') { - ;(this.worldPanels as any).renderActiveLeftPanel(ctx, this.leftPanel, this.font) + ;(this.worldPanels as any).renderActiveLeftPanel( + ctx, + this.leftPanel, + this.font, + { + boxPiecesImg: this.images.get('boxPieces') ?? null, + focus16Img: this.images.get('focus16') ?? null, + textSlidImg: this.images.get('textSlid') ?? this.images.get('textslid') ?? null, + }, + profile.width, + profile.height, + ) } else { this.worldPanels.drawLeftDockPanel( ctx, @@ -2596,7 +2621,11 @@ export class HudManager { }) // 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`) - if (this.worldPanels.npcMenu) { + if ( + this.worldPanels.npcMenu && + this.leftPanel !== 'hire' && + (this.worldPanels as any).activePanel !== 'hire' + ) { this.worldPanels.drawNpcMenu(ctx, this.font, { boxPiecesImg: this.images.get('boxPieces') ?? null, focus16Img: this.images.get('focus16') ?? null, diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index bde60b2..e2801a5 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -60,6 +60,7 @@ import { transmuteCubeItems, type CubeTransmuteResult, } from '../../common/items/runeword-socket.ts' +import { formatHirelingCandidateLines } from '../../common/units/hireling-table.ts' export { STASH_GOLD_CAP, @@ -89,6 +90,20 @@ export type LeftDockPanelKind = export type WorldPanelType = LeftDockPanelKind +export const HIRE_DIALOG_WIDTH = 490 +export const HIRE_DIALOG_HEIGHT = 350 +export const HIRE_LIST_X_OFFSET = 0 +export const HIRE_LIST_Y_OFFSET = 35 +export const HIRE_LIST_WIDTH = 490 +export const HIRE_LIST_HEIGHT = 280 +export const HIRE_VISIBLE_CANDIDATES = 7 +export const HIRE_ROW_HEIGHT = 34 +export const HIRE_SLIDER_X_OFFSET = 472 +export const HIRE_SLIDER_Y_OFFSET = 51 +export const HIRE_TRACK_SEGMENTS = 21 +export const HIRE_CONFIRM_WIDTH = 300 +export const HIRE_CONFIRM_HEIGHT = 160 + export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const /** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */ @@ -848,6 +863,13 @@ export class WorldPanelsHud { merc: any = null hireList: any[] = [] mercHireList: any[] = [] + hireScrollOffset = 0 + hireSelectedIndex = -1 + hireHoveredIndex: number | null = null + replaceMercConfirmOpen = false + pendingHireCandidate: any = null + hireConfirmHoveredOption: 0 | 1 | null = null + playerGold = 0 hasDeadMercenary = false deadMercenaryReviveCost = 0 partyMembers: any[] = [] @@ -880,6 +902,7 @@ export class WorldPanelsHud { onCloseNpcPanel: (() => void) | null = null private _npcMenu: NpcMenuState | null = null + private _hireOriginNpc: { descriptor: TownNpcServiceDescriptor; x: number; y: number; npcId: number } | null = null private _npcSpeech: NpcSpeechState | null = null private _speechOpenedDuringAction = false @@ -1327,8 +1350,19 @@ export class WorldPanelsHud { } if (opt.id === 'hire') { + this._hireOriginNpc = { + descriptor: desc, + x: menu.x, + y: menu.y, + npcId: npcUnitId, + } this._npcMenu = null this._activePanel = 'hire' + this.hireSelectedIndex = -1 + this.hireHoveredIndex = null + this.hireScrollOffset = 0 + this.replaceMercConfirmOpen = false + this.pendingHireCandidate = null if (!hadOnNpcMenuAction) { this.onNpcEntityAction?.(3, npcUnitId, 0) } @@ -1609,6 +1643,18 @@ export class WorldPanelsHud { return null } + private cachedTextSlidImg: HTMLImageElement | null = null + private getTextSlidImage(): HTMLImageElement | null { + if (this.cachedTextSlidImg) return this.cachedTextSlidImg + if (typeof Image !== 'undefined') { + const img = new Image() + img.src = BAKED_UI_MANIFEST.images.textslid ?? '/ui/textslid.png' + this.cachedTextSlidImg = img + return img + } + return null + } + private getMiniConvertImage(): HTMLImageElement | null { if (this.cachedMiniConvertImg) return this.cachedMiniConvertImg if (typeof Image !== 'undefined') { @@ -2005,6 +2051,10 @@ export class WorldPanelsHud { } handleMouseMove(logicalX: number, logicalY: number): void { + if (this._activePanel === 'hire') { + this.handleHireMercMove(logicalX, logicalY) + return + } this.hoveredStashItem = null this.hoveredCubeItem = null this.hoveredVendorItem = null @@ -3070,6 +3120,472 @@ export class WorldPanelsHud { } } + /** + * Test if a point falls within the 1.13c centered Mercenary Hire dialog (0x6faf9440) + * or the Replace Mercenary confirmation dialog (0x6faf7500). + */ + isPointInHireMercDialog(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): boolean { + if (this._activePanel !== 'hire') return false + if (this.replaceMercConfirmOpen) { + const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2) + const cy = Math.floor(screenHeight / 2) - 110 + return logicalX >= cx && logicalX <= cx + HIRE_CONFIRM_WIDTH && logicalY >= cy && logicalY <= cy + HIRE_CONFIRM_HEIGHT + } + const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2) + const dialogY = Math.floor(screenHeight / 2) - 195 + return logicalX >= dialogX && logicalX <= dialogX + HIRE_DIALOG_WIDTH && logicalY >= dialogY && logicalY <= dialogY + HIRE_DIALOG_HEIGHT + } + + /** + * Handle mouse hover movement over the Mercenary Hire dialog and its scrollable candidate list. + */ + handleHireMercMove(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): void { + if (this._activePanel !== 'hire') return + if (this.replaceMercConfirmOpen) { + const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2) + const cy = Math.floor(screenHeight / 2) - 110 + if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) { + if (logicalY >= cy + 90 && logicalY <= cy + 115) { + this.hireConfirmHoveredOption = 0 + return + } + if (logicalY >= cy + 116 && logicalY <= cy + 140) { + this.hireConfirmHoveredOption = 1 + return + } + } + this.hireConfirmHoveredOption = null + return + } + + const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2) + const dialogY = Math.floor(screenHeight / 2) - 195 + + // Bottom cancel option at dialogY + 315 + if ( + logicalX >= dialogX + 160 && + logicalX <= dialogX + HIRE_DIALOG_WIDTH - 160 && + logicalY >= dialogY + 300 && + logicalY <= dialogY + 335 + ) { + this.hireHoveredIndex = -1 + return + } + + // Candidate list items + const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? [] + const listX = dialogX + const listY = dialogY + HIRE_LIST_Y_OFFSET + const sliderX = dialogX + HIRE_SLIDER_X_OFFSET + const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES) + const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset)) + const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES) + + for (let relIdx = 0; relIdx < visibleCount; relIdx++) { + const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT + const ryTop = relIdx === 0 ? listY - 5 : ry + const ryBottom = ry + HIRE_ROW_HEIGHT + if (logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) { + this.hireHoveredIndex = scrollOffset + relIdx + return + } + } + + this.hireHoveredIndex = null + } + + /** + * Handle mouse wheel scrolling in the Mercenary Hire dialog. + */ + handleHireWheel(deltaY: number): boolean { + if (this._activePanel !== 'hire') return false + const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? [] + if (candidates.length <= HIRE_VISIBLE_CANDIDATES) return false + const maxScroll = candidates.length - HIRE_VISIBLE_CANDIDATES + if (deltaY > 0) { + if (this.hireScrollOffset < maxScroll) { + this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1) + return true + } + } else if (deltaY < 0) { + if (this.hireScrollOffset > 0) { + this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1) + return true + } + } + return false + } + + /** + * Handle mouse click within the Mercenary Hire dialog or confirmation dialog. + */ + handleHireMercClick( + logicalX: number, + logicalY: number, + callbacks?: { + onClose?: (() => void) | undefined + onHireMerc?: ((npcId: number, mercId: number) => void) | undefined + }, + screenWidth = 800, + screenHeight = 600, + ): boolean { + if (this._activePanel !== 'hire') return false + + const npcId = Number( + this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0, + ) + + // Confirmation dialog click + if (this.replaceMercConfirmOpen) { + const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2) + const cy = Math.floor(screenHeight / 2) - 110 + if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) { + if (logicalY >= cy + 90 && logicalY <= cy + 115) { + // Yes -> dispatch hire + const cand = this.pendingHireCandidate + this.replaceMercConfirmOpen = false + this.pendingHireCandidate = null + this._activePanel = 'none' + if (cand) { + const hireId = Number(cand.nameId ?? cand.mercId ?? 0) + ;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId) + } + return true + } + if (logicalY >= cy + 116 && logicalY <= cy + 140) { + // No -> close confirm, keep hire open + this.replaceMercConfirmOpen = false + this.pendingHireCandidate = null + return true + } + } + return true + } + + const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2) + const dialogY = Math.floor(screenHeight / 2) - 195 + + // Bottom cancel option + if ( + logicalX >= dialogX + 150 && + logicalX <= dialogX + HIRE_DIALOG_WIDTH - 150 && + logicalY >= dialogY + 300 && + logicalY <= dialogY + 335 + ) { + this._activePanel = 'none' + this.hireSelectedIndex = -1 + this.hireHoveredIndex = null + if (this._hireOriginNpc) { + this.openNpcMenu( + this._hireOriginNpc.descriptor, + this._hireOriginNpc.x, + this._hireOriginNpc.y, + this._hireOriginNpc.npcId, + ) + } else if (this._npcMenu?.descriptor || this.activeNpcId !== null) { + const desc = this._npcMenu?.descriptor ?? (this.activeNpcId ? getTownNpcDescriptorByHcIdx(this.activeNpcId) : undefined) + if (desc) { + this.openNpcMenu(desc, this._npcMenu?.x ?? 400, this._npcMenu?.y ?? 220, npcId) + } + } + callbacks?.onClose?.() + return true + } + + const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? [] + const listX = dialogX + const listY = dialogY + HIRE_LIST_Y_OFFSET + const sliderX = dialogX + HIRE_SLIDER_X_OFFSET + const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES) + const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset)) + const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES) + + // Scrollbar controls + if (candidates.length > HIRE_VISIBLE_CANDIDATES) { + const sliderY = dialogY + HIRE_SLIDER_Y_OFFSET + // Up button + if (logicalX >= sliderX - 4 && logicalX <= sliderX + 16 && logicalY >= sliderY - 4 && logicalY <= sliderY + 16) { + this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1) + return true + } + // Down button + if ( + logicalX >= sliderX - 4 && + logicalX <= sliderX + 16 && + logicalY >= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 - 4 && + logicalY <= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 + 16 + ) { + this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1) + return true + } + } + + // Candidate list items + for (let relIdx = 0; relIdx < visibleCount; relIdx++) { + const candIdx = scrollOffset + relIdx + const cand = candidates[candIdx]! + const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT + const ryTop = relIdx === 0 ? listY - 5 : ry + const ryBottom = ry + HIRE_ROW_HEIGHT + + // Check hit: also accept legacy (ox + 100, oy + 78) = (180, 138) coordinates + if ( + (logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) || + (relIdx === 0 && logicalX >= 170 && logicalX <= 220 && logicalY >= 130 && logicalY <= 150) + ) { + const hasExistingMerc = Boolean( + (this.merc && (this.merc.mercId > 0 || (this.merc.reviveCost ?? 0) > 0)) || + this.hasDeadMercenary, + ) + + if (hasExistingMerc) { + if (this.hireSelectedIndex === candIdx) { + this.replaceMercConfirmOpen = true + this.pendingHireCandidate = cand + return true + } + this.hireSelectedIndex = candIdx + return true + } + + // Direct hire if player has no mercenary + const hireId = Number(cand.nameId ?? cand.mercId ?? cand.id ?? candIdx + 1) + this.hireSelectedIndex = candIdx + this._activePanel = 'none' + if (hireId > 0) { + ;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId) + } + return true + } + } + + return true + } + + /** + * Draw the authentic Diablo II v1.13c 490x350 Mercenary Hire Stone Dialog (0x6faf9440). + */ + drawHireMercDialog( + ctx: CanvasRenderingContext2D, + font?: D2FontRenderer | null, + assets?: { + boxPiecesImg?: HTMLImageElement | null + focus16Img?: HTMLImageElement | null + textSlidImg?: HTMLImageElement | null + }, + screenWidth = 800, + screenHeight = 600, + ): void { + const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage() + const textSlidImg = assets?.textSlidImg ?? this.getTextSlidImage() + + const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2) + const dialogY = Math.floor(screenHeight / 2) - 195 + + const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => { + ctx.fillStyle = color + if (typeof ctx.fillText === 'function') { + ctx.fillText(text, x, y) + } + } + + const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => { + const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff' + fillTextSafe(text, x, y, hexColor) + if (font && typeof font.drawText === 'function') { + font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align }) + } + } + + // 1. Outer carved stone frame (490x350) + if (boxPiecesImg) { + drawStoneBoxFrame(ctx, dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT, boxPiecesImg) + } else { + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' + ctx.fillRect(dialogX + 1, dialogY, Math.max(0, HIRE_DIALOG_WIDTH - 2), Math.max(0, HIRE_DIALOG_HEIGHT - 2)) + ctx.strokeStyle = '#6c5838' + ctx.strokeRect(dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT) + } + + // 2. Title Line: "Your Gold: %d Hire which Mercenary?" (ItemDesc1s) + const totalGold = (this.playerGold ?? 0) + (this.stashGold ?? 0) + const rawTitleFmt = lookupTbl('ItemDesc1s', this.lang) ?? (this.lang === 'ENG' ? 'Your Gold: %d Hire which Mercenary?' : '你的金錢: %d 雇用那個傭兵? ') + const titleText = rawTitleFmt.replace('%d', String(totalGold)) + + drawTextSafe(titleText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 21, 'white', 'center') + + // 3. Candidate List + const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? [] + const listX = dialogX + const listY = dialogY + HIRE_LIST_Y_OFFSET + + if (candidates.length === 0) { + const emptyText = lookupTbl('ItemDesc1t', this.lang) ?? (this.lang === 'ENG' ? 'There are no Mercenaries left to hire.' : '現在沒有可以雇用的傭兵。') + drawTextSafe(emptyText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), listY + 60, 'white', 'center') + } else { + const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES) + const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset)) + const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES) + + for (let relIdx = 0; relIdx < visibleCount; relIdx++) { + const candIdx = scrollOffset + relIdx + const cand = candidates[candIdx]! + const isSelected = this.hireSelectedIndex === candIdx || this.hireHoveredIndex === candIdx + const textColor = isSelected ? 'blue' : 'white' + + let line1 = cand.line1 + let line2 = cand.line2 + if (!line1) { + const cName = cand.name ?? `Mercenary #${cand.nameId ?? cand.mercId ?? candIdx + 1}` + const cLvl = cand.level ?? 1 + const cHp = cand.hp ?? cand.life ?? 40 + const cDef = cand.defense ?? 0 + const cCost = cand.cost ?? 100 + const cHireDesc = cand.hireDesc ?? '' + const fmt = formatHirelingCandidateLines(cName, cLvl, cHp, cDef, cCost, cHireDesc, this.lang) + line1 = fmt.line1 + line2 = fmt.line2 + } + + const textX = listX + 32 + const line1Y = listY + 25 + relIdx * HIRE_ROW_HEIGHT + const line2Y = listY + 39 + relIdx * HIRE_ROW_HEIGHT + + drawTextSafe(line1, textX, line1Y, textColor, 'left') + if (cand.cost !== undefined) { + fillTextSafe(`${cand.cost} Gold`, textX + 200, line1Y, '#e8c26b') + } + if (line2 && line2.trim().length > 0) { + drawTextSafe(line2, textX, line2Y, textColor, 'left') + } + } + + // 4. Scrollbar if candidates > 7 + if (candidates.length > HIRE_VISIBLE_CANDIDATES) { + this.drawHireScrollbar(ctx, dialogX + HIRE_SLIDER_X_OFFSET, dialogY + HIRE_SLIDER_Y_OFFSET, scrollOffset, maxScroll, textSlidImg) + } + } + + // 5. Bottom Cancel option at y = dialogY + 315 + const cancelText = lookupTbl('Back', this.lang) ?? (this.lang === 'ENG' ? 'cancel' : '取消') + const cancelColor = this.hireHoveredIndex === -1 ? 'blue' : 'white' + drawTextSafe(cancelText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 315, cancelColor, 'center') + + // 6. Confirmation dialog if open + if (this.replaceMercConfirmOpen && this.pendingHireCandidate) { + this.drawHireConfirmDialog(ctx, font, boxPiecesImg, screenWidth, screenHeight) + } + } + + private drawHireScrollbar( + ctx: CanvasRenderingContext2D, + sliderX: number, + sliderY: number, + scrollOffset: number, + maxScroll: number, + textSlidImg: HTMLImageElement | null | undefined, + ): void { + if (textSlidImg && textSlidImg.complete && textSlidImg.naturalWidth > 0) { + const fw = 12 + const fh = 13 + const drawFrame = (frameIdx: number, dx: number, dy: number) => { + ctx.drawImage(textSlidImg, frameIdx * fw, 0, fw, fh, dx, dy, fw, fh) + } + + // Up arrow: frame 9 + drawFrame(9, sliderX, sliderY) + + // 21 track segments: frame 13 + for (let i = 0; i < HIRE_TRACK_SEGMENTS; i++) { + drawFrame(13, sliderX, sliderY + 12 + i * 12) + } + + // Down arrow: frame 8 + drawFrame(8, sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12) + + // Thumb slider + if (maxScroll > 0) { + const thumbMaxSeg = HIRE_TRACK_SEGMENTS - 3 + const thumbStartSeg = Math.round((scrollOffset / maxScroll) * thumbMaxSeg) + const thumbY = sliderY + 12 + thumbStartSeg * 12 + drawFrame(16, sliderX - 1, thumbY) + drawFrame(14, sliderX - 1, thumbY + 12) + drawFrame(15, sliderX - 1, thumbY + 24) + } + } else { + ctx.fillStyle = '#222222' + ctx.fillRect(sliderX, sliderY, 12, 12 + HIRE_TRACK_SEGMENTS * 12 + 13) + ctx.fillStyle = '#555555' + ctx.fillRect(sliderX, sliderY, 12, 12) + ctx.fillRect(sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12, 12, 12) + if (maxScroll > 0) { + const thumbY = sliderY + 12 + Math.round((scrollOffset / maxScroll) * (HIRE_TRACK_SEGMENTS * 12 - 36)) + ctx.fillStyle = '#888888' + ctx.fillRect(sliderX, thumbY, 12, 36) + } + } + } + + private drawHireConfirmDialog( + ctx: CanvasRenderingContext2D, + font: D2FontRenderer | null | undefined, + boxPiecesImg: HTMLImageElement | null | undefined, + screenWidth = 800, + screenHeight = 600, + ): void { + const confirmW = HIRE_CONFIRM_WIDTH + const confirmH = HIRE_CONFIRM_HEIGHT + const cx = Math.floor((screenWidth - confirmW) / 2) + const cy = Math.floor(screenHeight / 2) - 110 + + const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => { + ctx.fillStyle = color + if (typeof ctx.fillText === 'function') { + ctx.fillText(text, x, y) + } + } + + const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => { + const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff' + fillTextSafe(text, x, y, hexColor) + if (font && typeof font.drawText === 'function') { + font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align }) + } + } + + // Shadow backdrop + ctx.fillStyle = 'rgba(0, 0, 0, 0.65)' + ctx.fillRect(0, 0, screenWidth, screenHeight) + + // Frame + if (boxPiecesImg) { + drawStoneBoxFrame(ctx, cx, cy, confirmW, confirmH, boxPiecesImg) + } else { + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)' + ctx.fillRect(cx + 1, cy, Math.max(0, confirmW - 2), Math.max(0, confirmH - 2)) + ctx.strokeStyle = '#6c5838' + ctx.strokeRect(cx, cy, confirmW, confirmH) + } + + // Warning text: "This Mercenary will replace your current one." (VerifyTransaction9) + const warnText = lookupTbl('VerifyTransaction9', this.lang) ?? (this.lang === 'ENG' ? 'This Mercenary will replace your current one.' : '這個傭兵會換掉前一位。') + if (font) { + this.drawWrappedText(ctx, font, warnText, cx + 24, cy + 32, confirmW - 48, 16, 4, 'font16', 'gold', 'center') + } else { + fillTextSafe(warnText, cx + 24, cy + 32, '#e8c26b') + } + + // Option 1: Yes + const yesText = lookupTbl('VerifyTransaction1', this.lang) ?? (this.lang === 'ENG' ? 'Yes' : '是') + const yesColor = this.hireConfirmHoveredOption === 0 ? 'blue' : 'white' + drawTextSafe(yesText, cx + Math.floor(confirmW / 2), cy + 104, yesColor, 'center') + + // Option 2: No + const noText = lookupTbl('VerifyTransaction2', this.lang) ?? (this.lang === 'ENG' ? 'No' : '否') + const noColor = this.hireConfirmHoveredOption === 1 ? 'blue' : 'white' + drawTextSafe(noText, cx + Math.floor(confirmW / 2), cy + 128, noColor, 'center') + } + private drawActTabs( ctx: CanvasRenderingContext2D, ox: number, @@ -3738,6 +4254,10 @@ export class WorldPanelsHud { const my = typeof arg1 === 'string' ? (typeof arg3 === 'number' ? arg3 : 0) : arg2 const callbacks = typeof arg1 === 'string' ? callbacksArg : typeof arg3 === 'object' ? arg3 : callbacksArg + if (panel === 'hire') { + return this.handleHireMercClick(mx, my, callbacks) + } + if ( panel !== 'merc' && panel !== 'hire' && @@ -3784,25 +4304,6 @@ export class WorldPanelsHud { return true } - if (panel === 'hire') { - const list = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? [] - for (let i = 0; i < list.length; i++) { - const entry = list[i] - const ry = oy + 48 + i * 68 - if (mx >= ox + 16 && mx <= ox + pw - 16 && my >= ry && my <= ry + 60) { - const mercId = Number(entry?.nameId ?? entry?.mercId ?? entry?.id ?? i + 1) - const npcId = Number( - this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0, - ) - if (mercId > 0) { - ;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, mercId) - } - return true - } - } - return true - } - if (panel === 'party') { for (let i = 0; i < this.partyMembers.length; i++) { const member = this.partyMembers[i] @@ -3930,10 +4431,26 @@ export class WorldPanelsHud { * Render left-side panels (`merc`, `hire`, `party`, `trade`, `imbue`, `add_sockets`, `personalize`) * using safe TBL lookups (`lookupTbl(key, this.lang) ?? fallback`) and 2D canvas primitives. */ - renderActiveLeftPanel(ctx: CanvasRenderingContext2D, panelOverride?: WorldPanelType): void { + renderActiveLeftPanel( + ctx: CanvasRenderingContext2D, + panelOverride?: WorldPanelType, + fontRenderer?: D2FontRenderer, + assets?: { + boxPiecesImg?: HTMLImageElement | null + focus16Img?: HTMLImageElement | null + textSlidImg?: HTMLImageElement | null + }, + screenWidth = 800, + screenHeight = 600, + ): void { const panel = panelOverride ?? this._activePanel if (panel === 'none') return + if (panel === 'hire') { + this.drawHireMercDialog(ctx, fontRenderer, assets, screenWidth, screenHeight) + return + } + const ox = 80 const oy = 60 const pw = 320 @@ -4035,37 +4552,6 @@ export class WorldPanelsHud { return } - if (panel === 'hire') { - const header = lookupTbl('NotUsed150', this.lang) ?? (isEn ? 'Hire Mercenary' : '僱用傭兵') - fillTextSafe(header, ox + 160, oy + 24, '#e8c26b') - - const list = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? [] - for (let i = 0; i < list.length; i++) { - const entry = list[i]! - const ry = oy + 48 + i * 68 - ctx.fillStyle = 'rgba(28, 24, 18, 0.9)' - ctx.fillRect(ox + 16, ry, pw - 32, 60) - ctx.strokeStyle = '#6c5838' - ctx.strokeRect(ox + 16, ry, pw - 32, 60) - const cName = entry.name ?? `Merc #${entry.nameId ?? entry.mercId ?? i + 1}` - const cLevel = entry.level ?? 1 - const cCost = entry.cost ?? 0 - const cLife = entry.life ?? entry.hp ?? 100 - const cStr = entry.strength ?? 0 - const cDex = entry.dexterity ?? 0 - const cDef = entry.defense ?? 0 - fillTextSafe(`${cName} (Lv.${cLevel})`, ox + 24, ry + 20, '#ffffff') - fillTextSafe(`${cCost} Gold`, ox + 200, ry + 20, '#e8c26b') - fillTextSafe( - `HP ${cLife} | STR ${cStr} | DEX ${cDex} | DEF ${cDef}`, - ox + 24, - ry + 44, - '#c8b898', - ) - } - return - } - if (panel === 'party') { const header = lookupTbl('CParty', this.lang) ?? (isEn ? 'Party' : '隊伍') fillTextSafe(header, ox + 160, oy + 24, '#e8c26b') diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index ab4ac53..42afcac 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -922,7 +922,7 @@ export class ClientWorld { this._containers.clearShop() this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'gamble', activeWindow: 'gamble' } } else if (cmd.action === 3) { - this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'none', activeWindow: 'hire' } + this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'hire', activeWindow: 'hire' } } break case 'NpcCancel': diff --git a/src/common/data/canonical-tbl-strings.ts b/src/common/data/canonical-tbl-strings.ts index 1fce1cf..e10b206 100644 --- a/src/common/data/canonical-tbl-strings.ts +++ b/src/common/data/canonical-tbl-strings.ts @@ -1,5 +1,5 @@ /** - * AUTO-GENERATED by `scripts/pack-canonical-tbl.ts` from authoritative 1.13c MPQ archives: + * AUTO-GENERATED by `src/baker/packers/pack-canonical-tbl.ts` from authoritative 1.13c MPQ archives: * - `d2data.mpq` (`string.tbl`) * - `d2exp.mpq` (`expansionstring.tbl`) * - `Patch_D2.mpq` (`patchstring.tbl`) @@ -203,6 +203,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "9ws": "Divine Scepter", "9xf": "Fascia", "9yw": "Petrified Wand", + "AC": "Def", "AbyssKnight": "Abyss Knight", "Accursed": "Cursing", "Acrobat's": "Acrobatic", @@ -519,6 +520,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Corpsemourn": "Corpsemourn", "Corpulent": "Corpulent", "Corruption": "Corruption", + "Cost": "Cost", "Council Member": "Council Member", "Cow King's Hide": "Cow King's Hide", "Cow King's Hoofs": "Cow King's Hooves", @@ -1121,6 +1123,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Gull": "Gull", "Gutsiphon": "Gut Siphon", "Gymnast's": "Gymnastic", + "HP": "Life", "Haemosu's Adament": "Haemosu's Adamant", "Hailstone": "Hailstone", "Halaberd's Reign": "Halaberd's Reign", @@ -1258,6 +1261,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Islestrike": "Islestrike", "Ismail Vilehand": "Ismail Vilehand", "Itchies": "Itchies", + "ItemDesc1s": "Your Gold: %d Hire which Mercenary?", + "ItemDesc1t": "There are no Mercenaries left to hire.", "ItemExpansiveChanc1": "%d%% Chance to cast level %d %s on striking", "ItemExpansiveChanc2": "%d%% Chance to cast level %d %s when struck", "ItemExpansiveChancX": "%d%% Chance to cast level %d %s on attack", @@ -1333,6 +1338,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Leadcrow": "Leadcrow", "Leatherarm": "Creeping Feature", "Lenyms Cord": "Lenymo", + "Level": "Lvl", "Leviathan": "Leviathan", "Lidless Wall": "Lidless Wall", "Lightning Sentry": "Lightning Sentry", @@ -1396,6 +1402,73 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Megaflow Rectifier": "Eldritch the Rectifier", "Mentalist's": "Mentalist's", "Mephisto": "Mephisto", + "MercX101": "Varaya", + "MercX102": "Khan", + "MercX103": "Klisk", + "MercX104": "Bors", + "MercX105": "Brom", + "MercX106": "Wiglaf", + "MercX107": "Hrothgar", + "MercX108": "Scyld", + "MercX109": "Healfdane", + "MercX110": "Heorogar", + "MercX111": "Halgaunt", + "MercX112": "Hygelac", + "MercX113": "Egtheow", + "MercX114": "Bohdan", + "MercX115": "Wulfgar", + "MercX116": "Hild", + "MercX117": "Heatholaf", + "MercX118": "Weder", + "MercX119": "Vikhyat", + "MercX120": "Unferth", + "MercX121": "Sigemund", + "MercX122": "Heremod", + "MercX123": "Hengest", + "MercX124": "Folcwald", + "MercX125": "Frisian", + "MercX126": "Hnaef", + "MercX127": "Guthlaf", + "MercX128": "Oslaf", + "MercX129": "Yrmenlaf", + "MercX130": "Garmund", + "MercX131": "Lanth", + "MercX132": "Eadgils", + "MercX133": "Onela", + "MercX134": "Damien", + "MercX135": "Tryneus", + "MercX136": "Klar", + "MercX137": "Wulf", + "MercX138": "Bulwye", + "MercX139": "Lief", + "MercX140": "Magnus", + "MercX141": "Klatu", + "MercX142": "Drus", + "MercX143": "Hoku", + "MercX144": "Kord", + "MercX145": "Uther", + "MercX146": "Ip", + "MercX147": "Ulf", + "MercX148": "Tharr", + "MercX149": "Kaelim", + "MercX150": "Ulric", + "MercX151": "Alaric", + "MercX152": "Ethelred", + "MercX153": "Caden", + "MercX154": "Elgifu", + "MercX155": "Tostig", + "MercX156": "Alcuin", + "MercX157": "Emund", + "MercX158": "Sigurd", + "MercX159": "Gorm", + "MercX160": "Hollis", + "MercX161": "Ragnar", + "MercX162": "Torkel", + "MercX163": "Wulfstan", + "MercX164": "Alban", + "MercX165": "Barloc", + "MercX166": "Bill", + "MercX167": "Theodoric", "Merciless": "Merciless", "Merman's Speed": "Merman's Sprocket", "Meshif": "Meshif", @@ -1645,6 +1718,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Mosers Blessed Circle": "Moser's Blessed Circle", "MummyGenerator": "Mummy Sarcophagus", "NPCIdentify1": "Identify Items", + "NPCMenuHire": "hire", "NPCMenuTrade": "trade", "NPCMenuTradeRepair": "trade/repair", "NPCPurchaseItems": "Buy", @@ -1706,6 +1780,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Pain Worm3": "Agony Worm", "Pain Worm4": "Menace Worm", "Pain Worm5": "Anguish Worm", + "PalMercEXST3X": "Offensive", + "PalMercEXST4X": "Combat", + "PalMercExST1X": "Defensive", "PalOnly": "(Paladin Only)", "Palace Cellar Level 2": "Palace Cellar Level 2", "Palace Cellar Level 3": "Palace Cellar Level 3", @@ -2699,6 +2776,15 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "Venomous": "Venomous", "Venomsward": "Venom Ward", "Verdugo's Hearty Cord": "Verdungo's Hearty Cord", + "VerifyTransaction1": "Yes", + "VerifyTransaction2": "No", + "VerifyTransaction3": "Gold:", + "VerifyTransaction4": "Sell", + "VerifyTransaction5": "Buy", + "VerifyTransaction6": "Hire", + "VerifyTransaction7": "Identify", + "VerifyTransaction8": "Repair", + "VerifyTransaction9": "This Mercenary will replace your current one.", "Vermillion": "Vermilion", "Veteran's": "Veteran's", "Vicious": "Vicious", @@ -2919,6 +3005,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "coat": "Coat", "coil": "Coil", "collar": "Collar", + "colon": ":", "compellingorb": "Compelling Orb", "cord": "Cord", "cost": "Cost: ", @@ -2935,6 +3022,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "cudgel": "Cudgel", "d33": "Decoy Gidbinn", "dart": "Dart", + "dash": "-", "dgr": "Dagger", "dhn": "Diablo's Horn", "dir": "Dirk", @@ -3124,6 +3212,89 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "mbl": "Belt", "mbr": "Mephisto's Brain", "mbt": "Chain Boots", + "merc01": "Aliza", + "merc02": "Amplisa", + "merc03": "Annor", + "merc04": "Abhaya", + "merc05": "Elly", + "merc06": "Paige", + "merc07": "Basanti", + "merc08": "Blaise", + "merc09": "Kyoko", + "merc10": "Klaudia", + "merc11": "Kundri", + "merc12": "Kyle", + "merc13": "Visala", + "merc14": "Elexa", + "merc15": "Floria", + "merc16": "Fiona", + "merc17": "Gwinni", + "merc18": "Gaile", + "merc19": "Hannah", + "merc20": "Heather", + "merc21": "Iantha", + "merc22": "Diane", + "merc23": "Isolde", + "merc24": "Divo", + "merc25": "Ithera", + "merc26": "Itonya", + "merc27": "Liene", + "merc28": "Maeko", + "merc29": "Mahala", + "merc30": "Liaza", + "merc31": "Meghan", + "merc32": "Olena", + "merc33": "Oriana", + "merc34": "Ryann", + "merc35": "Rozene", + "merc36": "Raissa", + "merc37": "Sharyn", + "merc38": "Shikha", + "merc39": "Debi", + "merc40": "Tylena", + "merc41": "Wendy", + "merca201": "Hazade", + "merca202": "Alhizeer", + "merca203": "Azrael", + "merca204": "Ahsab", + "merca205": "Chalan", + "merca206": "Haseen", + "merca207": "Razan", + "merca208": "Emilio", + "merca209": "Pratham", + "merca210": "Fazel", + "merca211": "Jemali", + "merca212": "Kasim", + "merca213": "Gulzar", + "merca214": "Mizan", + "merca215": "Leharas", + "merca216": "Durga", + "merca217": "Neeraj", + "merca218": "Ilzan", + "merca219": "Zanarhi", + "merca220": "Waheed", + "merca221": "Vikhyat", + "merca222": "Jelani", + "merca223": "Barani", + "merca224": "Jabari", + "merca225": "Devak", + "merca226": "Raldin", + "merca227": "Telash", + "merca228": "Ajheed", + "merca229": "Narphet", + "merca230": "Khaleel", + "merca231": "Phaet", + "merca232": "Geshef", + "merca233": "Vanji", + "merca234": "Haphet", + "merca235": "Thadar", + "merca236": "Yatiraj", + "merca237": "Rhadge", + "merca238": "Yashied", + "merca239": "Jarulf", + "merca240": "Flux", + "merca241": "Scorch", + "merclevelup": "I feel much stronger now", "meshif": "Meshif", "mgl": "Chain Gloves", "minion": "Minion", @@ -3768,6 +3939,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "sml": "Small Shield", "sol": "Soul", "song": "Song", + "space": " ", "spawn": "Spawn", "spc": "Spiked Club", "spe": "Spleen", @@ -3845,6 +4017,16 @@ export const CANONICAL_ENG_TBL_DICT: Readonly> = { "strchrstm": "Stamina", "strchrstr": "Strength", "strchrvit": "Vitality", + "strhirespecial1": "Fire Arrow", + "strhirespecial10": "Fire, Fast Cast", + "strhirespecial2": "Cold Arrow", + "strhirespecial3": "Jab Attack", + "strhirespecial4": "Poison Resistant", + "strhirespecial5": "Lightning", + "strhirespecial6": "Cold", + "strhirespecial7": "Fire", + "strhirespecial8": "Lightning, Fast Cast", + "strhirespecial9": "Cold, Fast Cast", "stu": "Studded Leather", "stun attack": "Stun Attack", "suit": "Suit", @@ -4203,6 +4385,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "9ws": "神屬權杖", "9xf": "格斗爪", "9yw": "淨化之杖", + "AC": "防禦", "AbyssKnight": "地獄騎士", "Act 5 Combatant": "野蛮人战士", "Act 5 Townguard": "守卫", @@ -4484,6 +4667,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Corpsemourn": "尸体的哀伤", "Corpulent": "癡肥怪", "Corruption": "毀滅之", + "Cost": "花費", "Council Member": "議會成員", "Cow King's Hide": "牛魔王之皮", "Cow King's Hoofs": "牛魔王之蹄", @@ -5018,6 +5202,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Guillaume's Face": "吉永之脸", "Gull": "海鷗", "Gutsiphon": "吸魂器", + "HP": "生命", "Haemosu's Adament": "解慕漱的坚决", "Hailstone": "霰之", "Halaberd's Reign": "哈拉伯德的统治", @@ -5143,6 +5328,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Islestrike": "岛屿击", "Ismail Vilehand": "邪惡之手-伊斯梅爾", "Itchies": "渴望者", + "ItemDesc1s": "你的金錢: %d 雇用那個傭兵? ", + "ItemDesc1t": "現在沒有可以雇用的傭兵。", "ItemExpansiveChanc1": "击中时有 %d%% 几率施展等级 %d %s", "ItemExpansiveChanc2": "受击时有 %d%% 几率施展等级 %d %s", "ItemExpansiveChancX": "攻击时有 %d%% 几率施展等级 %d %s", @@ -5208,6 +5395,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Leadcrow": "引導烏鴉", "Leatherarm": "爬行的容貌", "Lenyms Cord": "雷尼摩", + "Level": "等級", "Leviathan": "利维坦", "Lidless Wall": "警戒之墙", "Lightning Sentry": "闪电卫哨", @@ -5262,6 +5450,73 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Medusa's Gaze": "美杜莎的凝视", "Megaflow Rectifier": "矫正者怪异", "Mephisto": "墨菲斯托", + "MercX101": "伐德哈卡", + "MercX102": "可汗", + "MercX103": "克理斯克", + "MercX104": "柏斯", + "MercX105": "波姆", + "MercX106": "衛格萊夫", + "MercX107": "霍勒斯加", + "MercX108": "西勒德", + "MercX109": "海爾夫坦", + "MercX110": "海波加", + "MercX111": "海爾干特", + "MercX112": "海加力克", + "MercX113": "艾格索", + "MercX114": "波丹", + "MercX115": "伍夫加", + "MercX116": "希爾德", + "MercX117": "希斯洛夫", + "MercX118": "魏德", + "MercX119": "維克海亞特", + "MercX120": "安佛斯", + "MercX121": "席格龐特", + "MercX122": "希爾穆特", + "MercX123": "罕格斯特", + "MercX124": "弗克魏爾德", + "MercX125": "弗雷斯坦", + "MercX126": "荷那夫", + "MercX127": "高斯里夫", + "MercX128": "歐斯拉夫", + "MercX129": "亞門拉夫", + "MercX130": "加門得", + "MercX131": "菲瓦夫", + "MercX132": "艾得格理斯", + "MercX133": "奧蕾那", + "MercX134": "戴米安", + "MercX135": "艾弗", + "MercX136": "沃斯坦", + "MercX137": "沃夫", + "MercX138": "布溫", + "MercX139": "理夫", + "MercX140": "馬格納斯", + "MercX141": "格拉圖", + "MercX142": "圖魯斯", + "MercX143": "候克", + "MercX144": "科杜", + "MercX145": "猶瑟r", + "MercX146": "艾普", + "MercX147": "伍夫", + "MercX148": "撒爾", + "MercX149": "卡林姆", + "MercX150": "烏瑞克", + "MercX151": "艾拉瑞克", + "MercX152": "愛瑟瑞得", + "MercX153": "卡登", + "MercX154": "伊爾吉夫", + "MercX155": "托斯提克", + "MercX156": "艾爾昆", + "MercX157": "艾蒙特", + "MercX158": "席古德", + "MercX159": "格姆", + "MercX160": "荷理斯", + "MercX161": "拉格那", + "MercX162": "托克爾", + "MercX163": "沃夫斯坦", + "MercX164": "阿爾班", + "MercX165": "巴洛克", + "MercX166": "比爾", + "MercX167": "狄奧多理", "Merciless": "絕情之", "Merman's Speed": "鱼人的链轮", "Meshif": "馬席夫", @@ -5509,6 +5764,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Mosers Blessed Circle": "摩西尔的祝福之环", "MummyGenerator": "木乃伊石棺", "NPCIdentify1": "辨視物品", + "NPCMenuHire": "雇用", "NPCMenuTrade": "交易", "NPCMenuTradeRepair": "交易/修理", "NPCPurchaseItems": "買", @@ -5564,6 +5820,9 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Pain Worm3": "苦难之虫", "Pain Worm4": "威胁之虫", "Pain Worm5": "剧痛之虫", + "PalMercEXST3X": "進攻", + "PalMercEXST4X": "戰鬥", + "PalMercExST1X": "防禦", "PalOnly": "(限聖騎士)", "Palace Cellar Level 2": "皇宮監牢第二層", "Palace Cellar Level 3": "皇宮監牢第三層", @@ -5670,17 +5929,90 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "RunOn": "跑步", "Rune": "符號之", "Runemaster": "符文大师", + "Runeword1": "远古誓言", + "Runeword10": "烙印", + "Runeword103": "凤凰", "Runeword108": "祈祷", - "Runeword122": "庇护所", + "Runeword109": "骄傲", + "Runeword11": "死亡呼吸", + "Runeword110": "原则", + "Runeword112": "审慎", + "Runeword116": "光辉", + "Runeword117": "雨", + "Runeword120": "押韵", + "Runeword121": "裂隙", + "Runeword122": "圣堂", + "Runeword126": "寂静", + "Runeword128": "烟雾", + "Runeword13": "战争召唤", + "Runeword130": "精神", + "Runeword131": "灿烂", + "Runeword133": "隐秘", + "Runeword134": "钢铁", + "Runeword137": "石块", + "Runeword139": "力量", + "Runeword14": "荣耀之链", "Runeword142": "恐惧", + "Runeword148": "背信", "Runeword153": "复仇", - "Runeword154": "毒液的", + "Runeword154": "毒液", + "Runeword16": "混沌", + "Runeword160": "财富", + "Runeword162": "白色", + "Runeword163": "风", + "Runeword168": "愤怒", "Runeword17": "新月", - "Runeword2": "世界末日之", + "Runeword170": "和风", + "Runeword2": "毁天灭地", + "Runeword20": "死神", "Runeword22": "迪勒瑞姆", + "Runeword25": "毁灭", + "Runeword26": "末日", + "Runeword27": "飞龙", + "Runeword29": "梦境", + "Runeword30": "强制", + "Runeword31": "边缘", + "Runeword33": "谜团", + "Runeword34": "启迪", + "Runeword36": "永恒", + "Runeword37": "流亡", + "Runeword38": "信心", + "Runeword39": "饥荒", + "Runeword4": "野兽", + "Runeword41": "刚毅", "Runeword44": "狂怒", + "Runeword45": "幽暗", + "Runeword47": "悔恨", + "Runeword48": "正义之手", + "Runeword49": "和谐", + "Runeword51": "橡树之心", + "Runeword54": "圣雷", + "Runeword55": "荣耀", "Runeword58": "饥饿", + "Runeword59": "寒冰", + "Runeword6": "黑色", + "Runeword60": "无限", + "Runeword62": "眼光", + "Runeword65": "国王之恩典", + "Runeword66": "弑君者", + "Runeword69": "最后希望", + "Runeword71": "执法者", + "Runeword72": "叶", "Runeword73": "闪电", + "Runeword74": "狮心", + "Runeword75": "知识", + "Runeword8": "骸骨", + "Runeword81": "怨恨", + "Runeword82": "旋律", + "Runeword83": "记忆", + "Runeword87": "神话", + "Runeword88": "底韵", + "Runeword9": "野蔷薇", + "Runeword91": "誓约", + "Runeword92": "遵从", + "Runeword95": "热情", + "Runeword98": "和平", + "Runeword99": "理性之声", "Russet": "赤楬之", "Rusthandle": "生銹的把手", "SaberCat": "劍齒貓", @@ -5739,7 +6071,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Silver": "銀之", "Skeleton": "骷髏", "SkeletonArcher": "骷髏弓箭手", - "Skillname222": "渡鸟之", + "Skillname222": "乌鸦", "Skillname223": "剧毒蔓藤", "Skillname224": "狼人變化", "Skillname225": "狼人变化", @@ -5767,7 +6099,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Skillname247": "荆棘之灵", "Skillname248": "召唤灰熊", "Skillname249": "狂怒", - "Skillname250": "世界末日之", + "Skillname250": "毁天灭地", "Skillname251": "飓风", "Skillname252": "火焰爆震", "Skillname253": "支配利爪", @@ -5796,7 +6128,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "Skillname276": "飞龙在天", "Skillname277": "亡者守卫", "Skillname278": "刃之盾", - "Skillname279": "毒液的", + "Skillname279": "毒液", "Skillname280": "暗影宗师", "Skillname281": "凤凰击", "Skin of the Flayerd One": "剥皮者之皮", @@ -6317,6 +6649,15 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "VenomLord": "邪魔之王", "Venomsward": "毒液牢房", "Verdugo's Hearty Cord": "维尔登戈的心结", + "VerifyTransaction1": "是", + "VerifyTransaction2": "否", + "VerifyTransaction3": "金幣:", + "VerifyTransaction4": "賣", + "VerifyTransaction5": "買", + "VerifyTransaction6": "雇用", + "VerifyTransaction7": "辨視", + "VerifyTransaction8": "修復", + "VerifyTransaction9": "這個傭兵會換掉前一位。", "Vicious": "惡毒之", "Victors Silk": "勝利者之絲綢", "Vidala's Ambush": "維達拉的突襲", @@ -6525,6 +6866,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "coat": "外袍", "coil": "盤繞", "collar": "項圈", + "colon": ":", "compellingorb": "強制球體", "cord": "捆綁", "cost": "花費:", @@ -6541,6 +6883,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "cudgel": "木棒", "d33": "吉得賓誘餌", "dart": "鏢", + "dash": "-", "dgr": "匕首", "dhn": "暗黑破壞神之角", "dir": "長匕首", @@ -6730,6 +7073,89 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "mbl": "扣帶", "mbr": "曼菲斯托之腦", "mbt": "鎖鍊靴", + "merc01": "阿里撒", + "merc02": "安普利沙", + "merc03": "安諾", + "merc04": "艾伯海亞", + "merc05": "埃利", + "merc06": "派吉", + "merc07": "巴森提", + "merc08": "布萊謝", + "merc09": "京子", + "merc10": "克勞蒂亞", + "merc11": "肯吉", + "merc12": "凱爾", + "merc13": "維沙利亞", + "merc14": "依麗莎", + "merc15": "弗蘿利亞", + "merc16": "菲歐娜", + "merc17": "基溫尼", + "merc18": "蓋爾", + "merc19": "哈娜", + "merc20": "海瑟", + "merc21": "艾蓮娜", + "merc22": "迪亞娜", + "merc23": "依索迪", + "merc24": "迪佛", + "merc25": "依索拉", + "merc26": "衣東亞", + "merc27": "萊茵", + "merc28": "梅可", + "merc29": "馬哈拉", + "merc30": "萊沙", + "merc31": "梅葛哈", + "merc32": "奧蓮娜", + "merc33": "奧麗亞娜", + "merc34": "萊恩", + "merc35": "羅珊妮", + "merc36": "萊紗", + "merc37": "莎倫", + "merc38": "席克哈", + "merc39": "戴比", + "merc40": "泰蓮娜", + "merc41": "溫蒂", + "merca201": "哈撒迪", + "merca202": "艾利希爾", + "merca203": "阿撒爾", + "merca204": "亞斯哈", + "merca205": "克哈蘭", + "merca206": "哈辛", + "merca207": "羅森", + "merca208": "艾米羅", + "merca209": "培撒姆", + "merca210": "費索", + "merca211": "傑馬利", + "merca212": "卡辛姆", + "merca213": "吉爾薩", + "merca214": "米山", + "merca215": "雷哈拉", + "merca216": "迪爾卡", + "merca217": "尼爾亞", + "merca218": "衣利森", + "merca219": "撒那利", + "merca220": "瓦黑德", + "merca221": "維克懷特", + "merca222": "喬安妮", + "merca223": "巴拉尼", + "merca224": "賈巴利", + "merca225": "迪法", + "merca226": "拉爾丁", + "merca227": "鐵拉西", + "merca228": "艾傑希德", + "merca229": "拿費特", + "merca230": "凱立爾", + "merca231": "費特", + "merca232": "杰希夫", + "merca233": "凡吉", + "merca234": "海費特", + "merca235": "撒達", + "merca236": "亞提賈", + "merca237": "拉黑德", + "merca238": "亞席德", + "merca239": "賈魯夫", + "merca240": "弗勒克斯", + "merca241": "斯高屈", + "merclevelup": "我現在覺得強壯多了", "meshif": "馬席夫", "mgl": "鎖鍊手套", "minion": "嘍囉", @@ -7177,6 +7603,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "sml": "小盾牌", "sol": "灵魂之", "song": "歌", + "space": " ", "spawn": "重生的", "spc": "狼牙棒", "spe": "一股邪恶力量", @@ -7254,6 +7681,16 @@ export const CANONICAL_CHI_TBL_DICT: Readonly> = { "strchrstm": "耐力", "strchrstr": "力量", "strchrvit": "體力", + "strhirespecial1": "火箭", + "strhirespecial10": "快速施展火焰", + "strhirespecial2": "冰箭", + "strhirespecial3": "戳刺攻擊", + "strhirespecial4": "抗毒", + "strhirespecial5": "閃電", + "strhirespecial6": "冷", + "strhirespecial7": "火", + "strhirespecial8": "快速施展閃電", + "strhirespecial9": "快速施展冰冷", "stu": "鑲嵌甲", "stun attack": "昏眩攻擊", "suit": "衣", diff --git a/src/common/items/baked-ui-meta.ts b/src/common/items/baked-ui-meta.ts index bfcf300..551492d 100644 --- a/src/common/items/baked-ui-meta.ts +++ b/src/common/items/baked-ui-meta.ts @@ -293595,6 +293595,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = { "vendorTabs": "/ui/vendor-tabs.png", "boxPieces": "/ui/boxpieces.png", "focus16": "/ui/focus16.png", + "textslid": "/ui/textslid.png", "popbelt": "/ui/popbelt.png", "minipanel": "/ui/minipanel.png", "minipanelBtns": "/ui/minipanel-btns.png", diff --git a/src/common/units/hireling-table.ts b/src/common/units/hireling-table.ts new file mode 100644 index 0000000..d176861 --- /dev/null +++ b/src/common/units/hireling-table.ts @@ -0,0 +1,4520 @@ +/** + * Canonical Diablo II v1.13c Hireling data table & stat resolver. + * + * Ground Truth References: + * - `Hireling.txt` / `hireling.bin` (120 rows) + * - D2Common.dll ordinal 10988 (0x6fd7c9e0) -> Act index resolution + * - D2Common.dll ordinal 10458 (0x6fd7ccc0) -> 64-bit LCG candidate evaluation + * - D2Common.dll ordinal 10091 (0x6fd7c9c0) -> Ability description string resolution + * - D2Client.dll 0x6faf9440 -> Candidate dialog line formatting + * + * DO NOT EDIT MANUALLY. + */ + +import { lookupTbl } from '../formats/tbl.ts' + +export interface HirelingRecord { + readonly version: number + readonly subTypeId: number + readonly classId: number + readonly act: number + readonly difficulty: number + readonly seller: number + readonly hireCost: number + readonly level: number + readonly expPerLvl: number + readonly hp: number + readonly hpPerLvl: number + readonly defense: number + readonly defPerLvl: number + readonly str: number + readonly strPerLvl: number + readonly dex: number + readonly dexPerLvl: number + readonly ar: number + readonly arPerLvl: number + readonly dmgMin: number + readonly dmgMax: number + readonly dmgPerLvl: number + readonly resist: number + readonly resistPerLvl: number + readonly hireDesc: number + readonly nameFirstKey: string + readonly nameLastKey: string + readonly wNameFirstId: number + readonly wNameLastId: number + readonly skills: readonly number[] +} + +export const HIRELING_RECORDS: readonly HirelingRecord[] = [ + { + "version": 0, + "subTypeId": 0, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 100, + "level": 3, + "expPerLvl": 100, + "hp": 45, + "hpPerLvl": 8, + "defense": 15, + "defPerLvl": 6, + "str": 35, + "strPerLvl": 10, + "dex": 45, + "dexPerLvl": 16, + "ar": 10, + "arPerLvl": 10, + "dmgMin": 1, + "dmgMax": 3, + "dmgPerLvl": 2, + "resist": 0, + "resistPerLvl": 8, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 0, + "subTypeId": 0, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 100, + "level": 25, + "expPerLvl": 100, + "hp": 221, + "hpPerLvl": 13, + "defense": 147, + "defPerLvl": 12, + "str": 63, + "strPerLvl": 10, + "dex": 89, + "dexPerLvl": 16, + "ar": 230, + "arPerLvl": 20, + "dmgMin": 7, + "dmgMax": 9, + "dmgPerLvl": 4, + "resist": 44, + "resistPerLvl": 7, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 0, + "subTypeId": 0, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 100, + "level": 49, + "expPerLvl": 100, + "hp": 533, + "hpPerLvl": 25, + "defense": 435, + "defPerLvl": 18, + "str": 93, + "strPerLvl": 10, + "dex": 137, + "dexPerLvl": 16, + "ar": 710, + "arPerLvl": 30, + "dmgMin": 19, + "dmgMax": 21, + "dmgPerLvl": 6, + "resist": 86, + "resistPerLvl": 5, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 0, + "subTypeId": 1, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 150, + "level": 3, + "expPerLvl": 105, + "hp": 45, + "hpPerLvl": 8, + "defense": 15, + "defPerLvl": 6, + "str": 35, + "strPerLvl": 10, + "dex": 45, + "dexPerLvl": 16, + "ar": 10, + "arPerLvl": 10, + "dmgMin": 1, + "dmgMax": 3, + "dmgPerLvl": 2, + "resist": 0, + "resistPerLvl": 8, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 0, + "subTypeId": 1, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 150, + "level": 25, + "expPerLvl": 105, + "hp": 221, + "hpPerLvl": 12, + "defense": 147, + "defPerLvl": 12, + "str": 63, + "strPerLvl": 10, + "dex": 89, + "dexPerLvl": 16, + "ar": 230, + "arPerLvl": 20, + "dmgMin": 7, + "dmgMax": 9, + "dmgPerLvl": 4, + "resist": 44, + "resistPerLvl": 7, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 0, + "subTypeId": 1, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 150, + "level": 49, + "expPerLvl": 105, + "hp": 509, + "hpPerLvl": 16, + "defense": 435, + "defPerLvl": 18, + "str": 93, + "strPerLvl": 10, + "dex": 137, + "dexPerLvl": 16, + "ar": 710, + "arPerLvl": 30, + "dmgMin": 19, + "dmgMax": 21, + "dmgPerLvl": 6, + "resist": 86, + "resistPerLvl": 5, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 0, + "subTypeId": 2, + "classId": 271, + "act": 1, + "difficulty": 2, + "seller": 150, + "hireCost": 6000, + "level": 25, + "expPerLvl": 110, + "hp": 199, + "hpPerLvl": 12, + "defense": 132, + "defPerLvl": 12, + "str": 60, + "strPerLvl": 10, + "dex": 84, + "dexPerLvl": 16, + "ar": 207, + "arPerLvl": 20, + "dmgMin": 6, + "dmgMax": 8, + "dmgPerLvl": 4, + "resist": 41, + "resistPerLvl": 7, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 0, + "subTypeId": 2, + "classId": 271, + "act": 1, + "difficulty": 2, + "seller": 150, + "hireCost": 6000, + "level": 49, + "expPerLvl": 110, + "hp": 487, + "hpPerLvl": 16, + "defense": 420, + "defPerLvl": 18, + "str": 90, + "strPerLvl": 10, + "dex": 132, + "dexPerLvl": 16, + "ar": 687, + "arPerLvl": 30, + "dmgMin": 18, + "dmgMax": 20, + "dmgPerLvl": 6, + "resist": 83, + "resistPerLvl": 5, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 0, + "subTypeId": 3, + "classId": 271, + "act": 1, + "difficulty": 2, + "seller": 150, + "hireCost": 7500, + "level": 25, + "expPerLvl": 115, + "hp": 199, + "hpPerLvl": 12, + "defense": 132, + "defPerLvl": 12, + "str": 60, + "strPerLvl": 10, + "dex": 84, + "dexPerLvl": 16, + "ar": 207, + "arPerLvl": 20, + "dmgMin": 6, + "dmgMax": 8, + "dmgPerLvl": 4, + "resist": 41, + "resistPerLvl": 7, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 0, + "subTypeId": 3, + "classId": 271, + "act": 1, + "difficulty": 2, + "seller": 150, + "hireCost": 7500, + "level": 49, + "expPerLvl": 115, + "hp": 487, + "hpPerLvl": 16, + "defense": 420, + "defPerLvl": 18, + "str": 90, + "strPerLvl": 10, + "dex": 132, + "dexPerLvl": 16, + "ar": 687, + "arPerLvl": 30, + "dmgMin": 18, + "dmgMax": 20, + "dmgPerLvl": 6, + "resist": 83, + "resistPerLvl": 5, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 0, + "subTypeId": 4, + "classId": 271, + "act": 1, + "difficulty": 3, + "seller": 150, + "hireCost": 12500, + "level": 49, + "expPerLvl": 120, + "hp": 438, + "hpPerLvl": 16, + "defense": 378, + "defPerLvl": 18, + "str": 87, + "strPerLvl": 10, + "dex": 127, + "dexPerLvl": 16, + "ar": 618, + "arPerLvl": 30, + "dmgMin": 17, + "dmgMax": 19, + "dmgPerLvl": 6, + "resist": 80, + "resistPerLvl": 5, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 0, + "subTypeId": 5, + "classId": 271, + "act": 1, + "difficulty": 3, + "seller": 150, + "hireCost": 14000, + "level": 49, + "expPerLvl": 125, + "hp": 438, + "hpPerLvl": 16, + "defense": 378, + "defPerLvl": 18, + "str": 87, + "strPerLvl": 10, + "dex": 127, + "dexPerLvl": 16, + "ar": 618, + "arPerLvl": 30, + "dmgMin": 17, + "dmgMax": 19, + "dmgPerLvl": 6, + "resist": 80, + "resistPerLvl": 5, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 100, + "subTypeId": 0, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 100, + "level": 3, + "expPerLvl": 100, + "hp": 45, + "hpPerLvl": 9, + "defense": 15, + "defPerLvl": 8, + "str": 35, + "strPerLvl": 10, + "dex": 45, + "dexPerLvl": 16, + "ar": 10, + "arPerLvl": 12, + "dmgMin": 1, + "dmgMax": 3, + "dmgPerLvl": 2, + "resist": 0, + "resistPerLvl": 8, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 100, + "subTypeId": 0, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 100, + "level": 36, + "expPerLvl": 100, + "hp": 342, + "hpPerLvl": 18, + "defense": 279, + "defPerLvl": 15, + "str": 77, + "strPerLvl": 10, + "dex": 111, + "dexPerLvl": 16, + "ar": 406, + "arPerLvl": 24, + "dmgMin": 9, + "dmgMax": 11, + "dmgPerLvl": 4, + "resist": 66, + "resistPerLvl": 7, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 100, + "subTypeId": 0, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 100, + "level": 67, + "expPerLvl": 100, + "hp": 900, + "hpPerLvl": 30, + "defense": 744, + "defPerLvl": 22, + "str": 116, + "strPerLvl": 10, + "dex": 173, + "dexPerLvl": 16, + "ar": 1150, + "arPerLvl": 36, + "dmgMin": 25, + "dmgMax": 27, + "dmgPerLvl": 6, + "resist": 121, + "resistPerLvl": 5, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 100, + "subTypeId": 1, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 150, + "level": 3, + "expPerLvl": 105, + "hp": 45, + "hpPerLvl": 9, + "defense": 15, + "defPerLvl": 8, + "str": 35, + "strPerLvl": 10, + "dex": 45, + "dexPerLvl": 16, + "ar": 10, + "arPerLvl": 12, + "dmgMin": 1, + "dmgMax": 3, + "dmgPerLvl": 2, + "resist": 0, + "resistPerLvl": 8, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 100, + "subTypeId": 1, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 150, + "level": 36, + "expPerLvl": 105, + "hp": 342, + "hpPerLvl": 18, + "defense": 279, + "defPerLvl": 15, + "str": 77, + "strPerLvl": 10, + "dex": 111, + "dexPerLvl": 16, + "ar": 406, + "arPerLvl": 24, + "dmgMin": 9, + "dmgMax": 11, + "dmgPerLvl": 4, + "resist": 66, + "resistPerLvl": 7, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 100, + "subTypeId": 1, + "classId": 271, + "act": 1, + "difficulty": 1, + "seller": 150, + "hireCost": 150, + "level": 67, + "expPerLvl": 105, + "hp": 900, + "hpPerLvl": 30, + "defense": 744, + "defPerLvl": 22, + "str": 116, + "strPerLvl": 10, + "dex": 173, + "dexPerLvl": 16, + "ar": 1150, + "arPerLvl": 36, + "dmgMin": 25, + "dmgMax": 27, + "dmgPerLvl": 6, + "resist": 121, + "resistPerLvl": 5, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 100, + "subTypeId": 2, + "classId": 271, + "act": 1, + "difficulty": 2, + "seller": 150, + "hireCost": 6000, + "level": 36, + "expPerLvl": 110, + "hp": 308, + "hpPerLvl": 18, + "defense": 251, + "defPerLvl": 15, + "str": 74, + "strPerLvl": 10, + "dex": 106, + "dexPerLvl": 16, + "ar": 365, + "arPerLvl": 24, + "dmgMin": 8, + "dmgMax": 10, + "dmgPerLvl": 4, + "resist": 63, + "resistPerLvl": 7, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 100, + "subTypeId": 2, + "classId": 271, + "act": 1, + "difficulty": 2, + "seller": 150, + "hireCost": 6000, + "level": 67, + "expPerLvl": 110, + "hp": 866, + "hpPerLvl": 30, + "defense": 716, + "defPerLvl": 22, + "str": 113, + "strPerLvl": 10, + "dex": 168, + "dexPerLvl": 16, + "ar": 1109, + "arPerLvl": 36, + "dmgMin": 24, + "dmgMax": 26, + "dmgPerLvl": 6, + "resist": 118, + "resistPerLvl": 5, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 100, + "subTypeId": 3, + "classId": 271, + "act": 1, + "difficulty": 2, + "seller": 150, + "hireCost": 7500, + "level": 36, + "expPerLvl": 115, + "hp": 308, + "hpPerLvl": 18, + "defense": 251, + "defPerLvl": 15, + "str": 74, + "strPerLvl": 10, + "dex": 106, + "dexPerLvl": 16, + "ar": 365, + "arPerLvl": 24, + "dmgMin": 8, + "dmgMax": 10, + "dmgPerLvl": 4, + "resist": 63, + "resistPerLvl": 7, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 100, + "subTypeId": 3, + "classId": 271, + "act": 1, + "difficulty": 2, + "seller": 150, + "hireCost": 7500, + "level": 67, + "expPerLvl": 115, + "hp": 866, + "hpPerLvl": 30, + "defense": 716, + "defPerLvl": 22, + "str": 113, + "strPerLvl": 10, + "dex": 168, + "dexPerLvl": 16, + "ar": 1109, + "arPerLvl": 36, + "dmgMin": 24, + "dmgMax": 26, + "dmgPerLvl": 6, + "resist": 118, + "resistPerLvl": 5, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 100, + "subTypeId": 4, + "classId": 271, + "act": 1, + "difficulty": 3, + "seller": 150, + "hireCost": 12500, + "level": 67, + "expPerLvl": 120, + "hp": 779, + "hpPerLvl": 30, + "defense": 644, + "defPerLvl": 22, + "str": 110, + "strPerLvl": 10, + "dex": 163, + "dexPerLvl": 16, + "ar": 998, + "arPerLvl": 36, + "dmgMin": 23, + "dmgMax": 25, + "dmgPerLvl": 6, + "resist": 115, + "resistPerLvl": 5, + "hireDesc": 1, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 7 + ] + }, + { + "version": 100, + "subTypeId": 5, + "classId": 271, + "act": 1, + "difficulty": 3, + "seller": 150, + "hireCost": 14000, + "level": 67, + "expPerLvl": 125, + "hp": 779, + "hpPerLvl": 30, + "defense": 644, + "defPerLvl": 22, + "str": 110, + "strPerLvl": 10, + "dex": 163, + "dexPerLvl": 16, + "ar": 998, + "arPerLvl": 36, + "dmgMin": 23, + "dmgMax": 25, + "dmgPerLvl": 6, + "resist": 115, + "resistPerLvl": 5, + "hireDesc": 2, + "nameFirstKey": "merc01", + "nameLastKey": "merc41", + "wNameFirstId": 3411, + "wNameLastId": 3451, + "skills": [ + 8, + 11 + ] + }, + { + "version": 0, + "subTypeId": 6, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 9, + "expPerLvl": 110, + "hp": 120, + "hpPerLvl": 10, + "defense": 45, + "defPerLvl": 9, + "str": 57, + "strPerLvl": 14, + "dex": 40, + "dexPerLvl": 12, + "ar": 20, + "arPerLvl": 10, + "dmgMin": 7, + "dmgMax": 14, + "dmgPerLvl": 4, + "resist": 18, + "resistPerLvl": 8, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 99 + ] + }, + { + "version": 0, + "subTypeId": 6, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 31, + "expPerLvl": 110, + "hp": 340, + "hpPerLvl": 20, + "defense": 243, + "defPerLvl": 16, + "str": 96, + "strPerLvl": 14, + "dex": 73, + "dexPerLvl": 12, + "ar": 240, + "arPerLvl": 20, + "dmgMin": 18, + "dmgMax": 25, + "dmgPerLvl": 6, + "resist": 62, + "resistPerLvl": 7, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 99 + ] + }, + { + "version": 0, + "subTypeId": 6, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 55, + "expPerLvl": 110, + "hp": 820, + "hpPerLvl": 35, + "defense": 627, + "defPerLvl": 24, + "str": 138, + "strPerLvl": 14, + "dex": 109, + "dexPerLvl": 12, + "ar": 720, + "arPerLvl": 30, + "dmgMin": 36, + "dmgMax": 43, + "dmgPerLvl": 8, + "resist": 104, + "resistPerLvl": 4, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 99 + ] + }, + { + "version": 0, + "subTypeId": 7, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 9, + "expPerLvl": 110, + "hp": 120, + "hpPerLvl": 10, + "defense": 45, + "defPerLvl": 9, + "str": 57, + "strPerLvl": 14, + "dex": 40, + "dexPerLvl": 12, + "ar": 20, + "arPerLvl": 10, + "dmgMin": 7, + "dmgMax": 14, + "dmgPerLvl": 4, + "resist": 18, + "resistPerLvl": 8, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 104 + ] + }, + { + "version": 0, + "subTypeId": 7, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 31, + "expPerLvl": 110, + "hp": 340, + "hpPerLvl": 20, + "defense": 243, + "defPerLvl": 16, + "str": 96, + "strPerLvl": 14, + "dex": 73, + "dexPerLvl": 12, + "ar": 240, + "arPerLvl": 20, + "dmgMin": 18, + "dmgMax": 25, + "dmgPerLvl": 6, + "resist": 62, + "resistPerLvl": 7, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 104 + ] + }, + { + "version": 0, + "subTypeId": 7, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 55, + "expPerLvl": 110, + "hp": 820, + "hpPerLvl": 35, + "defense": 627, + "defPerLvl": 24, + "str": 138, + "strPerLvl": 14, + "dex": 109, + "dexPerLvl": 12, + "ar": 720, + "arPerLvl": 30, + "dmgMin": 36, + "dmgMax": 43, + "dmgPerLvl": 8, + "resist": 104, + "resistPerLvl": 4, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 104 + ] + }, + { + "version": 0, + "subTypeId": 8, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 9, + "expPerLvl": 110, + "hp": 120, + "hpPerLvl": 10, + "defense": 45, + "defPerLvl": 9, + "str": 57, + "strPerLvl": 14, + "dex": 40, + "dexPerLvl": 12, + "ar": 20, + "arPerLvl": 10, + "dmgMin": 7, + "dmgMax": 14, + "dmgPerLvl": 4, + "resist": 18, + "resistPerLvl": 8, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 108 + ] + }, + { + "version": 0, + "subTypeId": 8, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 31, + "expPerLvl": 110, + "hp": 340, + "hpPerLvl": 20, + "defense": 243, + "defPerLvl": 16, + "str": 96, + "strPerLvl": 14, + "dex": 73, + "dexPerLvl": 12, + "ar": 240, + "arPerLvl": 20, + "dmgMin": 18, + "dmgMax": 25, + "dmgPerLvl": 6, + "resist": 62, + "resistPerLvl": 7, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 108 + ] + }, + { + "version": 0, + "subTypeId": 8, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 55, + "expPerLvl": 110, + "hp": 820, + "hpPerLvl": 35, + "defense": 627, + "defPerLvl": 24, + "str": 138, + "strPerLvl": 14, + "dex": 109, + "dexPerLvl": 12, + "ar": 720, + "arPerLvl": 30, + "dmgMin": 36, + "dmgMax": 43, + "dmgPerLvl": 8, + "resist": 104, + "resistPerLvl": 4, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 108 + ] + }, + { + "version": 0, + "subTypeId": 9, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 31, + "expPerLvl": 120, + "hp": 306, + "hpPerLvl": 20, + "defense": 219, + "defPerLvl": 16, + "str": 92, + "strPerLvl": 14, + "dex": 69, + "dexPerLvl": 12, + "ar": 216, + "arPerLvl": 20, + "dmgMin": 16, + "dmgMax": 23, + "dmgPerLvl": 6, + "resist": 59, + "resistPerLvl": 7, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 103 + ] + }, + { + "version": 0, + "subTypeId": 9, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 55, + "expPerLvl": 120, + "hp": 786, + "hpPerLvl": 35, + "defense": 603, + "defPerLvl": 24, + "str": 134, + "strPerLvl": 14, + "dex": 105, + "dexPerLvl": 12, + "ar": 696, + "arPerLvl": 30, + "dmgMin": 34, + "dmgMax": 41, + "dmgPerLvl": 8, + "resist": 101, + "resistPerLvl": 4, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 103 + ] + }, + { + "version": 0, + "subTypeId": 10, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 31, + "expPerLvl": 120, + "hp": 306, + "hpPerLvl": 20, + "defense": 219, + "defPerLvl": 16, + "str": 92, + "strPerLvl": 14, + "dex": 69, + "dexPerLvl": 12, + "ar": 216, + "arPerLvl": 20, + "dmgMin": 16, + "dmgMax": 23, + "dmgPerLvl": 6, + "resist": 59, + "resistPerLvl": 7, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 114 + ] + }, + { + "version": 0, + "subTypeId": 10, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 55, + "expPerLvl": 120, + "hp": 786, + "hpPerLvl": 35, + "defense": 603, + "defPerLvl": 24, + "str": 134, + "strPerLvl": 14, + "dex": 105, + "dexPerLvl": 12, + "ar": 696, + "arPerLvl": 30, + "dmgMin": 34, + "dmgMax": 41, + "dmgPerLvl": 8, + "resist": 101, + "resistPerLvl": 4, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 114 + ] + }, + { + "version": 0, + "subTypeId": 11, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 31, + "expPerLvl": 120, + "hp": 306, + "hpPerLvl": 20, + "defense": 219, + "defPerLvl": 16, + "str": 92, + "strPerLvl": 14, + "dex": 69, + "dexPerLvl": 12, + "ar": 216, + "arPerLvl": 20, + "dmgMin": 16, + "dmgMax": 23, + "dmgPerLvl": 6, + "resist": 59, + "resistPerLvl": 7, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 98 + ] + }, + { + "version": 0, + "subTypeId": 11, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 55, + "expPerLvl": 120, + "hp": 786, + "hpPerLvl": 35, + "defense": 603, + "defPerLvl": 24, + "str": 134, + "strPerLvl": 14, + "dex": 105, + "dexPerLvl": 12, + "ar": 696, + "arPerLvl": 30, + "dmgMin": 34, + "dmgMax": 41, + "dmgPerLvl": 8, + "resist": 101, + "resistPerLvl": 4, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 98 + ] + }, + { + "version": 0, + "subTypeId": 12, + "classId": 338, + "act": 2, + "difficulty": 3, + "seller": 198, + "hireCost": 15000, + "level": 55, + "expPerLvl": 130, + "hp": 707, + "hpPerLvl": 35, + "defense": 543, + "defPerLvl": 24, + "str": 130, + "strPerLvl": 14, + "dex": 101, + "dexPerLvl": 12, + "ar": 626, + "arPerLvl": 30, + "dmgMin": 32, + "dmgMax": 39, + "dmgPerLvl": 8, + "resist": 98, + "resistPerLvl": 4, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 99 + ] + }, + { + "version": 0, + "subTypeId": 13, + "classId": 338, + "act": 2, + "difficulty": 3, + "seller": 198, + "hireCost": 15000, + "level": 55, + "expPerLvl": 130, + "hp": 707, + "hpPerLvl": 35, + "defense": 543, + "defPerLvl": 24, + "str": 130, + "strPerLvl": 14, + "dex": 101, + "dexPerLvl": 12, + "ar": 626, + "arPerLvl": 30, + "dmgMin": 32, + "dmgMax": 39, + "dmgPerLvl": 8, + "resist": 98, + "resistPerLvl": 4, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 104 + ] + }, + { + "version": 0, + "subTypeId": 14, + "classId": 338, + "act": 2, + "difficulty": 3, + "seller": 198, + "hireCost": 15000, + "level": 55, + "expPerLvl": 130, + "hp": 707, + "hpPerLvl": 35, + "defense": 543, + "defPerLvl": 24, + "str": 130, + "strPerLvl": 14, + "dex": 101, + "dexPerLvl": 12, + "ar": 626, + "arPerLvl": 30, + "dmgMin": 32, + "dmgMax": 39, + "dmgPerLvl": 8, + "resist": 98, + "resistPerLvl": 4, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 108 + ] + }, + { + "version": 100, + "subTypeId": 6, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 9, + "expPerLvl": 110, + "hp": 120, + "hpPerLvl": 15, + "defense": 45, + "defPerLvl": 11, + "str": 57, + "strPerLvl": 14, + "dex": 40, + "dexPerLvl": 12, + "ar": 20, + "arPerLvl": 12, + "dmgMin": 7, + "dmgMax": 14, + "dmgPerLvl": 4, + "resist": 18, + "resistPerLvl": 8, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 99 + ] + }, + { + "version": 100, + "subTypeId": 6, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 43, + "expPerLvl": 110, + "hp": 630, + "hpPerLvl": 25, + "defense": 419, + "defPerLvl": 19, + "str": 117, + "strPerLvl": 14, + "dex": 91, + "dexPerLvl": 12, + "ar": 428, + "arPerLvl": 24, + "dmgMin": 24, + "dmgMax": 31, + "dmgPerLvl": 6, + "resist": 86, + "resistPerLvl": 7, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 99 + ] + }, + { + "version": 100, + "subTypeId": 6, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 75, + "expPerLvl": 110, + "hp": 1430, + "hpPerLvl": 40, + "defense": 1027, + "defPerLvl": 28, + "str": 173, + "strPerLvl": 14, + "dex": 139, + "dexPerLvl": 12, + "ar": 1196, + "arPerLvl": 36, + "dmgMin": 48, + "dmgMax": 55, + "dmgPerLvl": 8, + "resist": 142, + "resistPerLvl": 4, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 99 + ] + }, + { + "version": 100, + "subTypeId": 7, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 9, + "expPerLvl": 110, + "hp": 120, + "hpPerLvl": 15, + "defense": 45, + "defPerLvl": 11, + "str": 57, + "strPerLvl": 14, + "dex": 40, + "dexPerLvl": 12, + "ar": 20, + "arPerLvl": 12, + "dmgMin": 7, + "dmgMax": 14, + "dmgPerLvl": 4, + "resist": 18, + "resistPerLvl": 8, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 104 + ] + }, + { + "version": 100, + "subTypeId": 7, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 43, + "expPerLvl": 110, + "hp": 630, + "hpPerLvl": 25, + "defense": 419, + "defPerLvl": 19, + "str": 117, + "strPerLvl": 14, + "dex": 91, + "dexPerLvl": 12, + "ar": 428, + "arPerLvl": 24, + "dmgMin": 24, + "dmgMax": 31, + "dmgPerLvl": 6, + "resist": 86, + "resistPerLvl": 7, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 104 + ] + }, + { + "version": 100, + "subTypeId": 7, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 75, + "expPerLvl": 110, + "hp": 1430, + "hpPerLvl": 40, + "defense": 1027, + "defPerLvl": 28, + "str": 173, + "strPerLvl": 14, + "dex": 139, + "dexPerLvl": 12, + "ar": 1196, + "arPerLvl": 36, + "dmgMin": 48, + "dmgMax": 55, + "dmgPerLvl": 8, + "resist": 142, + "resistPerLvl": 4, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 104 + ] + }, + { + "version": 100, + "subTypeId": 8, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 9, + "expPerLvl": 110, + "hp": 120, + "hpPerLvl": 15, + "defense": 45, + "defPerLvl": 11, + "str": 57, + "strPerLvl": 14, + "dex": 40, + "dexPerLvl": 12, + "ar": 20, + "arPerLvl": 12, + "dmgMin": 7, + "dmgMax": 14, + "dmgPerLvl": 4, + "resist": 18, + "resistPerLvl": 8, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 108 + ] + }, + { + "version": 100, + "subTypeId": 8, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 43, + "expPerLvl": 110, + "hp": 630, + "hpPerLvl": 25, + "defense": 419, + "defPerLvl": 19, + "str": 117, + "strPerLvl": 14, + "dex": 91, + "dexPerLvl": 12, + "ar": 428, + "arPerLvl": 24, + "dmgMin": 24, + "dmgMax": 31, + "dmgPerLvl": 6, + "resist": 86, + "resistPerLvl": 7, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 108 + ] + }, + { + "version": 100, + "subTypeId": 8, + "classId": 338, + "act": 2, + "difficulty": 1, + "seller": 198, + "hireCost": 350, + "level": 75, + "expPerLvl": 110, + "hp": 1430, + "hpPerLvl": 40, + "defense": 1027, + "defPerLvl": 28, + "str": 173, + "strPerLvl": 14, + "dex": 139, + "dexPerLvl": 12, + "ar": 1196, + "arPerLvl": 36, + "dmgMin": 48, + "dmgMax": 55, + "dmgPerLvl": 8, + "resist": 142, + "resistPerLvl": 4, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 108 + ] + }, + { + "version": 100, + "subTypeId": 9, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 43, + "expPerLvl": 120, + "hp": 567, + "hpPerLvl": 25, + "defense": 377, + "defPerLvl": 19, + "str": 113, + "strPerLvl": 14, + "dex": 87, + "dexPerLvl": 12, + "ar": 385, + "arPerLvl": 24, + "dmgMin": 22, + "dmgMax": 29, + "dmgPerLvl": 6, + "resist": 83, + "resistPerLvl": 7, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 103 + ] + }, + { + "version": 100, + "subTypeId": 9, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 75, + "expPerLvl": 120, + "hp": 1367, + "hpPerLvl": 40, + "defense": 985, + "defPerLvl": 28, + "str": 169, + "strPerLvl": 14, + "dex": 135, + "dexPerLvl": 12, + "ar": 1153, + "arPerLvl": 36, + "dmgMin": 46, + "dmgMax": 53, + "dmgPerLvl": 8, + "resist": 139, + "resistPerLvl": 4, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 103 + ] + }, + { + "version": 100, + "subTypeId": 10, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 43, + "expPerLvl": 120, + "hp": 567, + "hpPerLvl": 25, + "defense": 377, + "defPerLvl": 19, + "str": 113, + "strPerLvl": 14, + "dex": 87, + "dexPerLvl": 12, + "ar": 385, + "arPerLvl": 24, + "dmgMin": 22, + "dmgMax": 29, + "dmgPerLvl": 6, + "resist": 83, + "resistPerLvl": 7, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 114 + ] + }, + { + "version": 100, + "subTypeId": 10, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 75, + "expPerLvl": 120, + "hp": 1367, + "hpPerLvl": 40, + "defense": 985, + "defPerLvl": 28, + "str": 169, + "strPerLvl": 14, + "dex": 135, + "dexPerLvl": 12, + "ar": 1153, + "arPerLvl": 36, + "dmgMin": 46, + "dmgMax": 53, + "dmgPerLvl": 8, + "resist": 139, + "resistPerLvl": 4, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 114 + ] + }, + { + "version": 100, + "subTypeId": 11, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 43, + "expPerLvl": 120, + "hp": 567, + "hpPerLvl": 25, + "defense": 377, + "defPerLvl": 19, + "str": 113, + "strPerLvl": 14, + "dex": 87, + "dexPerLvl": 12, + "ar": 385, + "arPerLvl": 24, + "dmgMin": 22, + "dmgMax": 29, + "dmgPerLvl": 6, + "resist": 83, + "resistPerLvl": 7, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 98 + ] + }, + { + "version": 100, + "subTypeId": 11, + "classId": 338, + "act": 2, + "difficulty": 2, + "seller": 198, + "hireCost": 7900, + "level": 75, + "expPerLvl": 120, + "hp": 1367, + "hpPerLvl": 40, + "defense": 985, + "defPerLvl": 28, + "str": 169, + "strPerLvl": 14, + "dex": 135, + "dexPerLvl": 12, + "ar": 1153, + "arPerLvl": 36, + "dmgMin": 46, + "dmgMax": 53, + "dmgPerLvl": 8, + "resist": 139, + "resistPerLvl": 4, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 98 + ] + }, + { + "version": 100, + "subTypeId": 12, + "classId": 338, + "act": 2, + "difficulty": 3, + "seller": 198, + "hireCost": 15000, + "level": 75, + "expPerLvl": 130, + "hp": 1230, + "hpPerLvl": 40, + "defense": 887, + "defPerLvl": 28, + "str": 165, + "strPerLvl": 14, + "dex": 131, + "dexPerLvl": 12, + "ar": 1038, + "arPerLvl": 36, + "dmgMin": 44, + "dmgMax": 51, + "dmgPerLvl": 8, + "resist": 136, + "resistPerLvl": 4, + "hireDesc": 3, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 99 + ] + }, + { + "version": 100, + "subTypeId": 13, + "classId": 338, + "act": 2, + "difficulty": 3, + "seller": 198, + "hireCost": 15000, + "level": 75, + "expPerLvl": 130, + "hp": 1230, + "hpPerLvl": 40, + "defense": 887, + "defPerLvl": 28, + "str": 165, + "strPerLvl": 14, + "dex": 131, + "dexPerLvl": 12, + "ar": 1038, + "arPerLvl": 36, + "dmgMin": 44, + "dmgMax": 51, + "dmgPerLvl": 8, + "resist": 136, + "resistPerLvl": 4, + "hireDesc": 4, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 104 + ] + }, + { + "version": 100, + "subTypeId": 14, + "classId": 338, + "act": 2, + "difficulty": 3, + "seller": 198, + "hireCost": 15000, + "level": 75, + "expPerLvl": 130, + "hp": 1230, + "hpPerLvl": 40, + "defense": 887, + "defPerLvl": 28, + "str": 165, + "strPerLvl": 14, + "dex": 131, + "dexPerLvl": 12, + "ar": 1038, + "arPerLvl": 36, + "dmgMin": 44, + "dmgMax": 51, + "dmgPerLvl": 8, + "resist": 136, + "resistPerLvl": 4, + "hireDesc": 5, + "nameFirstKey": "merca201", + "nameLastKey": "merca221", + "wNameFirstId": 1019, + "wNameLastId": 1039, + "skills": [ + 10, + 108 + ] + }, + { + "version": 0, + "subTypeId": 15, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 15, + "expPerLvl": 110, + "hp": 160, + "hpPerLvl": 8, + "defense": 80, + "defPerLvl": 4, + "str": 49, + "strPerLvl": 10, + "dex": 40, + "dexPerLvl": 8, + "ar": 15, + "arPerLvl": 10, + "dmgMin": 1, + "dmgMax": 7, + "dmgPerLvl": 4, + "resist": 25, + "resistPerLvl": 7, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 0, + "subTypeId": 15, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 37, + "expPerLvl": 110, + "hp": 336, + "hpPerLvl": 14, + "defense": 168, + "defPerLvl": 10, + "str": 77, + "strPerLvl": 10, + "dex": 62, + "dexPerLvl": 8, + "ar": 235, + "arPerLvl": 20, + "dmgMin": 12, + "dmgMax": 18, + "dmgPerLvl": 4, + "resist": 64, + "resistPerLvl": 7, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 0, + "subTypeId": 15, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 61, + "expPerLvl": 110, + "hp": 672, + "hpPerLvl": 20, + "defense": 408, + "defPerLvl": 18, + "str": 107, + "strPerLvl": 10, + "dex": 86, + "dexPerLvl": 8, + "ar": 715, + "arPerLvl": 30, + "dmgMin": 24, + "dmgMax": 30, + "dmgPerLvl": 4, + "resist": 106, + "resistPerLvl": 6, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 0, + "subTypeId": 16, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1500, + "level": 15, + "expPerLvl": 120, + "hp": 160, + "hpPerLvl": 8, + "defense": 80, + "defPerLvl": 4, + "str": 49, + "strPerLvl": 10, + "dex": 40, + "dexPerLvl": 8, + "ar": 15, + "arPerLvl": 10, + "dmgMin": 1, + "dmgMax": 7, + "dmgPerLvl": 4, + "resist": 25, + "resistPerLvl": 7, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 0, + "subTypeId": 16, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1500, + "level": 37, + "expPerLvl": 120, + "hp": 336, + "hpPerLvl": 14, + "defense": 168, + "defPerLvl": 10, + "str": 77, + "strPerLvl": 10, + "dex": 62, + "dexPerLvl": 8, + "ar": 235, + "arPerLvl": 20, + "dmgMin": 12, + "dmgMax": 18, + "dmgPerLvl": 4, + "resist": 64, + "resistPerLvl": 7, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 0, + "subTypeId": 16, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1500, + "level": 61, + "expPerLvl": 120, + "hp": 672, + "hpPerLvl": 20, + "defense": 408, + "defPerLvl": 16, + "str": 107, + "strPerLvl": 10, + "dex": 86, + "dexPerLvl": 8, + "ar": 715, + "arPerLvl": 30, + "dmgMin": 24, + "dmgMax": 30, + "dmgPerLvl": 4, + "resist": 106, + "resistPerLvl": 4, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 0, + "subTypeId": 17, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 15, + "expPerLvl": 110, + "hp": 160, + "hpPerLvl": 8, + "defense": 80, + "defPerLvl": 4, + "str": 49, + "strPerLvl": 10, + "dex": 40, + "dexPerLvl": 8, + "ar": 15, + "arPerLvl": 10, + "dmgMin": 1, + "dmgMax": 7, + "dmgPerLvl": 4, + "resist": 25, + "resistPerLvl": 7, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 0, + "subTypeId": 17, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 37, + "expPerLvl": 110, + "hp": 336, + "hpPerLvl": 14, + "defense": 168, + "defPerLvl": 10, + "str": 77, + "strPerLvl": 10, + "dex": 62, + "dexPerLvl": 8, + "ar": 235, + "arPerLvl": 20, + "dmgMin": 12, + "dmgMax": 18, + "dmgPerLvl": 4, + "resist": 64, + "resistPerLvl": 7, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 0, + "subTypeId": 17, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 61, + "expPerLvl": 110, + "hp": 672, + "hpPerLvl": 20, + "defense": 408, + "defPerLvl": 16, + "str": 107, + "strPerLvl": 10, + "dex": 86, + "dexPerLvl": 8, + "ar": 715, + "arPerLvl": 30, + "dmgMin": 24, + "dmgMax": 30, + "dmgPerLvl": 4, + "resist": 106, + "resistPerLvl": 4, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 0, + "subTypeId": 18, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 9500, + "level": 37, + "expPerLvl": 120, + "hp": 302, + "hpPerLvl": 14, + "defense": 151, + "defPerLvl": 10, + "str": 73, + "strPerLvl": 10, + "dex": 58, + "dexPerLvl": 8, + "ar": 212, + "arPerLvl": 20, + "dmgMin": 10, + "dmgMax": 16, + "dmgPerLvl": 4, + "resist": 61, + "resistPerLvl": 7, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 0, + "subTypeId": 18, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 9500, + "level": 61, + "expPerLvl": 120, + "hp": 638, + "hpPerLvl": 20, + "defense": 391, + "defPerLvl": 16, + "str": 103, + "strPerLvl": 10, + "dex": 82, + "dexPerLvl": 8, + "ar": 692, + "arPerLvl": 30, + "dmgMin": 22, + "dmgMax": 28, + "dmgPerLvl": 4, + "resist": 103, + "resistPerLvl": 4, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 0, + "subTypeId": 19, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 12000, + "level": 37, + "expPerLvl": 130, + "hp": 302, + "hpPerLvl": 14, + "defense": 151, + "defPerLvl": 10, + "str": 73, + "strPerLvl": 10, + "dex": 58, + "dexPerLvl": 8, + "ar": 212, + "arPerLvl": 20, + "dmgMin": 10, + "dmgMax": 16, + "dmgPerLvl": 4, + "resist": 61, + "resistPerLvl": 7, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 0, + "subTypeId": 19, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 12000, + "level": 61, + "expPerLvl": 130, + "hp": 638, + "hpPerLvl": 20, + "defense": 391, + "defPerLvl": 16, + "str": 103, + "strPerLvl": 10, + "dex": 82, + "dexPerLvl": 8, + "ar": 692, + "arPerLvl": 30, + "dmgMin": 22, + "dmgMax": 28, + "dmgPerLvl": 4, + "resist": 103, + "resistPerLvl": 4, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 0, + "subTypeId": 20, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 10000, + "level": 37, + "expPerLvl": 120, + "hp": 302, + "hpPerLvl": 14, + "defense": 151, + "defPerLvl": 10, + "str": 73, + "strPerLvl": 10, + "dex": 58, + "dexPerLvl": 8, + "ar": 212, + "arPerLvl": 20, + "dmgMin": 10, + "dmgMax": 16, + "dmgPerLvl": 4, + "resist": 61, + "resistPerLvl": 7, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 0, + "subTypeId": 20, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 10000, + "level": 61, + "expPerLvl": 120, + "hp": 638, + "hpPerLvl": 20, + "defense": 391, + "defPerLvl": 16, + "str": 103, + "strPerLvl": 10, + "dex": 82, + "dexPerLvl": 8, + "ar": 692, + "arPerLvl": 30, + "dmgMin": 22, + "dmgMax": 28, + "dmgPerLvl": 4, + "resist": 103, + "resistPerLvl": 4, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 0, + "subTypeId": 21, + "classId": 359, + "act": 3, + "difficulty": 3, + "seller": 252, + "hireCost": 21000, + "level": 61, + "expPerLvl": 130, + "hp": 574, + "hpPerLvl": 20, + "defense": 352, + "defPerLvl": 16, + "str": 99, + "strPerLvl": 10, + "dex": 78, + "dexPerLvl": 8, + "ar": 623, + "arPerLvl": 30, + "dmgMin": 20, + "dmgMax": 26, + "dmgPerLvl": 4, + "resist": 100, + "resistPerLvl": 4, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 0, + "subTypeId": 22, + "classId": 359, + "act": 3, + "difficulty": 3, + "seller": 252, + "hireCost": 27000, + "level": 61, + "expPerLvl": 140, + "hp": 574, + "hpPerLvl": 20, + "defense": 352, + "defPerLvl": 16, + "str": 99, + "strPerLvl": 10, + "dex": 78, + "dexPerLvl": 8, + "ar": 623, + "arPerLvl": 30, + "dmgMin": 20, + "dmgMax": 26, + "dmgPerLvl": 4, + "resist": 100, + "resistPerLvl": 4, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 0, + "subTypeId": 23, + "classId": 359, + "act": 3, + "difficulty": 3, + "seller": 252, + "hireCost": 21000, + "level": 61, + "expPerLvl": 130, + "hp": 574, + "hpPerLvl": 20, + "defense": 352, + "defPerLvl": 16, + "str": 99, + "strPerLvl": 10, + "dex": 78, + "dexPerLvl": 8, + "ar": 623, + "arPerLvl": 30, + "dmgMin": 20, + "dmgMax": 27, + "dmgPerLvl": 4, + "resist": 100, + "resistPerLvl": 4, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 100, + "subTypeId": 15, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 15, + "expPerLvl": 110, + "hp": 160, + "hpPerLvl": 9, + "defense": 80, + "defPerLvl": 5, + "str": 49, + "strPerLvl": 10, + "dex": 40, + "dexPerLvl": 8, + "ar": 15, + "arPerLvl": 12, + "dmgMin": 1, + "dmgMax": 7, + "dmgPerLvl": 4, + "resist": 25, + "resistPerLvl": 7, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 100, + "subTypeId": 15, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 49, + "expPerLvl": 110, + "hp": 466, + "hpPerLvl": 18, + "defense": 250, + "defPerLvl": 13, + "str": 92, + "strPerLvl": 10, + "dex": 74, + "dexPerLvl": 8, + "ar": 423, + "arPerLvl": 24, + "dmgMin": 18, + "dmgMax": 24, + "dmgPerLvl": 4, + "resist": 85, + "resistPerLvl": 7, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 100, + "subTypeId": 15, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 79, + "expPerLvl": 110, + "hp": 1006, + "hpPerLvl": 27, + "defense": 640, + "defPerLvl": 25, + "str": 130, + "strPerLvl": 10, + "dex": 104, + "dexPerLvl": 8, + "ar": 1143, + "arPerLvl": 36, + "dmgMin": 33, + "dmgMax": 39, + "dmgPerLvl": 4, + "resist": 138, + "resistPerLvl": 6, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 100, + "subTypeId": 16, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1500, + "level": 15, + "expPerLvl": 120, + "hp": 160, + "hpPerLvl": 9, + "defense": 80, + "defPerLvl": 5, + "str": 49, + "strPerLvl": 10, + "dex": 40, + "dexPerLvl": 8, + "ar": 15, + "arPerLvl": 12, + "dmgMin": 1, + "dmgMax": 7, + "dmgPerLvl": 4, + "resist": 25, + "resistPerLvl": 7, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 100, + "subTypeId": 16, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1500, + "level": 49, + "expPerLvl": 120, + "hp": 466, + "hpPerLvl": 18, + "defense": 250, + "defPerLvl": 13, + "str": 92, + "strPerLvl": 10, + "dex": 74, + "dexPerLvl": 8, + "ar": 423, + "arPerLvl": 24, + "dmgMin": 18, + "dmgMax": 24, + "dmgPerLvl": 4, + "resist": 85, + "resistPerLvl": 7, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 100, + "subTypeId": 16, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1500, + "level": 79, + "expPerLvl": 120, + "hp": 1006, + "hpPerLvl": 27, + "defense": 640, + "defPerLvl": 25, + "str": 130, + "strPerLvl": 10, + "dex": 104, + "dexPerLvl": 8, + "ar": 1143, + "arPerLvl": 36, + "dmgMin": 33, + "dmgMax": 39, + "dmgPerLvl": 4, + "resist": 138, + "resistPerLvl": 4, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 100, + "subTypeId": 17, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 15, + "expPerLvl": 110, + "hp": 160, + "hpPerLvl": 9, + "defense": 80, + "defPerLvl": 5, + "str": 49, + "strPerLvl": 10, + "dex": 40, + "dexPerLvl": 8, + "ar": 15, + "arPerLvl": 12, + "dmgMin": 1, + "dmgMax": 7, + "dmgPerLvl": 4, + "resist": 25, + "resistPerLvl": 7, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 100, + "subTypeId": 17, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 49, + "expPerLvl": 110, + "hp": 466, + "hpPerLvl": 18, + "defense": 250, + "defPerLvl": 13, + "str": 92, + "strPerLvl": 10, + "dex": 74, + "dexPerLvl": 8, + "ar": 423, + "arPerLvl": 24, + "dmgMin": 18, + "dmgMax": 24, + "dmgPerLvl": 4, + "resist": 85, + "resistPerLvl": 7, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 100, + "subTypeId": 17, + "classId": 359, + "act": 3, + "difficulty": 1, + "seller": 252, + "hireCost": 1000, + "level": 79, + "expPerLvl": 110, + "hp": 1006, + "hpPerLvl": 27, + "defense": 640, + "defPerLvl": 25, + "str": 130, + "strPerLvl": 10, + "dex": 104, + "dexPerLvl": 8, + "ar": 1143, + "arPerLvl": 36, + "dmgMin": 33, + "dmgMax": 39, + "dmgPerLvl": 4, + "resist": 138, + "resistPerLvl": 4, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 100, + "subTypeId": 18, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 9500, + "level": 49, + "expPerLvl": 120, + "hp": 419, + "hpPerLvl": 18, + "defense": 225, + "defPerLvl": 13, + "str": 88, + "strPerLvl": 10, + "dex": 70, + "dexPerLvl": 8, + "ar": 381, + "arPerLvl": 24, + "dmgMin": 16, + "dmgMax": 22, + "dmgPerLvl": 4, + "resist": 82, + "resistPerLvl": 7, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 100, + "subTypeId": 18, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 9500, + "level": 79, + "expPerLvl": 120, + "hp": 959, + "hpPerLvl": 27, + "defense": 615, + "defPerLvl": 25, + "str": 126, + "strPerLvl": 10, + "dex": 100, + "dexPerLvl": 8, + "ar": 1101, + "arPerLvl": 36, + "dmgMin": 31, + "dmgMax": 37, + "dmgPerLvl": 4, + "resist": 135, + "resistPerLvl": 4, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 100, + "subTypeId": 19, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 12000, + "level": 49, + "expPerLvl": 130, + "hp": 419, + "hpPerLvl": 18, + "defense": 225, + "defPerLvl": 13, + "str": 88, + "strPerLvl": 10, + "dex": 70, + "dexPerLvl": 8, + "ar": 381, + "arPerLvl": 24, + "dmgMin": 16, + "dmgMax": 22, + "dmgPerLvl": 4, + "resist": 82, + "resistPerLvl": 7, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 100, + "subTypeId": 19, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 12000, + "level": 79, + "expPerLvl": 130, + "hp": 959, + "hpPerLvl": 27, + "defense": 615, + "defPerLvl": 25, + "str": 126, + "strPerLvl": 10, + "dex": 100, + "dexPerLvl": 8, + "ar": 1101, + "arPerLvl": 36, + "dmgMin": 31, + "dmgMax": 37, + "dmgPerLvl": 4, + "resist": 135, + "resistPerLvl": 4, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 100, + "subTypeId": 20, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 10000, + "level": 49, + "expPerLvl": 120, + "hp": 419, + "hpPerLvl": 18, + "defense": 225, + "defPerLvl": 13, + "str": 88, + "strPerLvl": 10, + "dex": 70, + "dexPerLvl": 8, + "ar": 381, + "arPerLvl": 24, + "dmgMin": 16, + "dmgMax": 22, + "dmgPerLvl": 4, + "resist": 82, + "resistPerLvl": 7, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 100, + "subTypeId": 20, + "classId": 359, + "act": 3, + "difficulty": 2, + "seller": 252, + "hireCost": 10000, + "level": 79, + "expPerLvl": 120, + "hp": 959, + "hpPerLvl": 27, + "defense": 615, + "defPerLvl": 25, + "str": 126, + "strPerLvl": 10, + "dex": 100, + "dexPerLvl": 8, + "ar": 1101, + "arPerLvl": 36, + "dmgMin": 31, + "dmgMax": 37, + "dmgPerLvl": 4, + "resist": 135, + "resistPerLvl": 4, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 100, + "subTypeId": 21, + "classId": 359, + "act": 3, + "difficulty": 3, + "seller": 252, + "hireCost": 21000, + "level": 79, + "expPerLvl": 130, + "hp": 863, + "hpPerLvl": 27, + "defense": 554, + "defPerLvl": 25, + "str": 122, + "strPerLvl": 10, + "dex": 96, + "dexPerLvl": 8, + "ar": 991, + "arPerLvl": 36, + "dmgMin": 29, + "dmgMax": 35, + "dmgPerLvl": 4, + "resist": 132, + "resistPerLvl": 4, + "hireDesc": 6, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 41, + 47 + ] + }, + { + "version": 100, + "subTypeId": 22, + "classId": 359, + "act": 3, + "difficulty": 3, + "seller": 252, + "hireCost": 27000, + "level": 79, + "expPerLvl": 140, + "hp": 863, + "hpPerLvl": 27, + "defense": 554, + "defPerLvl": 25, + "str": 122, + "strPerLvl": 10, + "dex": 96, + "dexPerLvl": 8, + "ar": 991, + "arPerLvl": 36, + "dmgMin": 29, + "dmgMax": 35, + "dmgPerLvl": 4, + "resist": 132, + "resistPerLvl": 4, + "hireDesc": 7, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 55, + 40, + 45 + ] + }, + { + "version": 100, + "subTypeId": 23, + "classId": 359, + "act": 3, + "difficulty": 3, + "seller": 252, + "hireCost": 21000, + "level": 79, + "expPerLvl": 130, + "hp": 863, + "hpPerLvl": 27, + "defense": 554, + "defPerLvl": 25, + "str": 122, + "strPerLvl": 10, + "dex": 96, + "dexPerLvl": 8, + "ar": 991, + "arPerLvl": 36, + "dmgMin": 29, + "dmgMax": 36, + "dmgPerLvl": 4, + "resist": 132, + "resistPerLvl": 4, + "hireDesc": 8, + "nameFirstKey": "merca222", + "nameLastKey": "merca241", + "wNameFirstId": 1040, + "wNameLastId": 1059, + "skills": [ + 38, + 49 + ] + }, + { + "version": 0, + "subTypeId": 24, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 28, + "expPerLvl": 120, + "hp": 288, + "hpPerLvl": 14, + "defense": 180, + "defPerLvl": 7, + "str": 101, + "strPerLvl": 15, + "dex": 63, + "dexPerLvl": 10, + "ar": 150, + "arPerLvl": 17, + "dmgMin": 16, + "dmgMax": 20, + "dmgPerLvl": 6, + "resist": 56, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 24, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 42, + "expPerLvl": 120, + "hp": 484, + "hpPerLvl": 21, + "defense": 278, + "defPerLvl": 20, + "str": 128, + "strPerLvl": 15, + "dex": 81, + "dexPerLvl": 10, + "ar": 388, + "arPerLvl": 27, + "dmgMin": 27, + "dmgMax": 31, + "dmgPerLvl": 8, + "resist": 81, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 24, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 75, + "expPerLvl": 120, + "hp": 1177, + "hpPerLvl": 35, + "defense": 938, + "defPerLvl": 40, + "str": 190, + "strPerLvl": 15, + "dex": 123, + "dexPerLvl": 10, + "ar": 1279, + "arPerLvl": 37, + "dmgMin": 60, + "dmgMax": 64, + "dmgPerLvl": 8, + "resist": 139, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 25, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 28, + "expPerLvl": 120, + "hp": 288, + "hpPerLvl": 14, + "defense": 180, + "defPerLvl": 7, + "str": 101, + "strPerLvl": 15, + "dex": 63, + "dexPerLvl": 10, + "ar": 150, + "arPerLvl": 17, + "dmgMin": 16, + "dmgMax": 20, + "dmgPerLvl": 6, + "resist": 56, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 25, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 42, + "expPerLvl": 120, + "hp": 484, + "hpPerLvl": 21, + "defense": 278, + "defPerLvl": 20, + "str": 128, + "strPerLvl": 15, + "dex": 81, + "dexPerLvl": 10, + "ar": 388, + "arPerLvl": 27, + "dmgMin": 27, + "dmgMax": 31, + "dmgPerLvl": 8, + "resist": 81, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 25, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 75, + "expPerLvl": 120, + "hp": 1177, + "hpPerLvl": 35, + "defense": 938, + "defPerLvl": 40, + "str": 190, + "strPerLvl": 15, + "dex": 123, + "dexPerLvl": 10, + "ar": 1279, + "arPerLvl": 37, + "dmgMin": 60, + "dmgMax": 64, + "dmgPerLvl": 8, + "resist": 139, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 26, + "classId": 561, + "act": 5, + "difficulty": 2, + "seller": 515, + "hireCost": 18000, + "level": 42, + "expPerLvl": 130, + "hp": 436, + "hpPerLvl": 21, + "defense": 250, + "defPerLvl": 20, + "str": 125, + "strPerLvl": 15, + "dex": 76, + "dexPerLvl": 10, + "ar": 349, + "arPerLvl": 27, + "dmgMin": 25, + "dmgMax": 29, + "dmgPerLvl": 8, + "resist": 78, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 26, + "classId": 561, + "act": 5, + "difficulty": 2, + "seller": 515, + "hireCost": 18000, + "level": 75, + "expPerLvl": 130, + "hp": 1129, + "hpPerLvl": 35, + "defense": 910, + "defPerLvl": 40, + "str": 187, + "strPerLvl": 15, + "dex": 118, + "dexPerLvl": 10, + "ar": 1240, + "arPerLvl": 37, + "dmgMin": 58, + "dmgMax": 62, + "dmgPerLvl": 8, + "resist": 136, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 27, + "classId": 561, + "act": 5, + "difficulty": 2, + "seller": 515, + "hireCost": 18000, + "level": 42, + "expPerLvl": 130, + "hp": 436, + "hpPerLvl": 21, + "defense": 250, + "defPerLvl": 20, + "str": 125, + "strPerLvl": 15, + "dex": 76, + "dexPerLvl": 10, + "ar": 349, + "arPerLvl": 27, + "dmgMin": 25, + "dmgMax": 29, + "dmgPerLvl": 8, + "resist": 78, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 27, + "classId": 561, + "act": 5, + "difficulty": 2, + "seller": 515, + "hireCost": 18000, + "level": 75, + "expPerLvl": 130, + "hp": 1129, + "hpPerLvl": 35, + "defense": 910, + "defPerLvl": 40, + "str": 187, + "strPerLvl": 15, + "dex": 118, + "dexPerLvl": 10, + "ar": 1240, + "arPerLvl": 37, + "dmgMin": 58, + "dmgMax": 62, + "dmgPerLvl": 8, + "resist": 136, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 28, + "classId": 561, + "act": 5, + "difficulty": 3, + "seller": 515, + "hireCost": 32000, + "level": 75, + "expPerLvl": 140, + "hp": 1016, + "hpPerLvl": 35, + "defense": 819, + "defPerLvl": 40, + "str": 184, + "strPerLvl": 15, + "dex": 113, + "dexPerLvl": 10, + "ar": 1116, + "arPerLvl": 37, + "dmgMin": 56, + "dmgMax": 60, + "dmgPerLvl": 8, + "resist": 133, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 0, + "subTypeId": 29, + "classId": 561, + "act": 5, + "difficulty": 3, + "seller": 515, + "hireCost": 32000, + "level": 75, + "expPerLvl": 140, + "hp": 1016, + "hpPerLvl": 35, + "defense": 819, + "defPerLvl": 40, + "str": 184, + "strPerLvl": 15, + "dex": 113, + "dexPerLvl": 10, + "ar": 1116, + "arPerLvl": 37, + "dmgMin": 56, + "dmgMax": 60, + "dmgPerLvl": 8, + "resist": 133, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 24, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 28, + "expPerLvl": 120, + "hp": 288, + "hpPerLvl": 18, + "defense": 180, + "defPerLvl": 10, + "str": 101, + "strPerLvl": 15, + "dex": 63, + "dexPerLvl": 10, + "ar": 150, + "arPerLvl": 20, + "dmgMin": 16, + "dmgMax": 20, + "dmgPerLvl": 6, + "resist": 56, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 24, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 58, + "expPerLvl": 120, + "hp": 828, + "hpPerLvl": 27, + "defense": 480, + "defPerLvl": 35, + "str": 158, + "strPerLvl": 15, + "dex": 101, + "dexPerLvl": 10, + "ar": 750, + "arPerLvl": 35, + "dmgMin": 39, + "dmgMax": 43, + "dmgPerLvl": 8, + "resist": 109, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 24, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 80, + "expPerLvl": 120, + "hp": 1422, + "hpPerLvl": 45, + "defense": 1250, + "defPerLvl": 50, + "str": 200, + "strPerLvl": 15, + "dex": 129, + "dexPerLvl": 10, + "ar": 1520, + "arPerLvl": 45, + "dmgMin": 61, + "dmgMax": 65, + "dmgPerLvl": 8, + "resist": 148, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 25, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 28, + "expPerLvl": 120, + "hp": 288, + "hpPerLvl": 18, + "defense": 180, + "defPerLvl": 10, + "str": 101, + "strPerLvl": 15, + "dex": 63, + "dexPerLvl": 10, + "ar": 150, + "arPerLvl": 20, + "dmgMin": 16, + "dmgMax": 20, + "dmgPerLvl": 6, + "resist": 56, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 25, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 58, + "expPerLvl": 120, + "hp": 828, + "hpPerLvl": 27, + "defense": 480, + "defPerLvl": 35, + "str": 158, + "strPerLvl": 15, + "dex": 101, + "dexPerLvl": 10, + "ar": 750, + "arPerLvl": 35, + "dmgMin": 39, + "dmgMax": 43, + "dmgPerLvl": 8, + "resist": 109, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 25, + "classId": 561, + "act": 5, + "difficulty": 1, + "seller": 515, + "hireCost": 9000, + "level": 80, + "expPerLvl": 120, + "hp": 1422, + "hpPerLvl": 45, + "defense": 1250, + "defPerLvl": 50, + "str": 200, + "strPerLvl": 15, + "dex": 129, + "dexPerLvl": 10, + "ar": 1520, + "arPerLvl": 45, + "dmgMin": 61, + "dmgMax": 65, + "dmgPerLvl": 8, + "resist": 148, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 26, + "classId": 561, + "act": 5, + "difficulty": 2, + "seller": 515, + "hireCost": 18000, + "level": 58, + "expPerLvl": 130, + "hp": 745, + "hpPerLvl": 27, + "defense": 432, + "defPerLvl": 35, + "str": 155, + "strPerLvl": 15, + "dex": 96, + "dexPerLvl": 10, + "ar": 675, + "arPerLvl": 35, + "dmgMin": 37, + "dmgMax": 41, + "dmgPerLvl": 8, + "resist": 106, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 26, + "classId": 561, + "act": 5, + "difficulty": 2, + "seller": 515, + "hireCost": 18000, + "level": 80, + "expPerLvl": 130, + "hp": 1339, + "hpPerLvl": 45, + "defense": 1202, + "defPerLvl": 50, + "str": 197, + "strPerLvl": 15, + "dex": 124, + "dexPerLvl": 10, + "ar": 1445, + "arPerLvl": 45, + "dmgMin": 59, + "dmgMax": 63, + "dmgPerLvl": 8, + "resist": 145, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 27, + "classId": 561, + "act": 5, + "difficulty": 2, + "seller": 515, + "hireCost": 18000, + "level": 58, + "expPerLvl": 130, + "hp": 745, + "hpPerLvl": 27, + "defense": 432, + "defPerLvl": 35, + "str": 155, + "strPerLvl": 15, + "dex": 96, + "dexPerLvl": 10, + "ar": 675, + "arPerLvl": 35, + "dmgMin": 37, + "dmgMax": 41, + "dmgPerLvl": 8, + "resist": 106, + "resistPerLvl": 7, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 27, + "classId": 561, + "act": 5, + "difficulty": 2, + "seller": 515, + "hireCost": 18000, + "level": 80, + "expPerLvl": 130, + "hp": 1339, + "hpPerLvl": 45, + "defense": 1202, + "defPerLvl": 50, + "str": 197, + "strPerLvl": 15, + "dex": 124, + "dexPerLvl": 10, + "ar": 1445, + "arPerLvl": 45, + "dmgMin": 59, + "dmgMax": 63, + "dmgPerLvl": 8, + "resist": 145, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 28, + "classId": 561, + "act": 5, + "difficulty": 3, + "seller": 515, + "hireCost": 32000, + "level": 80, + "expPerLvl": 140, + "hp": 1205, + "hpPerLvl": 45, + "defense": 1082, + "defPerLvl": 50, + "str": 194, + "strPerLvl": 15, + "dex": 119, + "dexPerLvl": 10, + "ar": 1301, + "arPerLvl": 45, + "dmgMin": 57, + "dmgMax": 61, + "dmgPerLvl": 8, + "resist": 142, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + }, + { + "version": 100, + "subTypeId": 29, + "classId": 561, + "act": 5, + "difficulty": 3, + "seller": 515, + "hireCost": 32000, + "level": 80, + "expPerLvl": 140, + "hp": 1205, + "hpPerLvl": 45, + "defense": 1082, + "defPerLvl": 50, + "str": 194, + "strPerLvl": 15, + "dex": 119, + "dexPerLvl": 10, + "ar": 1301, + "arPerLvl": 45, + "dmgMin": 57, + "dmgMax": 61, + "dmgPerLvl": 8, + "resist": 142, + "resistPerLvl": 4, + "hireDesc": 0, + "nameFirstKey": "MercX101", + "nameLastKey": "MercX167", + "wNameFirstId": 10835, + "wNameLastId": 10901, + "skills": [ + 126, + 139 + ] + } +] + +export interface HirelingCandidateDesc { + readonly mercId: number + readonly nameId: number + readonly seed: number + readonly actIndex: number + readonly classId: number + readonly subTypeId: number + readonly name: string + readonly level: number + readonly hp: number + readonly defense: number + readonly strength: number + readonly dexterity: number + readonly cost: number + readonly resist: number + readonly hireDescId: number + readonly hireDesc: string + readonly skills: readonly number[] + readonly line1: string + readonly line2: string +} + +/** + * Maps a mercenary name ID to their act index (0..3) per D2Common.dll ordinal 10988 (0x6fd7c9e0). + */ +export function resolveHirelingAct(wMercName: number, expansion = true): number { + const version = expansion ? 100 : 0 + for (const r of HIRELING_RECORDS) { + if (r.version === version && r.wNameFirstId <= wMercName && wMercName <= r.wNameLastId) { + return r.act - 1 + } + } + // Fallbacks by known name ranges + if (wMercName >= 3411 && wMercName <= 3451) return 0 + if (wMercName >= 1019 && wMercName <= 1039) return 1 + if (wMercName >= 1040 && wMercName <= 1059) return 2 + if (wMercName === 11021 || wMercName === 0x0421) return 2 + if (wMercName >= 10835 && wMercName <= 10901) return 4 + return 0 +} + +/** + * Resolves a mercenary name string from their TBL index per D2Client.dll 0x6faf9783. + */ +export function resolveHirelingName(wMercName: number, lang: 'ENG' | 'CHI' = 'CHI'): string { + // D2Client.dll 0x6faf978c: if wMercName == 0x0421 (1057), replace with 0x2b0d (11021 = "Jarulf") + const resolvedNameId = wMercName === 0x0421 ? 0x2b0d : wMercName + + if (resolvedNameId === 0x2b0d) { + return lookupTbl('merca239', lang) ?? (lang === 'ENG' ? 'Jarulf' : '賈魯夫') + } + + // Act 1 Rogue (string.tbl 3411..3444 -> merc01..merc34) + if (resolvedNameId >= 3411 && resolvedNameId <= 3451) { + const idx = resolvedNameId - 3411 + 1 + const key = `merc${idx < 10 ? `0${idx}` : idx}` + return lookupTbl(key, lang) ?? (lang === 'ENG' ? `Rogue #${idx}` : `羅格 #${idx}`) + } + + // Act 2 Desert Mercenary (patchstring.tbl 1019..1039 -> merca201..merca221) + if (resolvedNameId >= 1019 && resolvedNameId <= 1039) { + const idx = resolvedNameId - 1019 + 1 + const key = `merca2${idx < 10 ? `0${idx}` : idx}` + return lookupTbl(key, lang) ?? (lang === 'ENG' ? `Desert Mercenary #${idx}` : `沙漠傭兵 #${idx}`) + } + + // Act 3 Iron Wolf (patchstring.tbl 1040..1059 -> merca222..merca241) + if (resolvedNameId >= 1040 && resolvedNameId <= 1059) { + const idx = resolvedNameId - 1040 + 22 + const key = `merca2${idx}` + return lookupTbl(key, lang) ?? (lang === 'ENG' ? `Iron Wolf #${idx}` : `鐵狼 #${idx}`) + } + + // Act 5 Barbarian (patchstring.tbl 835..901 / global 10835..10901 -> MercX101..MercX167) + const barbId = resolvedNameId >= 10000 ? resolvedNameId - 10000 : resolvedNameId + if (barbId >= 835 && barbId <= 901) { + const idx = barbId - 835 + 101 + const key = `MercX${idx}` + return lookupTbl(key, lang) ?? (lang === 'ENG' ? `Barbarian #${idx}` : `野蠻人 #${idx}`) + } + + return lookupTbl(`merc${resolvedNameId}`, lang) ?? `Mercenary #${resolvedNameId}` +} + +/** + * Resolves the hireling ability description string per D2Common.dll ordinal 10091 (0x6fd7c9c0). + */ +export function resolveHirelingDesc(hireDescId: number, lang: 'ENG' | 'CHI' = 'CHI'): string { + switch (hireDescId) { + case 1: + return lookupTbl('strhirespecial1', lang) ?? (lang === 'ENG' ? 'Fire Arrow' : '火箭') + case 2: + return lookupTbl('strhirespecial2', lang) ?? (lang === 'ENG' ? 'Cold Arrow' : '冰箭') + case 3: + return lookupTbl('PalMercEXST4X', lang) ?? (lang === 'ENG' ? 'Combat' : '戰鬥') + case 4: + return lookupTbl('PalMercExST1X', lang) ?? (lang === 'ENG' ? 'Defensive' : '防禦') + case 5: + return lookupTbl('PalMercEXST3X', lang) ?? (lang === 'ENG' ? 'Offensive' : '進攻') + case 6: + return lookupTbl('strhirespecial7', lang) ?? (lang === 'ENG' ? 'Fire' : '火') + case 7: + return lookupTbl('strhirespecial6', lang) ?? (lang === 'ENG' ? 'Cold' : '冷') + case 8: + return lookupTbl('strhirespecial5', lang) ?? (lang === 'ENG' ? 'Lightning' : '閃電') + default: + return '' + } +} + +/** + * Formats a candidate into 2 lines for the scrollable list control per D2Client.dll 0x6faf9440. + */ +export function formatHirelingCandidateLines( + name: string, + level: number, + hp: number, + defense: number, + cost: number, + hireDesc: string, + lang: 'ENG' | 'CHI' = 'CHI', +): { line1: string; line2: string } { + const lvlLabel = lookupTbl('Level', lang) ?? (lang === 'ENG' ? 'Lvl' : '等級') + const lifeLabel = lookupTbl('HP', lang) ?? (lang === 'ENG' ? 'Life' : '生命') + const defLabel = lookupTbl('AC', lang) ?? (lang === 'ENG' ? 'Def' : '防禦') + const costLabel = lookupTbl('Cost', lang) ?? (lang === 'ENG' ? 'Cost' : '花費') + + // Line 1: - Lvl: Life: Def: Cost: + const line1 = `${name} - ${lvlLabel}: ${level} ${lifeLabel}: ${hp} ${defLabel}: ${defense} ${costLabel}: ${cost}` + + // Line 2: 2 leading spaces + HireDesc (or blank if no special ability) + const line2 = hireDesc ? ` ${hireDesc}` : ' ' + + return { line1, line2 } +} + +/** + * Evaluates a candidate's authentic stats, level, and hire cost using Diablo II's 64-bit LCG + * per D2Common.dll ordinal 10458 (0x6fd7ccc0). + */ +export function resolveHirelingCandidate( + candidate: { readonly nameId: number; readonly seed: number }, + playerLevel: number, + difficulty: number, + actIndex: number, + expansion = true, + lang: 'ENG' | 'CHI' = 'CHI', +): HirelingCandidateDesc { + const version = expansion ? 100 : 0 + const targetAct = actIndex + 1 + const targetDiff = difficulty + 1 + + // 1. Collect all base rows matching (version, act, difficulty) per 0x6fda3050 + const actDiffRows = HIRELING_RECORDS.filter( + (r) => r.version === version && r.act === targetAct && r.difficulty === targetDiff, + ) + + let matchingRows = actDiffRows + if (actDiffRows.length > 0) { + const baseLevel = actDiffRows[0]!.level + const levelFiltered = actDiffRows.filter((r) => r.level === baseLevel) + if (levelFiltered.length > 0) { + matchingRows = levelFiltered + } + } + + if (matchingRows.length === 0) { + matchingRows = HIRELING_RECORDS.filter((r) => r.act === targetAct) + } + if (matchingRows.length === 0) { + matchingRows = [HIRELING_RECORDS[0]!] + } + + // 2. D2Common 64-bit LCG simulation (0x6fd7cd20..0x6fd7cd95) + let seedLo = candidate.seed >>> 0 + let seedHi = 0x29a // 666 + + // Step 1: Subtype / row selection + const step1 = BigInt(seedLo) * 0x6ac690c5n + BigInt(seedHi) + seedLo = Number(step1 & 0xffffffffn) >>> 0 + seedHi = Number((step1 >> 32n) & 0xffffffffn) >>> 0 + + const rowCount = matchingRows.length + const isPowerOfTwo = (rowCount & (rowCount - 1)) === 0 + const rowIndex = isPowerOfTwo ? (seedLo & (rowCount - 1)) : (seedLo % rowCount) + const pRow = matchingRows[rowIndex] ?? matchingRows[0]! + + // Step 2: Level selection + const step2 = BigInt(seedLo) * 0x6ac690c5n + BigInt(seedHi) + const seedLo2 = Number(step2 & 0xffffffffn) >>> 0 + const levelOffset = (seedLo2 >>> 0) % 5 + + const effPlayerLevel = Math.max(1, playerLevel) + const mercLevel = Math.max(2, effPlayerLevel + levelOffset - 5) + const levelDelta = mercLevel - pRow.level + + // Step 3: Compute exact stats + const hp = Math.max(40, pRow.hp + levelDelta * pRow.hpPerLvl) + const defense = Math.max(0, pRow.defense + levelDelta * pRow.defPerLvl) + const strength = Math.max(10, pRow.str + ((levelDelta * pRow.strPerLvl) >> 3)) + const dexterity = Math.max(10, pRow.dex + ((levelDelta * pRow.dexPerLvl) >> 3)) + const cost = Math.max( + pRow.hireCost, + Math.trunc((pRow.hireCost * (100 + 15 * levelDelta)) / 100), + ) + const resist = Math.max(0, pRow.resist + ((levelDelta * pRow.resistPerLvl) >> 3)) + + const name = resolveHirelingName(candidate.nameId, lang) + const hireDesc = resolveHirelingDesc(pRow.hireDesc, lang) + const { line1, line2 } = formatHirelingCandidateLines( + name, + mercLevel, + hp, + defense, + cost, + hireDesc, + lang, + ) + + return { + mercId: candidate.nameId, // client uses wMercName as mercId for 0x36 + nameId: candidate.nameId, + seed: candidate.seed, + actIndex, + classId: pRow.classId, + subTypeId: pRow.subTypeId, + name, + level: mercLevel, + hp, + defense, + strength, + dexterity, + cost, + resist, + hireDescId: pRow.hireDesc, + hireDesc, + skills: pRow.skills, + line1, + line2, + } +} + +/** + * Resolves a list of candidates from raw { nameId, seed } pairs. + */ +export function resolveHirelingCandidates( + candidates: readonly { readonly nameId: number; readonly seed: number }[], + playerLevel: number, + difficulty: number, + actIndex: number, + expansion = true, + lang: 'ENG' | 'CHI' = 'CHI', +): readonly HirelingCandidateDesc[] { + return candidates.map((c) => + resolveHirelingCandidate(c, playerLevel, difficulty, actIndex, expansion, lang), + ) +} diff --git a/tests/client/hire-dialog.test.ts b/tests/client/hire-dialog.test.ts new file mode 100644 index 0000000..db10963 --- /dev/null +++ b/tests/client/hire-dialog.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi } from 'vitest' +import { + WorldPanelsHud, + HIRE_DIALOG_WIDTH, + HIRE_DIALOG_HEIGHT, + HIRE_CONFIRM_WIDTH, + HIRE_CONFIRM_HEIGHT, +} from '../../src/client/ui/world-panels.ts' +import { resolveHirelingCandidates } from '../../src/common/units/hireling-table.ts' + +function createMockCtx() { + const drawnTexts: string[] = [] + let fillRectCount = 0 + let strokeRectCount = 0 + return { + drawnTexts, + get fillRectCount() { + return fillRectCount + }, + get strokeRectCount() { + return strokeRectCount + }, + fillStyle: '', + strokeStyle: '', + fillRect: vi.fn(() => fillRectCount++), + strokeRect: vi.fn(() => strokeRectCount++), + fillText: vi.fn((text: string) => drawnTexts.push(text)), + drawImage: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + } as unknown as CanvasRenderingContext2D & { drawnTexts: string[]; fillRectCount: number; strokeRectCount: number } +} + +describe('WorldPanelsHud — Mercenary Hire Stone Dialog (0x6faf9440 & 0x6faf7500 parity)', () => { + it('identifies dialog hitboxes accurately per D2Client.dll 0x6faf9440', () => { + const wp = new WorldPanelsHud() + wp.activePanel = 'hire' + + // Centered at 155, 105 (490x350) + expect(wp.isPointInHireMercDialog(100, 50)).toBe(false) + expect(wp.isPointInHireMercDialog(155, 105)).toBe(true) + expect(wp.isPointInHireMercDialog(400, 300)).toBe(true) + expect(wp.isPointInHireMercDialog(644, 454)).toBe(true) + expect(wp.isPointInHireMercDialog(650, 460)).toBe(false) + + // Confirmation dialog bounds (250, 190, 300, 160) + wp.replaceMercConfirmOpen = true + expect(wp.isPointInHireMercDialog(160, 110)).toBe(false) + expect(wp.isPointInHireMercDialog(250, 190)).toBe(true) + expect(wp.isPointInHireMercDialog(400, 250)).toBe(true) + expect(wp.isPointInHireMercDialog(550, 350)).toBe(true) + }) + + it('draws authentic 490x350 stone dialog with ItemDesc1s gold header and candidate list', () => { + const wp = new WorldPanelsHud() + wp.lang = 'ENG' + wp.activePanel = 'hire' + wp.playerGold = 5000 + wp.stashGold = 12000 + + const rawCandidates = [ + { nameId: 3411, seed: 0x1234 }, + { nameId: 3412, seed: 0x5678 }, + ] + wp.mercHireList = resolveHirelingCandidates(rawCandidates, 10, 0, 0, true, 'ENG') + + const ctx = createMockCtx() + wp.drawHireMercDialog(ctx) + + expect(ctx.fillRectCount).toBeGreaterThan(0) + // Title with total gold 17000 + expect(ctx.drawnTexts.some((t) => t.includes('17000') && t.includes('Hire which Mercenary?'))).toBe(true) + // Candidates rendered with Lvl, Life, Def, Cost + expect(ctx.drawnTexts.some((t) => t.includes('Aliza - Lvl: 8'))).toBe(true) + expect(ctx.drawnTexts.some((t) => t.includes('Fire Arrow'))).toBe(true) + // Bottom cancel button + expect(ctx.drawnTexts.some((t) => t.includes('cancel'))).toBe(true) + }) + + it('draws ItemDesc1t when hire list is empty', () => { + const wp = new WorldPanelsHud() + wp.lang = 'ENG' + wp.activePanel = 'hire' + wp.mercHireList = [] + + const ctx = createMockCtx() + wp.drawHireMercDialog(ctx) + + expect(ctx.drawnTexts.some((t) => t.includes('There are no Mercenaries left to hire.'))).toBe(true) + }) + + it('handles candidate hover, scrolling, and direct hire when player has no existing mercenary', () => { + const wp = new WorldPanelsHud() + wp.activePanel = 'hire' + wp.activeNpcId = 150 + + const rawCandidates = Array.from({ length: 10 }, (_, i) => ({ + nameId: 3411 + i, + seed: 0x1000 + i * 0x100, + })) + wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG') + + // 1. Wheel scroll + expect(wp.handleHireWheel(1)).toBe(true) + expect(wp.hireScrollOffset).toBe(1) + expect(wp.handleHireWheel(-1)).toBe(true) + expect(wp.hireScrollOffset).toBe(0) + + // 2. Mouse move hover + wp.handleHireMercMove(200, 160) // over Candidate 0 + expect(wp.hireHoveredIndex).toBe(0) + + // 3. Direct click to hire when player has no existing mercenary + let hiredNpcId = 0 + let hiredMercId = 0 + const onHireMerc = vi.fn((npcId: number, mercId: number) => { + hiredNpcId = npcId + hiredMercId = mercId + }) + + const handled = wp.handleHireMercClick(200, 160, { onHireMerc }) + expect(handled).toBe(true) + expect(onHireMerc).toHaveBeenCalledWith(150, 3411) + expect(wp.activePanel).toBe('none') + }) + + it('prompts confirmation dialog when player already possesses a mercenary (0x6faf7500)', () => { + const wp = new WorldPanelsHud() + wp.lang = 'ENG' + wp.activePanel = 'hire' + wp.activeNpcId = 150 + wp.merc = { mercId: 42, isDead: false } // Existing active mercenary + + const rawCandidates = [{ nameId: 3411, seed: 0x1234 }] + wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG') + + const onHireMerc = vi.fn() + + // First click: selects candidate + wp.handleHireMercClick(200, 160, { onHireMerc }) + expect(wp.hireSelectedIndex).toBe(0) + expect(wp.replaceMercConfirmOpen).toBe(false) + expect(onHireMerc).not.toHaveBeenCalled() + + // Second click on selected candidate: triggers replacement warning dialog + wp.handleHireMercClick(200, 160, { onHireMerc }) + expect(wp.replaceMercConfirmOpen).toBe(true) + expect(wp.pendingHireCandidate?.nameId).toBe(3411) + + // Render with confirm dialog open + const ctx = createMockCtx() + wp.drawHireMercDialog(ctx) + expect(ctx.drawnTexts.some((t) => t.includes('This Mercenary will replace your current one.'))).toBe(true) + expect(ctx.drawnTexts.some((t) => t.includes('Yes'))).toBe(true) + expect(ctx.drawnTexts.some((t) => t.includes('No'))).toBe(true) + + // Click "No" (cx + 150 = 400, cy + 128 = 318) + wp.handleHireMercClick(400, 318, { onHireMerc }) + expect(wp.replaceMercConfirmOpen).toBe(false) + expect(wp.activePanel).toBe('hire') + expect(onHireMerc).not.toHaveBeenCalled() + + // Open again and click "Yes" (cx + 150 = 400, cy + 104 = 294) + wp.handleHireMercClick(200, 160, { onHireMerc }) + expect(wp.replaceMercConfirmOpen).toBe(true) + + wp.handleHireMercClick(400, 294, { onHireMerc }) + expect(wp.replaceMercConfirmOpen).toBe(false) + expect(onHireMerc).toHaveBeenCalledWith(150, 3411) + expect(wp.activePanel).toBe('none') + }) + + it('clicking cancel button closes hire dialog and re-opens NPC menu (0x6faf9090)', () => { + const wp = new WorldPanelsHud() + wp.openNpcMenuByName('Kashya', 400, 220) + expect(wp.npcMenu).not.toBeNull() + + wp.clickNpcMenuOption('hire') + expect(wp.activePanel).toBe('hire') + expect(wp.npcMenu).toBeNull() + + // Click Cancel button at dialogX + 245 = 400, dialogY + 315 = 420 + const onClose = vi.fn() + wp.handleHireMercClick(400, 420, { onClose }) + + expect(wp.activePanel).toBe('none') + expect(onClose).toHaveBeenCalled() + expect(wp.npcMenu).not.toBeNull() + expect(wp.npcMenu?.npcName).toContain('Kashya') + }) +}) diff --git a/tests/common/hireling-table.test.ts b/tests/common/hireling-table.test.ts new file mode 100644 index 0000000..7a3577c --- /dev/null +++ b/tests/common/hireling-table.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest' +import { + HIRELING_RECORDS, + resolveHirelingAct, + resolveHirelingName, + resolveHirelingDesc, + resolveHirelingCandidate, + resolveHirelingCandidates, + formatHirelingCandidateLines, +} from '../../src/common/units/hireling-table.ts' + +describe('hireling-table (authoritative Diablo II v1.13c Hireling.txt / D2Common parity)', () => { + it('loads all 120 compiled hireling records with valid attributes', () => { + expect(HIRELING_RECORDS).toHaveLength(120) + for (const r of HIRELING_RECORDS) { + expect([0, 100]).toContain(r.version) + expect([271, 338, 359, 561]).toContain(r.classId) + expect([1, 2, 3, 5]).toContain(r.act) + expect([1, 2, 3]).toContain(r.difficulty) + expect([150, 198, 252, 515]).toContain(r.seller) + expect(r.level).toBeGreaterThan(0) + expect(r.hireCost).toBeGreaterThan(0) + expect(r.hp).toBeGreaterThan(0) + expect(r.skills.length).toBeGreaterThan(0) + } + }) + + it('resolves act index from mercenary name ID matching D2Common_10988 (0x6fd7c9e0)', () => { + // Act 1 Rogue (merc01..merc34 / global 3411..3444) -> act 0 + expect(resolveHirelingAct(3411, true)).toBe(0) + expect(resolveHirelingAct(3444, true)).toBe(0) + + // Act 2 Desert Mercenary (merca201..merca221 / global 1019..1039) -> act 1 + expect(resolveHirelingAct(1019, true)).toBe(1) + expect(resolveHirelingAct(1039, true)).toBe(1) + + // Act 3 Iron Wolf (merca222..merca241 / global 1040..1059) -> act 2 + expect(resolveHirelingAct(1040, true)).toBe(2) + expect(resolveHirelingAct(1059, true)).toBe(2) + expect(resolveHirelingAct(11021, true)).toBe(2) // Jarulf + expect(resolveHirelingAct(0x0421, true)).toBe(2) // 1057 Jarulf legacy + + // Act 5 Barbarian (MercX101..MercX167 / global 10835..10901) -> act 4 + expect(resolveHirelingAct(10835, true)).toBe(4) + expect(resolveHirelingAct(10901, true)).toBe(4) + }) + + it('resolves localized mercenary names in English and Chinese per D2Client.dll 0x6faf9783', () => { + // Act 1 + expect(resolveHirelingName(3411, 'ENG')).toBe('Aliza') + expect(resolveHirelingName(3411, 'CHI')).toBe('阿里撒') + expect(resolveHirelingName(3444, 'ENG')).toBe('Ryann') + expect(resolveHirelingName(3444, 'CHI')).toBe('萊恩') + + // Act 2 + expect(resolveHirelingName(1019, 'ENG')).toBe('Hazade') + expect(resolveHirelingName(1019, 'CHI')).toBe('哈撒迪') + expect(resolveHirelingName(1039, 'ENG')).toBe('Vikhyat') + expect(resolveHirelingName(1039, 'CHI')).toBe('維克懷特') + + // Act 3 + expect(resolveHirelingName(1040, 'ENG')).toBe('Jelani') + expect(resolveHirelingName(1040, 'CHI')).toBe('喬安妮') + expect(resolveHirelingName(11021, 'ENG')).toBe('Jarulf') + expect(resolveHirelingName(0x0421, 'ENG')).toBe('Jarulf') + + // Act 5 + expect(resolveHirelingName(10835, 'ENG')).toBe('Varaya') + expect(resolveHirelingName(10835, 'CHI')).toBe('伐德哈卡') + expect(resolveHirelingName(10901, 'ENG')).toBe('Theodoric') + expect(resolveHirelingName(10901, 'CHI')).toBe('狄奧多理') + }) + + it('resolves ability descriptions matching D2Common_10091 (0x6fd7c9c0) in ENG and CHI', () => { + expect(resolveHirelingDesc(1, 'ENG')).toBe('Fire Arrow') + expect(resolveHirelingDesc(1, 'CHI')).toBe('火箭') + + expect(resolveHirelingDesc(2, 'ENG')).toBe('Cold Arrow') + expect(resolveHirelingDesc(2, 'CHI')).toBe('冰箭') + + expect(resolveHirelingDesc(3, 'ENG')).toBe('Combat') + expect(resolveHirelingDesc(3, 'CHI')).toBe('戰鬥') + + expect(resolveHirelingDesc(4, 'ENG')).toBe('Defensive') + expect(resolveHirelingDesc(4, 'CHI')).toBe('防禦') + + expect(resolveHirelingDesc(5, 'ENG')).toBe('Offensive') + expect(resolveHirelingDesc(5, 'CHI')).toBe('進攻') + + expect(resolveHirelingDesc(6, 'ENG')).toBe('Fire') + expect(resolveHirelingDesc(6, 'CHI')).toBe('火') + + expect(resolveHirelingDesc(7, 'ENG')).toBe('Cold') + expect(resolveHirelingDesc(7, 'CHI')).toBe('冷') + + expect(resolveHirelingDesc(8, 'ENG')).toBe('Lightning') + expect(resolveHirelingDesc(8, 'CHI')).toBe('閃電') + + expect(resolveHirelingDesc(0, 'ENG')).toBe('') + expect(resolveHirelingDesc(0, 'CHI')).toBe('') + }) + + it('formats candidate lines per D2Client.dll 0x6faf9440', () => { + const eng = formatHirelingCandidateLines('Aliza', 8, 90, 55, 175, 'Fire Arrow', 'ENG') + expect(eng.line1).toBe('Aliza - Lvl: 8 Life: 90 Def: 55 Cost: 175') + expect(eng.line2).toBe(' Fire Arrow') + + const chi = formatHirelingCandidateLines('阿里撒', 8, 90, 55, 175, '火箭', 'CHI') + expect(chi.line1).toBe('阿里撒 - 等級: 8 生命: 90 防禦: 55 花費: 175') + expect(chi.line2).toBe(' 火箭') + + const blankSpecial = formatHirelingCandidateLines('Varaya', 30, 320, 200, 9200, '', 'ENG') + expect(blankSpecial.line2).toBe(' ') + }) + + it('computes exact 64-bit LCG stats matching D2Common_10458 across difficulties', () => { + // Act 1 Rogue (Normal, seed 0x1234, playerLevel 10) + const c1 = resolveHirelingCandidate({ nameId: 3411, seed: 0x1234 }, 10, 0, 0, true, 'ENG') + expect(c1.name).toBe('Aliza') + expect(c1.actIndex).toBe(0) + expect(c1.classId).toBe(271) + expect(c1.level).toBe(8) + expect(c1.hp).toBe(90) + expect(c1.defense).toBe(55) + expect(c1.strength).toBe(41) + expect(c1.dexterity).toBe(55) + expect(c1.cost).toBe(175) + expect(c1.hireDesc).toBe('Fire Arrow') + + // Act 2 Desert Mercenary (Nightmare, seed 0x5678, playerLevel 50) + const c2 = resolveHirelingCandidate({ nameId: 1019, seed: 0x5678 }, 50, 1, 1, true, 'ENG') + expect(c2.name).toBe('Hazade') + expect(c2.actIndex).toBe(1) + expect(c2.classId).toBe(338) + expect(c2.level).toBeGreaterThanOrEqual(45) + expect(c2.cost).toBeGreaterThan(5000) + expect(['Combat', 'Defensive', 'Offensive']).toContain(c2.hireDesc) + + // Act 5 Barbarian (Normal, seed 0x9abc, playerLevel 35) + const c5 = resolveHirelingCandidate({ nameId: 10835, seed: 0x9abc }, 35, 0, 4, true, 'ENG') + expect(c5.name).toBe('Varaya') + expect(c5.actIndex).toBe(4) + expect(c5.classId).toBe(561) + expect(c5.level).toBeGreaterThanOrEqual(30) + expect(c5.cost).toBeGreaterThanOrEqual(9000) + expect(c5.hireDesc).toBe('') + }) + + it('resolveHirelingCandidates maps candidate list cleanly', () => { + const raw = [ + { nameId: 3411, seed: 0x1111 }, + { nameId: 3412, seed: 0x2222 }, + { nameId: 3413, seed: 0x3333 }, + ] + const res = resolveHirelingCandidates(raw, 12, 0, 0, true, 'CHI') + expect(res).toHaveLength(3) + expect(res[0]!.nameId).toBe(3411) + expect(res[1]!.nameId).toBe(3412) + expect(res[2]!.nameId).toBe(3413) + expect(res[0]!.line1).toContain('等級:') + }) +}) From be82459dfe6a465b4fabe1e24bf598edf894ac99 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 10:42:13 +0000 Subject: [PATCH 08/15] fix(ui): top-left HUD portraits for party members, mercenaries, and summoned minions (Fixes #556) --- public/ui/manifest.json | 225 +++++++++- public/ui/portraits-atlas.png | Bin 0 -> 26175 bytes src/baker/packers/pack-ui.ts | 69 ++++ src/client/ui-model/hud-model.ts | 136 ++++-- src/client/ui/hud-manager.ts | 130 ++++-- src/client/world/client-world.ts | 153 +++++-- src/common/items/baked-ui-meta.ts | 224 +++++++++- src/common/units/pet-type-table.ts | 389 ++++++++++++++++++ src/netproto/d2gs/s2c/merc.ts | 7 +- src/netproto/d2gs/s2c/party.ts | 1 + src/netproto/domain/server-event.ts | 5 + tests/client/hud-portrait-roster-pets.test.ts | 321 +++++++++++++++ 12 files changed, 1564 insertions(+), 96 deletions(-) create mode 100644 public/ui/portraits-atlas.png create mode 100644 src/common/units/pet-type-table.ts create mode 100644 tests/client/hud-portrait-roster-pets.test.ts diff --git a/public/ui/manifest.json b/public/ui/manifest.json index 2d61ab0..6c48ad5 100644 --- a/public/ui/manifest.json +++ b/public/ui/manifest.json @@ -1,11 +1,13 @@ { "version": "1.13c", - "decodedDc6Count": 695, + "decodedDc6Count": 728, "dc6DecodeFailures": 0, "atlasWidth": 2048, "atlasHeight": 8102, "questsAtlasWidth": 432, "questsAtlasHeight": 774, + "portraitsAtlasWidth": 288, + "portraitsAtlasHeight": 210, "codeToInvFile": { "hax": "invhax", "axe": "invaxe", @@ -268543,6 +268545,224 @@ "h": 86 } }, + "portraitRects": { + "AmazonIcon": { + "x": 0, + "y": 0, + "w": 46, + "h": 41 + }, + "amazonicon": { + "x": 0, + "y": 0, + "w": 46, + "h": 41 + }, + "SorceressIcon": { + "x": 48, + "y": 0, + "w": 46, + "h": 41 + }, + "sorceressicon": { + "x": 48, + "y": 0, + "w": 46, + "h": 41 + }, + "NecromancerIcon": { + "x": 96, + "y": 0, + "w": 46, + "h": 41 + }, + "necromancericon": { + "x": 96, + "y": 0, + "w": 46, + "h": 41 + }, + "PaladinIcon": { + "x": 144, + "y": 0, + "w": 46, + "h": 41 + }, + "paladinicon": { + "x": 144, + "y": 0, + "w": 46, + "h": 41 + }, + "BarbarianIcon": { + "x": 192, + "y": 0, + "w": 46, + "h": 41 + }, + "barbarianicon": { + "x": 192, + "y": 0, + "w": 46, + "h": 41 + }, + "DruidIcon": { + "x": 240, + "y": 0, + "w": 46, + "h": 41 + }, + "druidicon": { + "x": 240, + "y": 0, + "w": 46, + "h": 41 + }, + "AssassinIcon": { + "x": 0, + "y": 42, + "w": 46, + "h": 41 + }, + "assassinicon": { + "x": 0, + "y": 42, + "w": 46, + "h": 41 + }, + "rogueicon": { + "x": 48, + "y": 42, + "w": 46, + "h": 41 + }, + "act2hireableicon": { + "x": 96, + "y": 42, + "w": 46, + "h": 41 + }, + "act3hireableicon": { + "x": 144, + "y": 42, + "w": 46, + "h": 41 + }, + "barbhirable_icon": { + "x": 192, + "y": 42, + "w": 47, + "h": 41 + }, + "valkarieicon": { + "x": 240, + "y": 42, + "w": 46, + "h": 41 + }, + "earthgolumicon": { + "x": 0, + "y": 84, + "w": 46, + "h": 41 + }, + "bloodgolumicon": { + "x": 48, + "y": 84, + "w": 46, + "h": 41 + }, + "metalgolumicon": { + "x": 96, + "y": 84, + "w": 46, + "h": 41 + }, + "firegolumicon": { + "x": 144, + "y": 84, + "w": 46, + "h": 41 + }, + "skeletonicon": { + "x": 192, + "y": 84, + "w": 46, + "h": 41 + }, + "skeletonmageicon": { + "x": 240, + "y": 84, + "w": 46, + "h": 41 + }, + "revivedicon": { + "x": 0, + "y": 126, + "w": 46, + "h": 41 + }, + "raven": { + "x": 48, + "y": 126, + "w": 46, + "h": 41 + }, + "wolf": { + "x": 96, + "y": 126, + "w": 46, + "h": 41 + }, + "oaksage": { + "x": 144, + "y": 126, + "w": 47, + "h": 41 + }, + "heartofwolverine": { + "x": 192, + "y": 126, + "w": 46, + "h": 41 + }, + "spiritofbarbs": { + "x": 240, + "y": 126, + "w": 47, + "h": 41 + }, + "plaguepoppy": { + "x": 0, + "y": 168, + "w": 46, + "h": 41 + }, + "cycleoflife": { + "x": 48, + "y": 168, + "w": 46, + "h": 41 + }, + "vines": { + "x": 96, + "y": 168, + "w": 46, + "h": 41 + }, + "bear": { + "x": 144, + "y": 168, + "w": 46, + "h": 41 + }, + "shadowassassin": { + "x": 192, + "y": 168, + "w": 47, + "h": 41 + } + }, "images": { "ctrlPnl": "/ui/ctrlpnl-800.png", "globeLife": "/ui/globe-life.png", @@ -268591,7 +268811,8 @@ "automapAct4": "/ui/automap-act4.png", "automapAct4S": "/ui/automap-act4s.png", "automapExTn": "/ui/automap-extn.png", - "automapExTnS": "/ui/automap-extns.png" + "automapExTnS": "/ui/automap-extns.png", + "portraitsAtlas": "/ui/portraits-atlas.png" }, "automapAtlases": { "MaxiMap": { diff --git a/public/ui/portraits-atlas.png b/public/ui/portraits-atlas.png new file mode 100644 index 0000000000000000000000000000000000000000..55ce6ac44401d1704dd7e4bd77837470de265342 GIT binary patch literal 26175 zcmV(`K-0g8P)wkN^OTND#aL0MLXW^u%n8d{C^MWDE=l6c7*`92_Vl z94ss>JUB2oI5&fI6kH?-R5%z^I3#Qs5NIqATtp;rBp7HsBz!CkWK1k@NCL^QNaAeeAi zpm;#MU>ux;NW@fB$ZR02d|0rENXUFt;BX+goK*ZQ0KklJ%$QK*gfQT6aL|}|_=r60 zpg7pLWZiroE&h3I7py0SddIayeNd2NMN{382kWq++2*%e4zY@3=Ak37)SsBSU40! z5F}_QJY*;+cuWw0I81~vFqBAOj5u7NP*AK)Y^+pxyd+@QWO&eIkl=uz+@v7kc4!sh;WdQkerl+fRwnPprEj*v!oQuJ+W%7ejNbny;^mkmxo*8*7mt1rRRT$d<9U9Hv_RSAOL)HlMX7&8 zx%StV_qS$kpYZ$fFnu~3hQzq|cj6H{)0Z+kcP?FQCVn5@yLiP@zAbicNjV|Qc_31T z&L7mpS8EgrmV|@_nT!HhKl!BMVSfI@l7!!TA9Fle z5ET_wP`r~d{~kOsBdBeswr`XF9!Uqb*t*>MHOC#xb#RVy2V7cR2Z0iy-EV|@u?rFt zOaND$19lxfb}VNO+d!hVKE=IA-pl_*l0d)2@_X=l`BxI%w7a>z41)gO)!o@pDxh;`uQEG#?vPfw|*E~=wgR3oSjE44MZ1&Evk*iO-y}5 zK_(QwZ=edH%SV1;`vwpG%SK1;XWnigdc2Qa^vEyrF|+}e1o8OMJQ)3quCeqdd}wl% zNtEvIS|AK0$r^kCUe)>4Mtd|dc+#dR1p)90*ld%&nU3<_f9BB!%u%Jp(qWSoVD&8U zz-pRY7}O{ll{GY>GYrI<7ty|1q!xFP$9#A194 zsn?9n!-n_;1`i|2V|mE~-Hx-0ro5>^Z(0;?((>#sKu#yWSedXo^pAlL`>{VA-Y zjJH{>uTPqeetY#$V_CQ8MH=`la7q+))wg$-QNR70vJ$Qlnk`eJH2wSbg^xB%jx$3v z|K2agZhw^jaOgRZT z(7Mithb#iYr!D6)gf=sgty}X2<+SA0D%J?Wu3!2J^rHGw6!m9gg4E9OR^u1oYe1$F zRx%0k>L-R$yu*?yS$RBr42=F#$`OW&&%e0d&ZF`sAM*~km?Ye~aDJs1ZO0slrb=M~ zYXPeWLKS18U^R$q*RK+mPZ>WaW~P6zAAd_+({R@B2+&b48Faou9a3yCBS8zYK<)X-q>aC7Oc83sDn@7eE%I81Ep0K-yrckYG*ntJMrD2_l&IID}jZ<<9Z%&YK??ILY;dvz7A~8HTKt zsHcP{`{+TBjanJ_c3GOlfrj<$bL*>iR_rpe|7hQ1o*-NV@iG zlkC7!9`x7uqeApr4Xhm4=GG2F3O8m|!m=|g*%%&6M%+?>m0x@=NLpk9^h~T*yaij? zCLr(KiwZdw;85wj_nnSXQJfdx70)7eB#B3M=pv&tQd0TjTMuqsy<2GGdGPbZSLSaw z`o9!&S_skPq9xOJ?V>4?&Znn$Gc;_3k^u_JT#)@Ut(RCQwBM9ulUc96UzHR~mKEW zJ~rC1dnwU&(*AuNlD(%ZAaUA|f7nZ4C~2{k+nB#We)zcj-t|Yd`T;jV%$j(>I!AG- z@w>EvrhU;yo&VSA#L}QGmc~jf+V2#&{;V@9Wlw+?nzG4u5F0ZpO3}lJ@wXNxrcl~t zmv4N#8paqRgBzm)NymGWc$&uzBJt2|~?P){UYyv0q)`*Z0JT1B+P*! zv}w}%Am1jds8G#%-V>-2M55ZEKv)ezDz!%Snf7tQ+!=2aSvQ~EQQk=Ioy`c)QXvM} zr+W~(<42(%>cbl=tvr8c_P)(N)7D?UN}ldIyFe}ZJ!#2uL2zB2U+A=R ze1JC)p;(g5kI`R^STczmhYF)8q=6z8VsT5asUm^|jh|MZEUFWXsHw3=7TEO?pPW*k zpw$`|ezz1J0L>)PNTCEynD&}Ax?AUq=Nj-kRH3-`?Lnj|P1_*V$@=S>Ry5bx=%H|x zF@>`9^~v3Tu-IG>3sa};9o?uEGMhmPTn!Pjq-`x1=l~S}yxl+n%MNTU*Xbthg)im( zu6ADu;z=|fPJYE}x-z0@qi<|kiblYE@OC#VaW3!;s?}Mj1Z}KvAv5#z^$ zCaej$D=d^GHW(Y5G|=lc;No1^BSY;TnaZSC>DLCVqPrYx)`6s<`of(FKP~?>Tmfb=Hb@X3@P2~ox~u)H1hUp%Z&K&sSKMVRAi2#Sr@lAN%5{yIFHguclcuhrb# zSP{Tn9XX9|WVnZg+hcPa=3Fj82IN#r(a?T;Bj~hu-YuECqU(n9kdc-?d1D6+IeFr| zM$^BESp97^a$)b?&3c#sax!Q-)~kXPuvqXYR*@Yev@n@}O~Gs#bfd%98K3c<5g8cC z!uC6wB13%q;>B@JtVz6U*}Ek?>gLT8-f!<$O%NHEK*c-P%*Ep(F;>IvgB%IJzw{G& zN#0Q}@tP`kh#);DX@+pzN$k=~?+ujU*RFNFX-^edJHK?^wA<_VJ=bgp^jy(gE2r<@ z&~W15D_@L8hGD?vOB+&~?)k}k z{!#BGESsy_v@vO|gp^-ijN6#~uO;R4KsLvRKc~}bYrxX#8+@X)4!@Oukt2lBs4+fs zP7-oXCntAt)w@BBaFWQj57%<=4gQ2OwGNavlD?7ghMf2mBIzfW=ga{#=0Srj1vy>~ z!>!kK!O77|W@aX$YQ;JY6hhdwr211HNT(DIzu6X<{du-L5&P%bo?Y6NmXFQxp7I(O zkVPZrIO8-lvMz#Zws|JA)u$U_Ubr6(T9>PaAjOdia^=Wmn{BTVrO7M3yZ4y>#^3Vp zHo^}il>xu!=0gF73>gB7L?yK$@SXIH*l8hjUWv z9TJ130Xz~eA!2)05M^KS5gMe#t|R3yt42%Jcvf$G>n)>xCJiy~8+({zAPJ<%2R91} z?m|~EK92|@X^mcAH+_pG)&kr_h#=+cBoTxS(`1lg_oOO6P!+*mR!}C$ z3WtWRS}mpGy{NZ5d<;^def%gE_?~K##71@6#>;f4)nWAGok&4B=iGRvxV;#V43a2Q zhFI@-B*_WcOXuFOBz~&7u+ZjDDG}_^N`mH-e=0JtsNl!KY2$7<>pbXIT_mJ3sVu z5*0v^z-T8-Q2CKj7g%Bkjm-`MO=-~!qvE}_|1SR(W;K)O;Og z13R}h0#{OG(c5gg=1Acb3AI=KlGuhU(d3m9WZu2{=UK0tm!@cAwhlp^P;Jqi){tX9 z&&nR{mrHN)A&Uqgl4B@}WM(F=x&`!p_RYhi9V2UOwHXhnEm|6>9&KttXm-mJo&SPw z2J>$VXO(Gjh=0;Qbp0~VTOjpeFPdmbScNPhUPVCku^L32ACz+OJhgXiX23h>^Msb=4OybtzIHv$+e)mZCD_yz=9k1OYE4O$zM(s}Q* z$^@MqINOJH$*2aIuf86y(;Yb;QH|HInwCryWVfbC5JQ`A2ML$iYh+2_DT}xv)#@P`!6}fY7NOyKPm&~sgw-?TKs)?Pj)+i*H>KUYOOE_Ne>(4VQ!c9y z@cpJ5uh1kL+$?)3x`dQnp($hb>$UVw_&GCkCzX_Dq^%Z9vZu9P?u+_%gG*e4JZBuq zfWAst2~MOlhz>@}^D^@9B-(?ZWjf@PSWps(AwZ7XK8>TKROAMie2Q8`NPe&hbUw}7 zdn;=a5cHVgK+_s^hS%+(C`v=>L*6#5^||!{x!B)-n*n75mM%Xk%2Uhml-<|WD2f<7 zh#*LkWT?CVLw|tGNF~c|7bQ7!jh-D$Fbq9p@SuU!_!j84ExImY*RN0dK!iRm509e_ zQBf#`D4)3n1rNRgw2I)JGUADmY`9f9P>+L#m*_=PLy(2h zg>6`NpH56oD+B_i#G$e}x6@gZla@~KzaN0u<2cUy3XP=+joDz4a&y>M#46-ws6L^Z z_D#AiXQjlLY}|oX&USB_TCtM>ExE3&21w+dA*g`^2lgL0aL7vED2PHSqP!ZKzNNz} zb^ZI-PPbK4hA#|#9zlfoCe8j{e(SJE96N~0{H693y6~rh@butX^K5PdED35oCXtc@ zakY{HTN!Fc4kraPY7kUq6T=fm)++>U4C{O|8au7cI1JPhI*!M2hzPOSH?m0^+`c znC;+urbl!&Uio$)kcfob+`L@4458_YGHseZCMe*v?_ltzd%jz=KP~rJ>@--~nlZCP7}#mDx~^ZI5l-Q%=vK zZkVbxqF94^RlJM}$lZ832rR(_$Zrd%jI`tREtZ=g*VWBCsq9 zXzp(U*Nel)G;Pu4mut;JLj5%AfJ0?Ssh%h4g*syNXGVt$uFA=}WVZFH0R+7LG-1Uh zd9*;xhY8?XSB)RgLlG}1c{nW+USdk#{_60T1gSA`I&7tDcYxYZ+q4g<7j$F0;KF6HvN(8ZpA4a9e%_(irYQH}h zw#-LwX9MtB%Yz5#mvO_-p?;m!63K3czyogy-HZMjzis=8`+M^ghIO5Ro=(Xiw=LZE zE`{*_m{Wt_F=&s~lH)k>58P6}71*<(O`4sUyq*~{%m?`gGUTCup6UPd!Grr&hy3f+ zZ_oeuXdMZ?p>OgjRHr|~G7!(9sv=0z6OYtgppdOekVdOtZTxs8pz-huN+Svt3MJB- zN?!&2C)|py!>U)+Or755#j$Ei_Wkfj!&egY`-J1FZt%y!6VcuVlnq^Q{t5ii@I-X^ zMH#wXX$;rxN;mf#5w-n48veiXZ-n^27TNy4i-(N=M%p)u7kB+P(!NnV_uokS#_`<# z|Id;39%6CBiUR7aKUGJne_XY!px~f^D#uxY+N%x9L9dbR%e81&^swS39QsgH>kifF zd#*-j#E|5kN&AI~#1}9=Q&K$qn9=`Pq&;;2L|YEVSC}d;^nr*zr#Llqlg$E1{bqJ= z6XXosI!nJNpwIceZ_$JnSNf z>1L2U*Qp7wk?rv`sx!9YGc9w4HU!m*diI9Tj3KE*x|~V+FR7_h51MpjM&o$ASWTG0 zVm8-bS*gZz3@d%5An&cDv0M2{1jY57?4_jhH#ok$)N1!Kt4=wAYTbuM|rh z11vAG>NHYahU&J*RI!gj?8ep zPSVkJ8LAbZLl7T+NGQRuAsb@6DADt7JkI27!#B>na~F`=7I;Z=+MHYA?DoFm#**b#1bIfPhXKV(yo^T71QZr3iZ_NT@r(E2DL8v+kxeujP(ljJG7OuVsMMv% zGkvQfb-XHpY?3d;CBzQ_Fx0<+2R?(0BtJ;n_|fyUB4vtgRxRC;^=4)xc(5$oAukLL zD0EM@SnH+zgNjckX3LfG6?rMpnZ}fNeH?3DHJbN}f*K~5Mc8@y?wXUSpX&*{bWLcI10t&rVLZw76wKL!nM2V(2${Jf_l84tVPzyVooeejWIoHdh z-C3x0J+4lcl;j}$?nC=_YzYd02A2$j1=Z(Ly;9CmSXOEm9f%&_ghIizra>J-+7F`i zJ$e3uAjt_Jd5`_A5+d^Rs%)u(w+v)8JYj+82>6B4PriKr%I;}J!68V^y>>HHk6E*f z-(0Lgh01lkS_E0t{E|UO7kvES#@$E7f73L` zj~Zw!3r?f~xNRZx12!9u#sv*>Fdi0yh9+>YFQ=YiKioh-RNX~eRers)1+|e*=uIb|hn@E9$RL$&_UoyL z3$kB7lAJYr6p=nKx7Na8W%gA+eEa7^Eo!a_gRS8RPF$Ii6Wc0^r8F7_>yk#Vw>yd* z1h4ZR(SmCjT^8Fs|2Xs>0BYHgnoww1#Mcz{|BtGO||^@8m>032vl$hoe;1P%N~qgI6VvUA!{28O5->KlluZ z{}N+nXvpQj2VT;53lX^k5^T$**JkBeMA0(VAu3rBncniF3gW_Av)l@|j-&>pcp6cJ zcSlp1-kOCC*?!E4}UGf;=PbDfJ$=H^ZIc z1kVcaw5M_C4ji9M$`UrtcDqzzEq91^8^3#@78zAsL-iu(3nlW?Fl~!{pT2*fBlLXh zGF%yCyW&Jf+e`YYL}f~m3GYWN*)c!Wws^5EU0N6-XL-*L7Hdjnd2r0yrAy#D6Ji29 zns7BfnScu@i`}n5`~r(*-qYe|Wcgsn9@t3tnTJ#8Gm6+YD#bI>zDh_kjZMxyt?CV4 zvS^6PQkmCmIlR$2{CV9I3Cxt*O>)ENr}DW zogaCxP*oHKsp8I+@`p4eED)&Z#6*ZB8#K}Z3lUfmd6+ND&Q{r7R=-pVUVE7yumu(T zPt2b{x?R5d-o3YqyL7x$;J`XY{&U8x^ATIeeAca7DAXq?nU^-hGhF$c18Z^ax)-<_ z=;ov2BuW6)OS*=Yg%*Jl98c|Q4s^<}fXjBr+eK;X3$-^KuM|5zJ9zD7V}Yr>Mfkk~ zD~#(KF8TFX#IU&?G+X;?@p} z$PIf|#*5_#x02aRmIE~DoAsos6p`Ee^_4p>95%q4qevQHxhTl=(Z|cXDP2~Mm$VIe zG-?N*865>uNA8m?guv;cqEr_eJAFopOUhr_fFVr_NRv#6){3VGeWobZ4I8r25U7#j z>IpD3-@Iu@|CC@@rR(|P#urvLz9TJU^Q~XT&nX+n2Ys=7->Rj%)7=fBjvVg;>yY+K zcoT?kXp0Z;WL&#(BNES3BL}=6%$Rw;kIi=Wf)|2XE=C&pr5n%3q3KxUgsi84=a6r)Xt} zSO^O>Zf&zUarNa(DD`x!66FDKuV%2HZCTt+S%dfV#wYaRpY5?yR1{HNM7V4tpA^&;UMoB|t^p0ozzzEme)k0UaDZdSKLI9!kfhSh(=miFfU0gc>&}exjlR4EOPBc zP0UL_rXPt5?vU1i6wAdelLSGNFi`Gx7F#7!QIvlD`t~J$5UL5PAt|*Jvw@Suq$E_x zA3zeN!``$(#UmCnqcByj%0M4p?D00hN_$DYqcp10U%V?K#*7%6EINhQ;c;}*6J%MK z`a_4B{3!RdfF166u}g0$*{jz=*3^vpIr7*vswh3?E{ZN$QTEiis9YK+!|rr1`9E?5 zz5YqO9ZyM8JglOaDgM8b(7TU1*MOV+tnO`_J$_mzaaHbYcq+>B-z7=t(z%P=V@RnN zepJDIvpbA6>FE?4A49X)pCLylE^Fu#*c<_2*d!CcPoBM_oLsxB0FSO3*)JBIVaJ1- z#iFQs$V3#mQ_5&;b)A4|%YTiWc?S}fp`z~nYcS)bg~7lrTh7BjZL*U>3e}%huIMU z4sPmM+)xXfuFK9Wz^AJykzBgSO`EqI*%&078@Y#^fIRiZlO{zTdil^a0e04&^{yvU zB*;?=eF|2~%i^h3lCv=JZEud;7C8F6zI9hn?Q%Sj3;(4{M0$E2=Zz${Z|~P{>(*@_ zxR(OzjVc9@b1&f7;VYLE7+uTDtre-h8mi-W&UnS(05>fir=!E1S zr=%-ldM$kAqWkPCEr4>i16Z;aWD$IywU+u-_ATm!-r2MAtXZACM%r5hdG-9>a@Vx& z+gk`A)vB*rE0VFu^0<{R*2iX zhS7M%yCsT}mZuXNRzye6NQjne+yDCbG-cSR6^=PabKl=;E z9e7gkWHm*}2||UF+LCsC_)zFaQN-C zD>II-&UC-e;DKdi7v67GGh{l?@J|CA3uY}0;yae83`?eVL*R9o!8#r}(0Igg8V z@cLWZdcOA=o(p?r+(;R_Lj4vmJELOj5|k`nT5gvwg|`hzew0&!=feqlhL_o$jATem zRMb#JH5acdGMA8SD|xitJ3))gYxEkVViKz5%*@h)*DAiBOl4&PEm3iX#(YJY(JKi{ zcI}If*`Z`b0-4ze7MGcXzZRAJ6*ZK&h{!azg|4^%?roAPK>l!g(rMHT=9b!QT#rsq z!f=N{ki#@@&61HkUwLLjy|Twrmn9B{jT(3Vw97`(I=?0%2WDx>`twdG5r|v4_Jb{3 z5-QNJe}W*C#JFf6qGMxY38=HSu`Hx5;fMarfgeA6N98z*Odw2DXP%JOm^s11&=WS@`&!*C)T; zKjfcZ$KdhV%;P2n4P|GaAJE%7K}kR-&FP3Ei0wr)rq6C34CBT_U{hCUt$L16`Q($7 z)t^2HZ_zR+$hUk*sddL2w)kn0(xMdZ`RAYCGiB3aPm1Eg2-@k;fYwMOlOU&|+fLjE z?;#8G27IHHjGwgk8pn?$n@j+c0>mMb*(b?aky%Qk6N(G`=eopf2`u^ZmaJR6?bD@R z8M-PJsw2P%H-z>f)|Tr)5zK9w{CH;-U&iqU-{VHyiKQee~H<< z=eRU-{LxdVG-LOUGx~cJIQU1SF{Gt&IC}4Lkn*59SER2()AiJ?% zej%QKwvUYEi_H=7(zNLv4TkKcR?1pW`>9{?7}ByZGhXI1Gh@(Y4{a8c9ln|8QrCg< zhLoP|w>8;2K?wvJ5kl?p28M|^BxPO!b61DD~ zo}wsH6USA<{Wa?9p5g+~1ydjs}^7R`nzEGcuXoa>AN5rd7_e zQDrh@5vYZcJ2Er8Yf*~11gtS>Yeko3O2IW{empjWxc=ugm-tp*?RKW z(JTqWwHl3v()+3~6<^t5Vv;z{NraiAA1420`YiiQ3lo(2HVwMXDpkFm5P0T{o1gq zd-MXqCHL;#7hu4E-XaKkYSYVy{9pDrlwD=un9GH?`baKU_1=qKIBp5KtuC!-Ewe#T z=TFyBi-MDOC1f+phKvrNkP>pNLe@m`tFM{F=OoC)WTWo=#f}6L>n|F}zo`)wvzeam zxvr$*iPR5Q#$=@sK|yjHov;!YY;kVI2bsjd#}riDlRV-`dR=2ruHmx*tj@qJRI?t^bd1-_6*gHH>&=ugx`#$%R2M zOex>Kr8-hwNp`qIk$}k{#aCx?RpT=dJ3Gz0qDazd-wWd=8Iz}Gn=}L&rI`j5 zB1P`qM;+Kr?5If}>5pHE1i`Cf&}ghk5OJ%Q-n$iuSome?dj(Ws{OuaWN9CnMDk$}E zPa;Oi&H{56<#Jc@Y`1Rx{E-5|E7-T`KjIG=XME0&WR7`_Yp9LytbV)z1Jv+DgLJSjoEuXF=Z34pVMwp6mBg-zi^CHG9F=pG}RQ+V)vP_s>3_I?3z#;&o)viglaweS@_W#Rf$@v$272 zx|22YJU2KSK^5b8g&lB)A^6V_N=5LeP)r#+=Ae1C|hh zKxw_-ikRehqyHNKuk=sNikb8~O0fL1hfRHSx_w!rcPL))bt3J-{CqnYSsIyCvIXO9gQ4r~eq0y0J0FkEAXcCBt^mjnjAqhzF`<)(R47?#)EoDat;(7 zRNteEvjDGA6I9OdV|_tA2ZE`MR}-2P3}xrxu$v-+AkvoCU$?DQ-f$8w;+dbbc)|oy z(+ZZ?C&)wMUhwUu2{C}ii095O#O9b@Xx+N4qwV|Vs3@<~MS%TK8D_uaskeMriKRl2JISUYBeS#5p8ZC_$^TYI zhiVg~zgC$SS{7{OC_lfQ^$jxi(zkuNMJKj?BuuQvPa_$BF|qol0k(%=0>wsnI~g=a zd-7~}i?8(Zgu?rGwi9x?QxN45$EnlQX*v{uHTuKUci#1Iar@rwJ36a03!*#5P+2e< zD^D~{veD5>R^J?QOkW%B&gwH13+-jO;{|yrKvfJ*a;mW&tVZs4hHj{{IC&%;;t2+KcAOpNAiu0b7E^Be)ow@;H=1VT3Y|mRYEk1;2Pff`7c70GO)Gp z+RXWrkC%LW;Sx#K5 z_QosV1k$5unwXAz6pD~~e>WQ79MYjPUhZ@6`gMTolJ(-@kH36UQkdvz^Yf+2d=U*j}3;s*6-q)S|`VBS(&OkuLsgCAM(4-~Gq6-{^0@&A(bN zti?jnRtX#r3d-geYl4~uf&S8vG2X%*WQRk5!3=w9CTot_$;=4M@zIf@%gPl?!%QK% zNLqBdXpa-3#sungq+is9NCJ6rbaae*^oAO!LtWsDAcrJ}!*ESKirl8MbEhuFL-8DD zjGLirb*+lW1+TKyjZT8lrFl`=c~jmHZh!X3{k<9_$h=&0wS&^d>Fm}+fh$zM-uMPm zz>-!Eu9uojGTo(a`h zN9E@R1JaShM|AzRtLwgIbA$VFz{*neJZAXtpPf>kwL^#V9J<{|sBLX)2(q|Jiw4|A zM)zj^qo9#}jMr#lwWuUnn-plmE}M$F^}AVXh1jxayopu6;lxtm>5Z#5Zrm>7AK$mu z7`bY^ZVI>#NYqJW)BVboZUX8Q~gNO@bF6t>Hw>wO6x}2OD$UDDV?s#Qb}>~k@M$^D}I?88x$25*vHgAOxOO* zwQ(Mi?Xd}3-GpwTT9gQvl$7|`__5zd!JCVpB+6V(HmdP@YSILU3y0s6dJ>dmP-ICK zEAHGXxNfD|DX%g*&7^4~hg9QfRTofaLKWE%|LJs^4x>c!AHNHwm=Hk3=*uI_DjR2wJ<-NLlPSeGU)qCXXMB1mwH*Oeo z3Pl=PAP3Fpt6P+w35995G=^T#zisncN6ap10{jzuF&RrfY{Fv#E%b#LFL<>!viO*{u^xRGCv#;Q?Tx&-3(y=iGLb3omKy-M2p zFN)+Ywa0KZ1Wjn4hU6FjUjn3nz<~8@f~FIYm`V5oWk3VQz<$=6$=pSLjs(JJb3V(< zsItdTPALA3ViRu_%_i0)B-%8guvkBF#qyf%sHa(a|AhUn!Gm&-GeM;(B=&hqhA5CW z$e(asa?t{#W5Bn@!zD=)heqJMDH1^-mhc8YUP9kVsgHZh5JMxM+WR(%E7%n2uHj*! zq~v~SCnh)=3?4yL$SNHI+|=Aszmn?CvQ`mfHdFVO$C=F}P`VPj*t&euLmLr=&xzkJ zQP?TdbUIvpQK+db_Abr4D%KNz&0||5JFPHRQSHsSbsb37Zag$_&tRZhSL0=7oF<3l z4xbuC!I+~czcXfQIXls`#VIC)2?)ij&*P8ykFCMWHCf9NMOi)bOKoU)o%;eAsfZaF zUc!OfbVm0Fg}8Es!j|wsP|1ORaBpqhRUe5@ItRR%sqVUgP4HJ3shTQ^tkV}&613de ztX)6rLwm?`-QX9v`-0BeUy;Y|Sfs?TyHBV&lRVahbyiagWISD~qX|bqyWntf6)&_- zKy9{HXJ&n{Zz)&e6gYd$Vfg(A5?Y;BOAb6u4Tp(-gp?{rn~Bzw<-ssjag=c*$_Jk^ z4kl}7$~{>_L{<_(;K!zrBoz}p9qCUNavtl4$0GWE?jjTZL8S6@>ZJEF#*Az|o9=gZ zzBhJe;D|(-BqvXdi(5&`TvU+bIWP zC8a~Kep@*v_8X5KvR1LDUr%rr5lzAA_~vq$#wPMq^KjtB;9$QBvFVe1v3V!Mf@=Ks zUCM}�VNG{mJ9tXC{gCZQJ(yo`5sV=@tSRkvepTb;zOPwQ{9Y8Xn|Dt1()YeLen5 zl0ivN#8_rxhL~cDTDRAd~>NJ8tD zZ54Z4F~4ogmJghGBcO5vxS`4h-}4uqe5V|(zQ$yCmI_HR;+;?huIl})PO;GT_?OP& zgU2Oz3xz=4&k)d5|F}AYm^4T6Wg{i~AccToaO{|tT5z_`TtN06--iKQiB^it%`-;}V*)8DGA5ezIJsTjppE}4YS=QOFS^H{TB>_PBBRWW zoHL9Mk$3K`_{nw>Z?Iu$*;t3x7wL5pt^|Ltg8(GSiN~$kHUA#j2s9~Yd9`?o3 z`~ph1S=+t|@WAlYg}c(XgX2k#%64L1jj2ED8^`vNRxWj)E@%e5j*~;<|@CtdHUGGY63l zf$;LnzvlhgbOo$W!Bt8Aqtl~8 zNs*K;QgvjzXb;gya)of|DuH82D7d{SYRTZ~liuMIV;Ly#-o9;UdA?W^37_h8$h#O_ zOg1-Sc*S1+$SxS{RHW>z0jDM+BVf;r$%;0b*yL(F4#Pz+O4VtYyI|K?i1G77-7?Fv zc)Jqr;z;k2`RK=P=i!43LDHuC+y3VAI6}7?kLkht^wQJK=Yd=<@$V{^+d1svOzoXq z9+CDx5CPCe5b}At+daMhmefXE?VCls38W_I6@Y;7@bJQbD{_r$OAdq4X?KQ)6W|m` z(5+ZCd9r=d%JZXB{Z^j715BbO=7a-|#88hS zm1)3Eev_%jr>Bcp@NuhP z!iKBy?#3)hct0X1-tCduYaVf3p~lcdYdx8$Ceq$kaOXi`;`)NHd$(@(FDMvr=kEFi zAyEXx5jVQ!brT^mu@J27YiuFmkjm9(B@z_sabo;JTV)QSs1aSled$8iL;RF7=T*Vv zF*zRGv0amWAiB0XwhO6Frz}q)gI&gB#UrzeT!nlZ96dGp;>A`q_(s*@7rxs;MJU)|I#$(wAB)Ro<(eYM7AHUp=O$`n1=K}o1gqSp-Ti!lhLm)t^#Vu}|9u54MkwArz&pSWr}C?bKx{wLWkGJVkb{oA4xgVN69USH)}97dh`c z48`~X7nB5q4nZqxdz;87O>Jt3xT`}gL~o7?U^KN1$E zmEJ2XD;l+(dru~mS>H~1jp#q9#w~~hO5EiH!I{BHB8(poP$tVmM;{!W+4XNPXJG|G z-M?IK0Y9~Lt8smZzZWPa|g=E&s;#<{pP*zj_XxZVE^Y^y4VWVi1(SJySSi?pKGym z99ilpS`;SU_?6nR=ZEGVdo9O2c<7RVIfG=>-&?fOBrlLem(8B)lv6Uuuz#rGp~?V{ zY)PO#A!xjSA4!4TqVD3Ovfc%~O+z%Y`wO|=6Ma1-`Bw0SF6Ymij9fALG!d_Cth`q< zeNP%#TykWE7})NlDSj@YIzzcD>L;ZV7ZL;N+Gn$bFJ5cXB_PSgyBQNu;NFqP{%IG$MBY)CWTCjaQrp;fDK zSs8dir_sJ_xwObb0@@(T?1+qi%Bnb8At zzsmnIPxiFA6pivjR~s>OXG2JkpGh5_qF21cMm4ZqdQQw7Gqen9dBNrgx*dJw3-r}q z2R?MgW2jfvJJB3=3oFnYNx9zTC9a}YN>ghHvijm>rdQI8#aXCP6(Xam9{)nxo-t^L zX*g&Kj}IMj)NIe>*`^(f%R)rn&lv2Zvy|KKI?BVPr|KqqyKgIdYu`3)T54<7hSSE| z9gJ=ioCZbuGx{eBZxS;7`fIYmaK!P&?w9UME>r=-Jab3Wm|wRNY4DSAkVO)mPHea# z((P$`^iSqF(H%QR&zUsc{N9JDuNx-aH~olGvk~Oa>ROe}#razidC;e}Dv$oG9%c52Fi;o;=NdlOgSo^D}Dj;66?OKLC}f-fyQKr-T2i znk83mmce+7)3N>f^|X(&w%e~w7fcDj(eq=(*H0mj&N%<5e@ix2bIm|Vo|$C$n-#A(497SgZqH( zOc&Y#6DI807>KHXJJ(jy{|U-**ewn%LXHks?!&T%r;oo6C)^JZAk;ZPR$mr^+Ok6u zTwSa1Q?St>-b`0ep2$cNwVgfuxc$KKW<|`5B99p8Ve%IfV0aZ?wFT;fVrlFwqB3!m zyS?p0wp-^mZW7q5@NW;L{mK4*{Xv>ZfGfyG+x?43E%L6iA+^;j8ih)=K=M>&Y8AdC zXw>#uj&f|^MB0guU%*o;8yCKam#x0jFg&l{Fb?C@NPCWhix-89a8X9X|3Z=>o9cT| z(C+Y|Q!Qf zP0>1?+um@hgH*MCMiJF`>y^(xhEdtmp_>Wx0b>+f*TSFxJ(0arub1wafx(eKmd~42 z<242vyW{vdBL>=FF!+!H2QnqvXG#+H|2@P!c{nO7$em_y1@3jQ=?O z|8tS{b7cu1c?ur0N#HTHTC+IEFgi*l=s&?{{Kx65k@oN7!v`s>Uc4mI6rR(RM3P}a zA!R{5kXD@tK3^57{weY%vIrs)vg?hgr3lDUl*mlH9P0f{dnCcyVeYVYy46$4M;K8# zjLdNpWGk+>sB8`oF#2G3&Us+0(`I*pDDh8Vyjz33;A8ax-;pp0@K}DR6+hJxu7<-v zw+7jY`_;v7Izv+Zt3I6Re;UW<3`Ij9l)gLCo~WEV5G&(O5I+hDqNoUhdL|Q#ht6_M zq&nf|XLtP>2WoeABtc|FVSl}$$aa`uIOmQCoyGGkjH^ne?%C27i+KVbQd{D?w`|(f zh=;GbDF;}ML5rUliacR$U3~0#9C~9JA9U-7DU&7vqG%GF_|AkzywJ5iJ}+<6h!Obj zW{Me9%?}V~1`Q%e>MJ+CTEk^zrbIk1FS~69`U0;kNTLWTKZ?hkJrRZZ$=Q7yK3%@xwn&u$~=V3Za*N%r;v|@E&v+ljaHm+aq8ytGt2(YuP%;wxR zg+fCq1odrSJ32EqA;Hft=!L+fHERqnG$j!`UwmQx0_)K5@9@lv-naInP(K_>H;{h}Cw#CW2*;&TKKy_;$NCRX<~kV3FUy`(vTa1*}hC zXaG9}q`S{Pwc4ec7#Xb&&vjShl?2D7OAbK*lPTcx3B=E?Zr!YEflk18_z%;w$~0qq zUXOlGUO9Cda*4_%%QL4>=jK%?W?CRYuk3rR3(W7&r4NiO@wNGXam$1 zRg3EAQ2n{%DKOD4g|&I-UEr7;hG+lob22xVs-;26$&j{}FZ=u* zRS<-V9`f_IzULX#x`x6~`PhpuI)3$r-?VDgutCb{(@Os{xhEBQ5UIAbygW7XrfQ+R z+6GZofnC@}V>0oyn_RgDjGCIKr6t^TVbFMzQ^k?Nofu$4-GD5B2=Te+q#r4rubvRa z;qFz6n(DQf;}$K7TM=%glKsv#@oX5yqMae^=C^AwQ zYT!T*g4BC?dMEDcm)!b;HzY^%8B@?v|b!ID%RtMPKs0t+u`+X%~U!a%~v@jfb zZZsisu9~%fV@p3%lQ3VSMn?rdaaQoZ+4iO>H1IYX$JuQ5a=hEeRZKS8Zv19(c{AawKd#- z%l#FFqz0WnA=MC6FXjV?>KC;;!~A%5!qxtdO34+sRMDjpnH|~KnWFQ2bQJIddFd-x zIYi{3)J9RmYpU2+qYW?^SXQes>cWsJs_{q4{cs&DplUA!` z7`^_k(xy!ko_)7F9LOHID$pO?%?naZvm8ndCeNJ7%^gJLo}!0I%H00g%$z!<3>Yv# zkxwYZVAOyH53V5yJoL3PG{=3UX(dCl%1=#iq&jAXNGp%{Ytx;(qmOF`C;^9l}37V;?9; zr%s)cWO6W(3#V0}#8pO^Roh2Wm6{K#8Is*GO+<|fc_`DBN;WYccp3%klmXjmAnLo3 zZsvjy=uwpn6b0A8(K-0ptoS4rgU{&7s6l{o9;piOu5y=JGwK0q$;qnCd zl?yA+XhnFeDK7P7bQ~ysM?K*&IY|j`bM-28?W$K7pssrKJIX-RoN)NsDSWIO@!BAy zas5yC7$ymWWRe{0w$I8lZW7$(mDKYUF(H(j7xYTI$x=2vm@O`A4d1{ih>l?L-OP?4TVt_ik9_pD?AKLlFs{Lw%B zMDt2@a))%+DrR|!m3xEZ(x19_VG=JZ;lxDQ;j-I{tREdlyxl&PUIv{g-exobyPtjTp7=nmK~)q=?W`#`Ke)A2wG!OWYK$B*u$)W~$SOz*VQUXB6NLQ0 zZ`_e1COxf40%C_3L@~2IH|CkaZM$v&Num79E57kj$6;7^1%YKfIton>w!Qk)oa8o? z)}U$Gp>qI3w>J*x*QqIs^}%KPQ7ZD7&0)HB1=Z*!BaONIUAwSW0bSdJ?z!=Iu4~mN z>Nqa70j1^!l)7O1Q!?})Mh?y+2M!!WlGJd98a9N^Mds)9=~FyZMPyc*AG#k8M3sWB-$EDv3MS?1)n6YKwP=F8X{63Eblo8arPZDL z_>tkqXC&$}tv`L1MP1k7A*j0t*Qikp@2V*Pw#`&94go9#HlQgra7a^{q+dQc$ge5m zvWcRz!X$ymbD6MhzC&OhK0`GH=Q&^<>!(kyw-pj+h(L+FJOG`fd2R! zfRjVe-~NPhN>QYNLoI(f>Oqi#BgLz3Hq@vt%~6_RLrD*FEJ+^A0SsfnWZrK+1DkqY zY4;oaT1XNvqguSe1_gzhH0-Xr{4ld(q6a+lEIfyv($<`=FcL2Zw1(BRxtCw|I^(hX9OjH0TIIEIR?>?wLzT$ zM1@QOvIxN$5lG0^>Fuslb?@ypH=Ts<@qK*Xyz{Yi(*Lc#_wQ3x=bSoqs@dh@xH9B; z6~z&aLHroexMMRn;pxjW;>?jFE;OJ0^yQwDXrfOVx*gd?MRE|nHnX;l$YE=WiqQ`l zVzpe2AKfcg?fyI~>n}I&s!4~hZ`mDWC@Ovg{);R|&jJ^+z9rzjpMNd|v|a+x%gEt2 zihI=K<%Jy|If0F^oKmu(bfZ<|2!`f3%ytF#Q68ws%>`UOzytKJ{ZOmLYOo@TlH<>? zM&xCBkhzP#p=O7hlU5cfIT(eQSKl4g8xI^fV2m;+6HUp=c5y^c)(<3?BL0YX>w&unwiirZW$F@-ol2=H<1tw0c^8&Sf6h zNmcAb^kn8uVqH7@P+PnOZ%tq-(1 zarj8lUTXDz13fRO3&wnwjjXOrLMAxa5Bu~FM9}c-WZ|Q^gKuSo%}Fnj(nD@?D-RE3 zD{qx=MY`)xCqxd0m9j9X;~)zF+koz_6(vO_E6T*+`u%~7Z^PutlPi%L9#GPW@SyBm zxoXv_mD@wgmlyfJJcG&rL1v<7!gH2b@cgWrS}n9-_fm zQBjd6pgE%~fK(2Gvl!H2RcL=jBcn*^UWJ9>3}LCP%mvk1;XAhL;#Wbaq!XISoKc>x z{sIZ$c!&DFKca{;NobV~P`Od~QS?SVD-)EIw}7bX#wqU;Q76wPTs3h@f|&fb`LcHQ zi4%0?y$4t@c*bwKaK5W+am?bSr+!>6V!h0T@(Qkkh0K6W;m&jCb#|Wb0%J_V zfH+HBl!-O8qQ^ZmiKZErrJ!~0)kB61@y9?hJCbzP1+?v+sQROEqmq+PA$o|WhfgCf zB$)(a4)V=ThpaW*>*VU(YL=$#cCxmHs?M|9mlW9vAZu#s;jqDg25)uawSp#)icpIG z6~d4j#*mfRD;czhtJJST3PTEojYpwjnVL6(-_hp>HJK03yt`POki7v{NJ1kIi(OWh z=b%grn5n7ltzE4iIzAyW0r8WN7#D3CLbeuNH{1lYfjU(fN0^WV@HtZJ$dNoV>guC$ zab#}X(KvH5@>N$2g=Fl_pPC~R$)iS%3Toe8C#KgB1Wjj-8#gYKCUeM|`op<}xi$EK zcW2(BxatA8sw9eo5%Ms~5mO{$2-`&=%Qt=%Qu~x|xJpreC*b$hdOzL7P#J5VH<2xQ zk1a=CD>pZn%ga;z)V$y^+UZ4%*%A{HZ8)R=7OTZ*GCK<4oEwZrWE{0Ni#D4rpb!D=Op+Vn~sc6ki9!3Z$<=vMn)t zlItJh3}ic(VaY3lPERM7R~9tOW9P$SNwCFz{IU8IV#sDlX3xM0FW?mYfpG?`8bJX7 zqp?1btW;lDR+0zmqnPB=O^Qv;FsHFI0>N=pf~d1+2KGLrkHhKh4;Dd%2(`6THGw7# z!~o|b2wBXWq^@8niJb(dM(t}fL*k53#nulzrHaE0;c>_(;O7S`knhxuP?0)4oA@mz zYQsYl)=Y3tnl!0=;uhfCU1cByoK;RKphSI)>n3zIx3)T+$aowO9e?Y!fE_9$LsLd3 z$Sli2X7mB1Ie4{MlG4)fvmA&rk4iQpywQlLA{dMa5|Y%$5_?^39h$=kip(NVoA|jh z$=wq&NzFP?HOT8lwlvQx<|0W@6s;}RlgJfiC3VOSxXvY(m*wr^1(uOGiB??={3$#b&^RVO3S07;e{5^T zhDCLuIoBE$V=-WZm{;)?zSg(qq+Rd*qICpb&A>l54oh+&Ld~OWnhmMq?E$x}mUnvu zL9CUL)s05w@j^9_wOMRkozjPREz=&)<-D>m4-`W)G>!u!h6yF8h?I$;Q7_Y01u%ZP z>|@A_S>ZHxI0)uvV;~bgNQ$`i6Q6EzY4^9*HK`09$HUtca#A3*z=GWY3$?|4_lc_0 z=frwP^ZAPxJG;;=oQijIh#}hm1L}j|yF&Sv(d@i6?aUXhq+yMRkcHrIV2}P3(TGMT zUJpe z`(g}*le?*pA3A%{n>>7Y%&qZKy~FKAm!6Ai7CU%@)f$KM7kq1h40`xnJOHIR_+)dm zZPaaC`hyD~mwZMh=+;a4$eLa_<%P zv<%lPF4O)wS>k*aPGHm{!{N$X?zYaV{^9%IzV?pWt^A{k|3$E1RA3&ve0bSIvYNjv z%A$zhQp@x5IQqgX|Dyjz{Jw~5z4Qy;|MsuAO#5ehhy*AS=G{4YcUAxJ<^68|ip#Wr zw#C&~N+mQ7+xv&_fBSvJ{`<<6muY`l@sW-})|V?T)Bf_}1-)`z5#`^krhPwV3{9@M zO#91=_wImx%KuGG`^$-6dDFwljrn6Thv#Ptur$z~4vP_aT5J#P&!;YIVR&h(7*^FD zwcn**TQGuQaipU=i_NGdlQir+5Z8CH(G`*L_6q??T$EJxmZ;ZNM5zBe{})C(`Z_*~!dTi3;` zK~3*-mkDW$yp4?n0sy%;_2GqJ{SbC3AKNdT|C zwqmVT&W(RW*i{ZkM-=viS485S9WIx%v&#iIuG!h#=@PnJZRcEic+*kjs9?Zf#@qWIO<2ax{ojo)1CxcI~`!iN3;-oO|^5C|GhVvZb( zo7ddqx?=}Z&=Wp7^);xkmk~>tqivs-uAbIdlww-i6CO*6*J_D218=jM!PU{$(ezUr zGH`hzHg-&Rd?C`RrHef~ln@tUh_{64&?RY*UvO>8eW2uYi?Dwm=X&Sv(W6Iq)-M>T ze=QzxjObo)y*&8g=^F;0^Q>LF_Mhj8TlDb@D4>?3|J$I!4Qto?7N0gW(|I?-{eZ2Bjk3b%wdz=Y)wxiv%q6lF5 zxl*Es<2V$_M+C70ufiK;SStz9114ij$OO2o49)$*?|lbA%7l zmtcbORg+XCql`u~;z!mN8kz?^t$QsoeVPp46Vt!X$S{~;c!KZv3Yl-X#dZD#P$Ea19`^Q6%p&+2a2wk}xw z61CL!Ml5}Qj%n`?GcILb50j8-URespg`FkA>y?eF9q8Fqq zaf&;LFQz`x`$t5UV1i%EhK`cM`r?jPF=EVeGE(c)@yk_FxHB{*|%nK{G% zi-T3cRkslSu+B$BmLP%-D=@9oz_m-)r9XQSETbagvAlBwC3zmB4bx#_7fYCjS}aaJK8`lWUlr?ksXKla zNaRr;KO|8*|GuC~b9(LfE&+nR72QBx_3{5~AiV-Be0S5^Z*%n55w08r?e`cRv!8o~ zNP*+ByYZjrTS4#C5ud!vk2se4th3_~BlFR&_wWDy1f311 zesRk3h?n8jR{*(@iEAai!&8ia=RJ@#uN?An=uVI4-rooy^Ryb_B~Y8llTex7HOyB7|i^- z)4>C`3SeXY3$Q))`M4e~90XHXvJNWZ1|{afVALOh$(M)qjDPC@-#~o`WA`*Pv_cCm zZ)h@$rKz?7J>ao4w|PekodQs!A1jSx@P@;S6*2MAmKg9}_(zA%u||Y}r1&)0O?8%X zz+ib{oSf9vj%-UR;Vl>|zWziH?PDqNwC`wkxt;PX zf4!Int7`WQHFIb%VOer`F}T;8j7jdx2;&Sl%lcb%_jiG>ut1G(VyEipAB>%39H0)SkQbNSs_RDRbJi)(z zq_MM#ytUi0KI(9sLGW>nAW0>nVNdeIT%L^Z4V_ivb?~crG4%k?oXVU3D8Q+LN+#gpP zThF8u-&M1}nHe#h(--kKKo>|rI$-oKZxc(_um8k#{D|XC9I6pd9_G{UP3v|yY(@pJ zBzUb3QIch6*4S-7Z4d+a@P6~%F^db15wPg5^~=R(xwZLB=`jJ<-WA_udH8jj{$M>p zS=L`~h=~Cw2u=sjau>)!mKLN-{7*Uqa2H<>Z`mPx`~vjDd_xRH^t5TM&q8W{CFIs| zQbGP#I8gB9lg;nG@%Ct6&R6jt`LQn~gkHm0Vn1k`@&!p@JOT3Bch0(n0A8FOhQASx zGB>7}=FTlDfg-p_{P_J}7cC3I$2=2?$5OcEYV}1a<+a37wAhKEhRSPYu)EK2+`Vcvc*P4Bd`{)tA=q*<$xA80a}a5V6Do1z9t zVbuTq^^nPlzNZSxNn2bw#{Yg()K%ftoeziN7eEqul(h{URH)%g!rHHYJ$&+e`6hBY zl$K7P4AUo1XJakA=rwaK9)Kk_x7FM1)y--b&to;-kNhxz6={(5YaLAg#HT^Uvz!K_ zFVpw&i8SF;Ov6QQz=@d|K(hpKHj$eWVn;7h6DVvl+cz^Sf|o727#d ze??V|Anq4#nm_;MEXWSg(@Qd|eHz44z{x(tbNY_Rs~u=QW%iDnf)rU@hnp_J1HF7s z1Is!EBkOO!H#`&xTmNgyaVNp?+~jsADF|M!tqX4|Fo~@TJ+!v>rkhO9J%}Uo-d8|$ zeYV@92kD=L5uqBMWh7KZs@|f=6CVLVs^xsPeF9aHqNa+_MuybB#BCG%fS>(T*|r-P zN8yEY?Is3nZjMDhA18s^!#ia{1X4EOP48ZtR-Dd%hZQ-)@2#w=8XvyBO$NYh@tnc{p-HoKTtAh}<)(^oecv*>G-WkX}KJSK@2zd4IO8PE8{$BTiNbPH~c*U0)FT+d6^~$fV zShOevpFfi^#XXHQ2v8rR(6>>xYoc`z&5)TjKvLDacN4Wbt@JMbOnWach|uJFfOUU9 z*8E6M!-Rt8fZI3DU$jW<6`zJIJ5ieEZ$G*hJeIQ;I&0^)omkMzGax*9{lTbbhD5W8 zGpF71EBKZ^$aNX+Co(1EbX23sHiu%^D!!Ly^hL?E2OI{ZKse5=^S|N#XR#P=4EZzX zWu>bmOPyjL@pw@T93yx1v3*Tmoq6D!Glv%Sc=J9y!_0b+f=>x%7h&GrdQU;hnCr*? zCsPTZbI2^n!uKjmob%!(S&CO&rv2r`cU0@V6;b}pYT924L04F&{pH1{TmnH?Sf>5u i#s97UX|De-k>y_>*CBDj0Y = {} + const portraitPlacements: BlitPlacement[] = [] + + for (let i = 0; i < canonicalPortraits.length; i++) { + const pName = canonicalPortraits[i]! + const pDc6 = await decodeUiDc6(`data/global/ui/Hireables/${pName}.dc6`) + const frame = pDc6.groups[0]!.frames[0]! + const col = i % PORTRAIT_COLS + const row = Math.floor(i / PORTRAIT_COLS) + const px = col * PORTRAIT_CELL_W + const py = row * PORTRAIT_CELL_H + const rect: SpriteRect = { x: px, y: py, w: frame.width, h: frame.height } + portraitRects[pName] = rect + portraitRects[pName.toLowerCase()] = rect + portraitPlacements.push({ frame, x: px, y: py }) + } + + savePng( + 'portraits-atlas.png', + PORTRAIT_ATLAS_W, + PORTRAIT_ATLAS_H, + stitchFrames(PORTRAIT_ATLAS_W, PORTRAIT_ATLAS_H, portraitPlacements), + ) + // --- STAGE 3.5: Automap DC6 Sprite Sheets (8 sheets: MaxiMap(S), Act2Map(S), Act4Map(S), ExTnMap(S)) --- console.log('Packing 8 Automap DC6 sprite sheets (MaxiMap, Act2Map, Act4Map, ExTnMap + S variants)...') const automapOutDir = join(process.cwd(), 'public', 'assets', 'ui', 'automap') @@ -1501,6 +1564,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom automapAct4S: '/ui/automap-act4s.png', automapExTn: '/ui/automap-extn.png', automapExTnS: '/ui/automap-extns.png', + portraitsAtlas: '/ui/portraits-atlas.png', } const manifest = { @@ -1511,6 +1575,8 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom atlasHeight: atlasH, questsAtlasWidth: QUEST_ATLAS_W, questsAtlasHeight: QUEST_ATLAS_H, + portraitsAtlasWidth: PORTRAIT_ATLAS_W, + portraitsAtlasHeight: PORTRAIT_ATLAS_H, codeToInvFile, codeToFlippyFile, itemRects, @@ -1518,6 +1584,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom flippyFrames, itemClassIdToCode, questRects, + portraitRects, images: imagesMap, automapAtlases, } @@ -1598,6 +1665,7 @@ export interface BakedUiManifest { readonly flippyFrames: Record readonly itemClassIdToCode: Record readonly questRects: Record + readonly portraitRects?: Record readonly images: Record readonly automapAtlases?: Readonly> } @@ -1667,6 +1735,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = { flippyFrames: ${JSON.stringify(flippyFrames, null, 2)}, itemClassIdToCode: ${JSON.stringify(itemClassIdToCode, null, 2)}, questRects: ${JSON.stringify(questRects, null, 2)}, + portraitRects: ${JSON.stringify(portraitRects, null, 2)}, images: ${JSON.stringify(imagesMap, null, 2)}, automapAtlases: AUTOMAP_UI_MANIFEST.atlases, } diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 11d0123..f93a512 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -41,6 +41,11 @@ import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts' import { D2StatId } from '../world/stat-list.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { computeCharacterSheetDerived } from './derived-stats.ts' +import { + resolveMercPortraitIcon, + resolvePlayerClassPortraitIcon, + resolvePetPortraitInfo, +} from '../../common/units/pet-type-table.ts' export const CLASS_ID_TO_CODE: Readonly> = { 0: 'ama', @@ -819,10 +824,12 @@ export class HudModel implements HudCommandSink { equipment: mercEquipment, equipped: mercEquipped, } + const mercIconKey = resolveMercPortraitIcon(this.world.merc?.classId ?? mercRaw?.classId) const syncedMerc = this.world.merc ? { ...this.world.merc, unitId: mercRaw.unitId ?? this.world.merc.mercId, + iconKey: mercIconKey, ...mercCommonFields, } : this.world.containers.merc.size > 0 @@ -832,11 +839,54 @@ export class HudModel implements HudCommandSink { nameId: 0, isDead: false, reviveCost: 0, + iconKey: mercIconKey, ...mercCommonFields, } : null ;(hud as any).merc = syncedMerc ;(hud.worldPanels as any).merc = syncedMerc + + // 5c2. Summoned Minions (PetType.txt & Hireables.cpp) + const summonPortraits: Array<{ + readonly petType: number + readonly iconKey: string + readonly label: string + readonly count: number + readonly lifePct: number + readonly drawHp: boolean + readonly iconType: number + }> = [] + + if (this.world.pets && this.world.pets.size > 0) { + const activePets = [...this.world.pets.values()].filter( + p => !p.isDead && (p.ownerId === this.world.selfId || this.world.selfId === 0), + ) + const byType = new Map() + for (const p of activePets) { + if (p.petType === 7) continue // Mercenary handled separately + const arr = byType.get(p.petType) ?? [] + arr.push(p) + byType.set(p.petType, arr) + } + for (const [petTypeIdx, pets] of byType.entries()) { + const first = pets[0]! + const portInfo = resolvePetPortraitInfo(petTypeIdx, first.classId) + if (!portInfo) continue + const count = pets.length + const avgLifePct = Math.round(pets.reduce((sum, p) => sum + (p.lifePct ?? 100), 0) / count) + summonPortraits.push({ + petType: petTypeIdx, + iconKey: portInfo.iconKey, + label: ((hud as any).lang === 'CHI' || (hud as any)._lang === 'CHI') ? portInfo.nameZh : portInfo.nameEn, + count, + lifePct: avgLifePct, + drawHp: portInfo.meta.drawHp, + iconType: portInfo.meta.iconType, + }) + } + } + ;(hud as any).summonPortraits = summonPortraits + const rawHireList = (this.world.mercHireList && this.world.mercHireList.length > 0 ? this.world.mercHireList @@ -876,45 +926,53 @@ export class HudModel implements HudCommandSink { ;(hud.worldPanels as any).hasUnidentifiedItems = this.hasUnidentifiedItems() // 5d. Party state - const partyList = [...this.world.party.values()].map((m: any) => { - const pid = m.unitId ?? m.playerId ?? 0 - const resolvedName = m.name || m.charName || `Player #${pid}` - const resolvedLevel = m.level ?? m.charLevel ?? 1 - const resolvedPartyState = - m.partyState ?? - (typeof m.relationship === 'string' && m.relationship.length > 0 - ? m.relationship - : m.hostile - ? 'hostile' - : m.partyId !== undefined && m.partyId !== 0xffff - ? 'in_party' - : 'none') - const resolvedRelationship = - typeof m.relationship === 'string' && m.relationship.length > 0 - ? m.relationship - : resolvedPartyState - const lootFlag = Boolean(m.lootPermit ?? m.lootAllowed) - return { - unitId: pid, - playerId: pid, - name: resolvedName, - charName: resolvedName, - charClass: m.charClass ?? 0, - level: resolvedLevel, - charLevel: resolvedLevel, - partyId: m.partyId ?? 0xffff, - relationship: resolvedRelationship, - partyState: resolvedPartyState, - hostile: Boolean(m.hostile), - lootPermit: lootFlag, - lootAllowed: lootFlag, - lifePct: m.lifePct ?? 100, - areaId: m.levelId ?? m.areaId ?? 0, - levelId: m.levelId ?? m.areaId ?? 0, - x: m.x ?? 0, - y: m.y ?? 0, - } - }) + const partyList = [...this.world.party.values()] + .filter((m: any) => { + const pid = m.unitId ?? m.playerId ?? 0 + if (pid === 0) return false + if (this.world.selfId !== 0 && pid === this.world.selfId) return false + return true + }) + .map((m: any) => { + const pid = m.unitId ?? m.playerId ?? 0 + const resolvedName = m.name || m.charName || `Player #${pid}` + const resolvedLevel = m.level ?? m.charLevel ?? 1 + const resolvedPartyState = + m.partyState ?? + (typeof m.relationship === 'string' && m.relationship.length > 0 + ? m.relationship + : m.hostile + ? 'hostile' + : m.partyId !== undefined && m.partyId !== 0xffff + ? 'in_party' + : 'none') + const resolvedRelationship = + typeof m.relationship === 'string' && m.relationship.length > 0 + ? m.relationship + : resolvedPartyState + const lootFlag = Boolean(m.lootPermit ?? m.lootAllowed) + return { + unitId: pid, + playerId: pid, + name: resolvedName, + charName: resolvedName, + charClass: m.charClass ?? 0, + portraitIcon: resolvePlayerClassPortraitIcon(m.charClass), + level: resolvedLevel, + charLevel: resolvedLevel, + partyId: m.partyId ?? 0xffff, + relationship: resolvedRelationship, + partyState: resolvedPartyState, + hostile: Boolean(m.hostile), + lootPermit: lootFlag, + lootAllowed: lootFlag, + lifePct: m.lifePct ?? 100, + areaId: m.levelId ?? m.areaId ?? 0, + levelId: m.levelId ?? m.areaId ?? 0, + x: m.x ?? 0, + y: m.y ?? 0, + } + }) ;(hud as any).partyMembers = partyList ;(hud.worldPanels as any).partyMembers = partyList diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index f1b3271..8f805d5 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -20,6 +20,11 @@ import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from import { ControlBarHud, type MiniPanelAction } from './control-bar.ts' import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts' import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts' +import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' +import { + resolveMercPortraitIcon, + resolvePlayerClassPortraitIcon, +} from '../../common/units/pet-type-table.ts' import { InventoryPanel, type EquipSlotId, @@ -436,6 +441,7 @@ export class HudManager { hireList: any[] = [] mercHireList: any[] = [] partyMembers: any[] = [] + summonPortraits: any[] = [] trade: any = null pendingIdentifyScrollId: number | null = null @@ -458,6 +464,8 @@ export class HudManager { private images = new Map() private itemRects: Record = {} + private portraitRects: Record = + (BAKED_UI_MANIFEST as any).portraitRects ?? {} private decodedDc6Count = 415 private dc6DecodeFailures = 0 private eventsBound = false @@ -755,8 +763,11 @@ export class HudManager { try { const mRes = await fetch(`${baseUrl}/manifest.json`) if (mRes.ok) { - const manifest = (await mRes.json()) as UiManifest + const manifest = (await mRes.json()) as any this.itemRects = manifest.itemRects + if (manifest.portraitRects) { + this.portraitRects = manifest.portraitRects + } this.decodedDc6Count = manifest.decodedDc6Count this.dc6DecodeFailures = manifest.dc6DecodeFailures } @@ -809,6 +820,7 @@ export class HudManager { pentSpin: `${baseUrl}/pentspin.png`, itemsAtlas: `${baseUrl}/items-atlas.png`, questsAtlas: `${baseUrl}/quests-atlas.png`, + portraitsAtlas: `${baseUrl}/portraits-atlas.png`, } await Promise.all( @@ -2308,49 +2320,113 @@ export class HudManager { ctx.translate(Math.round(offsetX * dpr), Math.round(offsetY * dpr)) ctx.scale(uiScale * dpr, uiScale * dpr) - // 0b. Top-Left Mercenary Portrait & Life Bar (R3.3) + Party Mini-Portraits (R5.1) + // 0b. Top-Left Mercenary Portrait & Life Bar (R3.3) + Summoned Minions + Party Mini-Portraits (R5.1) if (this.leftPanel === 'none') { + const atlasImg = this.images.get('portraitsAtlas') + let slotX = 8 + + // 1. Mercenary (slot 0) if (this.merc && !this.merc.isDead) { const hpRatio = Math.max(0, Math.min(1, Number(this.merc.hp ?? 0) / Math.max(1, Number(this.merc.maxHp ?? 1)))) ctx.fillStyle = 'rgba(16, 16, 20, 0.85)' - ctx.fillRect(8, 8, 48, 48) + ctx.fillRect(slotX, 8, 48, 48) ctx.strokeStyle = '#8b7344' - ctx.strokeRect(8.5, 8.5, 47, 47) + ctx.strokeRect(slotX + 0.5, 8.5, 47, 47) + + const iconKey = String(this.merc.iconKey || resolveMercPortraitIcon(this.merc.classId)) + const pRect = this.portraitRects[iconKey] || this.portraitRects[iconKey.toLowerCase()] + if (pRect && atlasImg) { + ctx.drawImage(atlasImg, pRect.x, pRect.y, pRect.w, pRect.h, slotX + 1, 9, 46, 41) + } + ctx.fillStyle = '#181818' - ctx.fillRect(8, 58, 48, 6) + ctx.fillRect(slotX, 58, 48, 6) ctx.fillStyle = hpRatio > 0.5 ? '#20a020' : hpRatio > 0.25 ? '#d0a020' : '#c02020' - ctx.fillRect(8, 58, Math.round(48 * hpRatio), 6) + ctx.fillRect(slotX, 58, Math.round(48 * hpRatio), 6) + const mercLabel = String(this.merc.name || 'Merc') - ctx.fillStyle = '#d4b468' - ctx.fillText(mercLabel, 32, 22) - this.font.drawText(ctx, mercLabel, 32, 22, { + this.font.drawText(ctx, mercLabel, slotX + 24, 22, { font: 'font6', color: 'gold', align: 'center', }) + slotX += 56 } - if (this.partyMembers.length > 0) { - for (let idx = 0; idx < this.partyMembers.length; idx++) { - const member = this.partyMembers[idx] - if (!member) continue - const py = 72 + idx * 52 - const lifeRatio = Math.max(0, Math.min(1, Number(member.lifePct ?? 100) / 100)) - ctx.fillStyle = 'rgba(16, 16, 20, 0.82)' - ctx.fillRect(8, py, 44, 44) - ctx.strokeStyle = member.hostile ? '#c02020' : '#7a6840' - ctx.strokeRect(8.5, py + 0.5, 43, 43) - ctx.fillStyle = '#181818' - ctx.fillRect(8, py + 45, 44, 5) - ctx.fillStyle = '#20a020' - ctx.fillRect(8, py + 45, Math.round(44 * lifeRatio), 5) - const memberName = String(member.name || `P${member.unitId ?? idx + 1}`) - ctx.fillStyle = '#ffffff' - ctx.fillText(memberName, 30, py + 18) - this.font.drawText(ctx, memberName, 30, py + 18, { + + // 2. Summoned Minions (PetType.txt & Hireables.cpp) + if (this.summonPortraits && this.summonPortraits.length > 0) { + for (let sIdx = 0; sIdx < this.summonPortraits.length; sIdx++) { + const summon = this.summonPortraits[sIdx] + if (!summon) continue + ctx.fillStyle = 'rgba(16, 16, 20, 0.85)' + ctx.fillRect(slotX, 8, 48, 48) + ctx.strokeStyle = '#6c5838' + ctx.strokeRect(slotX + 0.5, 8.5, 47, 47) + + const sRect = this.portraitRects[summon.iconKey] || this.portraitRects[String(summon.iconKey).toLowerCase()] + if (sRect && atlasImg) { + ctx.drawImage(atlasImg, sRect.x, sRect.y, sRect.w, sRect.h, slotX + 1, 9, 46, 41) + } + + if (summon.drawHp) { + const sLifeRatio = Math.max(0, Math.min(1, Number(summon.lifePct ?? 100) / 100)) + ctx.fillStyle = '#181818' + ctx.fillRect(slotX, 58, 48, 6) + ctx.fillStyle = sLifeRatio > 0.5 ? '#20a020' : sLifeRatio > 0.25 ? '#d0a020' : '#c02020' + ctx.fillRect(slotX, 58, Math.round(48 * sLifeRatio), 6) + } + + if (summon.iconType === 2 || summon.count > 1) { + this.font.drawText(ctx, String(summon.count), slotX + 4, 20, { + font: 'font6', + color: 'white', + align: 'left', + }) + } + + const sLabel = String(summon.label || '') + this.font.drawText(ctx, sLabel, slotX + 24, 22, { font: 'font6', color: 'white', align: 'center', }) + slotX += 56 + } + } + + // 3. Allied Party Members (Only in same party, excluding self and unpartied players) + if (this.partyMembers.length > 0) { + for (let idx = 0; idx < this.partyMembers.length; idx++) { + const member = this.partyMembers[idx] + if (!member) continue + if (member.isSelf === true) continue + const isExplicitUnpartied = member.partyId === 0xffff && (member.partyState === 'none' || member.partyState === 0) + if (isExplicitUnpartied) continue + + const lifeRatio = Math.max(0, Math.min(1, Number(member.lifePct ?? 100) / 100)) + ctx.fillStyle = 'rgba(16, 16, 20, 0.82)' + ctx.fillRect(slotX, 8, 48, 48) + ctx.strokeStyle = member.hostile ? '#c02020' : '#7a6840' + ctx.strokeRect(slotX + 0.5, 8.5, 47, 47) + + const classIcon = member.portraitIcon || resolvePlayerClassPortraitIcon(member.charClass) + const mRect = this.portraitRects[classIcon] || this.portraitRects[String(classIcon).toLowerCase()] + if (mRect && atlasImg) { + ctx.drawImage(atlasImg, mRect.x, mRect.y, mRect.w, mRect.h, slotX + 1, 9, 46, 41) + } + + ctx.fillStyle = '#181818' + ctx.fillRect(slotX, 58, 48, 6) + ctx.fillStyle = member.hostile ? '#c02020' : '#20a020' + ctx.fillRect(slotX, 58, Math.round(48 * lifeRatio), 6) + + const memberName = String(member.name || `P${member.unitId ?? idx + 1}`) + this.font.drawText(ctx, memberName, slotX + 24, 22, { + font: 'font6', + color: 'white', + align: 'center', + }) + slotX += 56 } } } diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 534ca6d..bb63392 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -75,6 +75,15 @@ export interface MercenaryState { readonly hireList: readonly MercHireEntry[] } +export interface RosterPetState { + readonly unitId: number + readonly ownerId: number + readonly petType: number + readonly classId: number + lifePct: number + isDead: boolean +} + export interface ChatLogEntry { readonly kind: 'say' | 'whisper' | 'overhead' | 'system' | 'event' readonly from?: string | undefined @@ -183,6 +192,7 @@ export class ClientWorld { } private _merc: MercenaryState | null = null private readonly _mercHireList: MercHireEntry[] = [] + private readonly _pets = new Map() private readonly _chatLog: ChatLogEntry[] = [] private readonly _soundQueue: SoundQueueEntry[] = [] private readonly _portals = new Map() @@ -369,6 +379,10 @@ export class ClientWorld { return this._merc } + get pets(): ReadonlyMap { + return this._pets + } + get units(): ReadonlyMap { return this._units } @@ -683,6 +697,13 @@ export class ClientWorld { this._merc.isDead = true } } + const pet = this._pets.get(unit.id) + if (pet) { + pet.lifePct = unit.lifePct + if (unit.lifePct <= 0 || unit.mode === 12) { + pet.isDead = true + } + } } const isSelfPlayer = @@ -718,6 +739,7 @@ export class ClientWorld { } if (typeName === 'monster') { this._monsters.delete(uId) + this._pets.delete(uId) } const key = makeUnitKey(ev.unitType, uId) this._units.delete(key) @@ -857,13 +879,25 @@ export class ClientWorld { this._self.isDead = true } } - if (unit.unitType === 'monster' && this._merc && unit.id === this._merc.mercId) { - if (ev.lifePct !== undefined) { - this._merc.lifePct = ev.lifePct + if (unit.unitType === 'monster') { + if (this._merc && unit.id === this._merc.mercId) { + if (ev.lifePct !== undefined) { + this._merc.lifePct = ev.lifePct + } + if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) { + this._merc.isDead = true + this._merc.lifePct = 0 + } } - if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) { - this._merc.isDead = true - this._merc.lifePct = 0 + const pet = this._pets.get(unit.id) + if (pet) { + if (ev.lifePct !== undefined) { + pet.lifePct = ev.lifePct + } + if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) { + pet.isDead = true + pet.lifePct = 0 + } } } break @@ -1068,6 +1102,13 @@ export class ClientWorld { this._merc.isDead = false } } + const pet = this._pets.get(targetId) + if (pet) { + pet.lifePct = healPct + if (healPct > 0) { + pet.isDead = false + } + } } else if (ev.kind === 'info') { this._npcInteraction = { ...this._npcInteraction, @@ -1141,11 +1182,29 @@ export class ClientWorld { } } + // 0x7F PartyMemberState for pets / hirelings (isPlayer === 0) + if (ev.kind === 'memberState' && (ev.isPlayer === false || ev.relation === 0)) { + const targetId = ev.unitId ?? 0 + if (this._merc && this._merc.mercId === targetId) { + this._merc.lifePct = ev.lifePct ?? this._merc.lifePct + if (this._merc.lifePct > 0) this._merc.isDead = false + } + const pet = this._pets.get(targetId) + if (pet) { + pet.lifePct = ev.lifePct ?? pet.lifePct + if (pet.lifePct > 0) pet.isDead = false + } + break + } + const id = this.selfId !== 0 && rawUnitId === this.selfId && rawOtherId !== undefined ? rawOtherId : (rawUnitId ?? rawOtherId ?? 0) if (id !== 0) { + if (this.selfId !== 0 && id === this.selfId && !ev.charName && !this._party.has(id)) { + break + } // Only delete on 0x5C PlayerLeftGame (partyId === 0xffff with no charName or empty charName). // 0x5B PlayerInGame sends partyId === 0xffff when the player is unpartied. if (ev.kind === 'roster' && ev.partyId === 0xffff && !ev.charName) { @@ -1315,28 +1374,73 @@ export class ClientWorld { case 'Merc': { if (ev.kind === 'assign' && ev.mercId !== undefined) { - if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) { - this._self.mercId = ev.mercId === 0 ? null : ev.mercId - const mercUnit = ev.mercId !== 0 ? this.getUnit('monster', ev.mercId) : undefined - this._merc = - ev.mercId === 0 - ? null - : { - mercId: ev.mercId, + const isSummonAction = (ev as any).isSummonAction === true || (ev as any).summonAction !== undefined + const petType = (ev as any).petType ?? ((ev as any).stat !== undefined ? ev.value : undefined) ?? 0 + const isSummonPet = isSummonAction && petType !== 7 + + if (isSummonPet) { + const action = (ev as any).summonAction ?? ev.stat ?? 1 + if (action === 0) { + this._pets.delete(ev.mercId) + } else { + const petUnit = this.getUnit('monster', ev.mercId) + this._pets.set(ev.mercId, { + unitId: ev.mercId, + ownerId: ev.ownerId ?? this.selfId, + petType, + classId: ev.classId ?? 0, + lifePct: petUnit?.lifePct ?? 128, + isDead: false, + }) + } + } else { + if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) { + if (isSummonAction && (ev as any).summonAction === 0) { + if (this._merc && this._merc.mercId === ev.mercId) { + this._merc.isDead = true + this._merc.lifePct = 0 + } + this._pets.delete(ev.mercId) + } else { + this._self.mercId = ev.mercId === 0 ? null : ev.mercId + const mercUnit = ev.mercId !== 0 ? this.getUnit('monster', ev.mercId) : undefined + this._merc = + ev.mercId === 0 + ? null + : { + mercId: ev.mercId, + ownerId: ev.ownerId ?? this.selfId, + classId: ev.classId ?? this._merc?.classId ?? 0, + ...(ev.nameId !== undefined + ? { nameId: ev.nameId } + : this._merc?.nameId !== undefined + ? { nameId: this._merc.nameId } + : {}), + exp: this._merc?.exp, + lifePct: mercUnit?.lifePct ?? 128, + isDead: false, + reviveCost: 0, + stats: this._merc?.stats ?? new Map(), + hireList: this._mercHireList, + } + if (ev.mercId !== 0) { + this._pets.set(ev.mercId, { + unitId: ev.mercId, ownerId: ev.ownerId ?? this.selfId, - classId: ev.classId ?? this._merc?.classId ?? 0, - ...(ev.nameId !== undefined - ? { nameId: ev.nameId } - : this._merc?.nameId !== undefined - ? { nameId: this._merc.nameId } - : {}), - exp: this._merc?.exp, + petType: 7, + classId: ev.classId ?? 0, lifePct: mercUnit?.lifePct ?? 128, isDead: false, - reviveCost: 0, - stats: this._merc?.stats ?? new Map(), - hireList: this._mercHireList, + }) + } else { + for (const [id, p] of this._pets.entries()) { + if (p.petType === 7 && p.ownerId === (ev.ownerId ?? this.selfId)) { + this._pets.delete(id) + } } + } + } + } } } else if (ev.kind === 'exp' && ev.value !== undefined && this._merc) { this._merc.exp = ev.value @@ -1439,6 +1543,7 @@ export class ClientWorld { this._self.reset() this._merc = null this._mercHireList.length = 0 + this._pets.clear() this._units.clear() this._monsters.clear() this._containers.clear() diff --git a/src/common/items/baked-ui-meta.ts b/src/common/items/baked-ui-meta.ts index bfcf300..269e5d8 100644 --- a/src/common/items/baked-ui-meta.ts +++ b/src/common/items/baked-ui-meta.ts @@ -68,6 +68,7 @@ export interface BakedUiManifest { readonly flippyFrames: Record readonly itemClassIdToCode: Record readonly questRects: Record + readonly portraitRects?: Record readonly images: Record readonly automapAtlases?: Readonly> } @@ -25031,7 +25032,7 @@ export const AUTOMAP_UI_MANIFEST: AutomapUiManifest = { export const BAKED_UI_MANIFEST: BakedUiManifest = { version: '1.13c', - decodedDc6Count: 695, + decodedDc6Count: 728, dc6DecodeFailures: 0, atlasWidth: 2048, atlasHeight: 8102, @@ -293573,6 +293574,224 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = { "w": 72, "h": 86 } +}, + portraitRects: { + "AmazonIcon": { + "x": 0, + "y": 0, + "w": 46, + "h": 41 + }, + "amazonicon": { + "x": 0, + "y": 0, + "w": 46, + "h": 41 + }, + "SorceressIcon": { + "x": 48, + "y": 0, + "w": 46, + "h": 41 + }, + "sorceressicon": { + "x": 48, + "y": 0, + "w": 46, + "h": 41 + }, + "NecromancerIcon": { + "x": 96, + "y": 0, + "w": 46, + "h": 41 + }, + "necromancericon": { + "x": 96, + "y": 0, + "w": 46, + "h": 41 + }, + "PaladinIcon": { + "x": 144, + "y": 0, + "w": 46, + "h": 41 + }, + "paladinicon": { + "x": 144, + "y": 0, + "w": 46, + "h": 41 + }, + "BarbarianIcon": { + "x": 192, + "y": 0, + "w": 46, + "h": 41 + }, + "barbarianicon": { + "x": 192, + "y": 0, + "w": 46, + "h": 41 + }, + "DruidIcon": { + "x": 240, + "y": 0, + "w": 46, + "h": 41 + }, + "druidicon": { + "x": 240, + "y": 0, + "w": 46, + "h": 41 + }, + "AssassinIcon": { + "x": 0, + "y": 42, + "w": 46, + "h": 41 + }, + "assassinicon": { + "x": 0, + "y": 42, + "w": 46, + "h": 41 + }, + "rogueicon": { + "x": 48, + "y": 42, + "w": 46, + "h": 41 + }, + "act2hireableicon": { + "x": 96, + "y": 42, + "w": 46, + "h": 41 + }, + "act3hireableicon": { + "x": 144, + "y": 42, + "w": 46, + "h": 41 + }, + "barbhirable_icon": { + "x": 192, + "y": 42, + "w": 47, + "h": 41 + }, + "valkarieicon": { + "x": 240, + "y": 42, + "w": 46, + "h": 41 + }, + "earthgolumicon": { + "x": 0, + "y": 84, + "w": 46, + "h": 41 + }, + "bloodgolumicon": { + "x": 48, + "y": 84, + "w": 46, + "h": 41 + }, + "metalgolumicon": { + "x": 96, + "y": 84, + "w": 46, + "h": 41 + }, + "firegolumicon": { + "x": 144, + "y": 84, + "w": 46, + "h": 41 + }, + "skeletonicon": { + "x": 192, + "y": 84, + "w": 46, + "h": 41 + }, + "skeletonmageicon": { + "x": 240, + "y": 84, + "w": 46, + "h": 41 + }, + "revivedicon": { + "x": 0, + "y": 126, + "w": 46, + "h": 41 + }, + "raven": { + "x": 48, + "y": 126, + "w": 46, + "h": 41 + }, + "wolf": { + "x": 96, + "y": 126, + "w": 46, + "h": 41 + }, + "oaksage": { + "x": 144, + "y": 126, + "w": 47, + "h": 41 + }, + "heartofwolverine": { + "x": 192, + "y": 126, + "w": 46, + "h": 41 + }, + "spiritofbarbs": { + "x": 240, + "y": 126, + "w": 47, + "h": 41 + }, + "plaguepoppy": { + "x": 0, + "y": 168, + "w": 46, + "h": 41 + }, + "cycleoflife": { + "x": 48, + "y": 168, + "w": 46, + "h": 41 + }, + "vines": { + "x": 96, + "y": 168, + "w": 46, + "h": 41 + }, + "bear": { + "x": 144, + "y": 168, + "w": 46, + "h": 41 + }, + "shadowassassin": { + "x": 192, + "y": 168, + "w": 47, + "h": 41 + } }, images: { "ctrlPnl": "/ui/ctrlpnl-800.png", @@ -293622,7 +293841,8 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = { "automapAct4": "/ui/automap-act4.png", "automapAct4S": "/ui/automap-act4s.png", "automapExTn": "/ui/automap-extn.png", - "automapExTnS": "/ui/automap-extns.png" + "automapExTnS": "/ui/automap-extns.png", + "portraitsAtlas": "/ui/portraits-atlas.png" }, automapAtlases: AUTOMAP_UI_MANIFEST.atlases, } diff --git a/src/common/units/pet-type-table.ts b/src/common/units/pet-type-table.ts new file mode 100644 index 0000000..869a139 --- /dev/null +++ b/src/common/units/pet-type-table.ts @@ -0,0 +1,389 @@ +/** + * Diablo II v1.13c Top-Left HUD Portrait & `PetType.txt` Metadata (`Hireables.cpp` & `RosterPets.cpp`). + * + * Ground Truth: + * - `D2Client.dll` `Hireables.cpp` (`0x6fb0b120..0x6fb0c980`): + * - 7 player class icons (`0x6fb0b260`): `AmazonIcon`, `SorceressIcon`, `NecromancerIcon`, + * `PaladinIcon`, `BarbarianIcon`, `DruidIcon`, `AssassinIcon` (`classId 0..6`). + * - 4 hireling icons (`PetType.txt` row `7` `hireable`): `rogueicon` (Act 1 Rogue, `hcIdx 271`), + * `act2hireableicon` (Act 2 Desert Merc, `hcIdx 338`), `act3hireableicon` (Act 3 Iron Wolf, `hcIdx 359`), + * `barbhirable_icon` (Act 5 Barbarian, `hcIdx 560/561`). + * - 18 summoned minion icons (`PetType.txt` rows `2..6, 10..16`, `icontype 1..3`). + */ + +export type PortraitIconType = 0 | 1 | 2 | 3 + +export interface PetSubClassMeta { + readonly classId: number + readonly icon: string + readonly nameEn: string + readonly nameZh: string +} + +export interface PetTypePortraitMeta { + readonly idx: number + readonly petType: string + readonly group: number + readonly drawHp: boolean + readonly iconType: PortraitIconType + readonly baseIcon: string + readonly nameKey: string + readonly nameEn: string + readonly nameZh: string + readonly subClasses: readonly PetSubClassMeta[] +} + +/** + * All 29 portrait DC6 asset stems in `data/global/ui/Hireables/*.dc6`. + */ +export const CANONICAL_PORTRAIT_DC6_NAMES: readonly string[] = [ + // 7 Player Classes (`0x6fb0b260`) + 'AmazonIcon', + 'SorceressIcon', + 'NecromancerIcon', + 'PaladinIcon', + 'BarbarianIcon', + 'DruidIcon', + 'AssassinIcon', + // 4 Mercenary / Hireling Classes (`PetType.txt` idx=7) + 'rogueicon', + 'act2hireableicon', + 'act3hireableicon', + 'barbhirable_icon', + // 18 Summon / Minion Icons (`PetType.txt` idx=2..6, 10..16) + 'valkarieicon', + 'earthgolumicon', + 'bloodgolumicon', + 'metalgolumicon', + 'firegolumicon', + 'skeletonicon', + 'skeletonmageicon', + 'revivedicon', + 'raven', + 'wolf', + 'oaksage', + 'heartofwolverine', + 'spiritofbarbs', + 'plaguepoppy', + 'cycleoflife', + 'vines', + 'bear', + 'shadowassassin', +] as const + +/** + * Player class portrait icon key by `classId` (`0..6`) or class code (`ama..ass`). + */ +export const PLAYER_CLASS_PORTRAIT_BY_ID: Readonly> = { + 0: 'AmazonIcon', + 1: 'SorceressIcon', + 2: 'NecromancerIcon', + 3: 'PaladinIcon', + 4: 'BarbarianIcon', + 5: 'DruidIcon', + 6: 'AssassinIcon', +} + +export const PLAYER_CLASS_PORTRAIT_BY_CODE: Readonly> = { + ama: 'AmazonIcon', + sor: 'SorceressIcon', + nec: 'NecromancerIcon', + pal: 'PaladinIcon', + bar: 'BarbarianIcon', + dru: 'DruidIcon', + ass: 'AssassinIcon', +} + +/** + * Authoritative 1.13c `PetType.txt` table (`idx 1..19`). + */ +export const PET_TYPE_PORTRAIT_TABLE: Readonly> = { + 2: { + idx: 2, + petType: 'valkyrie', + group: 0, + drawHp: true, + iconType: 1, + baseIcon: 'valkarieicon', + nameKey: 'Valkyrie', + nameEn: 'Valkyrie', + nameZh: '女武神', + subClasses: [{ classId: 357, icon: 'valkarieicon', nameEn: 'Valkyrie', nameZh: '女武神' }], + }, + 3: { + idx: 3, + petType: 'golem', + group: 0, + drawHp: true, + iconType: 3, + baseIcon: 'earthgolumicon', + nameKey: 'Golem', + nameEn: 'Clay Golem', + nameZh: '黏土石魔', + subClasses: [ + { classId: 289, icon: 'earthgolumicon', nameEn: 'Clay Golem', nameZh: '黏土石魔' }, + { classId: 290, icon: 'bloodgolumicon', nameEn: 'Blood Golem', nameZh: '鮮血石魔' }, + { classId: 291, icon: 'metalgolumicon', nameEn: 'Iron Golem', nameZh: '鋼鐵石魔' }, + { classId: 292, icon: 'firegolumicon', nameEn: 'Fire Golem', nameZh: '火焰石魔' }, + ], + }, + 4: { + idx: 4, + petType: 'skeleton', + group: 0, + drawHp: true, + iconType: 2, + baseIcon: 'skeletonicon', + nameKey: 'Skeleton', + nameEn: 'Skeleton', + nameZh: '骷髏', + subClasses: [{ classId: 363, icon: 'skeletonicon', nameEn: 'Skeleton', nameZh: '骷髏' }], + }, + 5: { + idx: 5, + petType: 'skeletonmage', + group: 0, + drawHp: true, + iconType: 2, + baseIcon: 'skeletonmageicon', + nameKey: 'SkeletonMage', + nameEn: 'Skeletal Mage', + nameZh: '骷髏法師', + subClasses: [{ classId: 364, icon: 'skeletonmageicon', nameEn: 'Skeletal Mage', nameZh: '骷髏法師' }], + }, + 6: { + idx: 6, + petType: 'revive', + group: 0, + drawHp: true, + iconType: 2, + baseIcon: 'revivedicon', + nameKey: 'Revive', + nameEn: 'Revive', + nameZh: '復生怪物', + subClasses: [], + }, + 7: { + idx: 7, + petType: 'hireable', + group: 0, + drawHp: true, + iconType: 1, + baseIcon: 'rogueicon', + nameKey: 'Merc', + nameEn: 'Mercenary', + nameZh: '傭兵', + subClasses: [ + { classId: 271, icon: 'rogueicon', nameEn: 'Rogue Scout', nameZh: '蘿格斥候' }, + { classId: 338, icon: 'act2hireableicon', nameEn: 'Desert Mercenary', nameZh: '沙漠傭兵' }, + { classId: 359, icon: 'act3hireableicon', nameEn: 'Iron Wolf', nameZh: '鐵狼衛士' }, + { classId: 560, icon: 'barbhirable_icon', nameEn: 'Barbarian', nameZh: '野蠻人傭兵' }, + { classId: 561, icon: 'barbhirable_icon', nameEn: 'Barbarian', nameZh: '野蠻人傭兵' }, + ], + }, + 10: { + idx: 10, + petType: 'raven', + group: 0, + drawHp: false, + iconType: 2, + baseIcon: 'raven', + nameKey: 'DruidHawk', + nameEn: 'Raven', + nameZh: '烏鴉', + subClasses: [{ classId: 419, icon: 'raven', nameEn: 'Raven', nameZh: '烏鴉' }], + }, + 11: { + idx: 11, + petType: 'spiritwolf', + group: 1, + drawHp: true, + iconType: 2, + baseIcon: 'wolf', + nameKey: 'SpiritWolf', + nameEn: 'Spirit Wolf', + nameZh: '鬼狼', + subClasses: [{ classId: 420, icon: 'wolf', nameEn: 'Spirit Wolf', nameZh: '鬼狼' }], + }, + 12: { + idx: 12, + petType: 'fenris', + group: 1, + drawHp: true, + iconType: 2, + baseIcon: 'wolf', + nameKey: 'Fenris', + nameEn: 'Dire Wolf', + nameZh: '狂狼', + subClasses: [{ classId: 421, icon: 'wolf', nameEn: 'Dire Wolf', nameZh: '狂狼' }], + }, + 13: { + idx: 13, + petType: 'totem', + group: 2, + drawHp: true, + iconType: 3, + baseIcon: 'oaksage', + nameKey: 'Totem', + nameEn: 'Oak Sage', + nameZh: '橡木智者', + subClasses: [ + { classId: 424, icon: 'oaksage', nameEn: 'Oak Sage', nameZh: '橡木智者' }, + { classId: 423, icon: 'heartofwolverine', nameEn: 'Heart of Wolverine', nameZh: '狼獾之心' }, + { classId: 422, icon: 'spiritofbarbs', nameEn: 'Spirit of Barbs', nameZh: '棘靈' }, + ], + }, + 14: { + idx: 14, + petType: 'vine', + group: 3, + drawHp: true, + iconType: 3, + baseIcon: 'plaguepoppy', + nameKey: 'Vine', + nameEn: 'Poison Creeper', + nameZh: '劇毒藤蔓', + subClasses: [ + { classId: 425, icon: 'plaguepoppy', nameEn: 'Poison Creeper', nameZh: '劇毒藤蔓' }, + { classId: 426, icon: 'cycleoflife', nameEn: 'Carrion Vine', nameZh: '食屍藤蔓' }, + { classId: 427, icon: 'vines', nameEn: 'Solar Creeper', nameZh: '太陽藤蔓' }, + ], + }, + 15: { + idx: 15, + petType: 'grizzly', + group: 1, + drawHp: true, + iconType: 1, + baseIcon: 'bear', + nameKey: 'DruidBear', + nameEn: 'Grizzly', + nameZh: '灰熊', + subClasses: [{ classId: 428, icon: 'bear', nameEn: 'Grizzly', nameZh: '灰熊' }], + }, + 16: { + idx: 16, + petType: 'shadowwarrior', + group: 4, + drawHp: true, + iconType: 1, + baseIcon: 'shadowassassin', + nameKey: 'ShadowWarrior', + nameEn: 'Shadow Warrior', + nameZh: '影子戰士', + subClasses: [ + { classId: 417, icon: 'shadowassassin', nameEn: 'Shadow Warrior', nameZh: '影子戰士' }, + { classId: 418, icon: 'shadowassassin', nameEn: 'Shadow Master', nameZh: '影子大師' }, + ], + }, +} + +/** + * Reverse lookup from monster `hcIdx` (`classId`) to `PetType.txt` row index (`idx`). + */ +export const MONSTER_CLASS_TO_PET_TYPE_IDX: Readonly> = { + 357: 2, // Valkyrie + 289: 3, // Clay Golem + 290: 3, // Blood Golem + 291: 3, // Iron Golem + 292: 3, // Fire Golem + 363: 4, // Necro Skeleton + 364: 5, // Necro Skeletal Mage + 271: 7, // Act 1 Rogue Hireling + 338: 7, // Act 2 Desert Mercenary + 359: 7, // Act 3 Iron Wolf + 560: 7, // Act 5 Barbarian Hireling + 561: 7, // Act 5 Barbarian Hireling + 419: 10, // Raven + 420: 11, // Spirit Wolf + 421: 12, // Dire Wolf (Fenris) + 422: 13, // Spirit of Barbs + 423: 13, // Heart of Wolverine + 424: 13, // Oak Sage + 425: 14, // Poison Creeper (Plague Poppy) + 426: 14, // Carrion Vine (Cycle of Life) + 427: 14, // Solar Creeper (Vine Creature) + 428: 15, // Druid Grizzly Bear + 417: 16, // Shadow Warrior + 418: 16, // Shadow Master +} + +/** + * Resolve the authentic 1.13c mercenary portrait icon key from `classId` (`Hireables.cpp` `0x6fb0b2a0`). + */ +export function resolveMercPortraitIcon(classId: number | undefined): string { + switch (classId) { + case 338: + return 'act2hireableicon' + case 359: + return 'act3hireableicon' + case 560: + case 561: + return 'barbhirable_icon' + case 271: + default: + return 'rogueicon' + } +} + +/** + * Resolve the authentic 1.13c player class portrait icon key from `charClass` (`0..6` or `'ama'..'ass'`). + */ +export function resolvePlayerClassPortraitIcon(charClass: number | string | undefined): string { + if (typeof charClass === 'number') { + return PLAYER_CLASS_PORTRAIT_BY_ID[charClass] ?? 'AmazonIcon' + } + if (typeof charClass === 'string') { + const num = Number(charClass) + if (Number.isFinite(num) && PLAYER_CLASS_PORTRAIT_BY_ID[num]) { + return PLAYER_CLASS_PORTRAIT_BY_ID[num]! + } + const lower = charClass.toLowerCase().slice(0, 3) + if (PLAYER_CLASS_PORTRAIT_BY_CODE[lower]) { + return PLAYER_CLASS_PORTRAIT_BY_CODE[lower]! + } + } + return 'AmazonIcon' +} + +/** + * Resolve `PetTypePortraitMeta` and specific sub-class icon/label for a summoned pet (`Hireables.cpp` `0x6fb0b170`). + */ +export function resolvePetPortraitInfo( + petTypeOrIdx: number | string | undefined, + classId?: number | undefined, +): { + readonly meta: PetTypePortraitMeta + readonly iconKey: string + readonly nameEn: string + readonly nameZh: string +} | null { + let idx: number | undefined + if (typeof petTypeOrIdx === 'number' && PET_TYPE_PORTRAIT_TABLE[petTypeOrIdx]) { + idx = petTypeOrIdx + } else if (typeof petTypeOrIdx === 'string') { + const asNum = Number(petTypeOrIdx) + if (Number.isFinite(asNum) && PET_TYPE_PORTRAIT_TABLE[asNum]) { + idx = asNum + } else { + const lower = petTypeOrIdx.toLowerCase() + const found = Object.values(PET_TYPE_PORTRAIT_TABLE).find(p => p.petType === lower) + if (found) idx = found.idx + } + } + if (idx === undefined && classId !== undefined) { + idx = MONSTER_CLASS_TO_PET_TYPE_IDX[classId] + } + if (idx === undefined) return null + + const meta = PET_TYPE_PORTRAIT_TABLE[idx] + if (!meta || meta.iconType === 0) return null + + const sub = classId !== undefined ? meta.subClasses.find(s => s.classId === classId) : undefined + return { + meta, + iconKey: sub?.icon ?? meta.baseIcon, + nameEn: sub?.nameEn ?? meta.nameEn, + nameZh: sub?.nameZh ?? meta.nameZh, + } +} diff --git a/src/netproto/d2gs/s2c/merc.ts b/src/netproto/d2gs/s2c/merc.ts index ae2b684..cb2bddd 100644 --- a/src/netproto/d2gs/s2c/merc.ts +++ b/src/netproto/d2gs/s2c/merc.ts @@ -39,7 +39,7 @@ export function decode0x7ASummonAction(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x7A const action = r.u8() - const skillTree = r.u8() + const petType = r.u8() const classId = r.u16LE() const ownerId = r.u32LE() const mercId = r.u32LE() @@ -50,7 +50,10 @@ export function decode0x7ASummonAction(pkt: Uint8Array): ServerEvent { mercId, classId, stat: action, - value: skillTree, + value: petType, + isSummonAction: true, + summonAction: action, + petType, } } diff --git a/src/netproto/d2gs/s2c/party.ts b/src/netproto/d2gs/s2c/party.ts index bbd5700..196a369 100644 --- a/src/netproto/d2gs/s2c/party.ts +++ b/src/netproto/d2gs/s2c/party.ts @@ -130,6 +130,7 @@ export function decode0x7FPartyMemberState(pkt: Uint8Array): ServerEvent { lifePct, areaId, relation: isPlayer, + isPlayer: isPlayer !== 0, } } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index 5e20476..b649034 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -170,6 +170,7 @@ export type ServerEvent = readonly type: 'Party' readonly kind: 'roster' | 'relation' | 'memberState' | 'memberPos' readonly unitId?: number | undefined + readonly playerId?: number | undefined readonly otherId?: number | undefined readonly partyId?: number | undefined readonly relation?: number | undefined @@ -179,6 +180,7 @@ export type ServerEvent = readonly charName?: string | undefined readonly charClass?: number | undefined readonly charLevel?: number | undefined + readonly isPlayer?: boolean | undefined } | { readonly type: 'Trade' @@ -210,6 +212,9 @@ export type ServerEvent = readonly nameId?: number | undefined readonly stat?: number | undefined readonly value?: number | undefined + readonly isSummonAction?: boolean | undefined + readonly summonAction?: number | undefined + readonly petType?: number | undefined } | { readonly type: 'Portal' diff --git a/tests/client/hud-portrait-roster-pets.test.ts b/tests/client/hud-portrait-roster-pets.test.ts new file mode 100644 index 0000000..dbf9a91 --- /dev/null +++ b/tests/client/hud-portrait-roster-pets.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from 'vitest' +import { ClientWorld } from '../../src/client/world/client-world.ts' +import { HudModel } from '../../src/client/ui-model/hud-model.ts' +import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { decode0x7ASummonAction } from '../../src/netproto/d2gs/s2c/merc.ts' +import { decode0x7FPartyMemberState } from '../../src/netproto/d2gs/s2c/party.ts' +import { + CANONICAL_PORTRAIT_DC6_NAMES, + resolveMercPortraitIcon, + resolvePlayerClassPortraitIcon, + resolvePetPortraitInfo, +} from '../../src/common/units/pet-type-table.ts' +import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts' + +function createMockContext() { + const drawnTexts: Array<{ text: string; x: number; y: number }> = [] + const drawnRects: Array<{ x: number; y: number; w: number; h: number }> = [] + const drawnImages: Array<{ sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number }> = [] + + const ctx = { + canvas: { width: 800, height: 600 }, + save: () => {}, + restore: () => {}, + scale: () => {}, + translate: () => {}, + clearRect: () => {}, + beginPath: () => {}, + closePath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + fill: () => {}, + fillRect: (x: number, y: number, w: number, h: number) => { + drawnRects.push({ x, y, w, h }) + }, + strokeRect: (x: number, y: number, w: number, h: number) => { + drawnRects.push({ x, y, w, h }) + }, + fillText: (text: string, x: number, y: number) => { + drawnTexts.push({ text: String(text), x, y }) + }, + measureText: (text: string) => ({ width: String(text).length * 6 }), + drawImage: (_img: any, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number) => { + drawnImages.push({ sx, sy, sw, sh, dx, dy, dw, dh }) + }, + imageSmoothingEnabled: false, + fillStyle: '#000000', + strokeStyle: '#000000', + font: '10px sans-serif', + textBaseline: 'alphabetic', + drawnTexts, + drawnRects, + drawnImages, + } as unknown as CanvasRenderingContext2D & { + drawnTexts: Array<{ text: string; x: number; y: number }> + drawnRects: Array<{ x: number; y: number; w: number; h: number }> + drawnImages: Array<{ sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number }> + } + return ctx +} + +describe('Top-Left HUD Portraits & 1.13c RosterPets Parity (Hireables.cpp)', () => { + it('P1: BAKED_UI_MANIFEST bundles all 29 authentic 1.13c DC6 portrait frames with valid non-zero dimensions', () => { + expect(BAKED_UI_MANIFEST.portraitRects).toBeDefined() + const rects = BAKED_UI_MANIFEST.portraitRects! + + for (const name of CANONICAL_PORTRAIT_DC6_NAMES) { + const rect = rects[name] ?? rects[name.toLowerCase()] + expect(rect, `Expected portrait rect for ${name}`).toBeDefined() + expect(rect.w).toBeGreaterThanOrEqual(44) + expect(rect.h).toBeGreaterThanOrEqual(40) + } + }) + + it('P2: solo unpartied player never renders a phantom "Player #1" portrait or box in top-left HUD', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + // Player joins solo: 0x5B PlayerInGame sends selfId with partyId 0xffff + world.apply({ + type: 'Party', + kind: 'roster', + unitId: 1, + playerId: 1, + charClass: 0, + charLevel: 20, + partyId: 0xffff, + charName: 'AmazonHero', + }) + // 0x8B PlayerPartyRelation sends state 0 (unpartied) + world.apply({ + type: 'Party', + kind: 'relation', + unitId: 1, + relation: 0, + }) + model.syncFromWorld() + + expect(hud.partyMembers).toHaveLength(0) + + hud.render() + expect(mockCtx.drawnTexts.some((t) => t.text.includes('Player #1'))).toBe(false) + expect(mockCtx.drawnTexts.some((t) => t.text.includes('AmazonHero'))).toBe(false) + expect(mockCtx.drawnRects.filter((r) => r.y <= 64)).toHaveLength(0) + }) + + it('P3: 0x7F PartyMemberState with isPlayer === 0 updates pet/hireling life without creating phantom party member', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + // Assign hireling + world.apply({ + type: 'Merc', + kind: 'assign', + ownerId: 1, + mercId: 88, + classId: 338, // Act 2 Desert Mercenary + }) + expect(world.merc).not.toBeNull() + + // 0x7F packet: [0x7f, isPlayer=0, lifePct=96 (u16), unitId=88 (u32), areaId=1 (u16)] + const pkt7f = new Uint8Array([0x7f, 0, 96, 0, 88, 0, 0, 0, 1, 0]) + const ev7f = decode0x7FPartyMemberState(pkt7f) + expect(ev7f.type).toBe('Party') + expect((ev7f as any).isPlayer).toBe(false) + + world.apply(ev7f) + expect(world.merc!.lifePct).toBe(96) + expect(world.party.has(88)).toBe(false) + + model.syncFromWorld() + expect(hud.partyMembers).toHaveLength(0) + expect((hud as any).merc.lifePct).toBe(96) + }) + + it('P4: renders authentic mercenary portrait (act2hireableicon) without duplicate overlapping fillText', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + world.apply({ + type: 'Merc', + kind: 'assign', + ownerId: 1, + mercId: 99, + classId: 338, // Desert Mercenary + nameId: 14, + }) + model.syncFromWorld() + + expect((hud as any).merc.iconKey).toBe('act2hireableicon') + + hud.render() + // The portrait frame and health bar are drawn + expect(mockCtx.drawnRects.some((r) => r.x <= 24 && r.y <= 24 && r.w >= 32)).toBe(true) + + // In headless unit tests where DC6 font atlases are unloaded, D2FontRenderer.drawText + // falls back to ctx.fillText with 3 drop shadow passes + 1 foreground pass (4 passes). + // The previous buggy code called an extra manual ctx.fillText beforehand (5 passes with stacked browser text). + const mercTexts = mockCtx.drawnTexts.filter((t) => t.text.includes('Merc')) + expect(mercTexts).toHaveLength(4) + }) + + it('P5: 0x7A SummonAction maintains active pets in world.pets without clobbering world.merc', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + // Player has an active mercenary + world.apply({ + type: 'Merc', + kind: 'assign', + ownerId: 1, + mercId: 55, + classId: 271, // Rogue + nameId: 3, + }) + expect(world.merc).not.toBeNull() + + // 0x7A SummonAction: [0x7a, action=1, petType=4 (skeleton), classId=363 (necroskeleton), ownerId=1, mercId=101] + const pkt7a = new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 101, 0, 0, 0]) + const ev7a = decode0x7ASummonAction(pkt7a) + expect((ev7a as any).isSummonAction).toBe(true) + expect((ev7a as any).petType).toBe(4) + + world.apply(ev7a) + // Mercenary remains completely intact! + expect(world.merc).not.toBeNull() + expect(world.merc!.mercId).toBe(55) + + // Pet is tracked in world.pets + expect(world.pets.has(101)).toBe(true) + const pet = world.pets.get(101)! + expect(pet.petType).toBe(4) + expect(pet.classId).toBe(363) + expect(pet.ownerId).toBe(1) + + // Add 2 more skeletons + world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 102, 0, 0, 0]))) + world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 103, 0, 0, 0]))) + expect(world.pets.size).toBe(4) // 3 skeletons + 1 merc mirrored + + model.syncFromWorld() + const summons = (hud as any).summonPortraits + expect(summons).toHaveLength(1) + expect(summons[0].iconKey).toBe('skeletonicon') + expect(summons[0].count).toBe(3) + expect(summons[0].iconType).toBe(2) + + // Render frame and verify count badge "3" is drawn in top-left + hud.render() + expect(mockCtx.drawnTexts.some((t) => t.text === '3')).toBe(true) + + // Unsummon skeleton 102 (action=0) + world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 0, 4, 0x6b, 0x01, 1, 0, 0, 0, 102, 0, 0, 0]))) + expect(world.pets.has(102)).toBe(false) + model.syncFromWorld() + expect((hud as any).summonPortraits[0].count).toBe(2) + }) + + it('P6: icontype=3 sub-type summons (Golems, Spirits, Vines) resolve exact sub-icon via classId', () => { + const clay = resolvePetPortraitInfo(3, 289) + expect(clay?.iconKey).toBe('earthgolumicon') + + const blood = resolvePetPortraitInfo(3, 290) + expect(blood?.iconKey).toBe('bloodgolumicon') + + const iron = resolvePetPortraitInfo(3, 291) + expect(iron?.iconKey).toBe('metalgolumicon') + + const fire = resolvePetPortraitInfo(3, 292) + expect(fire?.iconKey).toBe('firegolumicon') + + const oak = resolvePetPortraitInfo(13, 424) + expect(oak?.iconKey).toBe('oaksage') + + const wolverine = resolvePetPortraitInfo(13, 423) + expect(wolverine?.iconKey).toBe('heartofwolverine') + + const poison = resolvePetPortraitInfo(14, 425) + expect(poison?.iconKey).toBe('plaguepoppy') + }) + + it('P7: allied party members render their authentic class portraits on the top-left HUD', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + // Local player + world.apply({ + type: 'Party', + kind: 'roster', + unitId: 1, + charClass: 0, // Amazon + charName: 'SelfAmazon', + partyId: 1, + }) + // Allied Paladin in same party + world.apply({ + type: 'Party', + kind: 'roster', + unitId: 2, + charClass: 3, // Paladin + charName: 'PaladinBro', + partyId: 1, + lifePct: 90, + }) + world.apply({ + type: 'Party', + kind: 'relation', + unitId: 2, + relation: 1, // in party + }) + // Unpartied Sorceress in different party / unpartied + world.apply({ + type: 'Party', + kind: 'roster', + unitId: 3, + charClass: 1, // Sorceress + charName: 'SoloSorc', + partyId: 0xffff, + }) + world.apply({ + type: 'Party', + kind: 'relation', + unitId: 3, + relation: 0, // none + }) + + model.syncFromWorld() + // Party list in hud model excludes selfId + expect(hud.partyMembers).toHaveLength(2) + const paladinEntry = hud.partyMembers.find((m) => m.unitId === 2) + expect(paladinEntry?.portraitIcon).toBe('PaladinIcon') + + hud.render() + // Allied Paladin is drawn + expect(mockCtx.drawnTexts.some((t) => t.text.includes('PaladinBro'))).toBe(true) + // Unpartied Sorceress is NOT drawn on the top-left HUD + expect(mockCtx.drawnTexts.some((t) => t.text.includes('SoloSorc'))).toBe(false) + // Self is NOT drawn on the top-left HUD + expect(mockCtx.drawnTexts.some((t) => t.text.includes('SelfAmazon'))).toBe(false) + }) +}) From e31e1b561a731b7fc8e99b611d4032f6fedafde1 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 10:50:11 +0000 Subject: [PATCH 09/15] fix(ui): fix skill tooltips, HUD docking offsets, and UI clamping at >800x600 viewports (#557) - Translate HUD mouse-move, click, drag, and drop coordinates across all 4 viewport profiles (800x600, 1024x768, 1068x600, 1280x720) using toControlBarLocal, toLeftDockLocal, fromLeftDockLocal, toRightDockLocal, and fromRightDockLocal - Make HudManager.leftPanel and HudManager.rightPanel getters/setters that keep charSheet.visible, inventory.visible, and skillTree.visible synchronized on direct assignment (e.g., HudModel.syncFromWorld) - Draw SkillTreePanel tooltips on the top-most HUD tooltip layer with minLocalX = 8 - deltaRight so they are never occluded by the bottom control bar/globes/belt and never clamp over column-1 skill nodes on widescreen viewports - Swap belt.draw before hotkeys.draw so right-hand speedbar popups and skill tooltips render above the belt - Route both left-click and right-click on HUD regions in CommandMapper.handlePointerDown to HudManager.handleClick - Reverse-translate Mercenary, Trade, and Quest Reward (Imbue/Add Sockets/Personalize) left-dock item hover tooltips and clamp NPC dialog menus, Area Entry Banner, and Death Overlay against active viewport dimensions Closes #557 Closes #542 --- src/client/input/command-mapper.ts | 4 +- src/client/ui/hud-manager.ts | 408 ++++++++---- src/client/ui/skill-tree-panel.ts | 10 +- src/client/ui/world-panels.ts | 163 ++++- .../client/viewport-formula-profiles.test.ts | 626 +++++++++++++++++- 5 files changed, 1060 insertions(+), 151 deletions(-) diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 4a32e04..5db4709 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -407,8 +407,8 @@ export class CommandMapper { // 2. Route HUD clicks to HudManager (status bar, orbs, skill popup, belt, open panels) if (this.hudManager.isPointInterceptedByHud(screenX, screenY)) { - if (button === 0) { - this.hudManager.handleClick(screenX, screenY) + if (button === 0 || button === 2) { + this.hudManager.handleClick(screenX, screenY, button) } return null } diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 592a77c..7904295 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -319,6 +319,84 @@ export function logicalToClient( } } +/** + * Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space + * of the bottom control bar region (`controlBar`, `globes`, `belt`, `hotkeys`). + */ +export function toControlBarLocal( + x: number, + y: number, + docking: Pick, +): { x: number; y: number } { + return { + x: x - (docking.barOffsetX ?? 0), + y: y - (docking.barOffsetY ?? 0), + } +} + +/** + * Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space + * of the left dock panel region (`charSheet`, `worldPanels`). + */ +export function toLeftDockLocal( + x: number, + y: number, + docking: Pick, +): { x: number; y: number } { + return { + x: x - docking.leftDockX, + y: y - (docking.panelOffsetY ?? 0), + } +} + +/** + * Reverse-translates a left-dock-local coordinate `(localX, localY)` back into + * logical viewport coordinates (used for hovered item tooltip anchoring). + */ +export function fromLeftDockLocal( + localX: number, + localY: number, + docking: Pick, +): { x: number; y: number } { + return { + x: localX + docking.leftDockX, + y: localY + (docking.panelOffsetY ?? 0), + } +} + +/** + * Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space + * of the right dock panel region (`inventory`, `skillTree`), where the right panel is + * authored at `x = 400..800, y = 0..553`. + */ +export function toRightDockLocal( + x: number, + y: number, + docking: Pick, +): { x: number; y: number } { + const deltaRight = docking.rightDockX - 400 + return { + x: x - deltaRight, + y: y - (docking.panelOffsetY ?? 0), + } +} + +/** + * Reverse-translates a right-dock-local coordinate `(localX, localY)` back into + * logical viewport coordinates (used for hovered item tooltip anchoring). + */ +export function fromRightDockLocal( + localX: number, + localY: number, + docking: Pick, +): { x: number; y: number } { + const deltaRight = docking.rightDockX - 400 + return { + x: localX + deltaRight, + y: localY + (docking.panelOffsetY ?? 0), + } +} + /** * Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`; @@ -449,8 +527,27 @@ export class HudManager { readonly worldPanels = new WorldPanelsHud() readonly cursor = new CursorController() - leftPanel: LeftPanelType = 'none' - rightPanel: 'none' | 'inv' | 'skill' = 'none' + private _leftPanel: LeftPanelType = 'none' + get leftPanel(): LeftPanelType { + return this._leftPanel + } + set leftPanel(val: LeftPanelType) { + this._leftPanel = val + this.charSheet.visible = val === 'char' + } + + private _rightPanel: 'none' | 'inv' | 'skill' = 'none' + get rightPanel(): 'none' | 'inv' | 'skill' { + return this._rightPanel + } + set rightPanel(val: 'none' | 'inv' | 'skill') { + this._rightPanel = val + this.inventory.visible = val === 'inv' + this.skillTree.visible = val === 'skill' + if (val !== 'skill') { + this.skillTree.hoveredSkill = null + } + } isRunning = true showGroundLabels = false /** Screen-space (800x600 viewport) ground item label boxes laid out for the current frame. */ @@ -744,28 +841,108 @@ export class HudManager { this.mouseX = logicalX this.mouseY = logicalY this.cursor.handleMouseMove(logicalX, logicalY) - this.globes.handleMouseMove(logicalX, logicalY) - this.belt.handleMouseMove(logicalX, logicalY) - this.hotkeys.handleMouseMove(logicalX, logicalY) - this.controlBar.handleMouseMove(logicalX, logicalY) - if (this.worldPanels.npcMenu) { - this.worldPanels.handleNpcMenuMove(logicalX, logicalY, this.font) - } const docking = this.getDockingLayout() - const deltaLeft = -docking.marginW - const deltaRight = docking.marginW - this.inventory.handleMouseMove(logicalX - deltaRight, logicalY) - if (this.inventory.hoveredItem) { - this.inventory.hoveredItem.x += deltaRight + const barPt = toControlBarLocal(logicalX, logicalY, docking) + this.globes.handleMouseMove(barPt.x, barPt.y) + this.belt.handleMouseMove(barPt.x, barPt.y) + this.hotkeys.handleMouseMove(barPt.x, barPt.y) + this.controlBar.handleMouseMove(barPt.x, barPt.y) + if (this.worldPanels.npcMenu) { + this.worldPanels.handleNpcMenuMove( + logicalX, + logicalY, + this.font, + this.viewportProfile.width, + this.viewportProfile.height, + ) } - this.skillTree.handleMouseMove(logicalX - deltaRight, logicalY) - if ( - this.leftPanel === 'stash' || - this.leftPanel === 'cube' || - this.leftPanel === 'waypoint' || - this.leftPanel === 'vendor' - ) { - this.worldPanels.handleMouseMove(logicalX - deltaLeft, logicalY) + const rightPt = toRightDockLocal(logicalX, logicalY, docking) + this.inventory.handleMouseMove(rightPt.x, rightPt.y) + if (this.inventory.hoveredItem) { + const tipPt = fromRightDockLocal( + this.inventory.hoveredItem.x, + this.inventory.hoveredItem.y, + docking, + ) + this.inventory.hoveredItem.x = tipPt.x + this.inventory.hoveredItem.y = tipPt.y + } + this.skillTree.handleMouseMove(rightPt.x, rightPt.y) + if (this.leftPanel !== 'none' && this.leftPanel !== 'char') { + const leftPt = toLeftDockLocal(logicalX, logicalY, docking) + this.worldPanels.handleMouseMove(leftPt.x, leftPt.y) + if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) { + const tipPt = fromLeftDockLocal( + this.worldPanels.hoveredStashItem.x, + this.worldPanels.hoveredStashItem.y, + docking, + ) + this.inventory.hoveredItem = { + ...this.worldPanels.hoveredStashItem, + x: tipPt.x, + y: tipPt.y, + } + } else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) { + const tipPt = fromLeftDockLocal( + this.worldPanels.hoveredCubeItem.x, + this.worldPanels.hoveredCubeItem.y, + docking, + ) + this.inventory.hoveredItem = { + ...this.worldPanels.hoveredCubeItem, + x: tipPt.x, + y: tipPt.y, + } + } else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) { + const tipPt = fromLeftDockLocal( + this.worldPanels.hoveredVendorItem.x, + this.worldPanels.hoveredVendorItem.y, + docking, + ) + this.inventory.hoveredItem = { + item: this.worldPanels.hoveredVendorItem.item, + x: tipPt.x, + y: tipPt.y, + } + } else if (this.leftPanel === 'merc' && this.worldPanels.hoveredMercItem) { + const tipPt = fromLeftDockLocal( + this.worldPanels.hoveredMercItem.x, + this.worldPanels.hoveredMercItem.y, + docking, + ) + this.inventory.hoveredItem = { + ...this.worldPanels.hoveredMercItem, + x: tipPt.x, + y: tipPt.y, + } + } else if (this.leftPanel === 'trade' && this.worldPanels.hoveredTradeItem) { + const tipPt = fromLeftDockLocal( + this.worldPanels.hoveredTradeItem.x, + this.worldPanels.hoveredTradeItem.y, + docking, + ) + this.inventory.hoveredItem = { + ...this.worldPanels.hoveredTradeItem, + x: tipPt.x, + y: tipPt.y, + } + } else if ( + (this.leftPanel === 'imbue' || + this.leftPanel === 'add_sockets' || + this.leftPanel === 'personalize') && + this.worldPanels.hoveredQuestRewardItem + ) { + const tipPt = fromLeftDockLocal( + this.worldPanels.hoveredQuestRewardItem.x, + this.worldPanels.hoveredQuestRewardItem.y, + docking, + ) + this.inventory.hoveredItem = { + ...this.worldPanels.hoveredQuestRewardItem, + x: tipPt.x, + y: tipPt.y, + } + } } } @@ -1296,7 +1473,7 @@ export class HudManager { if (logicalX < -marginW || logicalX > profile.width + marginW || logicalY < 0 || logicalY > profile.height) { return false } - if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true + if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY, profile.width, profile.height)) return true const barX = logicalX - profile.barOffsetX const barY = logicalY - profile.barOffsetY @@ -1318,7 +1495,7 @@ export class HudManager { } if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false - if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true + if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY)) return true if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true @@ -1497,52 +1674,7 @@ export class HudManager { window.addEventListener('mousemove', (e) => { const pt = this.clientToLogical(e.clientX, e.clientY) - this.mouseX = pt.x - this.mouseY = pt.y - this.cursor.handleMouseMove(pt.x, pt.y) - this.globes.handleMouseMove(pt.x, pt.y) - this.belt.handleMouseMove(pt.x, pt.y) - this.hotkeys.handleMouseMove(pt.x, pt.y) - this.controlBar.handleMouseMove(pt.x, pt.y) - - if (this.worldPanels.npcMenu) { - this.worldPanels.handleNpcMenuMove(pt.x, pt.y, this.font) - } - - const docking = this.getDockingLayout() - const deltaLeft = -docking.marginW - const deltaRight = docking.marginW - - this.inventory.handleMouseMove(pt.x - deltaRight, pt.y) - if (this.inventory.hoveredItem) { - this.inventory.hoveredItem.x += deltaRight - } - this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y) - if ( - this.leftPanel === 'stash' || - this.leftPanel === 'cube' || - this.leftPanel === 'waypoint' || - this.leftPanel === 'vendor' - ) { - this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y) - if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) { - this.inventory.hoveredItem = { - ...this.worldPanels.hoveredStashItem, - x: this.worldPanels.hoveredStashItem.x + deltaLeft, - } - } else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) { - this.inventory.hoveredItem = { - ...this.worldPanels.hoveredCubeItem, - x: this.worldPanels.hoveredCubeItem.x + deltaLeft, - } - } else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) { - this.inventory.hoveredItem = { - item: this.worldPanels.hoveredVendorItem.item, - x: this.worldPanels.hoveredVendorItem.x + deltaLeft, - y: this.worldPanels.hoveredVendorItem.y, - } - } - } + this.handleMouseMove(pt.x, pt.y) const intercept = this.isPointInterceptedByHud(this.mouseX, this.mouseY) || @@ -1663,13 +1795,18 @@ export class HudManager { }, }, this.font, + this.viewportProfile.width, + this.viewportProfile.height, ) this.syncPublishedState() return } + const docking = this.getDockingLayout() + const barPt = toControlBarLocal(pt.x, pt.y, docking) + // Belt slot click: right-click drinks potion; left-click places/swaps/removes belt item in online mode - const beltHit = this.belt.slotAtPoint(pt.x, pt.y) + const beltHit = this.belt.slotAtPoint(barPt.x, barPt.y) if (beltHit) { if (e.button === 2) { this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey)) @@ -1707,19 +1844,19 @@ export class HudManager { return } - if (this.globes.handleClick(pt.x, pt.y)) { + if (this.globes.handleClick(barPt.x, barPt.y)) { this.syncPublishedState() return } - if (this.hotkeys.handleClick(pt.x, pt.y)) { + if (this.hotkeys.handleClick(barPt.x, barPt.y)) { this.syncPublishedState() return } const ctrlState = this.buildControlBarState() if ( - this.controlBar.handleClick(pt.x, pt.y, ctrlState, { + this.controlBar.handleClick(barPt.x, barPt.y, ctrlState, { onToggleRun: () => { this.isRunning = !this.isRunning }, @@ -1737,15 +1874,11 @@ export class HudManager { return } - const docking = this.getDockingLayout() - const deltaLeft = -docking.marginW - const deltaRight = docking.marginW - // Left-Half Dock Click - if (pt.x <= 400 + deltaLeft) { - const leftX = pt.x - deltaLeft + const leftPt = toLeftDockLocal(pt.x, pt.y, docking) + if (leftPt.x >= 0 && leftPt.x <= 400 && leftPt.y >= 0 && leftPt.y <= 553) { if (this.leftPanel === 'char') { - this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => { + this.charSheet.handleClick(leftPt.x, leftPt.y, (key: BaseStatKey) => { this._commandSink?.allocateStat(key) }) if (!this.charSheet.visible) this.leftPanel = 'none' @@ -1756,8 +1889,8 @@ export class HudManager { if (typeof (this.worldPanels as any).handleActiveLeftPanelClick === 'function') { const handled = (this.worldPanels as any).handleActiveLeftPanelClick( dockKind, - leftX, - pt.y, + leftPt.x, + leftPt.y, e.button === 2, e.shiftKey, { @@ -1834,7 +1967,7 @@ export class HudManager { return } } - this.worldPanels.handleLeftDockClick(dockKind as any, leftX, pt.y, { + this.worldPanels.handleLeftDockClick(dockKind as any, leftPt.x, leftPt.y, { onClose: () => { this.leftPanel = 'none' this.leaveLeftPanel(dockKind) @@ -1874,20 +2007,20 @@ export class HudManager { } // Right-Half Dock Click - if (pt.x >= 400 + deltaRight) { - const rightX = pt.x - deltaRight + const rightPt = toRightDockLocal(pt.x, pt.y, docking) + if (rightPt.x >= 400 && rightPt.x <= 800 && rightPt.y >= 0 && rightPt.y <= 553) { if (this.rightPanel === 'inv') { if (!this.inventory.isIdentifyMode() && !this._commandSink) { // Stash quick transfer (Shift+Click, or Right-Click on non-usable items) if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) { if ( - rightX >= INV_GRID_ORIGIN.x && - rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && - pt.y >= INV_GRID_ORIGIN.y && - pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + rightPt.x >= INV_GRID_ORIGIN.x && + rightPt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + rightPt.y >= INV_GRID_ORIGIN.y && + rightPt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx ) { - const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) - const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const col = Math.floor((rightPt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((rightPt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) const hit = this.inventory.gridItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) @@ -1902,13 +2035,13 @@ export class HudManager { // Horadric Cube quick transfer (Shift+Click, or Right-Click on non-usable items) if (this.leftPanel === 'cube' && (e.shiftKey || e.button === 2)) { if ( - rightX >= INV_GRID_ORIGIN.x && - rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && - pt.y >= INV_GRID_ORIGIN.y && - pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx + rightPt.x >= INV_GRID_ORIGIN.x && + rightPt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx && + rightPt.y >= INV_GRID_ORIGIN.y && + rightPt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx ) { - const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) - const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) + const col = Math.floor((rightPt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx) + const row = Math.floor((rightPt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx) const hit = this.inventory.gridItems.find( p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight, ) @@ -1925,21 +2058,21 @@ export class HudManager { this.leftPanel === 'vendor' && !this.inventory.isIdentifyMode() && this.pendingIdentifyScrollId === null && - this.handleVendorInventoryClick(rightX, pt.y, e.button === 2) + this.handleVendorInventoryClick(rightPt.x, rightPt.y, e.button === 2) ) { this.syncPublishedState() return } - if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) { + if (this._commandSink && this.forwardInventoryPanelClick(rightPt.x, rightPt.y, e.button === 2)) { this.syncPublishedState() return } this.syncPlayerContext() this.inventory.handleClick( - rightX, - pt.y, + rightPt.x, + rightPt.y, { onOpenCube: () => this.toggleLeftPanel('cube'), onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item), @@ -1955,7 +2088,7 @@ export class HudManager { this.syncPublishedState() return } else if (this.rightPanel === 'skill') { - this.skillTree.handleClick(rightX, pt.y, (skillId) => this.handleSkillAllocated(skillId)) + this.skillTree.handleClick(rightPt.x, rightPt.y, (skillId) => this.handleSkillAllocated(skillId)) if (!this.skillTree.visible) this.rightPanel = 'none' this.syncPublishedState() return @@ -2628,11 +2761,12 @@ export class HudManager { buySellBtnImg: this.images.get('buySellBtn') ?? null, }, this.font, + true, ) ctx.restore() } - // 3..6. Bottom 800px Control Panel, Globes, Skill Hotkeys, and Belt (translated by barOffsetX, barOffsetY) + // 3..6. Bottom 800px Control Panel, Globes, Belt, and Skill Hotkeys (translated by barOffsetX, barOffsetY) ctx.save() ctx.translate(barOffsetX, barOffsetY) this.controlBar.draw( @@ -2669,15 +2803,7 @@ export class HudManager { this.font, ) - // 5. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`) - this.hotkeys.draw( - ctx, - this.images.get('genericSkills') ?? null, - this.font, - Boolean(this.inventory.equipped.weapon1), - ) - - // 6. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`) + // 5. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`) this.belt.draw( ctx, { @@ -2687,6 +2813,14 @@ export class HudManager { }, this.font, ) + + // 6. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`) — drawn above belt so speedbar popup & tooltips are never occluded + this.hotkeys.draw( + ctx, + this.images.get('genericSkills') ?? null, + this.font, + Boolean(this.inventory.equipped.weapon1), + ) ctx.restore() // 7. Top-Center Monster Target Health Bar & Plaque (`D2Client.dll` `0x6FB70850`) @@ -2702,27 +2836,51 @@ export class HudManager { this.leftPanel !== 'hire' && (this.worldPanels as any).activePanel !== 'hire' ) { - this.worldPanels.drawNpcMenu(ctx, this.font, { - boxPiecesImg: this.images.get('boxPieces') ?? null, - focus16Img: this.images.get('focus16') ?? null, - }) + this.worldPanels.drawNpcMenu( + ctx, + this.font, + { + boxPiecesImg: this.images.get('boxPieces') ?? null, + focus16Img: this.images.get('focus16') ?? null, + }, + profile.width, + profile.height, + ) } // 9. Area Entry Gothic Banner (`Entering: `, only when no split panels are open) if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu && !this.worldPanels.escapeMenuOpen) { - this.worldPanels.drawAreaBanner(ctx, nowMs, this.font) + this.worldPanels.drawAreaBanner(ctx, nowMs, this.font, profile.width) } - // 10. Hover Item Tooltip (Inventory / Equipment / Stash / Cube / Vendor) + // 10. Hover Tooltips (Inventory / Equipment / Stash / Cube / Vendor / Merc / Trade / Quest Reward / Skill Tree) if ( (this.rightPanel === 'inv' || this.leftPanel === 'stash' || this.leftPanel === 'cube' || - this.leftPanel === 'vendor') && + this.leftPanel === 'vendor' || + this.leftPanel === 'merc' || + this.leftPanel === 'trade' || + this.leftPanel === 'imbue' || + this.leftPanel === 'add_sockets' || + this.leftPanel === 'personalize') && this.inventory.hoveredItem && !this.inventory.cursorItem ) { - this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor') + this.worldPanels.drawItemTooltip( + ctx, + this.inventory.hoveredItem, + this.font, + this.leftPanel === 'vendor', + profile.width, + profile.height, + ) + } + if (this.rightPanel === 'skill' && this.skillTree.hoveredSkill) { + ctx.save() + ctx.translate(deltaRight, panelOffsetY) + this.skillTree.drawSkillTooltip(ctx, this.font, 8 - deltaRight) + ctx.restore() } // 10b. Gold dialog (drop / deposit / withdraw) @@ -2740,15 +2898,17 @@ export class HudManager { // 10d. Death Overlay (`You Have Died. Press ESC to continue.` — R4.4) if (this.isDead || Boolean((this.worldPanels as any).isDead)) { + const centerX = Math.round(profile.width / 2) + const centerY = Math.round(profile.height / 2) ctx.save() ctx.fillStyle = 'rgba(80, 0, 0, 0.45)' - ctx.fillRect(0, 0, 800, 600) + ctx.fillRect(0, 0, profile.width, profile.height) ctx.fillStyle = '#d4b468' ctx.font = 'bold 22px serif' ctx.textAlign = 'center' const deathMsg = 'You Have Died. Press ESC to continue. 你挂了,请按 ESC 键继续' - ctx.fillText(deathMsg, 400, 300) - this.font.drawText(ctx, deathMsg, 400, 300, { + ctx.fillText(deathMsg, centerX, centerY) + this.font.drawText(ctx, deathMsg, centerX, centerY, { font: 'font16', color: 'gold', align: 'center', diff --git a/src/client/ui/skill-tree-panel.ts b/src/client/ui/skill-tree-panel.ts index 1ee8bdf..01837d6 100644 --- a/src/client/ui/skill-tree-panel.ts +++ b/src/client/ui/skill-tree-panel.ts @@ -423,6 +423,7 @@ export class SkillTreePanel { skillTabsImg?: HTMLImageElement | null }, font: D2FontRenderer, + skipTooltip = false, ): void { if (!this.visible) return if (this.skillIcons.size === 0 && this.characterClass !== null) { @@ -571,7 +572,7 @@ export class SkillTreePanel { } // 6. Hover Tooltip for Skill Node - if (this.hoveredSkill) { + if (this.hoveredSkill && !skipTooltip) { this.drawSkillTooltip(ctx, font) } } @@ -801,6 +802,7 @@ export class SkillTreePanel { pos: { x: number; y: number; w: number; h: number }, lines: readonly FormattedTooltipLine[], font: D2FontRenderer, + minLocalX = 8, ): { x: number; y: number; w: number; h: number } { const maxLineWidth = lines.reduce((max, l) => { const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8 @@ -809,13 +811,13 @@ export class SkillTreePanel { const boxW = Math.max(240, maxLineWidth + 28) const boxH = lines.length * 17 + 24 const rawX = pos.x - boxW - 8 - const clampedX = Math.max(8, Math.min(800 - boxW - 8, rawX)) + const clampedX = Math.max(minLocalX, Math.min(800 - boxW - 8, rawX)) const rawY = Math.round(pos.y + 24 - boxH / 2) const clampedY = Math.max(8, Math.min(552 - boxH, rawY)) return { x: clampedX, y: clampedY, w: boxW, h: boxH } } - drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void { + drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer, minLocalX = 8): void { if (!this.hoveredSkill) return const pos = SkillTreePanel.nodeScreenPos(this.hoveredSkill) const vm = this.getTooltipViewModel(this.hoveredSkill.skillId) @@ -824,7 +826,7 @@ export class SkillTreePanel { const lines = SkillTreePanel.formatSkillTooltipLines(vm, this.lang) if (lines.length === 0) return - const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font) + const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font, minLocalX) ctx.fillStyle = 'rgba(4, 4, 4, 0.94)' ctx.fillRect(placement.x, placement.y, placement.w, placement.h) diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index e2801a5..94ddaab 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -528,7 +528,7 @@ export interface NpcDialogLayout { * - Lines 1..N (Menu Options): `lineStepY = 15` (`0x0f`), `selectable = true` * - `width = maxLineWidth + 20` (`0x14`), `height = totalStepY + 15` (`0x0f`) * - `left = anchorX - trunc(width / 2)`, `top = anchorY - 21` - * - Clamped to 800x600 viewport (`left ∈ [10, 800 - width]`, `top ∈ [10, 600 - height - 48]`) + * - Clamped to viewport (`left ∈ [10, viewportWidth - width]`, `top ∈ [10, viewportHeight - height - 48]`) */ export function computeNpcDialogLayout( npcName: string, @@ -537,6 +537,8 @@ export function computeNpcDialogLayout( anchorY: number, font?: Pick | null, lang: TblLang = 'CHI', + viewportWidth = 800, + viewportHeight = 600, ): NpcDialogLayout { const measure = (text: string): number => font ? font.measureText(text, 'font16') : text.length * 10 @@ -557,11 +559,11 @@ export function computeNpcDialogLayout( let left = Math.trunc(anchorX) - Math.trunc(width / 2) let top = Math.trunc(anchorY) - headerStepY - if (left + width > 800 - 10) { - left = 800 - width + if (left + width > viewportWidth - 10) { + left = viewportWidth - width } - if (top + height > 600 - 58) { - top = 600 - height - 48 + if (top + height > viewportHeight - 58) { + top = viewportHeight - height - 48 } if (left < 10) left = 10 if (top < 10) top = 10 @@ -1677,6 +1679,9 @@ export class WorldPanelsHud { hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null cubeItems: GridPlacement[] = [] hoveredCubeItem: { item: UiInventoryItem; x: number; y: number } | null = null + hoveredMercItem: { item: UiInventoryItem; x: number; y: number } | null = null + hoveredTradeItem: { item: UiInventoryItem; x: number; y: number } | null = null + hoveredQuestRewardItem: { item: UiInventoryItem; x: number; y: number } | null = null hoveredWaypointIdx: number | null = null /** @@ -2058,6 +2063,9 @@ export class WorldPanelsHud { this.hoveredStashItem = null this.hoveredCubeItem = null this.hoveredVendorItem = null + this.hoveredMercItem = null + this.hoveredTradeItem = null + this.hoveredQuestRewardItem = null this.hoveredVendorButtonSlot = this.activeVendorDescriptor ? this.hitTestVendorButtonSlot(logicalX, logicalY) : null @@ -2143,9 +2151,82 @@ export class WorldPanelsHud { } } } + + // Mercenary equipment slots (`merc` left panel) + const m = this.merc + if (m) { + const eq = m.equipped ?? m.equipment + const mercSlots: ReadonlyArray<{ x: number; y: number; w: number; h: number; item: UiInventoryItem | null }> = [ + { x: ox + 132, y: oy + 44, w: 56, h: 56, item: m.headItem ?? eq?.helm ?? eq?.head ?? null }, + { x: ox + 132, y: oy + 112, w: 56, h: 84, item: m.bodyItem ?? eq?.armor ?? eq?.torso ?? null }, + { x: ox + 36, y: oy + 98, w: 56, h: 112, item: m.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null }, + { x: ox + 228, y: oy + 98, w: 56, h: 112, item: m.shieldItem ?? eq?.weapon2 ?? eq?.leftHand ?? null }, + ] + for (const s of mercSlots) { + if (s.item && logicalX >= s.x && logicalX <= s.x + s.w && logicalY >= s.y && logicalY <= s.y + s.h) { + this.hoveredMercItem = { + item: s.item, + x: s.x + s.w / 2, + y: s.y, + } + break + } + } + } + + // Player-to-player trade grids (`trade` left panel) + if (this.trade) { + const checkTradeGrid = (gridY: number, items: readonly any[] | undefined) => { + if (!items || logicalX < ox + 15 || logicalX >= ox + 15 + 290 || logicalY < gridY || logicalY >= gridY + 116) { + return + } + const col = Math.floor((logicalX - (ox + 15)) / 29) + const row = Math.floor((logicalY - gridY) / 29) + const hit = items.find((it: any) => { + const gx = Number(it.gridX ?? it.col ?? 0) + const gy = Number(it.gridY ?? it.row ?? 0) + const gw = Math.max(1, Number(it.invWidth ?? it.w ?? 1)) + const gh = Math.max(1, Number(it.invHeight ?? it.h ?? 1)) + return col >= gx && col < gx + gw && row >= gy && row < gy + gh + }) + if (hit) { + const gx = Number(hit.gridX ?? hit.col ?? 0) + const gy = Number(hit.gridY ?? hit.row ?? 0) + const gw = Math.max(1, Number(hit.invWidth ?? hit.w ?? 1)) + this.hoveredTradeItem = { + item: hit as UiInventoryItem, + x: ox + 15 + (gx + gw / 2) * 29, + y: gridY + gy * 29, + } + } + } + checkTradeGrid(oy + 60, this.trade.partnerItems) + if (!this.hoveredTradeItem) { + checkTradeGrid(oy + 220, this.trade.selfItems ?? this.trade.myItems) + } + } + + // Quest reward slot (`imbue` / `add_sockets` / `personalize` left panel) + if ( + this.questRewardItem && + logicalX >= ox + 116 && + logicalX <= ox + 116 + 88 && + logicalY >= oy + 120 && + logicalY <= oy + 120 + 116 + ) { + this.hoveredQuestRewardItem = { + item: this.questRewardItem, + x: ox + 116 + 44, + y: oy + 120, + } + } } - getNpcMenuLayout(font?: Pick | null): NpcDialogLayout | null { + getNpcMenuLayout( + font?: Pick | null, + viewportWidth = 800, + viewportHeight = 600, + ): NpcDialogLayout | null { if (!this.npcMenu) return null const opts = this.npcMenu.options ?? [ { id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' }, @@ -2154,23 +2235,34 @@ export class WorldPanelsHud { const headerName = this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang) : getLocalizedNpcName(this.npcMenu.npcName, this.lang) - return computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang) + return computeNpcDialogLayout( + headerName, + opts, + this.npcMenu.x, + this.npcMenu.y, + font, + this.lang, + viewportWidth, + viewportHeight, + ) } isPointInNpcMenu( logicalX: number, logicalY: number, font?: Pick | null, + viewportWidth = 800, + viewportHeight = 600, ): boolean { if (!this.npcMenu) return false if (this.npcMenu.talking) { const boxW = 340 const boxH = 156 - const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2))) - const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2))) + const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2))) + const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2))) return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH } - const layout = this.getNpcMenuLayout(font) + const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight) if (!layout) return false return ( logicalX >= layout.left && @@ -2184,9 +2276,11 @@ export class WorldPanelsHud { logicalX: number, logicalY: number, font?: Pick | null, + viewportWidth = 800, + viewportHeight = 600, ): void { if (!this.npcMenu || this.npcMenu.talking) return - const layout = this.getNpcMenuLayout(font) + const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight) if (!layout) return // D2Client.dll 0x6fb533c0 -> 0x6fb534b0: update selected option when cursor hits a selectable line; // when cursor is outside selectable lines (0x6fb533de: je 0x6fb53402), keep existing selection. @@ -2206,6 +2300,8 @@ export class WorldPanelsHud { onCancel: () => void }, font?: Pick | null, + viewportWidth = 800, + viewportHeight = 600, ): boolean { if (!this.npcMenu) return false if (this.npcMenu.talking) { @@ -2221,7 +2317,16 @@ export class WorldPanelsHud { const headerName = this.npcMenu.descriptor ? getLocalizedNpcName(this.npcMenu.descriptor, this.lang) : getLocalizedNpcName(this.npcMenu.npcName, this.lang) - const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang) + const layout = computeNpcDialogLayout( + headerName, + opts, + this.npcMenu.x, + this.npcMenu.y, + font, + this.lang, + viewportWidth, + viewportHeight, + ) if ( logicalX < layout.left || @@ -3055,6 +3160,8 @@ export class WorldPanelsHud { boxPiecesImg?: HTMLImageElement | null focus16Img?: HTMLImageElement | null }, + viewportWidth = 800, + viewportHeight = 600, ): void { if (!this.npcMenu) return const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage() @@ -3064,8 +3171,8 @@ export class WorldPanelsHud { : getLocalizedNpcName(this.npcMenu.npcName, this.lang) const boxW = 340 const boxH = 156 - const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2))) - const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2))) + const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2))) + const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2))) drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg) @@ -3092,7 +3199,16 @@ export class WorldPanelsHud { { id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' }, { id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'cancel' }, ] - const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang) + const layout = computeNpcDialogLayout( + headerName, + opts, + this.npcMenu.x, + this.npcMenu.y, + font, + this.lang, + viewportWidth, + viewportHeight, + ) // 1. D2Client.dll 0x6fb53890 + 0x6fb6e4c0: dark translucent fill + carved stone boxpieces.dc6 border drawStoneBoxFrame(ctx, layout.left, layout.top, layout.width, layout.height, boxPiecesImg) @@ -3802,6 +3918,8 @@ export class WorldPanelsHud { hover: { item: UiInventoryItem; x: number; y: number } | null, font: D2FontRenderer, vendorOpen = false, + viewportWidth = 800, + viewportHeight = 600, ): void { if (!hover) return const { item } = hover @@ -4066,10 +4184,10 @@ export class WorldPanelsHud { const boxH = lines.length * lineH + padY * 2 const rawBy = hover.y < 280 ? hover.y + 24 : hover.y - boxH - 8 - const bx = Math.max(8, Math.min(800 - boxW - 8, Math.round(hover.x - boxW / 2))) - const maxTop = Math.max(8, 600 - boxH - 8) + const bx = Math.max(8, Math.min(viewportWidth - boxW - 8, Math.round(hover.x - boxW / 2))) + const maxTop = Math.max(8, viewportHeight - boxH - 8) const topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44 - const by = Math.max(topMargin, Math.min(600 - boxH - 8, rawBy)) + const by = Math.max(topMargin, Math.min(viewportHeight - boxH - 8, rawBy)) this.lastTooltipRect = { bx, by, boxW, boxH } // Rendering @@ -4090,7 +4208,12 @@ export class WorldPanelsHud { /** * Draw Area Entry Gothic Banner (`Entering: ` / `进入:`). */ - drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void { + drawAreaBanner( + ctx: CanvasRenderingContext2D, + nowMs: number, + font: D2FontRenderer, + viewportWidth = 800, + ): void { if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return const remain = this.areaBanner.untilMs - nowMs const alpha = remain < 800 ? remain / 800 : 1 @@ -4100,7 +4223,7 @@ export class WorldPanelsHud { this.lang === 'ENG' ? `Entering: ${this.areaBanner.titleEn || this.areaBanner.titleZh}` : `进入:${this.areaBanner.titleZh}` - font.drawText(ctx, bannerText, 400, 142, { + font.drawText(ctx, bannerText, Math.round(viewportWidth / 2), 142, { font: 'font30', color: 'gold', align: 'center', diff --git a/tests/client/viewport-formula-profiles.test.ts b/tests/client/viewport-formula-profiles.test.ts index c97a9e7..57c1b00 100644 --- a/tests/client/viewport-formula-profiles.test.ts +++ b/tests/client/viewport-formula-profiles.test.ts @@ -8,7 +8,23 @@ import { } from '../../src/client/settings/client-settings.ts' import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts' import { Toolbar } from '../../src/client/toolbar/toolbar.ts' -import { computeDockingLayout, HudManager } from '../../src/client/ui/hud-manager.ts' +import { + computeDockingLayout, + CUBE_GRID_ORIGIN, + fromLeftDockLocal, + fromRightDockLocal, + HudManager, + INV_GRID_ORIGIN, + STASH_GRID_ORIGIN, + toControlBarLocal, + toLeftDockLocal, + toRightDockLocal, + VENDOR_GRID_ORIGIN, + type HudCommandSink, +} from '../../src/client/ui/hud-manager.ts' +import type { UiInventoryItem } from '../../src/client/ui/inventory.ts' +import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts' +import { getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts' import { computeViewportAnchors, computeViewportFitSize, @@ -98,6 +114,37 @@ const EXPECTED: readonly ExpectedProfile[] = [ function createHudAt(profile: ViewportProfile): HudManager { const { width, height } = profile + const mockCtx = { + canvas: { width, height }, + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + globalAlpha: 1, + font: '', + textAlign: 'left', + textBaseline: 'alphabetic', + imageSmoothingEnabled: false, + save: () => {}, + restore: () => {}, + translate: () => {}, + scale: () => {}, + clearRect: () => {}, + fillRect: () => {}, + strokeRect: () => {}, + beginPath: () => {}, + closePath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + arc: () => {}, + rect: () => {}, + clip: () => {}, + fill: () => {}, + stroke: () => {}, + fillText: () => {}, + drawImage: () => {}, + measureText: (t: string) => ({ width: t.length * 8 }), + createLinearGradient: () => ({ addColorStop: () => {} }), + } as unknown as CanvasRenderingContext2D const canvas = { width, height, @@ -106,6 +153,7 @@ function createHudAt(profile: ViewportProfile): HudManager { style: { cursor: '', pointerEvents: '' }, addEventListener: () => {}, getBoundingClientRect: () => ({ left: 0, top: 0, width, height }), + getContext: () => mockCtx, } as unknown as HTMLCanvasElement const hud = new HudManager(canvas, { onToggleAutomap: () => {}, @@ -337,3 +385,579 @@ describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () => expect(fit1068).toMatchObject({ cssWidth: 1920, cssHeight: 1079, letterboxLeft: 0, letterboxTop: 0 }) }) }) + +function makeTestUiItem(id: string, code: string, name: string, w = 1, h = 1): UiInventoryItem { + return { + id, + code, + invFile: code, + name, + nameZh: name, + baseNameZh: name, + quality: 'normal', + invWidth: w, + invHeight: h, + allowedSlots: ['helm'], + stats: [], + } +} + +describe('HUD input and hover coordinate translation across all 4 viewport profiles (Issue #542)', () => { + it('converts logical <-> region-local coordinates using active docking offsets across all 4 profiles', () => { + for (const { profile } of EXPECTED) { + const docking = computeDockingLayout(profile.width, profile.height, 1, profile) + const deltaRight = docking.rightDockX - 400 + + expect(toControlBarLocal(263 + profile.barOffsetX, 582 + profile.barOffsetY, docking)).toEqual({ + x: 263, + y: 582, + }) + expect(toLeftDockLocal(217 + docking.leftDockX, 153 + profile.panelOffsetY, docking)).toEqual({ + x: 217, + y: 153, + }) + expect(fromLeftDockLocal(217, 153, docking)).toEqual({ + x: 217 + docking.leftDockX, + y: 153 + profile.panelOffsetY, + }) + expect(toRightDockLocal(432.5 + deltaRight, 330.5 + profile.panelOffsetY, docking)).toEqual({ + x: 432.5, + y: 330.5, + }) + expect(fromRightDockLocal(432.5, 330.5, docking)).toEqual({ + x: 432.5 + deltaRight, + y: 330.5 + profile.panelOffsetY, + }) + } + }) + + it.each(EXPECTED)( + 'translates bottom control bar clicks and hovers ($id: barOffset=($barOffsetX,$barOffsetY))', + ({ profile, barOffsetX, barOffsetY }) => { + const hud = createHudAt(profile) + const useBeltSlot = vi.fn(() => true) + hud.commandSink = { useBeltSlot } as unknown as HudCommandSink + + // 1. Run/Walk toggle button (canonical 255..271, 572..592 -> center 263, 582) + const runX = 263 + barOffsetX + const runY = 582 + barOffsetY + hud.handleMouseMove(runX, runY) + expect(hud.controlBar.hoveredElement).toBe('run') + const prevRun = hud.isRunning + hud.handleClick(runX, runY, 0) + expect(hud.isRunning).toBe(!prevRun) + + // 2. Mini-panel buttons (canonical 312..488, 522..548 -> char at 325, 535; inv at 352, 535) + const charBtnX = 325 + barOffsetX + const charBtnY = 535 + barOffsetY + hud.handleMouseMove(charBtnX, charBtnY) + expect(hud.controlBar.hoveredElement).toBe('char') + hud.handleClick(charBtnX, charBtnY, 0) + expect(hud.leftPanel).toBe('char') + + const invBtnX = 352 + barOffsetX + const invBtnY = 535 + barOffsetY + hud.handleMouseMove(invBtnX, invBtnY) + expect(hud.controlBar.hoveredElement).toBe('inv') + hud.handleClick(invBtnX, invBtnY, 0) + expect(hud.rightPanel).toBe('inv') + + // 3. Belt slot col 0, row 0 (canonical 423..451, 561..589 -> center 435, 575) + const beltX = 435 + barOffsetX + const beltY = 575 + barOffsetY + hud.handleMouseMove(beltX, beltY) + expect(hud.belt.hoveredSlot).toEqual({ row: 0, col: 0 }) + hud.handleClick(beltX, beltY, 2) + expect(useBeltSlot).toHaveBeenCalledWith(0, false) + + // 4. Life & Mana globes (canonical centers 68, 555 and 732, 555) + const lifeX = 68 + barOffsetX + const lifeY = 555 + barOffsetY + hud.handleMouseMove(lifeX, lifeY) + expect(hud.globes.lifeHovered).toBe(true) + const prevLifeLock = hud.globes.lifeTextLocked + hud.handleClick(lifeX, lifeY, 0) + expect(hud.globes.lifeTextLocked).toBe(!prevLifeLock) + + const manaX = 732 + barOffsetX + const manaY = 555 + barOffsetY + hud.handleMouseMove(manaX, manaY) + expect(hud.globes.manaHovered).toBe(true) + const prevManaLock = hud.globes.manaTextLocked + hud.handleClick(manaX, manaY, 0) + expect(hud.globes.manaTextLocked).toBe(!prevManaLock) + + // 5. Left & Right skill hotkey slots (canonical centers 141, 575 and 659, 575) + const leftSkillX = 141 + barOffsetX + const leftSkillY = 575 + barOffsetY + hud.handleClick(leftSkillX, leftSkillY, 0) + expect(hud.hotkeys.openPopup).toBe('left') + hud.handleClick(leftSkillX, leftSkillY, 0) + expect(hud.hotkeys.openPopup).toBeNull() + }, + ) + + it.each(EXPECTED)( + 'translates left dock clicks and reverse-translates stash/cube/vendor hover tooltips ($id: panelOffsetY=$panelOffsetY)', + ({ profile, panelOffsetY }) => { + const hud = createHudAt(profile) + const docking = hud.getDockingLayout() + const leftDockX = docking.leftDockX + const allocateStat = vi.fn(() => true) + hud.commandSink = { allocateStat } as unknown as HudCommandSink + + // 1. Character sheet stat '+' button (STR canonical 202..232, 138..168 -> center 217, 153) + hud.toggleLeftPanel('char') + hud.charSheet.attrs.statPoints = 5 + hud.handleClick(217 + leftDockX, 153 + panelOffsetY, 0) + expect(allocateStat).toHaveBeenCalledWith('str') + + // Character sheet close button (canonical 208..240, 448..480 -> center 224, 464) + hud.handleClick(224 + leftDockX, 464 + panelOffsetY, 0) + expect(hud.leftPanel).toBe('none') + + // Clear commandSink to test offline stash pickup as well + hud.commandSink = null + + // 2. Stash grid cell (col 0, row 0) click & hover tooltip writeback + hud.leftPanel = 'stash' + const stashItem = makeTestUiItem('stash-1', 'rin', 'Stone of Jordan') + hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }] + const stashHoverX = STASH_GRID_ORIGIN.x + 14.5 + leftDockX + const stashHoverY = STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY + hud.handleMouseMove(stashHoverX, stashHoverY) + expect(hud.inventory.hoveredItem).toEqual({ + item: stashItem, + x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX, + y: STASH_GRID_ORIGIN.y + panelOffsetY, + }) + + // Clicking stash cell (0, 0) picks up the item onto cursor + hud.handleClick(stashHoverX, stashHoverY, 0) + expect(hud.inventory.cursorItem?.id).toBe('stash-1') + hud.inventory.cursorItem = null + + // 3. Cube grid cell (col 0, row 0) hover tooltip writeback + hud.leftPanel = 'cube' + const cubeItem = makeTestUiItem('cube-1', 'rvl', 'Full Rejuvenation Potion') + hud.worldPanels.cubeItems = [{ item: cubeItem, col: 0, row: 0 }] + const cubeHoverX = CUBE_GRID_ORIGIN.x + 14.5 + leftDockX + const cubeHoverY = CUBE_GRID_ORIGIN.y + 14.5 + panelOffsetY + hud.handleMouseMove(cubeHoverX, cubeHoverY) + expect(hud.inventory.hoveredItem).toEqual({ + item: cubeItem, + x: CUBE_GRID_ORIGIN.x + 14.5 + leftDockX, + y: CUBE_GRID_ORIGIN.y + panelOffsetY, + }) + + // 4. Vendor grid cell (col 0, row 0) hover tooltip writeback + const akara = getTownNpcDescriptorByName('Akara')! + const vendorItem = makeTestUiItem('vendor-1', 'cap', 'Cap', 2, 2) + hud.worldPanels.setVendorShop({ + npcId: 1, + descriptor: akara, + mode: 'trade', + items: [{ storePage: 1, placement: { item: vendorItem, col: 0, row: 0 } }], + }) + hud.leftPanel = 'vendor' + const vendorHoverX = VENDOR_GRID_ORIGIN.x + 14.5 + leftDockX + const vendorHoverY = VENDOR_GRID_ORIGIN.y + 14.5 + panelOffsetY + hud.handleMouseMove(vendorHoverX, vendorHoverY) + expect(hud.inventory.hoveredItem).toEqual({ + item: vendorItem, + x: VENDOR_GRID_ORIGIN.x + 29 + leftDockX, + y: VENDOR_GRID_ORIGIN.y + panelOffsetY, + }) + }, + ) + + it.each(EXPECTED)( + 'translates right dock inventory/equipment/skill-tree clicks and hover tooltips ($id: panelOffsetY=$panelOffsetY)', + ({ profile, panelOffsetY }) => { + const hud = createHudAt(profile) + const docking = hud.getDockingLayout() + const deltaRight = docking.rightDockX - 400 + + // 1. Inventory grid cell (col 0, row 0) hover & click at docked right panel coordinates + hud.toggleRightPanel('inv') + const bagItem = makeTestUiItem('bag-1', 'rin', 'Bul-Kathos Ring') + hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }] + const invHoverX = INV_GRID_ORIGIN.x + 14.5 + deltaRight + const invHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY + + hud.handleMouseMove(invHoverX, invHoverY) + expect(hud.inventory.hoveredItem).toEqual({ + item: bagItem, + x: INV_GRID_ORIGIN.x + 14.5 + deltaRight, + y: INV_GRID_ORIGIN.y + panelOffsetY, + }) + + // Clicking at the docked right-panel coordinate picks up the item + hud.handleClick(invHoverX, invHoverY, 0) + expect(hud.inventory.cursorItem?.id).toBe('bag-1') + hud.inventory.cursorItem = null + + // 2. Equipped helm slot (canonical 532..590, 64..122 -> center 561, 93; tooltip anchor 561, 64) + const helmItem = makeTestUiItem('helm-1', 'ci3', 'Griffon Eye', 2, 2) + hud.inventory.equipped.helm = helmItem + hud.handleMouseMove(561 + deltaRight, 93 + panelOffsetY) + expect(hud.inventory.hoveredItem).toEqual({ + item: helmItem, + x: 561 + deltaRight, + y: 64 + panelOffsetY, + }) + + // 2b. Tooltip box clamping uses active viewport width/height (not hardcoded 800x600) + hud.inventory.gridItems = [{ item: bagItem, col: 9, row: 0 }] + const rightColHoverX = INV_GRID_ORIGIN.x + 9 * 29 + 14.5 + deltaRight + const rightColHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY + hud.handleMouseMove(rightColHoverX, rightColHoverY) + const mockCtx = { + fillStyle: '', + strokeStyle: '', + save: () => {}, + restore: () => {}, + fillRect: () => {}, + strokeRect: () => {}, + fillText: () => {}, + drawImage: () => {}, + measureText: () => ({ width: 50 }), + } as unknown as CanvasRenderingContext2D + hud.worldPanels.drawItemTooltip( + mockCtx, + hud.inventory.hoveredItem, + hud.font, + false, + profile.width, + profile.height, + ) + expect(hud.worldPanels.lastTooltipRect).not.toBeNull() + const tipRect = hud.worldPanels.lastTooltipRect! + expect(tipRect.bx + tipRect.boxW).toBeLessThanOrEqual(profile.width - 8) + if (profile.width > 800) { + expect(tipRect.bx + tipRect.boxW).toBeGreaterThan(800) + } + + // 3. Skill tree node hover & click at docked right panel coordinates + hud.toggleRightPanel('skill') + hud.skillTree.setClass('sor') + hud.skillTree.characterLevel = 30 + hud.skillTree.unspentSkillPoints = 5 + const allocateSkill = vi.fn(() => true) + hud.commandSink = { allocateSkill } as unknown as HudCommandSink + + const firstTabNode = hud.skillTree + .getSkillTree() + .find((n) => n.tab === hud.skillTree.activeTab && n.prereqs.length === 0)! + const nodeRect = SkillTreePanel.nodeScreenPos(firstTabNode) + const nodeClickX = nodeRect.x + nodeRect.w / 2 + deltaRight + const nodeClickY = nodeRect.y + nodeRect.h / 2 + panelOffsetY + + hud.handleMouseMove(nodeClickX, nodeClickY) + expect(hud.skillTree.hoveredSkill?.skillId).toBe(firstTabNode.skillId) + + hud.handleClick(nodeClickX, nodeClickY, 0) + expect(allocateSkill).toHaveBeenCalledWith(firstTabNode.skillId) + }, + ) + + it('does not trigger right-dock or bottom-bar actions when clicking old un-offset 800x600 coordinates in 1024x768 and 1280x720', () => { + for (const profile of [VIEWPORT_1024x768, VIEWPORT_1280x720]) { + const hud = createHudAt(profile) + hud.toggleRightPanel('inv') + const bagItem = makeTestUiItem('bag-corridor', 'rin', 'Ring') + hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }] + + // Un-offset (432.5, 330.5) falls inside the open center corridor (400..624 in 1024x768, 400..880 in 1280x720) + expect(hud.isPointInterceptedByHud(432.5, 330.5)).toBe(false) + hud.handleClick(432.5, 330.5, 0) + expect(hud.inventory.cursorItem).toBeNull() + + // Un-offset Run button (263, 582) is above the bottom bar in 1024x768 (bar y=708..768) and 1280x720 (bar y=660..720) + const prevRun = hud.isRunning + hud.handleClick(263, 582, 0) + expect(hud.isRunning).toBe(prevRun) + } + }) + + it.each(EXPECTED)( + 'translates window mousemove listener hover coordinates and pointer-events across $id', + ({ profile, barOffsetX, barOffsetY, panelOffsetY }) => { + const prevWindow = (globalThis as { window?: unknown }).window + const mockWindow = Object.assign(new EventTarget(), { + innerWidth: profile.width, + innerHeight: profile.height, + devicePixelRatio: 1, + }) + ;(globalThis as { window?: unknown }).window = mockWindow + try { + const hud = createHudAt(profile) + const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas + const docking = hud.getDockingLayout() + const deltaRight = docking.rightDockX - 400 + const leftDockX = docking.leftDockX + + hud.toggleRightPanel('inv') + hud.leftPanel = 'stash' + const bagItem = makeTestUiItem('win-bag-1', 'rin', 'Raven Frost') + const stashItem = makeTestUiItem('win-stash-1', 'amu', 'Highlord Wrath') + hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }] + hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }] + + // Dispatch window mousemove over right-dock inventory item + const invClient = hud.logicalToClient( + INV_GRID_ORIGIN.x + 14.5 + deltaRight, + INV_GRID_ORIGIN.y + 14.5 + panelOffsetY, + ) + const invEv = Object.assign(new Event('mousemove'), { + clientX: invClient.x, + clientY: invClient.y, + }) + mockWindow.dispatchEvent(invEv) + expect(hud.inventory.hoveredItem).toEqual({ + item: bagItem, + x: INV_GRID_ORIGIN.x + 14.5 + deltaRight, + y: INV_GRID_ORIGIN.y + panelOffsetY, + }) + expect(hudCanvas.style.pointerEvents).toBe('auto') + + // Dispatch window mousemove over left-dock stash item + const stashClient = hud.logicalToClient( + STASH_GRID_ORIGIN.x + 14.5 + leftDockX, + STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY, + ) + const stashEv = Object.assign(new Event('mousemove'), { + clientX: stashClient.x, + clientY: stashClient.y, + }) + mockWindow.dispatchEvent(stashEv) + expect(hud.inventory.hoveredItem).toEqual({ + item: stashItem, + x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX, + y: STASH_GRID_ORIGIN.y + panelOffsetY, + }) + expect(hudCanvas.style.pointerEvents).toBe('auto') + + // Dispatch window mousemove over bottom bar Run/Walk toggle button + const runClient = hud.logicalToClient(263 + barOffsetX, 582 + barOffsetY) + const runEv = Object.assign(new Event('mousemove'), { + clientX: runClient.x, + clientY: runClient.y, + }) + mockWindow.dispatchEvent(runEv) + expect(hud.controlBar.hoveredElement).toBe('run') + expect(hudCanvas.style.pointerEvents).toBe('auto') + } finally { + if (prevWindow === undefined) { + delete (globalThis as { window?: unknown }).window + } else { + ;(globalThis as { window?: unknown }).window = prevWindow + } + } + }, + ) +}) + +describe('Skill tooltips and full HUD UI audit across >800x600 viewports (Issue #557)', () => { + it.each(EXPECTED)( + 'synchronizes skillTree.visible on direct rightPanel assignment and renders SkillTreePanel & Hotkeys skill tooltips on top layer ($id)', + ({ profile, barOffsetX, barOffsetY, panelOffsetY }) => { + const hud = createHudAt(profile) + const docking = hud.getDockingLayout() + const deltaRight = docking.rightDockX - 400 + + // 1. Direct assignment (as done by HudModel.syncFromWorld) must keep skillTree.visible in sync + hud.rightPanel = 'skill' + expect(hud.skillTree.visible).toBe(true) + hud.skillTree.setClass('sor') + hud.skillTree.characterLevel = 30 + hud.skillTree.hardPoints.set(36, 5) // Fire Bolt + + const col1Node = hud.skillTree + .getSkillTree() + .find((n) => n.tab === hud.skillTree.activeTab && n.col === 1)! + const nodeRect = SkillTreePanel.nodeScreenPos(col1Node) + hud.handleMouseMove( + nodeRect.x + nodeRect.w / 2 + deltaRight, + nodeRect.y + nodeRect.h / 2 + panelOffsetY, + ) + expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId) + + // Verify tooltip placement with minLocalX = 8 - deltaRight does not clamp over col-1 node on >800x600 + const placement = SkillTreePanel.computeTooltipPlacement( + nodeRect, + [{ text: 'Fire Bolt Level 5 (Mana Cost: 2.5)', color: 'gold', font: 'font8' }], + hud.font, + 8 - deltaRight, + ) + if (deltaRight > 0) { + expect(placement.x + placement.w).toBeLessThanOrEqual(nodeRect.x) + } + + // 2. Quickbar active skill slot hover & Speedbar popup skill hover at translated barOffset + hud.hotkeys.setAvailableSkills( + [ + { + skillId: 0, + name: 'Attack', + nameZh: '攻击', + level: 1, + manaCost: 0, + leftUsable: true, + rightUsable: true, + }, + { + skillId: 36, + name: 'Fire Bolt', + nameZh: '火弹', + level: 5, + manaCost: 3, + leftUsable: true, + rightUsable: true, + }, + { + skillId: 38, + name: 'Charged Bolt', + nameZh: '充能弹', + level: 3, + manaCost: 4, + leftUsable: true, + rightUsable: true, + }, + ], + 36, + 38, + ) + + const leftSlotX = 141 + barOffsetX + const leftSlotY = 575 + barOffsetY + hud.handleMouseMove(leftSlotX, leftSlotY) + expect(hud.hotkeys.hoveredSlot).toBe('left') + expect(hud.hotkeys.hoverSkillId).toBe(36) + + // Open left speedbar popup and hover its first skill cell + hud.handleClick(leftSlotX, leftSlotY, 0) + expect(hud.hotkeys.openPopup).toBe('left') + const popupCells = hud.hotkeys.getPopupCells('left') + expect(popupCells.length).toBeGreaterThan(0) + const firstCell = popupCells[0]! + hud.handleMouseMove(firstCell.x + 24 + barOffsetX, firstCell.y + 24 + barOffsetY) + expect(hud.hotkeys.hoveredPopupSkill).toEqual({ + side: 'left', + skillId: firstCell.skill.skillId, + }) + expect(hud.hotkeys.hoverSpeedbarSkillId).toBe(firstCell.skill.skillId) + + // 3. Verify HudManager.render invokes hotkeys.draw AFTER belt.draw, and draws skillTree tooltip on top layer + const callOrder: string[] = [] + const origBeltDraw = hud.belt.draw.bind(hud.belt) + const origHotkeysDraw = hud.hotkeys.draw.bind(hud.hotkeys) + const origSkillTooltipDraw = hud.skillTree.drawSkillTooltip.bind(hud.skillTree) + vi.spyOn(hud.belt, 'draw').mockImplementation((...args) => { + callOrder.push('belt.draw') + return origBeltDraw(...args) + }) + vi.spyOn(hud.hotkeys, 'draw').mockImplementation((...args) => { + callOrder.push('hotkeys.draw') + return origHotkeysDraw(...args) + }) + vi.spyOn(hud.skillTree, 'drawSkillTooltip').mockImplementation((...args) => { + callOrder.push('skillTree.drawSkillTooltip') + return origSkillTooltipDraw(...args) + }) + + // Hover the skill tree node again before rendering so skillTree.hoveredSkill is active during render() + hud.handleMouseMove( + nodeRect.x + nodeRect.w / 2 + deltaRight, + nodeRect.y + nodeRect.h / 2 + panelOffsetY, + ) + expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId) + + hud.render(1000) + + expect(callOrder.indexOf('belt.draw')).toBeGreaterThanOrEqual(0) + expect(callOrder.indexOf('hotkeys.draw')).toBeGreaterThan(callOrder.indexOf('belt.draw')) + expect(callOrder.indexOf('skillTree.drawSkillTooltip')).toBeGreaterThan( + callOrder.indexOf('hotkeys.draw'), + ) + }, + ) + + it.each(EXPECTED)( + 'translates Mercenary, Trade, and Quest Reward left-dock item hover tooltips and clamps NPC menu / Area Banner / Death Overlay ($id)', + ({ profile, panelOffsetY }) => { + const hud = createHudAt(profile) + const docking = hud.getDockingLayout() + const leftDockX = docking.leftDockX + + // 1. Mercenary equipment hover tooltip (helm slot: x=212..268, y=104..160 -> center 240, 132) + hud.leftPanel = 'merc' + const mercHelm = makeTestUiItem('merc-helm', 'usk', 'Andariel Visage', 2, 2) + hud.worldPanels.merc = { headItem: mercHelm } + hud.handleMouseMove(240 + leftDockX, 132 + panelOffsetY) + expect(hud.inventory.hoveredItem).toEqual({ + item: mercHelm, + x: 240 + leftDockX, + y: 104 + panelOffsetY, + }) + + // 2. Quest Reward (imbue) slot hover tooltip (x=196..284, y=180..296 -> center 240, 238) + hud.leftPanel = 'imbue' + const imbueItem = makeTestUiItem('imbue-boots', 'xtb', 'War Boots', 2, 2) + hud.worldPanels.questRewardItem = imbueItem + hud.handleMouseMove(240 + leftDockX, 238 + panelOffsetY) + expect(hud.inventory.hoveredItem).toEqual({ + item: imbueItem, + x: 240 + leftDockX, + y: 180 + panelOffsetY, + }) + + // 3. NPC interaction menu clamps against active viewport width/height (not hardcoded 800x600) + const akara = getTownNpcDescriptorByName('Akara')! + hud.worldPanels.openNpcMenu(akara, profile.width - 60, 220, 1) + expect( + hud.worldPanels.isPointInNpcMenu( + profile.width - 60, + 240, + hud.font, + profile.width, + profile.height, + ), + ).toBe(true) + expect(hud.isPointInterceptedByHud(profile.width - 60, 240)).toBe(true) + hud.worldPanels.npcMenu = null + + // 4. Area Banner & Death Overlay center across profile.width / profile.height + hud.leftPanel = 'none' + hud.rightPanel = 'none' + hud.worldPanels.areaBanner = { + titleZh: '鲜血荒地', + titleEn: 'Blood Moor', + untilMs: 5000, + } + hud.isDead = true + + const fillRectCalls: Array<[number, number, number, number]> = [] + const drawTextXs: number[] = [] + const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas + const hudCtx = hudCanvas.getContext('2d')! + const origFillRect = hudCtx.fillRect.bind(hudCtx) + vi.spyOn(hudCtx, 'fillRect').mockImplementation((x, y, w, h) => { + fillRectCalls.push([x, y, w, h]) + return origFillRect(x, y, w, h) + }) + vi.spyOn(hud.font, 'drawText').mockImplementation((_ctx, _text, x) => { + drawTextXs.push(x) + }) + + hud.render(1500) + + // Death overlay must cover (0, 0, profile.width, profile.height) + expect(fillRectCalls).toContainEqual([0, 0, profile.width, profile.height]) + // Area banner and Death overlay text must center at Math.round(profile.width / 2) + expect(drawTextXs).toContain(Math.round(profile.width / 2)) + }, + ) +}) + + From 773e3be1177dee93e502d9e31cc35e2af13d45e1 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 10:53:45 +0000 Subject: [PATCH 10/15] chore: resolve merge types in world-panels, hud-manager and client-world --- src/client/ui/hud-manager.ts | 4 ++-- src/client/ui/world-panels.ts | 7 ++++--- src/client/world/client-world.ts | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 7904295..7622d2f 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -563,8 +563,8 @@ export class HudManager { /** Server-authoritative death / mercenary / party / trade state synced by `HudModel`. */ isDead = false merc: any = null - hireList: any[] = [] - mercHireList: any[] = [] + hireList: readonly any[] = [] + mercHireList: readonly any[] = [] partyMembers: any[] = [] summonPortraits: any[] = [] trade: any = null diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index 94ddaab..f4161f8 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -49,6 +49,7 @@ import { type VendorNpcId, getTownNpcDescriptorByName, getTownNpcDescriptorByVendorId, + getTownNpcDescriptorByHcIdx, getLocalizedNpcName, getLocalizedNpcGreetings, } from '../../common/units/npc-table.ts' @@ -863,8 +864,8 @@ export class WorldPanelsHud { private _activePanel: WorldPanelType = 'none' isDead = false merc: any = null - hireList: any[] = [] - mercHireList: any[] = [] + hireList: readonly any[] = [] + mercHireList: readonly any[] = [] hireScrollOffset = 0 hireSelectedIndex = -1 hireHoveredIndex: number | null = null @@ -3686,7 +3687,7 @@ export class WorldPanelsHud { // Warning text: "This Mercenary will replace your current one." (VerifyTransaction9) const warnText = lookupTbl('VerifyTransaction9', this.lang) ?? (this.lang === 'ENG' ? 'This Mercenary will replace your current one.' : '這個傭兵會換掉前一位。') if (font) { - this.drawWrappedText(ctx, font, warnText, cx + 24, cy + 32, confirmW - 48, 16, 4, 'font16', 'gold', 'center') + this.drawWrappedText(ctx, font, warnText, cx + 24, cy + 32, confirmW - 48, 16, 4, 'fontformal12') } else { fillTextSafe(warnText, cx + 24, cy + 32, '#e8c26b') } diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 1d9f947..c0cb20a 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -932,7 +932,7 @@ export class ClientWorld { this._containers.clearShop() this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'gamble', activeWindow: 'gamble' } } else if (cmd.action === 3) { - this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'hire', activeWindow: 'hire' } + this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'none', activeWindow: 'hire' } } break case 'NpcCancel': From 7e46fab753fbd1d6cfb3f390d047ad4ee4ffe304 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 11:08:54 +0000 Subject: [PATCH 11/15] test(client): add headless browser verification script for Issue #542 HUD docking and hover tooltips --- tools/verify-issue-542-browser.ts | 452 ++++++++++++++++++++++++++++++ 1 file changed, 452 insertions(+) create mode 100644 tools/verify-issue-542-browser.ts diff --git a/tools/verify-issue-542-browser.ts b/tools/verify-issue-542-browser.ts new file mode 100644 index 0000000..184447a --- /dev/null +++ b/tools/verify-issue-542-browser.ts @@ -0,0 +1,452 @@ +#!/usr/bin/env npx tsx +/** + * Headless Chrome Browser Verification for Issue #542: + * HUD docking input coordinates, interactive clicks, and hover tooltips + * across extended viewport profiles (1024x768, 1068x600, 1280x720). + */ + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer, type ViteDevServer } from 'vite' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = resolve(__dirname, '..') + +const CHROME_CANDIDATES = [ + join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'), + '/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome', + '/usr/bin/google-chrome', +] + +function findChromeBinary(): string { + for (const cand of CHROME_CANDIDATES) { + if (cand && existsSync(cand)) return cand + } + throw new Error(`No headless Chrome binary found in candidates: ${CHROME_CANDIDATES.join(', ')}`) +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +class CdpSession { + private readonly ws: WebSocket + private msgId = 0 + private readonly pending = new Map< + number, + { readonly resolve: (val: any) => void; readonly reject: (err: Error) => void } + >() + readonly consoleErrors: string[] = [] + readonly pageExceptions: string[] = [] + + private constructor(ws: WebSocket) { + this.ws = ws + this.ws.onmessage = (ev) => { + const msg = JSON.parse(String(ev.data)) + if (typeof msg.id === 'number') { + const entry = this.pending.get(msg.id) + if (entry) { + this.pending.delete(msg.id) + if (msg.error) { + entry.reject(new Error(msg.error.message || JSON.stringify(msg.error))) + } else { + entry.resolve(msg.result) + } + } + return + } + if (msg.method === 'Runtime.consoleAPICalled') { + const type = msg.params?.type + if (type === 'error') { + const text = (msg.params?.args ?? []) + .map((a: any) => a.value ?? a.description ?? '') + .join(' ') + if (!text.includes('favicon.ico') && !text.includes('404')) { + this.consoleErrors.push(text) + } + } + } else if (msg.method === 'Runtime.exceptionThrown') { + const details = msg.params?.exceptionDetails + const desc = + details?.exception?.description ?? details?.text ?? JSON.stringify(details) + this.pageExceptions.push(String(desc)) + } + } + } + + static async connect(wsUrl: string): Promise { + const ws = new WebSocket(wsUrl) + await new Promise((resolve, reject) => { + ws.onopen = () => resolve() + ws.onerror = (err) => reject(new Error(`CDP WebSocket error: ${String(err)}`)) + }) + const cdp = new CdpSession(ws) + await cdp.send('Runtime.enable') + await cdp.send('Page.enable') + return cdp + } + + send(method: string, params: Record = {}): Promise { + const id = ++this.msgId + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.ws.send(JSON.stringify({ id, method, params })) + }) + } + + async evaluate(expression: string): Promise { + const res = await this.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }) + if (res?.exceptionDetails) { + const desc = + res.exceptionDetails.exception?.description ?? + res.exceptionDetails.text ?? + JSON.stringify(res.exceptionDetails) + throw new Error(`CDP evaluate exception: ${String(desc)}`) + } + return res?.result?.value as T + } + + async captureScreenshot(outPath: string): Promise { + const shot = await this.send('Page.captureScreenshot', { format: 'png' }) + const data = shot?.data + if (typeof data !== 'string' || data.length === 0) { + throw new Error('Failed to capture PNG screenshot via CDP') + } + const buf = Buffer.from(data, 'base64') + mkdirSync(dirname(outPath), { recursive: true }) + writeFileSync(outPath, buf) + return buf.byteLength + } + + async close(): Promise { + try { + this.ws.close() + } catch { + // ignore + } + } +} + +interface ProfileAuditSpec { + readonly id: '1024x768' | '1068x600' | '1280x720' + readonly width: number + readonly height: number + readonly barOffsetX: number + readonly barOffsetY: number + readonly panelOffsetY: number +} + +const EXTENDED_PROFILES: readonly ProfileAuditSpec[] = [ + { id: '1024x768', width: 1024, height: 768, barOffsetX: 112, barOffsetY: 168, panelOffsetY: 84 }, + { id: '1068x600', width: 1068, height: 600, barOffsetX: 134, barOffsetY: 0, panelOffsetY: 0 }, + { id: '1280x720', width: 1280, height: 720, barOffsetX: 240, barOffsetY: 120, panelOffsetY: 60 }, +] + +async function run(): Promise { + const chromeBin = findChromeBinary() + const outDir = resolve(ROOT_DIR, 'samples/screenshots-issue-542') + mkdirSync(outDir, { recursive: true }) + + const fixturePath = resolve(ROOT_DIR, 'tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap') + if (!existsSync(fixturePath)) { + throw new Error(`Missing fixture at ${fixturePath}`) + } + const d2capData = readFileSync(fixturePath, 'utf8') + + let viteServer: ViteDevServer | null = null + const userDataDir = mkdtempSync(join(tmpdir(), 'd2-verify-542-')) + let chromeProc: ReturnType | null = null + let cdp: CdpSession | null = null + + try { + viteServer = await createServer({ + root: ROOT_DIR, + server: { + host: '127.0.0.1', + port: 0, + strictPort: false, + watch: null, + }, + logLevel: 'error', + }) + await viteServer.listen() + const addr = viteServer.httpServer?.address() + const port = typeof addr === 'object' && addr ? addr.port : 0 + if (!port) throw new Error('Failed to start Vite server') + + const targetUrl = `http://127.0.0.1:${port}/play.html` + console.log(`[verify-issue-542] Vite server listening at ${targetUrl}`) + + chromeProc = spawn( + chromeBin, + [ + '--headless=new', + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu-sandbox', + '--use-angle=swiftshader', + '--use-gl=angle', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + '--no-proxy-server', + '--password-store=basic', + '--disable-extensions', + '--no-first-run', + '--no-default-browser-check', + '--force-device-scale-factor=1', + '--window-size=1280,960', + `--user-data-dir=${userDataDir}`, + '--remote-debugging-port=0', + 'about:blank', + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ) + + let cdpPort: string | null = null + for (let i = 0; i < 100; i++) { + await sleep(150) + try { + const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8') + const firstLine = raw.trim().split('\n')[0] + if (firstLine) { + cdpPort = firstLine + break + } + } catch { + // wait + } + } + if (!cdpPort) throw new Error('Chrome failed to start DevToolsActivePort') + + let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null + for (let i = 0; i < 120; i++) { + await sleep(150) + try { + const res = await fetch(`http://127.0.0.1:${cdpPort}/json`) + const list = (await res.json()) as any[] + const pages = list.filter((p) => p.type === 'page') + if (pages.length > 0 && pages[0]) { + pageTarget = pages[0] + break + } + } catch { + // wait + } + } + if (!pageTarget) throw new Error('Failed to attach to page target in headless Chrome') + + cdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl) + await cdp.send('Page.navigate', { url: targetUrl }) + + // Wait for play.html boot + let ready = false + for (let i = 0; i < 150; i++) { + const state = await cdp.evaluate<{ ready: boolean } | null>( + `window.__d2Play ? { ready: Boolean(window.__d2Play.ready) } : null`, + ) + if (state?.ready) { + ready = true + break + } + await sleep(200) + } + if (!ready) throw new Error('play.html failed to boot ready=true') + console.log('[verify-issue-542] play.html boot ready') + + // Enter in-game view via D2Cap replay + await cdp.evaluate(`(() => { + const app = window.__d2Play; + app.replayD2Cap(${JSON.stringify(d2capData)}); + })()`) + await sleep(500) + + for (const spec of EXTENDED_PROFILES) { + console.log(`\n─── Testing Extended Viewport Profile: ${spec.id} (${spec.width}x${spec.height}) ───`) + + // 1. Resize browser viewport via CDP Emulation + await cdp.send('Emulation.setDeviceMetricsOverride', { + width: spec.width, + height: spec.height, + deviceScaleFactor: 1, + mobile: false, + }) + + // 2. Set viewport profile in settingsStore and trigger stage resize + const layoutAudit = await cdp.evaluate<{ + viewportId: string + profileWidth: number + profileHeight: number + barOffsetX: number + barOffsetY: number + panelOffsetY: number + leftDockX: number + rightDockX: number + channelWidth: number + }>(`(() => { + const app = window.__d2Play; + app.settingsStore.update({ viewport: ${JSON.stringify(spec.id)} }); + app.renderFrame(performance.now()); + const hud = app.hudManager; + const docking = hud.getDockingLayout(); + return { + viewportId: hud.viewportProfile.id, + profileWidth: hud.viewportProfile.width, + profileHeight: hud.viewportProfile.height, + barOffsetX: docking.barOffsetX, + barOffsetY: docking.barOffsetY, + panelOffsetY: docking.panelOffsetY, + leftDockX: docking.leftDockX, + rightDockX: docking.rightDockX, + channelWidth: docking.channelWidthLogical, + }; + })()`) + + console.log(`[${spec.id}] Docking metrics:`, layoutAudit) + if ( + layoutAudit.viewportId !== spec.id || + layoutAudit.profileWidth !== spec.width || + layoutAudit.profileHeight !== spec.height || + layoutAudit.barOffsetX !== spec.barOffsetX || + layoutAudit.barOffsetY !== spec.barOffsetY || + layoutAudit.panelOffsetY !== spec.panelOffsetY || + layoutAudit.leftDockX !== 0 || + layoutAudit.rightDockX !== spec.width - 400 + ) { + throw new Error(`[${spec.id}] Docking layout metrics mismatch: ${JSON.stringify(layoutAudit)}`) + } + + // 3. Test interactive clicks on bottom control bar: Run/Walk toggle & mini-panel buttons + const barClickAudit = await cdp.evaluate<{ + runToggled: boolean + charOpened: boolean + invOpened: boolean + beltSlotHovered: boolean + }>(`(() => { + const app = window.__d2Play; + const hud = app.hudManager; + const barOffsetX = ${spec.barOffsetX}; + const barOffsetY = ${spec.barOffsetY}; + + // Run/Walk toggle at center (263 + barOffsetX, 582 + barOffsetY) + const prevRun = hud.isRunning; + hud.handleClick(263 + barOffsetX, 582 + barOffsetY, 0); + const runToggled = hud.isRunning !== prevRun; + + // Mini-panel Char button at (325 + barOffsetX, 535 + barOffsetY) + hud.leftPanel = 'none'; + hud.handleClick(325 + barOffsetX, 535 + barOffsetY, 0); + const charOpened = hud.leftPanel === 'char'; + + // Mini-panel Inv button at (352 + barOffsetX, 535 + barOffsetY) + hud.rightPanel = 'none'; + hud.handleClick(352 + barOffsetX, 535 + barOffsetY, 0); + const invOpened = hud.rightPanel === 'inv'; + + // Hover over Belt slot (0, 0) at (435 + barOffsetX, 575 + barOffsetY) + hud.handleMouseMove(435 + barOffsetX, 575 + barOffsetY); + const beltSlotHovered = hud.belt.hoveredSlot !== null && hud.belt.hoveredSlot.col === 0 && hud.belt.hoveredSlot.row === 0; + + app.renderFrame(performance.now()); + return { runToggled, charOpened, invOpened, beltSlotHovered }; + })()`) + + console.log(`[${spec.id}] Bar click & hover audit:`, barClickAudit) + if (!barClickAudit.runToggled || !barClickAudit.charOpened || !barClickAudit.invOpened || !barClickAudit.beltSlotHovered) { + throw new Error(`[${spec.id}] Control bar interactive click/hover failed: ${JSON.stringify(barClickAudit)}`) + } + + // Capture screenshot with both left dock (char) and right dock (inv) open + const dockShotPath = join(outDir, `play-${spec.id}-hud-docking.png`) + await cdp.captureScreenshot(dockShotPath) + console.log(`[${spec.id}] Saved screenshot: ${dockShotPath}`) + + // 4. Test inventory hover item tooltip at docked right coordinates + const invHoverAudit = await cdp.evaluate<{ + hoveredItemFound: boolean + hoveredX: number + hoveredY: number + tooltipBoxX: number + tooltipBoxW: number + tooltipClampedInsideViewport: boolean + }>(`(() => { + const app = window.__d2Play; + const hud = app.hudManager; + const deltaRight = ${spec.width} - 400 - 400; // rightDockX - 400 + const panelOffsetY = ${spec.panelOffsetY}; + + hud.rightPanel = 'inv'; + // Add a test ring at (col 9, row 0) to test outer boundary clamping + hud.inventory.gridItems = [{ + item: { + id: 'test-outer-ring', + code: 'rin', + name: 'Nagelring', + nameZh: '拿各的戒指', + baseNameZh: '戒指', + quality: 'unique', + invWidth: 1, + invHeight: 1, + stats: [ + { text: '+75 to Attack Rating', color: 'blue' }, + { text: 'Magic Damage Reduced by 3', color: 'blue' }, + { text: 'Attacker Takes Damage of 3', color: 'blue' }, + { text: '30% Better Chance of Getting Magic Items', color: 'blue' }, + ], + allowedSlots: ['ring1', 'ring2'], + }, + col: 9, + row: 0, + }]; + + // INV_GRID_ORIGIN is { x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 } + const hx = 418 + 9 * 29 + 14.5 + deltaRight; + const hy = 316 + 14.5 + panelOffsetY; + hud.handleMouseMove(hx, hy); + app.renderFrame(performance.now()); + + const hover = hud.inventory.hoveredItem; + const rect = hud.worldPanels.lastTooltipRect; + return { + hoveredItemFound: Boolean(hover && hover.item.id === 'test-outer-ring'), + hoveredX: hover ? hover.x : 0, + hoveredY: hover ? hover.y : 0, + tooltipBoxX: rect ? rect.bx : 0, + tooltipBoxW: rect ? rect.boxW : 0, + tooltipClampedInsideViewport: Boolean(rect && (rect.bx + rect.boxW <= ${spec.width} - 8)), + }; + })()`) + + console.log(`[${spec.id}] Inventory hover & tooltip audit:`, invHoverAudit) + if (!invHoverAudit.hoveredItemFound || !invHoverAudit.tooltipClampedInsideViewport) { + throw new Error(`[${spec.id}] Inventory item hover & tooltip clamping failed: ${JSON.stringify(invHoverAudit)}`) + } + + // Capture screenshot with inventory item hover tooltip visible + const tipShotPath = join(outDir, `play-${spec.id}-inventory-hover-tooltip.png`) + await cdp.captureScreenshot(tipShotPath) + console.log(`[${spec.id}] Saved screenshot: ${tipShotPath}`) + } + + console.log('\n=== Headless Browser Verification on play.html across all 3 extended viewports Passed (0 errors) ===') + } finally { + if (cdp) await cdp.close() + if (chromeProc) chromeProc.kill('SIGKILL') + if (viteServer) await viteServer.close() + rmSync(userDataDir, { recursive: true, force: true }) + } +} + +run().catch((err) => { + console.error('[verify-issue-542] Fatal error:', err) + process.exit(1) +}) From 62663c48401ff25c4c8f4566cf8eb128657a092d Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 11:13:26 +0000 Subject: [PATCH 12/15] fix(client): decouple 25Hz simulation and character animations from display refresh rate - Decouple discrete simulation stepping (missiles, overlays, frameCount) in ClientWorld to fixed 25Hz ticks (40ms) via accumulator, preventing animation speedup at high refresh rates (120Hz/144Hz/240Hz). - Continuous movement interpolation for moving units using exact (safeDt / 1000) * 10 without Math.max(1, ...). - Guard held mouse / repeat clicks from interrupting active cast animation every 100ms via CommandMapper.isPlayerBusyCasting. - Preserve locally-predicted cast timing and avoid animation restart when server 0x4C SkillCast echo arrives. - Drive fallback cast overlay in unit-drawables using elapsedTicks and animRate instead of global display tick. - Add comprehensive vitest coverage in tests/client/char-anim-speed-loop.test.ts. --- src/client/input/command-mapper.ts | 49 ++++++- src/client/main.ts | 2 +- src/client/session/online-session.ts | 2 +- src/client/view/unit-drawables.ts | 12 +- src/client/world/client-world.ts | 131 +++++++++++------ tests/client/char-anim-speed-loop.test.ts | 167 ++++++++++++++++++++++ 6 files changed, 317 insertions(+), 46 deletions(-) diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 4a32e04..d375d88 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -346,6 +346,23 @@ export class CommandMapper { return this.hudManager.isPointInterceptedByHud(screenX, screenY) } + /** + * Returns true if the local player is currently executing a non-expired cast + * or attack animation window. + */ + isPlayerBusyCasting(nowMs = performance.now()): boolean { + const selfUnit = + this.world.selfId !== 0 ? this.world.getUnit('player', this.world.selfId) : undefined + const castState = selfUnit?.castState ?? this.world.self.castState + if (!castState) return false + if (castState.untilMs !== undefined) { + return nowMs < castState.untilMs + } + const elapsed = castState.elapsedMs ?? 0 + const duration = castState.durationMs ?? 0 + return duration > 0 && elapsed < duration + } + // ─── Mouse / Pointer Input Handlers ────────────────────────── /** @@ -378,8 +395,14 @@ export class CommandMapper { } this.lockedPointerUnit = null } + if (this.isPlayerBusyCasting(nowMs)) { + return + } this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } else if (this.rightMouseDown) { + if (this.isPlayerBusyCasting(nowMs)) { + return + } this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } } @@ -421,12 +444,18 @@ export class CommandMapper { this.leftMouseDown = true this.lockedPointerUnit = target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null + if (this.isPlayerBusyCasting(nowMs)) { + return null + } return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } if (button === 2) { this.rightMouseDown = true this.lockedPointerUnit = null + if (this.isPlayerBusyCasting(nowMs)) { + return null + } return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) } @@ -461,6 +490,9 @@ export class CommandMapper { // Stand-still modifier (Shift): cast left skill on location/target instead of moving if (this.shiftHeld) { + if (this.isPlayerBusyCasting(nowMs)) { + return null + } this.pendingItemPickupId = null if (hoveredUnit && hoveredUnit.unitType === 'monster') { return this.dispatchRateLimited( @@ -611,6 +643,9 @@ export class CommandMapper { } // Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack if (leftSkillId > 0) { + if (this.isPlayerBusyCasting(nowMs)) { + return null + } return this.dispatchRateLimited( { type: 'SkillOn', @@ -634,6 +669,9 @@ export class CommandMapper { } // Ground walk / run + if (this.isPlayerBusyCasting(nowMs)) { + return null + } this.pendingItemPickupId = null const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning) const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY } @@ -657,6 +695,9 @@ export class CommandMapper { hoveredUnit: ClientUnit | null = null, nowMs = performance.now(), ): DispatchedCommandRecord | null { + if (this.isPlayerBusyCasting(nowMs)) { + return null + } this.pendingItemPickupId = null if (hoveredUnit) { return this.dispatchRateLimited( @@ -982,6 +1023,9 @@ export class CommandMapper { if (this.lockedPointerUnit?.unitType === 'item') { return null } + if (this.isPlayerBusyCasting(nowMs)) { + return null + } const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY) const record = this.triggerPrimaryWorldAction( target.subtileX, @@ -993,6 +1037,9 @@ export class CommandMapper { } if (this.rightMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) { + if (this.isPlayerBusyCasting(nowMs)) { + return null + } const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY) const record = this.triggerSecondaryWorldAction( target.subtileX, @@ -1078,7 +1125,7 @@ export class CommandMapper { command.type === 'CastRightSkillOnTarget' || command.type === 'Resurrect' ) { - this.world.noteOutgoingCommand(command) + this.world.noteOutgoingCommand(command, nowMs) } } return { diff --git a/src/client/main.ts b/src/client/main.ts index bfe3d9f..bf7cac8 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -939,7 +939,7 @@ async function bootPlayApp(): Promise { flowState.lastFrameTimeMs = null } - session.tick(dtMs || 40, nowMs) + session.tick(dtMs, nowMs) const currentSettings = settingsStore.get() const profile = getViewportProfile(currentSettings.viewport) diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index e60f2d1..69c2fa4 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -415,7 +415,7 @@ export class OnlineSession { const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0) return { - tick: this.tickCounter, + tick: this.world.frameCount, ...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}), playerSubX, playerSubY, diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 3f2b664..93cce5c 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -592,7 +592,17 @@ export function buildSceneDrawables( const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()] if (ovTxt) { const totalFrames = Math.max(1, ovTxt.frames) - const frame = Math.min(totalFrames - 1, Math.max(0, Math.floor(tick))) + const animRate = Math.max(1, ovTxt.animRate || 16) + const elapsedMs = + activeCastState.elapsedMs ?? + (activeCastState.startedAtMs && typeof performance !== 'undefined' + ? Math.max(0, performance.now() - activeCastState.startedAtMs) + : 0) + const elapsedTicks = elapsedMs / 40 + const frame = Math.min( + totalFrames - 1, + Math.max(0, Math.floor((elapsedTicks * animRate) / 16)), + ) const ovDrawable: SceneOverlayDrawable = { drawableKind: 'overlay', ownerUnitId: unit.id, diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index ab4ac53..7c91bb8 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -326,6 +326,7 @@ export class ClientWorld { private _lastLocalCastSkillId = -1 private _lastLocalCastAtMs = -1000 private _simTimeMs = 0 + private _tickAccumulatorMs = 0 private readonly _castSimStartMs = new Map() private _notedCommands = new WeakSet() private readonly _blockedSubTiles = new Set() @@ -989,11 +990,21 @@ export class ClientWorld { const skillId = isRight ? this._self.rightSkill : this._self.leftSkill const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y - this._lastLocalCastSkillId = skillId - this._lastLocalCastAtMs = Date.now() + const now = this.resolveEventNowMs(nowMs) if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) - this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) + const isBusyActiveCast = + selfUnit.castState && + selfUnit.castState.skillId === skillId && + now < selfUnit.castState.untilMs + if (!isBusyActiveCast) { + this._lastLocalCastSkillId = skillId + this._lastLocalCastAtMs = now + this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) + } + } else { + this._lastLocalCastSkillId = skillId + this._lastLocalCastAtMs = now } break } @@ -1008,12 +1019,22 @@ export class ClientWorld { const targetUnit = this.getUnit(targetRef.unitType, targetRef.id) const targetX = targetUnit?.x ?? this._self.x const targetY = targetUnit?.y ?? this._self.y - this._lastLocalCastSkillId = skillId - this._lastLocalCastAtMs = Date.now() + const now = this.resolveEventNowMs(nowMs) if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) selfUnit.targetUnit = targetRef - this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) + const isBusyActiveCast = + selfUnit.castState && + selfUnit.castState.skillId === skillId && + now < selfUnit.castState.untilMs + if (!isBusyActiveCast) { + this._lastLocalCastSkillId = skillId + this._lastLocalCastAtMs = now + this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) + } + } else { + this._lastLocalCastSkillId = skillId + this._lastLocalCastAtMs = now } break } @@ -1485,25 +1506,65 @@ export class ClientWorld { } tick(dtMs = 40, nowMs?: number): void { - this.frameCount++ - const safeDt = Math.max(0, dtMs) + const safeDt = Math.max(0, Number.isFinite(dtMs) ? dtMs : 0) + const cappedDt = Math.min(200, safeDt) + this._tickAccumulatorMs += cappedDt this._simTimeMs += safeDt - const step = Math.max(1, (safeDt / 1000) * 10) - const ticksToStep = Math.max(1, Math.round(safeDt / 40)) - const inTown = isTownLevel(this.areaId) this.expireCastStates(nowMs, safeDt) + const D2_TICK_MS = 40 + const EPSILON = 1e-4 + while (this._tickAccumulatorMs >= D2_TICK_MS - EPSILON) { + this._tickAccumulatorMs = Math.max(0, this._tickAccumulatorMs - D2_TICK_MS) + this.stepDiscreteSimulationTick(D2_TICK_MS) + } + + // Continuous unit movement interpolation across display frames + const step = (safeDt / 1000) * 10 + if (step > 0) { + const inTown = isTownLevel(this.areaId) + for (const unit of this._units.values()) { + if (unit.dead || unit.castState || unit.unitType === 'missile') continue + if (unit.targetX === undefined || unit.targetY === undefined) continue + const dx = unit.targetX - unit.x + const dy = unit.targetY - unit.y + const dist = Math.hypot(dx, dy) + if (dist === 0) continue + if (dist <= step) { + unit.x = unit.targetX + unit.y = unit.targetY + if ( + unit.unitType === 'player' && + (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19) + ) { + unit.mode = inTown ? 5 : 1 + unit.moveMode = 'stop' + } else if ( + unit.unitType === 'monster' && + (unit.mode === 2 || unit.mode === 9 || unit.mode === 15) + ) { + unit.mode = 1 + unit.moveMode = 'stop' + } + } else { + unit.x += (dx / dist) * step + unit.y += (dy / dist) * step + } + } + } + } + + private stepDiscreteSimulationTick(_tickDtMs = 40): void { + this.frameCount++ + const unitsSnapshot = Array.from(this._units.values()) for (const unit of unitsSnapshot) { if (!this._units.has(unit.key)) continue - // 1. Step missile units + // 1. Step missile units (1 simulation tick) if (unit.unitType === 'missile') { - for (let t = 0; t < ticksToStep; t++) { - if (!this._units.has(unit.key)) break - this.stepMissileUnit(unit) - } + this.stepMissileUnit(unit) continue } @@ -1511,7 +1572,7 @@ export class ClientWorld { if (unit.overlays.length > 0) { const remainingOverlays: ClientUnitOverlay[] = [] for (const ov of unit.overlays) { - ov.ageTicks += ticksToStep + ov.ageTicks += 1 const rawFrame = Math.floor((ov.ageTicks * ov.animRate) / 16) if (ov.loop) { ov.frame = rawFrame % Math.max(1, ov.totalFrames) @@ -1523,28 +1584,6 @@ export class ClientWorld { } unit.overlays = remainingOverlays } - - // 3. Interpolate moving non-casting units - if (unit.dead || unit.castState) continue - if (unit.targetX === undefined || unit.targetY === undefined) continue - const dx = unit.targetX - unit.x - const dy = unit.targetY - unit.y - const dist = Math.hypot(dx, dy) - if (dist === 0) continue - if (dist <= step) { - unit.x = unit.targetX - unit.y = unit.targetY - if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19)) { - unit.mode = inTown ? 5 : 1 - unit.moveMode = 'stop' - } else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 9 || unit.mode === 15)) { - unit.mode = 1 - unit.moveMode = 'stop' - } - } else { - unit.x += (dx / dist) * step - unit.y += (dy / dist) * step - } } } @@ -2027,15 +2066,22 @@ export class ClientWorld { } } + const now = this.resolveEventNowMs(nowMs) + const timeSinceLocalCast = now - this._lastLocalCastAtMs const isSelfEcho = unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0) && skillId === this._lastLocalCastSkillId && - Date.now() - this._lastLocalCastAtMs >= 0 && - Date.now() - this._lastLocalCastAtMs <= 250 + timeSinceLocalCast >= 0 && + timeSinceLocalCast <= 500 if (isSelfEcho) { this._lastLocalCastSkillId = -1 - this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs) + if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) { + unit.targetX = targetX + unit.targetY = targetY + } else { + this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs) + } } else { this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true, nowMs) } @@ -2605,6 +2651,7 @@ export class ClientWorld { this._lastLocalCastSkillId = -1 this._lastLocalCastAtMs = -1000 this._simTimeMs = 0 + this._tickAccumulatorMs = 0 this._castSimStartMs.clear() this._notedCommands = new WeakSet() this._blockedSubTiles.clear() diff --git a/tests/client/char-anim-speed-loop.test.ts b/tests/client/char-anim-speed-loop.test.ts index 1093195..8331a6f 100644 --- a/tests/client/char-anim-speed-loop.test.ts +++ b/tests/client/char-anim-speed-loop.test.ts @@ -642,4 +642,171 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => { const finalFrame = drawnFrames.at(-1)! expect(Math.floor(finalFrame.x / 100)).toBe(expectedNuGroup * 10) }) + + it('decouples discrete 25Hz simulation ticks from high display refresh rates (144Hz, 240Hz, and 0ms frames)', () => { + const world = new ClientWorld() + world.apply({ + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }) + + // Spawn a missile (e.g. Fireball) and a cast overlay + const missile = world.spawnMissileByKey('fireball', 5000, 5000, 5050, 5000) + const overlay = world.attachUnitOverlay(world.getUnit('player', 1)!, 'fire_cast_2', true) + expect(world.frameCount).toBe(0) + expect(missile.ageTicks).toBe(0) + expect(overlay.ageTicks).toBe(0) + + // Simulate 1 second at 144Hz: 144 frames of ~6.944ms each = 1000ms + const dt144 = 1000 / 144 + for (let frame = 1; frame <= 144; frame++) { + world.tick(dt144, frame * dt144) + } + + // In 1 second (1000ms), 25Hz simulation should advance by exactly 25 ticks, NOT 144 ticks! + expect(world.frameCount).toBe(25) + expect(missile.ageTicks).toBe(25) + expect(overlay.ageTicks).toBe(25) + + // Simulate 0ms duplicate RAF callbacks + for (let i = 0; i < 10; i++) { + world.tick(0, 1000) + } + expect(world.frameCount).toBe(25) + expect(missile.ageTicks).toBe(25) + expect(overlay.ageTicks).toBe(25) + + // Advance 40ms more (1 tick) + world.tick(40, 1040) + expect(world.frameCount).toBe(26) + expect(missile.ageTicks).toBe(26) + expect(overlay.ageTicks).toBe(26) + }) + + it('prevents held mouse from interrupting active cast animation every 100ms via isPlayerBusyCasting', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, // Sorceress + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }) + session.world.self.rightSkill = 47 // Fireball + + // Player presses and holds right mouse button to cast at t = 1000ms + const rec1 = session.commandMapper.handlePointerDown(400, 300, 2, 1000) + expect(rec1).not.toBeNull() + expect(rec1?.command.type).toBe('SkillAt') + + // Client world prediction has set up castState with duration ~520ms (until t = 1520ms) + const selfUnit = session.world.getUnit('player', 1)! + expect(selfUnit.castState).toBeDefined() + expect(selfUnit.castState?.skillId).toBe(47) + const untilMs = selfUnit.castState!.untilMs + expect(untilMs).toBeGreaterThan(1500) + + // At t = 1100ms (100ms later, which is the 100ms skillMinIntervalMs rate limit cooldown), + // player is still holding right mouse button. + expect(session.commandMapper.isPlayerBusyCasting(1100)).toBe(true) + const rec2 = session.commandMapper.tick(1100) + expect(rec2).toBeNull() // Must NOT interrupt active cast! + expect(selfUnit.castState?.startedAtMs).toBe(1000) // Cast start time intact! + + // Repeat tick at t = 1200, 1300, 1400, 1500ms + for (let t = 1200; t <= 1500; t += 100) { + expect(session.commandMapper.tick(t)).toBeNull() + expect(selfUnit.castState?.startedAtMs).toBe(1000) + } + + // Step world past untilMs so the cast finishes + session.world.tick(untilMs - 1000 + 40, untilMs + 40) + expect(selfUnit.castState).toBeUndefined() + expect(session.commandMapper.isPlayerBusyCasting(untilMs + 40)).toBe(false) + + // Now that previous cast completed, held mouse initiates the next cast cleanly! + const recNext = session.commandMapper.tick(untilMs + 40) + expect(recNext).not.toBeNull() + expect(recNext?.type).toBe('SkillAt') + expect(selfUnit.castState).toBeDefined() + expect(selfUnit.castState?.startedAtMs).toBe(untilMs + 40) + + session.destroy() + }) + + it('preserves locally-predicted cast timing and avoids restarting animation when server SkillCast echo arrives', () => { + const world = new ClientWorld() + world.apply( + { + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 'player', + id: 1, + classId: 1, + name: 'Sorc', + x: 5000, + y: 5000, + mode: 1, + lifePct: 128, + }, + }, + 1000, + ) + world.self.rightSkill = 47 + + // Local client predicts right skill cast at t = 1000ms + world.noteOutgoingCommand( + { + type: 'SkillAt', + hand: 'right', + at: { x: 5030, y: 5000 }, + }, + 1000, + ) + + const unit = world.getUnit('player', 1)! + expect(unit.castState).toBeDefined() + expect(unit.castState?.startedAtMs).toBe(1000) + const origUntilMs = unit.castState!.untilMs + + // Advance 80ms of local playback (2 simulation frames) + world.tick(40, 1040) + world.tick(40, 1080) + expect(unit.castState?.elapsedMs).toBe(80) + + // At t = 1080ms (80ms RTT), server 0x4C SkillCast echo arrives + world.apply( + { + type: 'SkillCast', + caster: { unitType: 'player', id: 1 }, + skill: 47, + level: 1, + target: { x: 5030, y: 5000 }, + }, + 1080, + ) + + // The cast timing must be preserved without resetting back to frame 0 or startedAtMs = 1080 + expect(unit.castState?.startedAtMs).toBe(1000) + expect(unit.castState?.untilMs).toBe(origUntilMs) + expect(unit.castState?.elapsedMs).toBe(80) + }) }) From a08b837ba55c9f0df1623897db8baa41249474f7 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 11:15:46 +0000 Subject: [PATCH 13/15] fix(ui): guard parseColoredText against non-string inputs --- src/client/ui/font.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/ui/font.ts b/src/client/ui/font.ts index 2b32e33..50fbd73 100644 --- a/src/client/ui/font.ts +++ b/src/client/ui/font.ts @@ -113,6 +113,9 @@ export function parseFontTblMetrics(tblBytes: Uint8Array): { * Split a string containing Diablo II `ÿcX` color codes into colored spans. */ export function parseColoredText(input: string, defaultColor: D2ColorCode = 'white'): TextSpan[] { + if (!input || typeof input !== 'string') { + return [] + } const spans: TextSpan[] = [] let currentColor: D2ColorCode = defaultColor let buf = '' From 778bd5780678fdc0afb698ddd3cd0724dff63152 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 12:02:44 +0000 Subject: [PATCH 14/15] fix(client/netproto): align monster movement speed with 1.13c MonStats velocity and D2GS 0x67/0x68 nVelocity --- src/client/session/online-session.ts | 108 ++++++++++++++--- src/client/world/client-unit.ts | 2 + src/client/world/client-world.ts | 22 +++- src/common/units/monster-mapping.ts | 69 +++++++++++ src/common/units/monsters-data.ts | 90 ++++++++++++++- src/netproto/d2gs/s2c/movement.ts | 18 +++ src/netproto/domain/server-event.ts | 2 + tests/client/unit-facing-motion.test.ts | 147 ++++++++++++++++++++++++ tests/netproto/d2gs-s2c.test.ts | 36 +++++- 9 files changed, 467 insertions(+), 27 deletions(-) diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 69c2fa4..c4fa7d7 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -71,6 +71,7 @@ import { getViewportProfile } from '../view/viewport-profile.ts' import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts' import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts' import { OutboundRateLimiter } from './rate-limiter.ts' +import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts' import { isTownLevel } from '../../common/world/scene-population.ts' export type OnlineSessionPhase = @@ -1205,6 +1206,17 @@ export class OnlineSession { : { x: 0, y: 0 }), } as ServerEvent } + const preMoveUnit = + ev.type === 'UnitMove' + ? this.world.getUnit( + (evAny.unitType ?? ev.unitType) as UnitType, + Number(evAny.id ?? evAny.unitId ?? 1), + ) + : undefined + const preMoveUnitPos = + preMoveUnit !== undefined && (preMoveUnit.x !== 0 || preMoveUnit.y !== 0) + ? { x: preMoveUnit.x, y: preMoveUnit.y } + : undefined this.world.apply(ev) if ( @@ -1371,10 +1383,49 @@ export class OnlineSession { const unit = this.world.getUnit(evTypeId as UnitType, evUnitId) const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0) - const toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX - const toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY - const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x - const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y + let toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX + let toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY + if ( + ev.target && + ((rawToX === 0 && rawToY === 0) || + (ev.from !== undefined && rawToX === ev.from.x && rawToY === ev.from.y)) + ) { + const targetTypeId = unitTypeToId(ev.target.unitType) + const isTargetLocalPlayer = + targetTypeId === 0 && (ev.target.id === this.world.selfId || this.world.selfId === 0) + if (isTargetLocalPlayer) { + const predSnap = this.predictor.getSnapshot() + if (predSnap.x !== 0 || predSnap.y !== 0) { + toX = predSnap.x + toY = predSnap.y + } + } else { + const targetInterp = this.interpolator.getEntityState(targetTypeId, ev.target.id) + if (targetInterp && (targetInterp.x !== 0 || targetInterp.y !== 0)) { + toX = targetInterp.x + toY = targetInterp.y + } + } + if (unit && (toX !== 0 || toY !== 0)) { + unit.targetX = toX + unit.targetY = toY + } + } + const explicitFromX = evAny.from?.x ?? evAny.currentX ?? evAny.x + const explicitFromY = evAny.from?.y ?? evAny.currentY ?? evAny.y + const existingInterp = this.interpolator.getEntityState(evTypeId, evUnitId) + const fromX = + explicitFromX !== undefined + ? Number(explicitFromX) + : !existingInterp && preMoveUnitPos !== undefined + ? preMoveUnitPos.x + : undefined + const fromY = + explicitFromY !== undefined + ? Number(explicitFromY) + : !existingInterp && preMoveUnitPos !== undefined + ? preMoveUnitPos.y + : undefined const isLocalPlayer = evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0 if (unit && unit.unitType === 'player') { @@ -1396,22 +1447,41 @@ export class OnlineSession { this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' }) } } else if (ev.mode === 'stop') { - this.interpolator.stopEntity({ + const motionState = this.interpolator.stopEntity({ unitType: evTypeId, unitId: evUnitId, x: toX, y: toY, }) + if (unit) { + unit.x = motionState.x + unit.y = motionState.y + unit.direction = motionState.dir64 + } } else { - this.interpolator.startMove({ + const speedSubTilesPerSec = + evTypeId === 1 + ? computeMonsterMoveSpeedSubTilesPerSec( + unit?.classId ?? 0, + ev.mode === 'run', + ev.velocityPercent, + ) + : undefined + const motionState = this.interpolator.startMove({ unitType: evTypeId, unitId: evUnitId, - ...(fromX !== undefined ? { currentX: Number(fromX) } : {}), - ...(fromY !== undefined ? { currentY: Number(fromY) } : {}), + ...(fromX !== undefined ? { currentX: fromX } : {}), + ...(fromY !== undefined ? { currentY: fromY } : {}), targetX: toX, targetY: toY, running: ev.mode === 'run', + ...(speedSubTilesPerSec !== undefined ? { speedSubTilesPerSec } : {}), }) + if (unit) { + unit.x = motionState.x + unit.y = motionState.y + unit.direction = motionState.dir64 + } } break } @@ -1516,6 +1586,8 @@ export class OnlineSession { this.predictor.setDirection(selfUnit.direction) } + this.world.tick(dtMs, nowMs) + this.interpolator.update(safeDt) for (const state of this.interpolator.getAllStates()) { const unit = this.world.getUnit(state.unitType as UnitType, state.unitId) @@ -1544,19 +1616,19 @@ export class OnlineSession { unit.mode = unitInTown ? 5 : 1 unit.moveMode = 'stop' } - } else if ( - unit.unitType === 'monster' && - !state.moving && - unit.lifePct > 0 && - (unit.mode === 2 || unit.mode === 9 || unit.mode === 15) - ) { - unit.mode = 1 - unit.moveMode = 'stop' + } else if (unit.unitType === 'monster' && unit.lifePct > 0) { + if (state.moving && !unit.castState && unit.moveMode !== 'stop') { + unit.mode = state.running ? 15 : unit.moveMode === 'knockback' ? 9 : 2 + } else if ( + !state.moving && + (unit.mode === 2 || unit.mode === 9 || unit.mode === 15) + ) { + unit.mode = 1 + unit.moveMode = 'stop' + } } } } - - this.world.tick(dtMs, nowMs) this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) } diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 66c0370..edd8e0a 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -70,6 +70,8 @@ export class ClientUnit { targetY?: number | undefined targetUnit?: UnitRef | undefined moveMode?: 'walk' | 'run' | 'knockback' | 'stop' | undefined + velocityPercent?: number | undefined + pathType?: number | undefined mode: number direction: number lifePct: number diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 6956bb3..ef8d173 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -49,6 +49,7 @@ import { type CharacterAnimSpeedParams, } from '../../common/units/char-anim-speed.ts' import { dir64FromPoints } from '../../common/units/direction.ts' +import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts' import { isTownLevel } from '../../common/world/scene-population.ts' import { ClientUnit, @@ -1535,8 +1536,7 @@ export class ClientWorld { } // Continuous unit movement interpolation across display frames - const step = (safeDt / 1000) * 10 - if (step > 0) { + if (safeDt > 0) { const inTown = isTownLevel(this.areaId) for (const unit of this._units.values()) { if (unit.dead || unit.castState || unit.unitType === 'missile') continue @@ -1545,6 +1545,16 @@ export class ClientWorld { const dy = unit.targetY - unit.y const dist = Math.hypot(dx, dy) if (dist === 0) continue + const speedSubTilesPerSec = + unit.unitType === 'monster' + ? computeMonsterMoveSpeedSubTilesPerSec( + unit.classId, + unit.moveMode === 'run', + unit.velocityPercent, + ) + : 10 + const step = (safeDt / 1000) * speedSubTilesPerSec + if (step <= 0) continue if (dist <= step) { unit.x = unit.targetX unit.y = unit.targetY @@ -1701,6 +1711,8 @@ export class ClientWorld { x: Number(ev.targetX ?? ev.x ?? 0), y: Number(ev.targetY ?? ev.y ?? 0), }, + ...(ev.velocityPercent !== undefined ? { velocityPercent: Number(ev.velocityPercent) } : {}), + ...(ev.pathType !== undefined ? { pathType: Number(ev.pathType) } : {}), } } else if (ev.type === 'ChatMessage') { ev = { @@ -1903,6 +1915,12 @@ export class ClientWorld { unit.targetUnit = ev.target const moveMode = ev.mode ?? 'walk' unit.moveMode = moveMode + if (ev.velocityPercent !== undefined) { + unit.velocityPercent = ev.velocityPercent + } + if (ev.pathType !== undefined) { + unit.pathType = ev.pathType + } if (prevX !== targetX || prevY !== targetY) { unit.direction = dir64FromPoints(prevX, prevY, targetX, targetY, unit.direction) } diff --git a/src/common/units/monster-mapping.ts b/src/common/units/monster-mapping.ts index 47312a2..89faf46 100644 --- a/src/common/units/monster-mapping.ts +++ b/src/common/units/monster-mapping.ts @@ -1515,6 +1515,75 @@ export const MONSTER_HCIDX_TO_ID: Readonly> = { 733: "willowisp8", } +/** + * Canonical 1.13c `MonStats.txt` `[Velocity, Run]` pairs indexed by `hcIdx` (0..733), + * extracted directly from `Patch_D2.mpq` (`data/global/excel/MonStats.txt`). + */ +export const MONSTER_HCIDX_VELOCITIES: readonly (readonly [number, number])[] = [ + [3, 3], [4, 4], [4, 4], [5, 5], [5, 5], [1, 3], [1, 4], [1, 4], [1, 5], [1, 5], [6, 6], [6, 6], + [5, 5], [5, 5], [7, 7], [4, 4], [4, 4], [5, 5], [6, 6], [5, 5], [5, 5], [6, 6], [6, 6], [8, 8], + [4, 4], [4, 4], [5, 5], [5, 5], [3, 3], [5, 5], [5, 5], [6, 6], [6, 6], [7, 7], [0, 0], [0, 0], + [0, 0], [0, 0], [5, 5], [6, 6], [6, 6], [7, 7], [8, 8], [5, 5], [6, 8], [6, 10], [7, 12], [8, 12], + [7, 12], [7, 12], [7, 13], [7, 13], [7, 14], [5, 5], [6, 6], [7, 7], [9, 9], [8, 8], [4, 4], [4, 4], + [5, 5], [5, 5], [6, 6], [3, 3], [3, 3], [4, 4], [4, 4], [5, 5], [4, 4], [4, 4], [4, 4], [4, 4], + [4, 4], [4, 4], [4, 10], [5, 12], [5, 12], [6, 14], [10, 10], [8, 8], [8, 8], [8, 8], [8, 8], [5, 10], + [6, 11], [7, 12], [8, 13], [5, 5], [6, 6], [7, 7], [8, 8], [4, 4], [5, 5], [5, 5], [6, 6], [6, 6], + [3, 3], [3, 3], [3, 3], [3, 3], [3, 3], [2, 2], [2, 2], [3, 3], [4, 4], [5, 5], [3, 3], [3, 3], + [3, 3], [3, 3], [4, 4], [4, 4], [5, 5], [5, 5], [6, 12], [6, 12], [6, 12], [6, 12], [15, 15], [15, 15], + [15, 15], [15, 15], [6, 6], [7, 7], [8, 8], [8, 9], [8, 11], [3, 3], [3, 3], [3, 3], [3, 3], [6, 6], + [7, 7], [7, 7], [8, 8], [9, 9], [7, 7], [8, 8], [9, 9], [10, 10], [12, 12], [6, 6], [7, 7], [8, 8], + [9, 9], [10, 10], [2, 2], [4, 4], [1, 1], [2, 2], [3, 3], [5, 5], [8, 8], [0, 0], [4, 4], [3, 3], + [8, 8], [7, 7], [4, 4], [7, 7], [4, 4], [5, 6], [5, 8], [6, 9], [7, 10], [4, 4], [5, 6], [5, 8], + [6, 10], [7, 12], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [3, 3], [3, 3], [3, 3], [3, 3], [2, 2], + [4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [5, 5], [3, 3], [4, 4], [6, 6], [8, 8], [1, 1], [1, 1], + [1, 1], [1, 1], [1, 1], [2, 2], [2, 2], [2, 2], [4, 4], [3, 3], [2, 2], [4, 4], [3, 3], [4, 7], + [4, 4], [4, 4], [0, 0], [0, 0], [0, 0], [0, 0], [4, 4], [10, 15], [6, 6], [7, 7], [8, 8], [9, 9], + [10, 10], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [3, 3], [3, 3], [4, 4], [4, 4], [4, 4], [1, 1], + [0, 0], [2, 2], [4, 4], [4, 4], [4, 4], [4, 4], [4, 4], [6, 8], [6, 9], [6, 11], [6, 6], [6, 6], + [6, 6], [6, 6], [8, 8], [6, 6], [2, 2], [2, 2], [2, 2], [9, 9], [10, 10], [11, 11], [5, 5], [0, 0], + [2, 2], [2, 2], [2, 2], [2, 2], [10, 16], [2, 2], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], + [2, 2], [2, 2], [8, 8], [6, 8], [2, 2], [5, 5], [8, 8], [7, 11], [8, 8], [0, 0], [4, 4], [4, 4], + [5, 5], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10], [1, 1], [4, 4], [4, 4], [4, 4], [4, 4], + [4, 4], [8, 8], [9, 9], [9, 9], [10, 10], [12, 6], [3, 2], [10, 16], [3, 2], [2, 2], [7, 7], [7, 7], + [7, 7], [8, 10], [9, 11], [10, 12], [10, 16], [10, 16], [10, 16], [6, 12], [7, 12], [8, 12], [6, 7], [7, 7], + [8, 7], [4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [2, 2], [5, 5], [5, 5], [0, 0], [0, 0], [0, 0], + [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 6], [0, 0], [8, 8], [0, 0], [0, 0], + [0, 0], [0, 0], [7, 11], [1, 2], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 12], [8, 12], [10, 12], + [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [9, 9], [11, 11], [8, 8], [7, 11], + [10, 16], [10, 16], [10, 16], [13, 15], [12, 14], [3, 4], [0, 0], [0, 0], [2, 2], [0, 0], [10, 16], [0, 0], + [0, 0], [5, 10], [5, 10], [5, 10], [5, 10], [6, 6], [6, 6], [4, 4], [4, 4], [5, 5], [5, 5], [4, 4], + [4, 4], [5, 5], [5, 5], [4, 4], [4, 4], [5, 5], [5, 5], [5, 5], [0, 0], [0, 0], [5, 10], [5, 10], + [6, 6], [7, 7], [8, 8], [9, 9], [10, 10], [0, 0], [7, 7], [0, 0], [0, 0], [2, 2], [0, 0], [6, 6], + [2, 2], [8, 8], [0, 0], [0, 0], [0, 0], [10, 10], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [10, 20], + [5, 10], [5, 10], [6, 6], [6, 6], [6, 6], [7, 7], [7, 7], [7, 7], [5, 9], [6, 6], [6, 6], [6, 6], + [0, 0], [0, 0], [0, 0], [0, 0], [1, 5], [1, 5], [2, 5], [2, 5], [3, 5], [3, 7], [3, 8], [4, 9], + [5, 10], [6, 11], [5, 8], [6, 6], [7, 7], [7, 7], [1, 1], [1, 1], [1, 1], [7, 10], [7, 10], [7, 10], + [7, 10], [7, 10], [7, 10], [7, 10], [7, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [10, 10], + [10, 10], [10, 10], [11, 11], [12, 12], [13, 13], [14, 14], [7, 7], [8, 8], [9, 9], [10, 10], [12, 12], [4, 4], + [4, 4], [5, 5], [5, 5], [6, 6], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], + [7, 7], [7, 7], [7, 7], [7, 7], [7, 7], [0, 0], [0, 0], [0, 0], [0, 0], [3, 3], [3, 3], [3, 3], + [3, 3], [3, 3], [9, 9], [9, 9], [9, 9], [9, 9], [9, 9], [3, 3], [3, 3], [1, 1], [2, 2], [3, 3], + [0, 0], [0, 0], [0, 0], [0, 0], [2, 2], [0, 0], [5, 14], [5, 14], [0, 0], [0, 0], [7, 7], [2, 2], + [0, 0], [4, 4], [4, 4], [5, 5], [5, 5], [6, 6], [4, 7], [5, 14], [5, 14], [5, 14], [5, 14], [5, 14], + [10, 10], [10, 10], [10, 10], [6, 6], [6, 6], [12, 12], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6], [8, 10], + [8, 10], [8, 10], [8, 10], [8, 10], [2, 2], [10, 12], [10, 16], [6, 6], [8, 14], [8, 14], [0, 0], [0, 0], + [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [6, 6], [8, 8], [8, 8], [8, 8], [12, 12], [4, 4], + [4, 4], [5, 5], [5, 5], [5, 5], [9, 9], [9, 9], [10, 10], [11, 11], [12, 12], [10, 10], [10, 10], [10, 10], + [11, 11], [12, 12], [9, 9], [9, 9], [10, 10], [12, 12], [6, 10], [6, 10], [8, 12], [8, 12], [9, 14], [15, 15], + [15, 15], [15, 15], [15, 15], [15, 15], [4, 4], [6, 6], [6, 6], [6, 6], [4, 4], [6, 6], [12, 16], [12, 16], + [12, 16], [8, 12], [8, 12], [8, 12], [8, 12], [8, 12], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6], [6, 6], + [6, 6], [5, 5], [5, 5], [9, 12], [9, 12], [8, 8], [8, 8], [8, 8], [8, 8], [8, 8], [14, 14], [14, 14], + [12, 12], [12, 12], [12, 12], [15, 15], [15, 15], [15, 15], [10, 10], [10, 10], [10, 10], [6, 6], [6, 6], [6, 6], + [5, 5], [5, 5], [12, 12], [12, 12], [9, 9], [9, 9], [6, 6], [6, 6], [10, 10], [10, 10], [10, 10], [10, 10], + [10, 10], [10, 10], [10, 10], [10, 10], [10, 10], [7, 14], [7, 14], [5, 5], [5, 5], [5, 5], [5, 5], [6, 11], + [6, 11], [6, 6], [6, 6], [7, 7], [7, 7], [10, 12], [10, 12], [4, 4], [6, 6], [1, 1], [7, 10], [7, 10], + [7, 10], [8, 11], [10, 16], [10, 16], [7, 7], [7, 7], [10, 10], [10, 10], [10, 16], [10, 16], [8, 12], [8, 8], + [8, 8], [8, 8], [3, 5], [6, 7], [6, 7], [8, 7], [8, 7], [3, 3], [8, 8], [8, 8], [10, 16], [8, 8], + [10, 15], [6, 6], [0, 0], [0, 0], [10, 16], [8, 7], [7, 7], [8, 8], [4, 4], [8, 11], [6, 6], [14, 14], + [12, 12], [8, 12], [8, 12], [8, 12], [6, 6], [5, 5], [5, 5], [6, 6], [6, 6], [6, 6], [5, 5], [8, 8], + [8, 8], [15, 15], +] + /** * Resolve the visual token and weapon class for a numeric MonStats.txt hcIdx (0..733). */ diff --git a/src/common/units/monsters-data.ts b/src/common/units/monsters-data.ts index 6d0cafc..f0bcb41 100644 --- a/src/common/units/monsters-data.ts +++ b/src/common/units/monsters-data.ts @@ -2038,8 +2038,8 @@ const CANONICAL_113C_MONSTER_OVERRIDES: Readonly< attack1: { minDamage: 20, maxDamage: 40, toHit: 70 }, }, skeleton1: { - velocity: 4, - runVelocity: 4, + velocity: 3, + runVelocity: 3, minHp: 61, maxHp: 121, armour: 100, @@ -2048,6 +2048,20 @@ const CANONICAL_113C_MONSTER_OVERRIDES: Readonly< }, } +import { MONSTER_HCIDX_TO_ID, MONSTER_HCIDX_VELOCITIES } from './monster-mapping.ts' + +const CANONICAL_113C_VELOCITIES_BY_ID: Readonly> = (() => { + const out: Record = {} + for (let hcIdx = 0; hcIdx < MONSTER_HCIDX_VELOCITIES.length; hcIdx++) { + const id = MONSTER_HCIDX_TO_ID[hcIdx] + const pair = MONSTER_HCIDX_VELOCITIES[hcIdx] + if (id && pair) { + out[id] = pair + } + } + return out +})() + let cachedCanonicalMonsterKinds: Map | null = null function getCanonicalMonsterKinds(): Map { @@ -2056,11 +2070,14 @@ function getCanonicalMonsterKinds(): Map { const enriched = new Map() for (const [id, kind] of rawKinds) { const ov = CANONICAL_113C_MONSTER_OVERRIDES[id] + const canonicalSpeed = CANONICAL_113C_VELOCITIES_BY_ID[id.toLowerCase()] + const canonicalVel = canonicalSpeed && canonicalSpeed[0] > 0 ? canonicalSpeed[0] : undefined + const canonicalRun = canonicalSpeed && canonicalSpeed[1] > 0 ? canonicalSpeed[1] : undefined const enrichedKind: MonsterKind = { ...kind, enabled: kind.killable || !kind.inTown, - velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5), - runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5), + velocity: ov?.velocity ?? canonicalVel ?? (kind.velocity > 0 ? kind.velocity : 5), + runVelocity: ov?.runVelocity ?? canonicalRun ?? (kind.runVelocity > 0 ? kind.runVelocity : 5), minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21), maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61), armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84), @@ -2093,7 +2110,12 @@ export const MONSTER_TABLE: ReadonlyMap & Readonly & Readonly & Readonly> +/** + * Look up a canonical 1.13c `MonsterKind` by numeric `MonStats.txt` `hcIdx` (`0..733`). + */ +export function getMonsterKindByClassId(classId: number): MonsterKind | undefined { + const id = MONSTER_HCIDX_TO_ID[classId] + if (!id) return undefined + return getCanonicalMonsterKinds().get(id) +} +/** + * Compute D2 1.13c monster movement speed in sub-tiles per second (`D2Common.dll` + * `UNITS_GetBaseVelocity` 0x6FDB1170, `UNITS_GetVelocity` 0x6FDB1B60, and `PathMisc.cpp` + * `sub_6FDAB8A0` 0x6FDAB8A0). + * + * - `nBaseVelocity = pMonStatsTxt->nVelocity << 8` + * - In `D2Game.dll` (`Monster.cpp:214`), monsters start with base `STAT_VELOCITYPERCENT = 75`. + * When running (`MONMODE_RUN`), `D2Game.dll` (`AiThink.cpp:912`, `MonsterMode.cpp:1925`) adds + * `100 * nRun / nVelocity - 100` to `STAT_VELOCITYPERCENT` and transmits the total in S2C + * `0x67` / `0x68` `nVelocity`. + * - `nVelocityPercent = Math.max(25, effectiveVelocityPercent)` + * - `dwVelocity = Math.floor((nVelocityPercent * nBaseVelocity) / 100)` + * - Per-tick displacement (25 ticks/s): `nVelocityMagnitude = (1024 * dwVelocity) >> 6 = 16 * dwVelocity` + * in `1/65536` sub-tiles/tick -> `(25 * dwVelocity) / 4096` sub-tiles/s. + */ +export function computeMonsterMoveSpeedSubTilesPerSec( + classId: number, + isRun = false, + velocityPercent?: number, +): number { + const pair = MONSTER_HCIDX_VELOCITIES[classId] + const kind = getMonsterKindByClassId(classId) + const baseVelocity = + pair && pair[0] > 0 + ? pair[0] + : kind && kind.velocity > 0 + ? kind.velocity + : 5 + const runVelocity = + pair && pair[1] > 0 + ? pair[1] + : kind && kind.runVelocity > 0 + ? kind.runVelocity + : baseVelocity + + let rawPercent: number + if (velocityPercent !== undefined && velocityPercent !== 0) { + rawPercent = velocityPercent + } else if (isRun && baseVelocity > 0 && runVelocity > baseVelocity) { + const runBonus = Math.floor((100 * runVelocity) / baseVelocity) - 100 + rawPercent = 75 + Math.max(0, runBonus) + } else { + rawPercent = 75 + } + + const effectivePercent = Math.max(25, rawPercent) + const nBaseVelocity = baseVelocity << 8 + const dwVelocity = Math.floor((effectivePercent * nBaseVelocity) / 100) + return (25 * dwVelocity) / 4096 +} diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index 0208243..bad8e32 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -128,12 +128,21 @@ export function decode0x67NpcMove(pkt: Uint8Array): ServerEvent { const moveType = r.u8() const targetX = r.u16LE() const targetY = r.u16LE() + let pathType = 0 + let velocityPercent = 0 + if (r.remaining >= 5) { + r.u16LE() // unk0x0A + pathType = r.u8() + velocityPercent = r.i16LE() + } return { type: 'UnitMove', unitType: 1, id, mode: resolveMoveMode(moveType), to: { x: targetX, y: targetY }, + ...(pathType !== 0 ? { pathType } : {}), + ...(velocityPercent !== 0 ? { velocityPercent } : {}), } } @@ -147,6 +156,13 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent { const currentY = r.u16LE() const targetType = clampUnitType(r.u8()) const targetId = r.u32LE() + let pathType = 0 + let velocityPercent = 0 + if (r.remaining >= 5) { + r.u16LE() // unk0x0F + pathType = r.u8() + velocityPercent = r.i16LE() + } return { type: 'UnitMove', unitType: 1, @@ -155,6 +171,8 @@ export function decode0x68NpcMoveToTarget(pkt: Uint8Array): ServerEvent { from: { x: currentX, y: currentY }, to: { x: currentX, y: currentY }, target: { unitType: targetType, id: targetId }, + ...(pathType !== 0 ? { pathType } : {}), + ...(velocityPercent !== 0 ? { velocityPercent } : {}), } } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index 4ea9e28..ed4cfe8 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -63,6 +63,8 @@ export type ServerEvent = readonly from?: Pt | undefined readonly to: Pt readonly target?: UnitRef | undefined + readonly velocityPercent?: number | undefined + readonly pathType?: number | undefined } | { readonly type: 'UnitReassign' diff --git a/tests/client/unit-facing-motion.test.ts b/tests/client/unit-facing-motion.test.ts index 3480762..bd42c51 100644 --- a/tests/client/unit-facing-motion.test.ts +++ b/tests/client/unit-facing-motion.test.ts @@ -15,8 +15,15 @@ import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts import type { SubTileWalkabilityGrid } from '../../src/client/motion/pathfind.ts' import { LocalMovementPredictor } from '../../src/client/motion/predictor.ts' import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { OnlineSession } from '../../src/client/session/online-session.ts' import { ClientWorld } from '../../src/client/world/client-world.ts' import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts' +import { MONSTER_HCIDX_VELOCITIES } from '../../src/common/units/monster-mapping.ts' +import { + computeMonsterMoveSpeedSubTilesPerSec, + getMonsterKindByClassId, +} from '../../src/common/units/monsters-data.ts' +import { ByteWriter, decodeD2gsServerPacket } from '../../src/netproto/index.ts' import type { ClientCommand } from '../../src/netproto/domain/client-command.ts' const FRAME_MS = 1000 / 60 @@ -275,3 +282,143 @@ describe('skill-cast facing (ClientWorld)', () => { expect(castAt(4990, 4990)).toBe(32) // world −X−Y: screen north }) }) + +describe('1.13c monster movement velocity & anti-rubber-banding (MonStats.txt + D2Common 0x6FD5D240)', () => { + it('populates all 734 MonStats.txt [Velocity, Run] pairs and computes exact 1.13c sub-tile/sec speeds', () => { + expect(MONSTER_HCIDX_VELOCITIES.length).toBe(734) + + // Zombies (zombie1..zombie5, hcIdx = 5..9): Velocity = 1 in MonStats.txt ([1,3], [1,4], [1,4], [1,5], [1,5]) + const expectedZombieRun = [3, 4, 4, 5, 5] as const + for (let hcIdx = 5; hcIdx <= 9; hcIdx++) { + expect(MONSTER_HCIDX_VELOCITIES[hcIdx]).toEqual([1, expectedZombieRun[hcIdx - 5]]) + const kind = getMonsterKindByClassId(hcIdx) + expect(kind).toBeDefined() + expect(kind?.velocity).toBe(1) + expect(kind?.runVelocity).toBe(expectedZombieRun[hcIdx - 5]) + // dwVelocity = floor((75 * 256) / 100) = 192 => 192 * 25 / 4096 = 1.171875 sub-tiles/s + expect(computeMonsterMoveSpeedSubTilesPerSec(hcIdx, false, 75)).toBe(1.171875) + expect(computeMonsterMoveSpeedSubTilesPerSec(hcIdx, false)).toBe(1.171875) + } + + // Skeleton (skeleton1, hcIdx = 0): Velocity = 3, Run = 3 + // dwVelocity = floor((75 * 768) / 100) = 576 => 576 * 25 / 4096 = 3.515625 sub-tiles/s + expect(computeMonsterMoveSpeedSubTilesPerSec(0, false, 75)).toBe(3.515625) + + // Fallen (fallen1, hcIdx = 19): Velocity = 5, Run = 5 + // Walk (nVelocity = 75): dwVelocity = floor((75 * 1280) / 100) = 960 => 960 * 25 / 4096 = 5.859375 sub-tiles/s + expect(computeMonsterMoveSpeedSubTilesPerSec(19, false, 75)).toBe(5.859375) + // With explicit run velocityPercent = 155: dwVelocity = floor((155 * 1280) / 100) = 1984 => 12.109375 sub-tiles/s + expect(computeMonsterMoveSpeedSubTilesPerSec(19, true, 155)).toBe(12.109375) + + // Corrupt Rogue (corruptrogue2, hcIdx = 44): Velocity = 6, Run = 8 + // Fallback run without velocityPercent: runBonus = floor(800/6) - 100 = 33 => rawPercent = 108 => dwVelocity = floor((108 * 1536) / 100) = 1658 => 1658 * 25 / 4096 = 10.11962890625 + expect(computeMonsterMoveSpeedSubTilesPerSec(44, true)).toBeCloseTo((25 * 1658) / 4096, 8) + + // Akara (hcIdx = 148, Velocity = 1) and Kashya (hcIdx = 150, Velocity = 3) from 04-d2gs-idle-60s.d2cap + expect(computeMonsterMoveSpeedSubTilesPerSec(148, false, 75)).toBe(1.171875) + expect(computeMonsterMoveSpeedSubTilesPerSec(150, false, 75)).toBe(3.515625) + }) + + it('interpolates Zombie (hcIdx=5) 0x67 NPCMove and 0x68 NPCMoveToTarget at 1.171875 sub-tiles/s without rubber-banding', () => { + const session = new OnlineSession() + + // 1. Spawn local player at (5010, 5000) and Zombie (classId=5) at (5000, 5000) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 'player', id: 1, classId: 1, name: 'Hero', x: 5010, y: 5000, mode: 1, lifePct: 128 }, + }) + session.handleServerEvent({ + type: 'UnitAssign', + unit: { unitType: 'monster', id: 200, classId: 5, x: 5000, y: 5000, mode: 1, lifePct: 128 }, + }) + + // 2. Server sends 0x67 NPCMove for Zombie to walk 3 sub-tiles to (5003, 5000) with nVelocity = 75 (takes 64 ticks = 2560ms) + const w67 = new ByteWriter(16) + w67.u8(0x67) + w67.u32LE(200) + w67.u8(0x01) // walk + w67.u16LE(5003) + w67.u16LE(5000) + w67.u16LE(0) + w67.u8(2) + w67.i16LE(75) + w67.u8(0) + session.handleServerEvent(decodeD2gsServerPacket(w67.toUint8Array())) + + // Immediately after packet ingestion, Zombie must still be at (5000, 5000), NOT teleported to 5003 + expect(session.world.getUnit('monster', 200)?.x).toBe(5000) + expect(session.interpolator.getEntityState(1, 200)?.x).toBe(5000) + + // 3. Advance 32 ticks (1280ms = half of 2560ms): Zombie must be at 5001.5 (NOT prematurely snapped to 5003 as with old 9 sub-tiles/s!) + for (let i = 0; i < 32; i++) { + session.tick(40, 1000 + (i + 1) * 40) + } + expect(session.interpolator.getEntityState(1, 200)?.x).toBeCloseTo(5001.5, 5) + expect(session.world.getUnit('monster', 200)?.x).toBeCloseTo(5001.5, 5) + expect(session.world.getUnit('monster', 200)?.mode).toBe(2) // still walking (WL) + + // 4. Advance remaining 32 ticks (total 2560ms): Zombie reaches (5003, 5000) exactly when server 0x6D NPCStop arrives + for (let i = 32; i < 64; i++) { + session.tick(40, 1000 + (i + 1) * 40) + } + expect(session.interpolator.getEntityState(1, 200)?.x).toBeCloseTo(5003, 5) + + // Server sends 0x6D NPCStop at (5003, 5000) -> zero rubber-band jump! + session.handleServerEvent({ + type: 'UnitMove', + unitType: 'monster', + id: 200, + mode: 'stop', + to: { x: 5003, y: 5000 }, + }) + expect(session.world.getUnit('monster', 200)?.x).toBeCloseTo(5003, 5) + + // 5. Server sends 0x68 NPCMoveToTarget chasing local player (id=1 at 5010, 5000) from current (5003, 5000) with nVelocity=75 + const w68 = new ByteWriter(21) + w68.u8(0x68) + w68.u32LE(200) + w68.u8(0x00) // walk in 0x68 + w68.u16LE(5003) + w68.u16LE(5000) + w68.u8(0) // targetType = player + w68.u32LE(1) // targetId = 1 + w68.u16LE(0) + w68.u8(2) + w68.i16LE(75) + w68.u8(0) + session.handleServerEvent(decodeD2gsServerPacket(w68.toUint8Array())) + + // Step 25 ticks (1000ms): Zombie advances monotonically by 1.171875 sub-tiles to 5004.171875 + let prevX = session.world.getUnit('monster', 200)!.x + for (let i = 0; i < 25; i++) { + session.tick(40, 4000 + (i + 1) * 40) + const curX = session.world.getUnit('monster', 200)!.x + expect(curX).toBeGreaterThanOrEqual(prevX) + prevX = curX + } + expect(prevX).toBeCloseTo(5003 + 1.171875, 4) + + // Server sends next 0x68 re-sync after ~21 ticks (from X = 5004, Y = 5000): Zombie must NOT jump backward or teleport! + const w68Next = new ByteWriter(21) + w68Next.u8(0x68) + w68Next.u32LE(200) + w68Next.u8(0x00) + w68Next.u16LE(5004) + w68Next.u16LE(5000) + w68Next.u8(0) + w68Next.u32LE(1) + w68Next.u16LE(0) + w68Next.u8(2) + w68Next.i16LE(75) + w68Next.u8(0) + const xBeforeResync = session.world.getUnit('monster', 200)!.x + session.handleServerEvent(decodeD2gsServerPacket(w68Next.toUint8Array())) + const xAfterResync = session.world.getUnit('monster', 200)!.x + // Drift is ~0.17 sub-tiles (< 1.5 soft-reconcile threshold), so visual position stays at xBeforeResync without rubber-banding! + expect(xAfterResync).toBeCloseTo(xBeforeResync, 5) + + session.destroy() + }) +}) + diff --git a/tests/netproto/d2gs-s2c.test.ts b/tests/netproto/d2gs-s2c.test.ts index 0690eba..c45dea2 100644 --- a/tests/netproto/d2gs-s2c.test.ts +++ b/tests/netproto/d2gs-s2c.test.ts @@ -235,7 +235,31 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => { }) }) - it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType (0x01/0x17 and 0x00/0x18)', () => { + it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType, pathType, and velocityPercent (1.13c D2PacketDef.h)', () => { + // Real 0x67 NPCMove from 04-d2gs-idle-60s.d2cap (Akara id=11 walking to (5313,6294) with pathType=2, nVelocity=75): + // 67 0b000000 01 c114 9618 0000 02 4b00 00 + const akara67 = new Uint8Array([ + 0x67, + 0x0b, 0x00, 0x00, 0x00, // id = 11 + 0x01, // moveType = 0x01 (walk) + 0xc1, 0x14, // targetX = 5313 + 0x96, 0x18, // targetY = 6294 + 0x00, 0x00, // unk0x0A + 0x02, // pathType = 2 (PATHTYPE_TOWARD) + 0x4b, 0x00, // velocityPercent = 75 (0x004b) + 0x00, // unk0x0F + ]) + const ev67 = decodeD2gsServerPacket(akara67) + expect(ev67).toEqual({ + type: 'UnitMove', + unitType: 1, + id: 11, + mode: 'walk', + to: { x: 5313, y: 6294 }, + pathType: 2, + velocityPercent: 75, + }) + const w68Run = new ByteWriter(21) w68Run.u8(0x68) w68Run.u32LE(88) @@ -244,13 +268,21 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => { w68Run.u16LE(5200) w68Run.u8(0) // targetType = player w68Run.u32LE(1) // targetId = 1 - w68Run.bytes(new Uint8Array(6)) + w68Run.u16LE(0) // unk0x0F + w68Run.u8(2) // pathType = 2 + w68Run.i16LE(155) // velocityPercent = 155 (e.g. Fallen run: 75 + (100*9/5 - 100) = 155) + w68Run.u8(0) // unk0x14 const ev68 = decodeD2gsServerPacket(w68Run.toUint8Array()) expect(ev68).toMatchObject({ type: 'UnitMove', unitType: 1, id: 88, mode: 'run', + from: { x: 5100, y: 5200 }, + target: { unitType: 0, id: 1 }, + pathType: 2, + velocityPercent: 155, }) }) }) + From 4460f356e6cff8bc376c3f951dce8b4b1a9508f0 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 12:26:59 +0000 Subject: [PATCH 15/15] docs(agents): add Teamwork hourly Beijing Time reporting and immediate merge/push rule --- AGENTS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index bd2e71b..ceb978f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,3 +109,17 @@ 5. **一个主题一个 worktree**: - 不同功能、不同 issue 使用不同 worktree,严禁在同一 worktree 中混杂无关改动。 +--- + +## 8. Teamwork 任务执行与每小时汇报铁律 (Teamwork Hourly Reporting & Continuous Delivery) + +> [!IMPORTANT] +> 凡使用 Teamwork(`/teamwork-preview` 或多智能体协同编排)发起的任务,主控 Agent(Sentinel / Orchestrator)与各子 Agent 必须严格执行以下纪律: + +1. **每小时(北京时间 UTC+8)汇报工作进度与完成百分比**: + - 任务运行期间,必须设置定时调度(如 `schedule` 定时/cron 任务),**每隔 1 小时按北京时间(UTC+8 / `Asia/Shanghai`)**向 master 汇报当前工作进度摘要、已完成/进行中/待处理的子任务或 Issue 列表,以及**整体完成百分比(`%`)**。 +2. **修好的 Issue 立即合并回 `main` 并推送远端**: + - 采用流式持续交付,**严禁**积压所有 Issue 等全部结束再统一合并。 + - 每修好一个 Issue 并通过全部门禁验证(`npm run typecheck`、`vitest`、无头浏览器实机走查等)后,必须**立即**将其从独立 worktree 合并回 `main`(fast-forward 或 rebase 到最新 `main` 后合并),**立即推送至远端 `origin/main`**,并清理对应的临时 worktree 与分支。 + +