merge: resolve conflicts preserving 1.13c turn transition and monster movement speed

This commit is contained in:
troytt 2026-10-02 13:42:19 +00:00
commit 659f442df0
51 changed files with 16057 additions and 700 deletions

View File

@ -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 与分支。

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
public/ui/textslid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -33,6 +33,7 @@ import {
const EXPANSION_CHI_FALLBACKS: Readonly<Record<string, string>> = {
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)

View File

@ -442,6 +442,14 @@ async function main(): Promise<void> {
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)

View File

@ -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)
@ -1278,6 +1287,69 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
savePng('quests-atlas.png', QUEST_ATLAS_W, QUEST_ATLAS_H, stitchFrames(QUEST_ATLAS_W, QUEST_ATLAS_H, questPlacements))
// --- STAGE 3.25: Top-Left HUD Portraits Atlas (29 icons from data/global/ui/Hireables/*.dc6) ---
console.log('Packing 29 Top-Left HUD Portrait DC6 icons (Player Classes, Hirelings, Summoned Minions)...')
const canonicalPortraits = [
'AmazonIcon',
'SorceressIcon',
'NecromancerIcon',
'PaladinIcon',
'BarbarianIcon',
'DruidIcon',
'AssassinIcon',
'rogueicon',
'act2hireableicon',
'act3hireableicon',
'barbhirable_icon',
'valkarieicon',
'earthgolumicon',
'bloodgolumicon',
'metalgolumicon',
'firegolumicon',
'skeletonicon',
'skeletonmageicon',
'revivedicon',
'raven',
'wolf',
'oaksage',
'heartofwolverine',
'spiritofbarbs',
'plaguepoppy',
'cycleoflife',
'vines',
'bear',
'shadowassassin',
]
const PORTRAIT_CELL_W = 48
const PORTRAIT_CELL_H = 42
const PORTRAIT_COLS = 6
const PORTRAIT_ROWS = Math.ceil(canonicalPortraits.length / PORTRAIT_COLS)
const PORTRAIT_ATLAS_W = PORTRAIT_COLS * PORTRAIT_CELL_W
const PORTRAIT_ATLAS_H = PORTRAIT_ROWS * PORTRAIT_CELL_H
const portraitRects: Record<string, SpriteRect> = {}
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')
@ -1473,6 +1545,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',
@ -1501,6 +1574,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 +1585,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 +1594,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
flippyFrames,
itemClassIdToCode,
questRects,
portraitRects,
images: imagesMap,
automapAtlases,
}
@ -1598,6 +1675,7 @@ export interface BakedUiManifest {
readonly flippyFrames: Record<string, readonly SpriteRect[]>
readonly itemClassIdToCode: Record<number, string>
readonly questRects: Record<string, SpriteRect>
readonly portraitRects?: Record<string, SpriteRect>
readonly images: Record<string, string>
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
}
@ -1667,6 +1745,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,
}

View File

@ -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 } : {}),

View File

@ -113,7 +113,7 @@ export interface CommandMapperDeps {
readonly mapService?: MapService | undefined
readonly automapView?: AutomapView | undefined
readonly rateLimiter?: OutboundRateLimiter | undefined
readonly sendCommand: (cmd: ClientCommand) => void
readonly sendCommand: (cmd: ClientCommand, nowMs?: number) => void
/** Optional hook to check if an external UI overlay (e.g. top toolbar / chat input) intercepted the point. */
readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined
/** Optional callback when Enter key toggles chat input. */
@ -150,7 +150,7 @@ export class CommandMapper {
private readonly mapService: MapService | null
private readonly automapView: AutomapView | null
private readonly rateLimiter: OutboundRateLimiter
private readonly sendCommand: (cmd: ClientCommand) => void
private readonly sendCommand: (cmd: ClientCommand, nowMs?: number) => void
private readonly isExternalUiIntercepted?: ((screenX: number, screenY: number) => boolean) | undefined
private readonly onToggleChatInput?: (() => void) | undefined
private readonly onEscapeMenu?: (() => void) | undefined
@ -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
@ -297,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 ──────────────────────────
/**
@ -323,8 +389,20 @@ 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
}
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)
}
}
@ -341,6 +419,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
@ -350,8 +430,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
}
@ -362,11 +442,20 @@ export class CommandMapper {
if (button === 0) {
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)
}
@ -379,6 +468,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 +490,10 @@ 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(
{
@ -434,11 +528,17 @@ 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.sendCommand({
type: 'MoveToUnit',
run: isRunning,
target: unitToRef(hoveredUnit),
})
this.pendingItemPickupId = hoveredUnit.id
this.sendCommand(
{
type: 'MoveToUnit',
run: isRunning,
target: unitToRef(hoveredUnit),
},
nowMs,
)
} else {
this.pendingItemPickupId = null
}
return this.dispatchRateLimited(
{
@ -451,6 +551,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(
@ -465,10 +567,13 @@ export class CommandMapper {
nowMs,
path,
)
this.sendCommand({
type: 'TakeWarp',
warpId: hoveredUnit.id,
})
this.sendCommand(
{
type: 'TakeWarp',
warpId: hoveredUnit.id,
},
nowMs,
)
return moveRec
}
@ -476,11 +581,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.sendCommand({
type: 'MoveToUnit',
run: isRunning,
target: unitToRef(hoveredUnit),
})
this.sendCommand(
{
type: 'MoveToUnit',
run: isRunning,
target: unitToRef(hoveredUnit),
},
nowMs,
)
}
return this.dispatchRateLimited(
{
@ -497,11 +605,14 @@ export class CommandMapper {
const ref = unitToRef(hoveredUnit)
const dist = Math.hypot(hoveredUnit.x - selfX, hoveredUnit.y - selfY)
if (dist > 4) {
this.sendCommand({
type: 'MoveToUnit',
run: isRunning,
target: ref,
})
this.sendCommand(
{
type: 'MoveToUnit',
run: isRunning,
target: ref,
},
nowMs,
)
}
return this.dispatchRateLimited(
{
@ -532,18 +643,27 @@ export class CommandMapper {
nowMs,
path,
)
this.sendCommand({
type: 'NpcInit',
target: ref,
})
this.sendCommand({
type: 'Interact',
target: ref,
})
this.sendCommand(
{
type: 'NpcInit',
target: ref,
},
nowMs,
)
this.sendCommand(
{
type: 'Interact',
target: ref,
},
nowMs,
)
return moveRecord
}
// 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',
@ -567,6 +687,10 @@ 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 }
return this.dispatchRateLimited(
@ -589,6 +713,10 @@ 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(
{
@ -635,6 +763,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 +973,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,16 +998,52 @@ 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 {
const flushed = this.rateLimiter.flushPendingMove(nowMs)
if (flushed) {
this.sendCommand(flushed)
this.sendCommand(flushed, nowMs)
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
}
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
const record = this.triggerPrimaryWorldAction(
target.subtileX,
@ -872,6 +1055,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,
@ -897,13 +1083,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 =
@ -938,7 +1133,7 @@ export class CommandMapper {
): DispatchedCommandRecord {
const rateLimit = this.rateLimiter.tryAcquire(command, nowMs)
if (rateLimit.allowed) {
this.sendCommand(command)
this.sendCommand(command, nowMs)
if (
command.type === 'SkillAt' ||
command.type === 'SkillOn' ||
@ -951,7 +1146,7 @@ export class CommandMapper {
command.type === 'NpcInit' ||
command.type === 'Resurrect'
) {
this.world.noteOutgoingCommand(command)
this.world.noteOutgoingCommand(command, nowMs)
}
if (this.predictor) {
if (command.type === 'SkillAt') {

View File

@ -843,7 +843,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
}
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())
})
@ -870,6 +870,10 @@ async function bootPlayApp(): Promise<PlayAppController> {
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 },
@ -912,6 +916,12 @@ async function bootPlayApp(): Promise<PlayAppController> {
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))
@ -933,7 +943,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
flowState.lastFrameTimeMs = null
}
session.tick(dtMs || 40, nowMs)
session.tick(dtMs, nowMs)
const currentSettings = settingsStore.get()
const profile = getViewportProfile(currentSettings.viewport)
@ -958,7 +968,6 @@ async function bootPlayApp(): Promise<PlayAppController> {
automapView: session.automapView,
hudManager: session.hudManager,
nowMs,
playerMoving: session.predictor.getSnapshot().moving,
})
}

View File

@ -120,7 +120,9 @@ 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 }

View File

@ -1,4 +1,11 @@
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'
import { npcDisplayName } from '../../common/units/npc-table.ts'
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
@ -36,14 +43,23 @@ 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,
type LightingPassTargets,
} from './lighting-presets.ts'
export { layoutGroundItemLabels }
export {
computeAnimFrameIndex,
computeStepSpeed256,
getCharAnimClip113c,
layoutGroundItemLabels,
}
/**
* Diagnostic entry recorded whenever an entity, item, missile, overlay, or tile
@ -62,9 +78,47 @@ declare global {
}
}
interface TrackedOneShotState {
readonly clipKey: string
readonly rawStartedAtMs: number | undefined
readonly effectiveStartMs: number
}
const missingArtRegistry = new Map<string, MissingArtEntry>()
const missingArtList: MissingArtEntry[] = []
const dropStartMsByItemId = new Map<number, number>()
const oneShotStartMsByUnitKey = new Map<string, TrackedOneShotState>()
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
@ -102,6 +156,7 @@ export function clearMissingArtEntries(): void {
missingArtRegistry.clear()
missingArtList.length = 0
dropStartMsByItemId.clear()
oneShotStartMsByUnitKey.clear()
if (typeof window !== 'undefined') {
window.__d2MissingArt = missingArtList
}
@ -175,19 +230,41 @@ const COMMON_HCIDX_TO_MONSTER_ID: Readonly<Record<number, string>> = {
520: 'cain5',
}
const PLAYER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
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<Record<string, string>> = {
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<Record<number, string>> = {
@ -345,7 +422,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 +483,84 @@ 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 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) {
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] ??
(fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) ??
(isMoving ? charArt.clips?.wl : charArt.clips?.nu)
const dirCount = Math.max(1, clip?.directions ?? charArt.directions)
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}`,
`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 frameIndex =
unit.isCorpse || requestedClipKey === 'dd'
? frameCount - 1
: isMoving
? Math.floor(nowMs / 65) % frameCount
: Math.floor(nowMs / 140) % frameCount
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 =
@ -473,31 +601,75 @@ 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] ??
(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 frameIndex =
unit.isCorpse || requestedClipKey === 'dd'
? frameCount - 1
: isMoving
? Math.floor(nowMs / 75) % frameCount
: Math.floor(nowMs / 130) % frameCount
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 =
@ -682,9 +854,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]
@ -734,25 +913,42 @@ 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())
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.loopAnim === false
? Math.min(frameCount - 1, Math.max(0, Math.floor(missile.frame)))
: (missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) %
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 +969,35 @@ 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 animRate = 'animRate' in art.meta ? art.meta.animRate : art.meta.animSpeed
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.loop === false
? Math.min(frameCount - 1, Math.max(0, Math.floor(overlay.frame)))
: (overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) %
frameCount
const frame = group?.[frameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX =
@ -826,16 +1044,18 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
renderer.flush()
}
if (ctx.hudManager) {
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
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) {
@ -927,11 +1147,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
@ -1011,7 +1239,56 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
}
if (ctx.hudManager) {
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
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)
return snapshot

View File

@ -72,6 +72,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 =
@ -271,6 +272,7 @@ export class OnlineSession {
private disconnectReasonValue: string | null = null
private pingMsValue = 0
private tickCounter = 0
private readonly knownAreaIds = new Set<number>()
private readonly outboundCommandsLog: ClientCommand[] = []
private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
@ -285,6 +287,7 @@ export class OnlineSession {
const initialViewport = getViewportProfile(currentSettings.viewport)
this.world = options.world ?? new ClientWorld()
this.world.skipSelfTurnStep = true
this.camera = options.camera ?? new ViewportCamera(initialViewport)
this.hudManager = options.hudManager ?? new HudManager()
this.hudManager.resize(initialViewport.width, initialViewport.height)
@ -299,6 +302,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 ??
@ -358,8 +395,8 @@ export class OnlineSession {
mapService: this.mapService ?? undefined,
automapView: this.automapView,
rateLimiter: this.rateLimiter,
sendCommand: (cmd) => {
this.forwardCommandToAdapter(cmd)
sendCommand: (cmd, nowMs) => {
this.forwardCommandToAdapter(cmd, nowMs)
},
isExternalUiIntercepted: options.isExternalUiIntercepted,
onToggleChatInput: options.onToggleChatInput,
@ -374,20 +411,14 @@ 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,
tick: this.world.frameCount,
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
playerSubX,
playerSubY,
@ -695,6 +726,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)
@ -922,6 +954,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
@ -933,11 +1025,37 @@ 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)
}
/**
* 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 ─────────────────────────
/**
@ -956,13 +1074,66 @@ export class OnlineSession {
: (cmd as ClientCommand)
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
if (res.allowed) {
this.forwardCommandToAdapter(normalized)
this.world.noteOutgoingCommand(normalized, nowMs)
this.forwardCommandToAdapter(normalized, nowMs)
return true
}
return false
}
private forwardCommandToAdapter(cmd: ClientCommand): void {
/**
* Alias for `dispatchCommand` used by input/HUD callers.
*/
sendCommand(cmd: ClientCommand | Record<string, any>, 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, nowMs?: number): void {
this.outboundCommandsLog.push(cmd)
if (this.outboundCommandsLog.length > 512) {
this.outboundCommandsLog.shift()
@ -970,14 +1141,20 @@ export class OnlineSession {
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
try {
this.gameAdapter.send(cmd)
this.world.noteOutgoingCommand(cmd)
this.world.noteOutgoingCommand(cmd, nowMs)
if (this.world.selfId !== 0) {
const selfUnit = this.world.getUnit('player', this.world.selfId)
if (selfUnit?.castState && !this.predictor.getSnapshot().moving) {
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
return
}
} else {
this.world.noteOutgoingCommand(cmd)
this.world.noteOutgoingCommand(cmd, nowMs)
}
if (
cmd.type === 'SkillAt' ||
@ -1053,20 +1230,36 @@ export class OnlineSession {
} else if (evAny.type === 'UnitMove') {
const uType = typeof evAny.unitType === 'number' ? unitTypeFromId(evAny.unitType) : (evAny.unitType ?? 'player')
const uId = Number(evAny.id ?? evAny.unitId ?? 0)
const fromX = Number(evAny.from?.x ?? evAny.currentX ?? evAny.x ?? 0)
const fromY = Number(evAny.from?.y ?? evAny.currentY ?? evAny.y ?? 0)
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? fromX)
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? fromY)
const hasFrom = evAny.from !== undefined || evAny.currentX !== undefined
const from = hasFrom
? {
x: Number(evAny.from?.x ?? evAny.currentX ?? 0),
y: Number(evAny.from?.y ?? evAny.currentY ?? 0),
}
: undefined
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? (from ? from.x : 0))
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? (from ? from.y : 0))
ev = {
...evAny,
type: 'UnitMove',
unitType: uType,
id: uId,
mode: evAny.mode ?? 'walk',
from: { x: fromX, y: fromY },
...(from ? { from } : {}),
to: { x: toX, y: toY },
} 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 (
@ -1133,7 +1326,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,
@ -1145,6 +1342,9 @@ export class OnlineSession {
}
case 'RoomReveal': {
if (ev.areaId > 0) {
this.knownAreaIds.add(ev.areaId)
}
if (this.mapService) {
try {
this.mapService.onAddRoomDataPacket({
@ -1251,34 +1451,109 @@ export class OnlineSession {
case 'UnitMove': {
const evAny = ev as any
const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType)
const evUnitId = Number(evAny.id ?? evAny.unitId ?? 0)
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
const evUnitId = Number(evAny.id ?? evAny.unitId ?? (evTypeId === 0 ? 1 : 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)
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') {
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' })
}
} 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.syncTurnState(motionState.dir64, motionState.targetDir64, motionState.stepDir)
}
const worldUnit = this.world.getUnit(ev.unitType, evUnitId)
if (worldUnit && ev.target) {
this.interpolator.setEntityTargetDirection(
@ -1382,6 +1657,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()
@ -1397,6 +1675,7 @@ export class OnlineSession {
* - Flushes rate-limited movement commands in `CommandMapper`
* - Steps `LocalMovementPredictor` and `Camera`
* - Steps `RemoteEntityInterpolator` and syncs interpolated positions and 25Hz facings to `ClientWorld` units
* - Expires finished one-shot cast/attack animations in `ClientWorld`
*/
tick(dtMs = 40, nowMs = performance.now()): void {
if (!Number.isFinite(dtMs)) {
@ -1405,6 +1684,7 @@ export class OnlineSession {
if (!Number.isFinite(nowMs)) {
throw new RangeError(`nowMs must be a finite number, got ${String(nowMs)}`)
}
const safeDt = Math.max(0, dtMs)
this.tickCounter++
this.commandMapper.tick(nowMs)
@ -1422,7 +1702,7 @@ export class OnlineSession {
}
}
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)
}
@ -1441,25 +1721,63 @@ export class OnlineSession {
this.interpolator.setEntityTargetDirection(uTypeId, unit.id, unit.targetDirection, false)
}
} else if (!unit.dead) {
unit.advanceTurn(dtMs)
unit.advanceTurn(safeDt)
}
}
this.interpolator.update(dtMs)
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)
if (unit) {
unit.x = state.x
unit.y = state.y
unit.syncTurnState(state.dir64, state.targetDir64, state.stepDir)
if (unit.unitType === 'player' && unit.id !== this.world.selfId) {
const unitInTown = this.isSubTileInTown(state.x, state.y)
if (state.moving && unit.moveMode !== 'stop') {
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 === 19 ||
unit.mode === 1 ||
unit.mode === 5
) {
unit.mode = unitInTown ? 5 : 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.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
}
/**
* 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
@ -1471,19 +1789,36 @@ 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
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',
@ -1502,24 +1837,54 @@ export class OnlineSession {
isLocalPlayer: true,
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 } : {}),
})
}
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()
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
}
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 isHovered = Boolean(
hoveredUnit && hoveredUnit.unitType === u.unitType && hoveredUnit.id === u.id,
)
const interp = this.interpolator.getEntityState(
u.unitType === 'player'
? 0
@ -1527,11 +1892,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
@ -1580,13 +1943,54 @@ export class OnlineSession {
direction: interp ? interp.dir64 : u.direction,
radiusX: 28,
radiusY: 20,
isHovered,
})
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))
if (interp?.moving && u.castState) {
this.world.clearUnitCastState(u)
}
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 =
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 || 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
@ -1619,6 +2023,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 } : {}),
@ -1649,11 +2054,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,
@ -1661,6 +2075,7 @@ export class OnlineSession {
direction: interp ? interp.dir64 : u.direction,
hpRatio: 1,
isCorpse: false,
isHovered,
...(goldAmount !== undefined ? { goldAmount } : {}),
...(itemQuality !== undefined ? { itemQuality, quality: itemQuality } : {}),
})
@ -1673,6 +2088,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,
@ -1687,12 +2105,17 @@ 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,
states: Array.from(u.states.list()),
...(castState !== undefined ? { castState } : {}),
...(u.unitType === 'player' ? { isPartyMember } : {}),
...(u.overlays.length > 0 ? { overlays: u.overlays } : {}),
...(unitAnimStartedAtMs !== undefined ? { animStartedAtMs: unitAnimStartedAtMs } : {}),
...(unitAnimStepSpeed256 !== undefined ? { animStepSpeed256: unitAnimStepSpeed256 } : {}),
...(unitAnimEffRate !== undefined ? { animEffRate: unitAnimEffRate } : {}),
...(u.unitType === 'player' ? { isPartyMember, isLocalPlayer: false } : {}),
})
}

View File

@ -41,6 +41,12 @@ 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'
import {
resolveMercPortraitIcon,
resolvePlayerClassPortraitIcon,
resolvePetPortraitInfo,
} from '../../common/units/pet-type-table.ts'
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
0: 'ama',
@ -819,10 +825,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,39 +840,77 @@ 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<number, typeof activePets>()
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
: (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,
@ -876,45 +922,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
@ -973,10 +1027,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 +1048,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 +1078,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 +1634,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
}

View File

@ -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 = ''

View File

@ -13,13 +13,19 @@
* 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'
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,
@ -313,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<DockingLayout, 'barOffsetX' | 'barOffsetY'>,
): { 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<DockingLayout, 'leftDockX' | 'panelOffsetY'>,
): { 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<DockingLayout, 'leftDockX' | 'panelOffsetY'>,
): { 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<DockingLayout, 'rightDockX' | 'panelOffsetY'>,
): { 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<DockingLayout, 'rightDockX' | 'panelOffsetY'>,
): { 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`;
@ -406,6 +490,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()
@ -418,10 +527,31 @@ 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. */
groundItemLabelBoxes: readonly GroundItemLabelBox[] = []
/** Server-authoritative vitals (`0x18`/`0x95` + stats 6..11); 0 until the server sends them. */
stamina = 0
maxStamina = 0
@ -433,9 +563,10 @@ 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
pendingIdentifyScrollId: number | null = null
@ -458,6 +589,8 @@ export class HudManager {
private images = new Map<string, HTMLImageElement>()
private itemRects: Record<string, { x: number; y: number; w: number; h: number }> = {}
private portraitRects: Record<string, { x: number; y: number; w: number; h: number }> =
(BAKED_UI_MANIFEST as any).portraitRects ?? {}
private decodedDc6Count = 415
private dc6DecodeFailures = 0
private eventsBound = false
@ -708,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,
}
}
}
}
@ -755,8 +968,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
}
@ -803,12 +1019,14 @@ 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`,
pentSpin: `${baseUrl}/pentspin.png`,
itemsAtlas: `${baseUrl}/items-atlas.png`,
questsAtlas: `${baseUrl}/quests-atlas.png`,
portraitsAtlas: `${baseUrl}/portraits-atlas.png`,
}
await Promise.all(
@ -1144,6 +1362,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' ||
@ -1248,7 +1473,8 @@ 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
if (barY >= 540 && barY <= 600 && barX >= 0 && barX <= 800) return true
@ -1269,7 +1495,8 @@ 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
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
@ -1447,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) ||
@ -1547,7 +1729,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)) {
@ -1609,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))
@ -1653,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
},
@ -1683,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'
@ -1702,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,
{
@ -1780,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)
@ -1820,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,
)
@ -1848,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,
)
@ -1871,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),
@ -1901,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
@ -2308,51 +2495,140 @@ 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
}
}
}
// 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`)
@ -2415,7 +2691,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,
@ -2474,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(
@ -2515,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,
{
@ -2533,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`)
@ -2543,28 +2831,56 @@ export class HudManager {
})
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
if (this.worldPanels.npcMenu) {
this.worldPanels.drawNpcMenu(ctx, this.font, {
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
})
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,
},
profile.width,
profile.height,
)
}
// 9. Area Entry Gothic Banner (`Entering: <Level Name>`, 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)
@ -2582,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',

View File

@ -498,6 +498,9 @@ export class Minimap {
/** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */
private readonly lastRevealSubPos = new Map<string, { x: number; y: number }>()
/** 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(

View File

@ -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)

View File

@ -49,6 +49,7 @@ import {
type VendorNpcId,
getTownNpcDescriptorByName,
getTownNpcDescriptorByVendorId,
getTownNpcDescriptorByHcIdx,
getLocalizedNpcName,
getLocalizedNpcGreetings,
} from '../../common/units/npc-table.ts'
@ -60,6 +61,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 +91,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). */
@ -513,7 +529,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,
@ -522,6 +538,8 @@ export function computeNpcDialogLayout(
anchorY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
lang: TblLang = 'CHI',
viewportWidth = 800,
viewportHeight = 600,
): NpcDialogLayout {
const measure = (text: string): number =>
font ? font.measureText(text, 'font16') : text.length * 10
@ -542,11 +560,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
@ -846,8 +864,15 @@ 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
replaceMercConfirmOpen = false
pendingHireCandidate: any = null
hireConfirmHoveredOption: 0 | 1 | null = null
playerGold = 0
hasDeadMercenary = false
deadMercenaryReviveCost = 0
partyMembers: any[] = []
@ -880,6 +905,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 +1353,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 +1646,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') {
@ -1631,6 +1680,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
/**
@ -2005,9 +2057,16 @@ 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
this.hoveredMercItem = null
this.hoveredTradeItem = null
this.hoveredQuestRewardItem = null
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
? this.hitTestVendorButtonSlot(logicalX, logicalY)
: null
@ -2093,9 +2152,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<D2FontRenderer, 'measureText'> | null): NpcDialogLayout | null {
getNpcMenuLayout(
font?: Pick<D2FontRenderer, 'measureText'> | 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' },
@ -2104,23 +2236,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<D2FontRenderer, 'measureText'> | 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 &&
@ -2134,9 +2277,11 @@ export class WorldPanelsHud {
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | 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.
@ -2156,6 +2301,8 @@ export class WorldPanelsHud {
onCancel: () => void
},
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
@ -2171,7 +2318,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 ||
@ -3005,6 +3161,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()
@ -3014,8 +3172,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)
@ -3042,7 +3200,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)
@ -3070,6 +3237,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, 'fontformal12')
} 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,
@ -3286,6 +3919,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
@ -3550,10 +4185,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
@ -3574,7 +4209,12 @@ export class WorldPanelsHud {
/**
* Draw Area Entry Gothic Banner (`Entering: <Level Name>` / `进入:<Level Name>`).
*/
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
@ -3584,7 +4224,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',
@ -3738,6 +4378,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 +4428,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 +4555,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 +4676,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')

View File

@ -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,15 +47,23 @@ 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 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<Record<string, string>> | undefined
@ -67,6 +84,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
@ -83,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`. */
@ -96,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
@ -108,6 +130,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
@ -121,6 +147,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 +170,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 +240,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 +272,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
}
/**
@ -348,19 +387,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] = [
@ -377,6 +414,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,
@ -407,11 +449,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,
@ -419,6 +481,7 @@ export function buildSceneDrawables(
token: ov.token ?? meta?.token ?? `OVL_${ov.overlayId}`,
frame,
totalFrames,
loop: !isOneShot,
worldX,
worldY: worldY + yOffset,
sortY: worldY + 1,
@ -427,15 +490,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],
})
}
}
@ -460,6 +531,7 @@ export function buildSceneDrawables(
token: meta.token,
frame: tick % meta.totalFrames,
totalFrames: meta.totalFrames,
loop: true,
worldX,
worldY: worldY + meta.yOffset,
sortY: worldY + 1,
@ -490,25 +562,88 @@ 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)
? '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 && 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 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,
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)
@ -544,6 +679,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,
@ -562,6 +699,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 } : {}),

View File

@ -28,13 +28,36 @@ 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
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 {
@ -47,8 +70,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
@ -56,6 +81,8 @@ export class ClientUnit {
targetY?: number | undefined
targetUnit?: UnitRef | undefined
moveMode?: 'walk' | 'run' | 'knockback' | 'stop' | undefined
velocityPercent?: number | undefined
pathType?: number | undefined
mode: number
private readonly _turnState: UnitTurnState
@ -93,6 +120,41 @@ 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<number> | 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
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()
@ -104,10 +166,18 @@ export class ClientUnit {
this.name = snapshot.name
this.x = snapshot.x
this.y = snapshot.y
this.mode = snapshot.mode ?? 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
const initialDir = snapshot.direction ?? 0
this._turnState = createUnitTurnState(initialDir)
this.lifePct = snapshot.lifePct ?? 128
this.flags = snapshot.flags ?? 0
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
this.objectState = snapshot.objectState
@ -249,6 +319,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
@ -306,6 +383,17 @@ export class ClientUnit {
}
}
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

File diff suppressed because it is too large Load Diff

View File

@ -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':

View File

@ -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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"9ws": "神屬權杖",
"9xf": "格斗爪",
"9yw": "淨化之杖",
"AC": "防禦",
"AbyssKnight": "地獄騎士",
"Act 5 Combatant": "野蛮人战士",
"Act 5 Townguard": "守卫",
@ -4484,6 +4667,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
"Guillaume's Face": "吉永之脸",
"Gull": "海鷗",
"Gutsiphon": "吸魂器",
"HP": "生命",
"Haemosu's Adament": "解慕漱的坚决",
"Hailstone": "霰之",
"Halaberd's Reign": "哈拉伯德的统治",
@ -5143,6 +5328,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
"Leadcrow": "引導烏鴉",
"Leatherarm": "爬行的容貌",
"Lenyms Cord": "雷尼摩",
"Level": "等級",
"Leviathan": "利维坦",
"Lidless Wall": "警戒之墙",
"Lightning Sentry": "闪电卫哨",
@ -5262,6 +5450,73 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
"Mosers Blessed Circle": "摩西尔的祝福之环",
"MummyGenerator": "木乃伊石棺",
"NPCIdentify1": "辨視物品",
"NPCMenuHire": "雇用",
"NPCMenuTrade": "交易",
"NPCMenuTradeRepair": "交易/修理",
"NPCPurchaseItems": "買",
@ -5564,6 +5820,9 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
"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<Record<string, string>> = {
"Silver": "銀之",
"Skeleton": "骷髏",
"SkeletonArcher": "骷髏弓箭手",
"Skillname222": "渡鸟之",
"Skillname222": "乌鸦",
"Skillname223": "剧毒蔓藤",
"Skillname224": "狼人變化",
"Skillname225": "狼人变化",
@ -5767,7 +6099,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Skillname247": "荆棘之灵",
"Skillname248": "召唤灰熊",
"Skillname249": "狂怒",
"Skillname250": "世界末日之",
"Skillname250": "毁天灭地",
"Skillname251": "飓风",
"Skillname252": "火焰爆震",
"Skillname253": "支配利爪",
@ -5796,7 +6128,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Skillname276": "飞龙在天",
"Skillname277": "亡者守卫",
"Skillname278": "刃之盾",
"Skillname279": "毒液的",
"Skillname279": "毒液",
"Skillname280": "暗影宗师",
"Skillname281": "凤凰击",
"Skin of the Flayerd One": "剥皮者之皮",
@ -6317,6 +6649,15 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
"coat": "外袍",
"coil": "盤繞",
"collar": "項圈",
"colon": ":",
"compellingorb": "強制球體",
"cord": "捆綁",
"cost": "花費:",
@ -6541,6 +6883,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"cudgel": "木棒",
"d33": "吉得賓誘餌",
"dart": "鏢",
"dash": "-",
"dgr": "匕首",
"dhn": "暗黑破壞神之角",
"dir": "長匕首",
@ -6730,6 +7073,89 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"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<Record<string, string>> = {
"sml": "小盾牌",
"sol": "灵魂之",
"song": "歌",
"space": " ",
"spawn": "重生的",
"spc": "狼牙棒",
"spe": "一股邪恶力量",
@ -7254,6 +7681,16 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"strchrstm": "耐力",
"strchrstr": "力量",
"strchrvit": "體力",
"strhirespecial1": "火箭",
"strhirespecial10": "快速施展火焰",
"strhirespecial2": "冰箭",
"strhirespecial3": "戳刺攻擊",
"strhirespecial4": "抗毒",
"strhirespecial5": "閃電",
"strhirespecial6": "冷",
"strhirespecial7": "火",
"strhirespecial8": "快速施展閃電",
"strhirespecial9": "快速施展冰冷",
"stu": "鑲嵌甲",
"stun attack": "昏眩攻擊",
"suit": "衣",

View File

@ -68,6 +68,7 @@ export interface BakedUiManifest {
readonly flippyFrames: Record<string, readonly SpriteRect[]>
readonly itemClassIdToCode: Record<number, string>
readonly questRects: Record<string, SpriteRect>
readonly portraitRects?: Record<string, SpriteRect>
readonly images: Record<string, string>
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
}
@ -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",
@ -293595,6 +293814,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",
@ -293622,7 +293842,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,
}

View File

@ -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<Record<string, MissileTxtData>> =
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<Record<number, string>> = Object.freeze(
(() => {
const map: Record<number, string> = {}
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<Record<string, OverlayTxtData>> =
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<Record<string, string>> = 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<Record<number, string>> = 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<Record<number, string>> = 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
)
}

View File

@ -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<number, Readonly<Record<CharAnimClipKey, CharAnimClip113c>>>
> = {
// 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<Record<string, number>> = {
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<Record<number, CharAnimClipKey>> = {
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<EquippedItemLike> | 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<EquippedItemLike> | null | undefined
readonly sockets?: ReadonlyMap<number, readonly EquippedItemLike[]> | 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<number>()
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<EquippedItemLike> | null | undefined
readonly sockets?: ReadonlyMap<number, readonly EquippedItemLike[]> | 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
}

File diff suppressed because it is too large Load Diff

View File

@ -1515,6 +1515,75 @@ export const MONSTER_HCIDX_TO_ID: Readonly<Record<number, string>> = {
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).
*/

View File

@ -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<Record<string, readonly [number, number]>> = (() => {
const out: Record<string, readonly [number, number]> = {}
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<string, MonsterKind> | null = null
function getCanonicalMonsterKinds(): Map<string, MonsterKind> {
@ -2056,11 +2070,14 @@ function getCanonicalMonsterKinds(): Map<string, MonsterKind> {
const enriched = new Map<string, MonsterKind>()
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<string, MonsterKind> & Readonly<Record<s
return typeof value === 'function' ? value.bind(map) : value
}
if (typeof prop === 'string') {
return map.get(prop) ?? map.get(prop.toLowerCase())
const direct = map.get(prop) ?? map.get(prop.toLowerCase())
if (direct !== undefined) return direct
if (/^\d+$/.test(prop)) {
const id = MONSTER_HCIDX_TO_ID[Number(prop)]
if (id) return map.get(id)
}
}
return undefined
},
@ -2104,4 +2126,62 @@ export const MONSTER_TABLE: ReadonlyMap<string, MonsterKind> & Readonly<Record<s
},
) as unknown as ReadonlyMap<string, MonsterKind> & Readonly<Record<string, MonsterKind>>
/**
* 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
}

View File

@ -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<Record<number, string>> = {
0: 'AmazonIcon',
1: 'SorceressIcon',
2: 'NecromancerIcon',
3: 'PaladinIcon',
4: 'BarbarianIcon',
5: 'DruidIcon',
6: 'AssassinIcon',
}
export const PLAYER_CLASS_PORTRAIT_BY_CODE: Readonly<Record<string, string>> = {
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<Record<number, PetTypePortraitMeta>> = {
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<Record<number, number>> = {
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,
}
}

View File

@ -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,
}
}

View File

@ -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 },
}
}

View File

@ -14,9 +14,27 @@ 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 (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
) {
return 'run'
}
return 'walk'
}
@ -110,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 } : {}),
}
}
@ -129,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,
@ -137,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 } : {}),
}
}

View File

@ -130,6 +130,7 @@ export function decode0x7FPartyMemberState(pkt: Uint8Array): ServerEvent {
lifePct,
areaId,
relation: isPlayer,
isPlayer: isPlayer !== 0,
}
}

View File

@ -94,7 +94,7 @@ export function decode0x11ReportKill(pkt: Uint8Array): ServerEvent {
type: 'UnitMode',
unitType,
id,
mode: 0x09,
mode: unitType === 0 ? 17 : resolveMonsterCmdMode(0x09),
lifePct: 0,
}
}
@ -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,
},

View File

@ -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'
@ -86,6 +88,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'
@ -170,6 +174,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 +184,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 +216,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'

View File

@ -0,0 +1,818 @@
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<string, AnimClipMeta> = {}
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()
const castDir = dir64FromPoints(5100, 5100, 5200, 5100, 0)
world.apply(
{
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1,
name: 'Sorc',
x: 5100,
y: 5100,
direction: castDir,
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)!
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()
const expectedDir = dir64FromPoints(5000, 5000, 5040, 5020, 0)
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1,
name: 'Sorc',
x: 5000,
y: 5000,
direction: expectedDir,
mode: 1,
lifePct: 128,
},
})
session.handleServerEvent({
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 47,
level: 1,
target: { x: 5040, y: 5020 },
})
const selfUnit = session.world.getUnit('player', 1)!
expect(selfUnit.targetDirection).toBe(expectedDir)
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()
const castDir = dir64FromPoints(5000, 5000, 5030, 5000, 0)
world.apply(
{
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1, // Sorceress
name: 'Sorc',
x: 5000,
y: 5000,
direction: castDir,
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<string, LoadedCharacter>([['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)
})
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)
})
})

View File

@ -678,4 +678,384 @@ 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)
})
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)
}
}
})
})

View File

@ -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
})
})

View File

@ -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')
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -27,6 +27,12 @@ import {
setUnitTargetDirection,
stepUnitTurnTick,
} 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
@ -918,4 +924,141 @@ describe('adversarial reviewer r3 deep regression & online session integration',
})
})
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()
})
})

View File

@ -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))
},
)
})

View File

@ -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('等級:')
})
})

View File

@ -141,4 +141,148 @@ 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, 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)
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.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,
})
})
})

View File

@ -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<void> {
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<CdpSession> {
const ws = new WebSocket(wsUrl)
await new Promise<void>((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<string, unknown> = {}): Promise<any> {
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<T = any>(expression: string): Promise<T> {
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<number> {
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<void> {
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<void> {
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<typeof spawn> | 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)
})