merge: resolve conflicts preserving 1.13c turn transition and monster movement speed
This commit is contained in:
commit
659f442df0
14
AGENTS.md
14
AGENTS.md
|
|
@ -109,3 +109,17 @@
|
|||
5. **一个主题一个 worktree**:
|
||||
- 不同功能、不同 issue 使用不同 worktree,严禁在同一 worktree 中混杂无关改动。
|
||||
|
||||
---
|
||||
|
||||
## 8. Teamwork 任务执行与每小时汇报铁律 (Teamwork Hourly Reporting & Continuous Delivery)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 凡使用 Teamwork(`/teamwork-preview` 或多智能体协同编排)发起的任务,主控 Agent(Sentinel / Orchestrator)与各子 Agent 必须严格执行以下纪律:
|
||||
|
||||
1. **每小时(北京时间 UTC+8)汇报工作进度与完成百分比**:
|
||||
- 任务运行期间,必须设置定时调度(如 `schedule` 定时/cron 任务),**每隔 1 小时按北京时间(UTC+8 / `Asia/Shanghai`)**向 master 汇报当前工作进度摘要、已完成/进行中/待处理的子任务或 Issue 列表,以及**整体完成百分比(`%`)**。
|
||||
2. **修好的 Issue 立即合并回 `main` 并推送远端**:
|
||||
- 采用流式持续交付,**严禁**积压所有 Issue 等全部结束再统一合并。
|
||||
- 每修好一个 Issue 并通过全部门禁验证(`npm run typecheck`、`vitest`、无头浏览器实机走查等)后,必须**立即**将其从独立 worktree 合并回 `main`(fast-forward 或 rebase 到最新 `main` 后合并),**立即推送至远端 `origin/main`**,并清理对应的临时 worktree 与分支。
|
||||
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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": "衣",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export function decode0x7FPartyMemberState(pkt: Uint8Array): ServerEvent {
|
|||
lifePct,
|
||||
areaId,
|
||||
relation: isPlayer,
|
||||
isPlayer: isPlayer !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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('等級:')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
Loading…
Reference in New Issue