Merge branch 'main' into fix/issue-550-spell-visual-effects
This commit is contained in:
commit
ec2e8d9b3e
|
|
@ -288,6 +288,25 @@ export class AutomapView {
|
||||||
return this.levelViews.get(areaId)!
|
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.
|
* 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`).
|
* 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 snap = this.buildSnapshot([], lang)
|
||||||
const view = this.ensureLevelView(this.currentAreaId)
|
const view = this.ensureLevelView(this.currentAreaId)
|
||||||
const allRooms = view.rooms
|
const allRooms = view.rooms
|
||||||
|
|
||||||
let minCellX = 0
|
let minCellX = 0
|
||||||
let minCellY = 0
|
let minCellY = 0
|
||||||
let maxCellX = 40
|
let cellsX = 40
|
||||||
let maxCellY = 40
|
let cellsY = 40
|
||||||
if (allRooms.length > 0) {
|
let subW = 200
|
||||||
minCellX = Math.min(...allRooms.map(r => r.tileX))
|
let blocked: Uint8Array
|
||||||
minCellY = Math.min(...allRooms.map(r => r.tileY))
|
let originX = 0
|
||||||
maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW))
|
let originY = 0
|
||||||
maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH))
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellsX = Math.max(1, maxCellX - minCellX)
|
if (
|
||||||
const cellsY = Math.max(1, maxCellY - minCellY)
|
view.cellsX > 0 &&
|
||||||
const subW = cellsX * 5
|
view.cellsY > 0 &&
|
||||||
const subH = cellsY * 5
|
view.blocked &&
|
||||||
const blocked = new Uint8Array(subW * subH)
|
view.blocked.length === view.cellsX * 5 * view.cellsY * 5
|
||||||
|
) {
|
||||||
for (let sy = 0; sy < subH; sy++) {
|
minCellX = view.tileOriginX ?? Math.floor((view.subOriginX ?? 0) / 5)
|
||||||
for (let sx = 0; sx < subW; sx++) {
|
minCellY = view.tileOriginY ?? Math.floor((view.subOriginY ?? 0) / 5)
|
||||||
const absSubX = minCellX * 5 + sx
|
cellsX = view.cellsX
|
||||||
const absSubY = minCellY * 5 + sy
|
cellsY = view.cellsY
|
||||||
if (!isWalkableWorldSubTile(view, absSubX, absSubY)) {
|
subW = view.gridWidth || cellsX * 5
|
||||||
blocked[sy * subW + sx] = 1
|
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 => ({
|
const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({
|
||||||
|
|
@ -538,6 +583,20 @@ export class AutomapView {
|
||||||
targetName: wm.label,
|
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 {
|
return {
|
||||||
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`,
|
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`,
|
||||||
cellsX,
|
cellsX,
|
||||||
|
|
@ -545,8 +604,8 @@ export class AutomapView {
|
||||||
blocked,
|
blocked,
|
||||||
gridWidth: subW,
|
gridWidth: subW,
|
||||||
markers,
|
markers,
|
||||||
originX: (minCellX - minCellY) * 80,
|
originX,
|
||||||
originY: (minCellX + minCellY) * 40,
|
originY,
|
||||||
levelId: this.currentAreaId,
|
levelId: this.currentAreaId,
|
||||||
levelName: levelDisplayName(this.currentAreaId, lang),
|
levelName: levelDisplayName(this.currentAreaId, lang),
|
||||||
...(view.automap !== undefined ? { automap: view.automap } : {}),
|
...(view.automap !== undefined ? { automap: view.automap } : {}),
|
||||||
|
|
|
||||||
|
|
@ -897,13 +897,22 @@ export class CommandMapper {
|
||||||
return this.lastPath
|
return this.lastPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snap = this.predictor.getSnapshot()
|
||||||
|
const sx = Math.round(snap.logicalX)
|
||||||
|
const sy = Math.round(snap.logicalY)
|
||||||
const levelView =
|
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) {
|
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 ox = levelView.subOriginX ?? 0
|
||||||
const oy = levelView.subOriginY ?? 0
|
const oy = levelView.subOriginY ?? 0
|
||||||
const inBounds =
|
const inBounds =
|
||||||
|
|
|
||||||
|
|
@ -958,7 +958,6 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
automapView: session.automapView,
|
automapView: session.automapView,
|
||||||
hudManager: session.hudManager,
|
hudManager: session.hudManager,
|
||||||
nowMs,
|
nowMs,
|
||||||
playerMoving: session.predictor.getSnapshot().moving,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,9 @@ export function resolveRemoteMoveSpeed(options: {
|
||||||
const running =
|
const running =
|
||||||
options.running ??
|
options.running ??
|
||||||
(options.moveType !== undefined
|
(options.moveType !== undefined
|
||||||
? options.moveType === 0x17 || (options.moveType & 0x02) !== 0
|
? options.moveType === 0x17 ||
|
||||||
|
options.moveType === 0x18 ||
|
||||||
|
(options.moveType & 0x02) !== 0
|
||||||
: false)
|
: false)
|
||||||
if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) {
|
if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) {
|
||||||
return { running, speedSubTilesPerSec: options.speedSubTilesPerSec }
|
return { running, speedSubTilesPerSec: options.speedSubTilesPerSec }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts'
|
import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts'
|
||||||
import { dir64ToClipDirection } from '../../common/units/direction.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 { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
|
||||||
import { npcDisplayName } from '../../common/units/npc-table.ts'
|
import { npcDisplayName } from '../../common/units/npc-table.ts'
|
||||||
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
||||||
|
|
@ -176,19 +177,41 @@ const COMMON_HCIDX_TO_MONSTER_ID: Readonly<Record<number, string>> = {
|
||||||
520: 'cain5',
|
520: 'cain5',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLAYER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
|
/**
|
||||||
0: 'dt',
|
* Secondary `PlrMode.txt` tokens that share HTH DCC sequences in `d2char.mpq` when no dedicated
|
||||||
1: 'nu',
|
* HTH DCC file exists for that mode (`KB`/`BL` -> `gh`, `TH`/`KK` -> `a1`, `S1..S4`/`SQ` -> `sc`).
|
||||||
2: 'wl',
|
* Primary modes (`dt`, `nu`, `wl`, `rn`, `gh`, `tn`, `tw`, `a1`, `a2`, `sc`, `dd`) never alias.
|
||||||
3: 'rn',
|
*/
|
||||||
4: 'gh',
|
const PLAYER_HTH_FALLBACK_CLIP: Readonly<Record<string, string>> = {
|
||||||
5: 'tn',
|
bl: 'gh',
|
||||||
6: 'tw',
|
th: 'a1',
|
||||||
7: 'a1',
|
kk: 'a1',
|
||||||
8: 'a2',
|
s1: 'sc',
|
||||||
9: 'bl',
|
s2: 'sc',
|
||||||
10: 'sc',
|
s3: 'sc',
|
||||||
17: 'dd',
|
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>> = {
|
const MONSTER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
|
||||||
|
|
@ -346,7 +369,6 @@ export interface SceneSourceRenderContext {
|
||||||
readonly automapView?: AutomapView | null | undefined
|
readonly automapView?: AutomapView | null | undefined
|
||||||
readonly hudManager?: HudManager | null | undefined
|
readonly hudManager?: HudManager | null | undefined
|
||||||
readonly nowMs?: number | undefined
|
readonly nowMs?: number | undefined
|
||||||
readonly playerMoving?: boolean | undefined
|
|
||||||
readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined
|
readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,32 +430,42 @@ function drawSceneUnitDrawable(
|
||||||
ctx.characterArt ??
|
ctx.characterArt ??
|
||||||
null
|
null
|
||||||
if (charArt !== null) {
|
if (charArt !== null) {
|
||||||
const isMoving =
|
const modeToken = PLR_MODES[unit.mode]?.token.toLowerCase()
|
||||||
Boolean(ctx.playerMoving) || unit.mode === 2 || unit.mode === 3 || unit.mode === 6
|
const requestedClipKey = unit.isCorpse && modeToken !== 'dt' ? 'dd' : modeToken
|
||||||
const requestedClipKey =
|
if (!requestedClipKey) {
|
||||||
Boolean(ctx.playerMoving) && (unit.mode === 1 || unit.mode === 5)
|
reportMissingArt(
|
||||||
? unit.mode === 5 && charArt.clips?.tw
|
'player-mode',
|
||||||
? 'tw'
|
`${unit.token}:${unit.mode}`,
|
||||||
: 'wl'
|
`Invalid player mode=${unit.mode} for token=${unit.token}`,
|
||||||
: (PLAYER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu'))
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fallbackHthKey = PLAYER_HTH_FALLBACK_CLIP[requestedClipKey]
|
||||||
const clip =
|
const clip =
|
||||||
charArt.clips?.[requestedClipKey] ??
|
charArt.clips?.[requestedClipKey] ??
|
||||||
(isMoving ? charArt.clips?.wl : charArt.clips?.nu)
|
(fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined)
|
||||||
const dirCount = Math.max(1, clip?.directions ?? charArt.directions)
|
if (!clip || !(clip.speed > 0)) {
|
||||||
|
reportMissingArt(
|
||||||
|
'player-clip',
|
||||||
|
`${unit.token}:${requestedClipKey}`,
|
||||||
|
`Missing player clip or AnimData.d2 speed for token=${unit.token} clip=${requestedClipKey} mode=${unit.mode}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dirCount = Math.max(1, clip.directions || charArt.directions)
|
||||||
const direction = dir64ToClipDirection(unit.direction, dirCount)
|
const direction = dir64ToClipDirection(unit.direction, dirCount)
|
||||||
const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand
|
const groupIndex = clip.group + direction
|
||||||
const groupIndex = baseGroup + direction
|
const group = charArt.groups[groupIndex]
|
||||||
const group =
|
|
||||||
charArt.groups[groupIndex] ??
|
|
||||||
charArt.groups[charArt.stand + direction] ??
|
|
||||||
charArt.groups[0]
|
|
||||||
const frameCount = Math.max(1, group?.length ?? 1)
|
const frameCount = Math.max(1, group?.length ?? 1)
|
||||||
|
const shouldLoop =
|
||||||
|
!unit.isCorpse &&
|
||||||
|
requestedClipKey !== 'dd' &&
|
||||||
|
requestedClipKey !== 'dt' &&
|
||||||
|
clip.loop !== false
|
||||||
const frameIndex =
|
const frameIndex =
|
||||||
unit.isCorpse || requestedClipKey === 'dd'
|
unit.isCorpse && requestedClipKey !== 'dt'
|
||||||
? frameCount - 1
|
? frameCount - 1
|
||||||
: isMoving
|
: computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop)
|
||||||
? Math.floor(nowMs / 65) % frameCount
|
|
||||||
: Math.floor(nowMs / 140) % frameCount
|
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX =
|
const drawX =
|
||||||
|
|
@ -474,31 +506,43 @@ function drawSceneUnitDrawable(
|
||||||
art = resolveOnlineMonsterArt(unit, ctx.monsterArtMap)
|
art = resolveOnlineMonsterArt(unit, ctx.monsterArtMap)
|
||||||
}
|
}
|
||||||
if (art !== undefined) {
|
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
|
const requestedClipKey = unit.isCorpse
|
||||||
? art.clips?.dd
|
? unit.mode === 0 && art.clips?.dt
|
||||||
? 'dd'
|
? 'dt'
|
||||||
: art.clips?.dt
|
: art.clips?.dd
|
||||||
? 'dt'
|
? 'dd'
|
||||||
: 'nu'
|
: art.clips?.dt
|
||||||
|
? 'dt'
|
||||||
|
: 'nu'
|
||||||
: (MONSTER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu'))
|
: (MONSTER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu'))
|
||||||
const clip =
|
const clip =
|
||||||
art.clips?.[requestedClipKey] ??
|
art.clips?.[requestedClipKey] ??
|
||||||
(isMoving ? art.clips?.wl : undefined) ??
|
(isMoving ? art.clips?.wl : undefined) ??
|
||||||
art.clips?.nu
|
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 direction = dir64ToClipDirection(unit.direction, dirCount)
|
||||||
const baseGroup =
|
const baseGroup = clip.group
|
||||||
clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset
|
|
||||||
const groupIndex = baseGroup + direction
|
const groupIndex = baseGroup + direction
|
||||||
const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0]
|
const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0]
|
||||||
const frameCount = Math.max(1, group?.length ?? 1)
|
const frameCount = Math.max(1, group?.length ?? 1)
|
||||||
|
const shouldLoop =
|
||||||
|
!unit.isCorpse &&
|
||||||
|
requestedClipKey !== 'dd' &&
|
||||||
|
requestedClipKey !== 'dt' &&
|
||||||
|
clip.loop !== false
|
||||||
const frameIndex =
|
const frameIndex =
|
||||||
unit.isCorpse || requestedClipKey === 'dd'
|
unit.isCorpse && requestedClipKey !== 'dt'
|
||||||
? frameCount - 1
|
? frameCount - 1
|
||||||
: isMoving
|
: computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop)
|
||||||
? Math.floor(nowMs / 75) % frameCount
|
|
||||||
: Math.floor(nowMs / 130) % frameCount
|
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX =
|
const drawX =
|
||||||
|
|
@ -683,9 +727,16 @@ function drawSceneUnitDrawable(
|
||||||
if (!shouldLoop) {
|
if (!shouldLoop) {
|
||||||
frameIndex = frameCount - 1
|
frameIndex = frameCount - 1
|
||||||
} else {
|
} else {
|
||||||
const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 128
|
const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0
|
||||||
const frameDurationMs = speed > 0 ? Math.max(40, Math.round((256 * 40) / speed)) : 100
|
if (!(speed > 0)) {
|
||||||
frameIndex = Math.abs(Math.floor(nowMs / frameDurationMs)) % frameCount
|
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]
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
|
|
@ -742,6 +793,15 @@ function drawSceneMissileDrawable(
|
||||||
ctx.missileArtMap?.get(missile.missileKey) ??
|
ctx.missileArtMap?.get(missile.missileKey) ??
|
||||||
ctx.missileArtMap?.get(missile.celFile.toLowerCase())
|
ctx.missileArtMap?.get(missile.celFile.toLowerCase())
|
||||||
if (art !== undefined) {
|
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 dirCount = Math.max(1, art.meta.directions)
|
||||||
const dir = ((missile.direction % dirCount) + dirCount) % dirCount
|
const dir = ((missile.direction % dirCount) + dirCount) % dirCount
|
||||||
const group = art.frames[dir] ?? art.frames[0]
|
const group = art.frames[dir] ?? art.frames[0]
|
||||||
|
|
@ -749,7 +809,8 @@ function drawSceneMissileDrawable(
|
||||||
const frameIdx =
|
const frameIdx =
|
||||||
missile.loopAnim === false
|
missile.loopAnim === false
|
||||||
? Math.min(frameCount - 1, Math.max(0, Math.floor(missile.frame)))
|
? Math.min(frameCount - 1, Math.max(0, Math.floor(missile.frame)))
|
||||||
: (missile.frame + Math.floor(nowMs / 50)) % frameCount
|
: (missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) %
|
||||||
|
frameCount
|
||||||
const frame = group?.[frameIdx] ?? group?.[0]
|
const frame = group?.[frameIdx] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const altitude = missile.altitude ?? 0
|
const altitude = missile.altitude ?? 0
|
||||||
|
|
@ -794,12 +855,22 @@ function drawSceneOverlayDrawable(
|
||||||
(celFile ? ctx.missileArtMap?.get(celFile) : undefined) ??
|
(celFile ? ctx.missileArtMap?.get(celFile) : undefined) ??
|
||||||
(lowerCel ? ctx.missileArtMap?.get(lowerCel) : undefined)
|
(lowerCel ? ctx.missileArtMap?.get(lowerCel) : undefined)
|
||||||
if (art !== 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 group = art.frames[0]
|
||||||
const frameCount = Math.max(1, group?.length ?? 1)
|
const frameCount = Math.max(1, group?.length ?? 1)
|
||||||
const frameIdx =
|
const frameIdx =
|
||||||
overlay.loop === false
|
overlay.loop === false
|
||||||
? Math.min(frameCount - 1, Math.max(0, Math.floor(overlay.frame)))
|
? Math.min(frameCount - 1, Math.max(0, Math.floor(overlay.frame)))
|
||||||
: (overlay.frame + Math.floor(nowMs / 60)) % frameCount
|
: (overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) %
|
||||||
|
frameCount
|
||||||
const frame = group?.[frameIdx] ?? group?.[0]
|
const frame = group?.[frameIdx] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX =
|
const drawX =
|
||||||
|
|
@ -846,7 +917,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
renderer.flush()
|
renderer.flush()
|
||||||
}
|
}
|
||||||
if (ctx.hudManager) {
|
if (ctx.hudManager) {
|
||||||
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
|
ctx.hudManager.render(nowMs)
|
||||||
}
|
}
|
||||||
ctx.onFrameSnapshot?.(snapshot)
|
ctx.onFrameSnapshot?.(snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
@ -947,11 +1018,19 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
ctx.minimap.visible = false
|
ctx.minimap.visible = false
|
||||||
} else {
|
} else {
|
||||||
ctx.minimap.visible = true
|
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 = {
|
const effectiveMinimapLevel: MinimapLevel = {
|
||||||
...baseMinimapLevel,
|
...baseMinimapLevel,
|
||||||
originX: levelView?.isoOriginX ?? 0,
|
originX: levelView?.isoOriginX ?? baseMinimapLevel.originX ?? 0,
|
||||||
originY: levelView?.isoOriginY ?? 0,
|
originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0,
|
||||||
preRevealTown: isTownLevel(snapshot.levelId),
|
preRevealTown: isTownLevel(snapshot.levelId),
|
||||||
}
|
}
|
||||||
const localSub = snapshot.playerLocalSubTile
|
const localSub = snapshot.playerLocalSubTile
|
||||||
|
|
@ -1031,7 +1110,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.hudManager) {
|
if (ctx.hudManager) {
|
||||||
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
|
ctx.hudManager.render(nowMs)
|
||||||
}
|
}
|
||||||
ctx.onFrameSnapshot?.(snapshot)
|
ctx.onFrameSnapshot?.(snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,7 @@ export class OnlineSession {
|
||||||
private disconnectReasonValue: string | null = null
|
private disconnectReasonValue: string | null = null
|
||||||
private pingMsValue = 0
|
private pingMsValue = 0
|
||||||
private tickCounter = 0
|
private tickCounter = 0
|
||||||
|
private readonly knownAreaIds = new Set<number>()
|
||||||
|
|
||||||
private readonly outboundCommandsLog: ClientCommand[] = []
|
private readonly outboundCommandsLog: ClientCommand[] = []
|
||||||
private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
|
private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
|
||||||
|
|
@ -407,17 +408,11 @@ export class OnlineSession {
|
||||||
// such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG
|
// 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
|
// canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a
|
||||||
// level exists the scene reports not-ready.
|
// 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 predSnap = this.predictor.getSnapshot()
|
||||||
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
|
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 playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
||||||
const levelView =
|
const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
|
||||||
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 levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
||||||
return {
|
return {
|
||||||
tick: this.tickCounter,
|
tick: this.tickCounter,
|
||||||
|
|
@ -728,6 +723,7 @@ export class OnlineSession {
|
||||||
const savedName = this.world.self.name
|
const savedName = this.world.self.name
|
||||||
const savedClass = this.world.self.charClass
|
const savedClass = this.world.self.charClass
|
||||||
this.world.reset()
|
this.world.reset()
|
||||||
|
this.knownAreaIds.clear()
|
||||||
this.world.self.name = savedName
|
this.world.self.name = savedName
|
||||||
this.world.self.charClass = savedClass
|
this.world.self.charClass = savedClass
|
||||||
this.predictor.setPosition(0, 0)
|
this.predictor.setPosition(0, 0)
|
||||||
|
|
@ -955,6 +951,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
|
* `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
|
* town level, or `null` while no loaded level contains it. The sub-tile is the one the gate
|
||||||
|
|
@ -966,11 +1022,37 @@ export class OnlineSession {
|
||||||
const levelView = this.mapService.findLevelViewAtWorldSubTile(
|
const levelView = this.mapService.findLevelViewAtWorldSubTile(
|
||||||
Math.round(snap.logicalX),
|
Math.round(snap.logicalX),
|
||||||
Math.round(snap.logicalY),
|
Math.round(snap.logicalY),
|
||||||
[this.world.areaId, ...this.world.getRevealedAreaIds()],
|
this.getCandidateAreaIds(),
|
||||||
)
|
)
|
||||||
return levelView === null ? null : isTownLevel(levelView.areaId)
|
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 ─────────────────────────
|
// ─── Outbound ClientCommand Dispatch ─────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1157,7 +1239,11 @@ export class OnlineSession {
|
||||||
break
|
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(
|
this.automapView.setContext(
|
||||||
ev.mapSeed,
|
ev.mapSeed,
|
||||||
this.world.difficulty,
|
this.world.difficulty,
|
||||||
|
|
@ -1169,6 +1255,9 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'RoomReveal': {
|
case 'RoomReveal': {
|
||||||
|
if (ev.areaId > 0) {
|
||||||
|
this.knownAreaIds.add(ev.areaId)
|
||||||
|
}
|
||||||
if (this.mapService) {
|
if (this.mapService) {
|
||||||
try {
|
try {
|
||||||
this.mapService.onAddRoomDataPacket({
|
this.mapService.onAddRoomDataPacket({
|
||||||
|
|
@ -1273,12 +1362,29 @@ export class OnlineSession {
|
||||||
const evAny = ev as any
|
const evAny = ev as any
|
||||||
const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType)
|
const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType)
|
||||||
const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1)
|
const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1)
|
||||||
const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
|
const unit = this.world.getUnit(evTypeId as UnitType, evUnitId)
|
||||||
const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
|
const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0)
|
||||||
|
const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0)
|
||||||
|
const toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX
|
||||||
|
const toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY
|
||||||
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
|
const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x
|
||||||
const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
|
const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y
|
||||||
const isLocalPlayer =
|
const isLocalPlayer =
|
||||||
evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0
|
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 (isLocalPlayer) {
|
||||||
if (ev.mode === 'stop') {
|
if (ev.mode === 'stop') {
|
||||||
this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' })
|
this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' })
|
||||||
|
|
@ -1358,6 +1464,9 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchronize active area and AutomapView from current player position (#549)
|
||||||
|
this.syncActiveAreaFromPlayerPosition({ emitHudSync: false })
|
||||||
|
|
||||||
// Synchronize HudManager via HudModel
|
// Synchronize HudManager via HudModel
|
||||||
this.hudModel.syncFromWorld()
|
this.hudModel.syncFromWorld()
|
||||||
|
|
||||||
|
|
@ -1390,10 +1499,41 @@ export class OnlineSession {
|
||||||
unit.x = state.x
|
unit.x = state.x
|
||||||
unit.y = state.y
|
unit.y = state.y
|
||||||
unit.direction = state.dir64
|
unit.direction = state.dir64
|
||||||
|
if (unit.unitType === 'player' && unit.id !== this.world.selfId) {
|
||||||
|
const unitInTown = this.isSubTileInTown(state.x, state.y)
|
||||||
|
if (state.moving && unit.moveMode !== 'stop') {
|
||||||
|
if (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' &&
|
||||||
|
!state.moving &&
|
||||||
|
unit.lifePct > 0 &&
|
||||||
|
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15)
|
||||||
|
) {
|
||||||
|
unit.mode = 1
|
||||||
|
unit.moveMode = 'stop'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.world.tick(dtMs, nowMs)
|
this.world.tick(dtMs, nowMs)
|
||||||
|
this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1411,17 +1551,31 @@ export class OnlineSession {
|
||||||
const selfUnit = this.world.getUnit('player', this.world.selfId)
|
const selfUnit = this.world.getUnit('player', this.world.selfId)
|
||||||
const isSelfDead = this.world.isSelfDead || this.world.isDead
|
const isSelfDead = this.world.isSelfDead || this.world.isDead
|
||||||
const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined
|
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
|
const selfMode = isSelfDead
|
||||||
? 12
|
? (selfUnit?.mode === 0 ? 0 : 17)
|
||||||
: selfCastState
|
: selfCastState
|
||||||
? selfCastState.mode || 10
|
? selfCastState.mode || 10
|
||||||
: predSnap.moving
|
: predSnap.moving
|
||||||
? predSnap.running
|
? predSnap.running
|
||||||
? 3
|
? 3
|
||||||
: 2
|
: walkMode
|
||||||
: selfUnit?.mode && selfUnit.mode > 1
|
: (unitExplicitMode ?? neutralMode)
|
||||||
? selfUnit.mode
|
if (selfUnit !== undefined && !selfCastState) {
|
||||||
: 1
|
selfUnit.mode = selfMode
|
||||||
|
}
|
||||||
const selfDirection =
|
const selfDirection =
|
||||||
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64
|
selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64
|
||||||
out.push({
|
out.push({
|
||||||
|
|
@ -1449,10 +1603,7 @@ export class OnlineSession {
|
||||||
const activeLevelView =
|
const activeLevelView =
|
||||||
this.levelView ??
|
this.levelView ??
|
||||||
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
|
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
|
||||||
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [
|
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, this.getCandidateAreaIds())
|
||||||
this.world.areaId,
|
|
||||||
...this.world.getRevealedAreaIds(),
|
|
||||||
])
|
|
||||||
: null) ??
|
: null) ??
|
||||||
this.mapService?.getActiveLevelView() ??
|
this.mapService?.getActiveLevelView() ??
|
||||||
this.automapView.getLevelView()
|
this.automapView.getLevelView()
|
||||||
|
|
@ -1547,9 +1698,46 @@ export class OnlineSession {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)
|
const isCorpse =
|
||||||
|
(u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)) ||
|
||||||
|
(u.unitType === 'player' && (u.mode === 0 || u.mode === 17 || u.lifePct <= 0))
|
||||||
const castState = !isCorpse ? u.castState : undefined
|
const 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 targetX = castState?.targetX ?? u.targetX
|
||||||
const targetY = castState?.targetY ?? u.targetY
|
const targetY = castState?.targetY ?? u.targetY
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,9 @@ export class Minimap {
|
||||||
/** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */
|
/** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */
|
||||||
private readonly lastRevealSubPos = new Map<string, { x: number; y: number }>()
|
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. */
|
/** Diagnostic telemetry from the most recent `draw()` invocation. */
|
||||||
readonly lastRenderStats: AutomapRenderTelemetry = {
|
readonly lastRenderStats: AutomapRenderTelemetry = {
|
||||||
mode: 'minimap',
|
mode: 'minimap',
|
||||||
|
|
@ -869,6 +872,11 @@ export class Minimap {
|
||||||
viewport: { readonly width: number; readonly height: number },
|
viewport: { readonly width: number; readonly height: number },
|
||||||
options?: MinimapDrawOptions,
|
options?: MinimapDrawOptions,
|
||||||
): void {
|
): 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 screenW = Math.max(1, Math.trunc(viewport.width))
|
||||||
const screenH = Math.max(1, Math.trunc(viewport.height))
|
const screenH = Math.max(1, Math.trunc(viewport.height))
|
||||||
const vp = computeAutomapViewport(
|
const vp = computeAutomapViewport(
|
||||||
|
|
|
||||||
|
|
@ -548,7 +548,7 @@ export function buildSceneDrawables(
|
||||||
const effectiveTargetY = unit.targetY ?? activeCastState?.targetY
|
const effectiveTargetY = unit.targetY ?? activeCastState?.targetY
|
||||||
const effectiveMode = activeCastState
|
const effectiveMode = activeCastState
|
||||||
? activeCastState.mode || unit.mode || 10
|
? activeCastState.mode || unit.mode || 10
|
||||||
: (unit.mode ?? 0)
|
: (unit.mode ?? (unit.kind === 'player' || unit.kind === 'monster' ? (isCorpse ? 12 : 1) : 0))
|
||||||
const animMode =
|
const animMode =
|
||||||
unit.animMode ??
|
unit.animMode ??
|
||||||
(!isCorpse && (effectiveMode === 7 || effectiveMode === 4)
|
(!isCorpse && (effectiveMode === 7 || effectiveMode === 4)
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,17 @@ export class ClientUnit {
|
||||||
this.name = snapshot.name
|
this.name = snapshot.name
|
||||||
this.x = snapshot.x
|
this.x = snapshot.x
|
||||||
this.y = snapshot.y
|
this.y = snapshot.y
|
||||||
this.mode = snapshot.mode ?? 0
|
|
||||||
this.direction = snapshot.direction ?? 0
|
|
||||||
this.lifePct = snapshot.lifePct ?? 128
|
this.lifePct = snapshot.lifePct ?? 128
|
||||||
|
const defaultMode =
|
||||||
|
this.unitType === 'monster'
|
||||||
|
? this.lifePct <= 0
|
||||||
|
? 12
|
||||||
|
: 1
|
||||||
|
: this.unitType === 'player'
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
this.mode = snapshot.mode ?? defaultMode
|
||||||
|
this.direction = snapshot.direction ?? 0
|
||||||
this.flags = snapshot.flags ?? 0
|
this.flags = snapshot.flags ?? 0
|
||||||
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
|
this.warpId = snapshot.warpId ?? (this.unitType === 'tile' ? this.classId : undefined)
|
||||||
this.objectState = snapshot.objectState
|
this.objectState = snapshot.objectState
|
||||||
|
|
@ -177,6 +185,13 @@ export class ClientUnit {
|
||||||
}
|
}
|
||||||
if (snapshot.lifePct !== undefined) {
|
if (snapshot.lifePct !== undefined) {
|
||||||
this.lifePct = snapshot.lifePct
|
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) {
|
if (snapshot.flags !== undefined) {
|
||||||
this.flags = snapshot.flags
|
this.flags = snapshot.flags
|
||||||
|
|
@ -229,6 +244,17 @@ export class ClientUnit {
|
||||||
this.y = from.y
|
this.y = from.y
|
||||||
}
|
}
|
||||||
this.moveMode = mode
|
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.targetX = to.x
|
||||||
this.targetY = to.y
|
this.targetY = to.y
|
||||||
this.targetUnit = target
|
this.targetUnit = target
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
getSkillCastOverlay,
|
getSkillCastOverlay,
|
||||||
} from '../../common/skills/missiles-data.ts'
|
} from '../../common/skills/missiles-data.ts'
|
||||||
import { dir64FromPoints } from '../../common/units/direction.ts'
|
import { dir64FromPoints } from '../../common/units/direction.ts'
|
||||||
|
import { isTownLevel } from '../../common/world/scene-population.ts'
|
||||||
import {
|
import {
|
||||||
ClientUnit,
|
ClientUnit,
|
||||||
makeUnitKey,
|
makeUnitKey,
|
||||||
|
|
@ -1012,6 +1013,18 @@ export class ClientWorld {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncPlayerTownModes(): void {
|
||||||
|
const inTown = isTownLevel(this.areaId)
|
||||||
|
for (const unit of this._units.values()) {
|
||||||
|
if (unit.unitType !== 'player') continue
|
||||||
|
if (unit.mode === 1 || unit.mode === 5) {
|
||||||
|
unit.mode = inTown ? 5 : 1
|
||||||
|
} else if (unit.mode === 2 || unit.mode === 6) {
|
||||||
|
unit.mode = inTown ? 6 : 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private stepMissileUnit(unit: ClientUnit): void {
|
private stepMissileUnit(unit: ClientUnit): void {
|
||||||
const prevX = unit.x
|
const prevX = unit.x
|
||||||
const prevY = unit.y
|
const prevY = unit.y
|
||||||
|
|
@ -1223,6 +1236,7 @@ export class ClientWorld {
|
||||||
this._simTimeMs += safeDt
|
this._simTimeMs += safeDt
|
||||||
const step = Math.max(1, (safeDt / 1000) * 10)
|
const step = Math.max(1, (safeDt / 1000) * 10)
|
||||||
const ticksToStep = Math.max(1, Math.round(safeDt / 40))
|
const ticksToStep = Math.max(1, Math.round(safeDt / 40))
|
||||||
|
const inTown = isTownLevel(this.areaId)
|
||||||
|
|
||||||
const unitsSnapshot = Array.from(this._units.values())
|
const unitsSnapshot = Array.from(this._units.values())
|
||||||
for (const unit of unitsSnapshot) {
|
for (const unit of unitsSnapshot) {
|
||||||
|
|
@ -1263,7 +1277,7 @@ export class ClientWorld {
|
||||||
unit.castState = undefined
|
unit.castState = undefined
|
||||||
this._castSimStartMs.delete(unit.key)
|
this._castSimStartMs.delete(unit.key)
|
||||||
if (!unit.dead) {
|
if (!unit.dead) {
|
||||||
unit.mode = 1 // return to NU (neutral)
|
unit.mode = unit.unitType === 'player' && inTown ? 5 : 1 // return to TN or NU (neutral)
|
||||||
unit.targetX = unit.x
|
unit.targetX = unit.x
|
||||||
unit.targetY = unit.y
|
unit.targetY = unit.y
|
||||||
}
|
}
|
||||||
|
|
@ -1284,6 +1298,13 @@ export class ClientWorld {
|
||||||
if (dist <= step) {
|
if (dist <= step) {
|
||||||
unit.x = unit.targetX
|
unit.x = unit.targetX
|
||||||
unit.y = unit.targetY
|
unit.y = unit.targetY
|
||||||
|
if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19)) {
|
||||||
|
unit.mode = inTown ? 5 : 1
|
||||||
|
unit.moveMode = 'stop'
|
||||||
|
} else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 9 || unit.mode === 15)) {
|
||||||
|
unit.mode = 1
|
||||||
|
unit.moveMode = 'stop'
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
unit.x += (dx / dist) * step
|
unit.x += (dx / dist) * step
|
||||||
unit.y += (dy / dist) * step
|
unit.y += (dy / dist) * step
|
||||||
|
|
@ -1310,12 +1331,14 @@ export class ClientWorld {
|
||||||
const key = makeUnitKey(unitType, id)
|
const key = makeUnitKey(unitType, id)
|
||||||
let unit = this._units.get(key)
|
let unit = this._units.get(key)
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
|
const typeName = unitTypeFromId(unitType)
|
||||||
unit = new ClientUnit({
|
unit = new ClientUnit({
|
||||||
unitType: unitTypeFromId(unitType),
|
unitType: typeName,
|
||||||
id,
|
id,
|
||||||
classId: 0,
|
classId: 0,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
...(typeName === 'player' ? { mode: isTownLevel(this.areaId) ? 5 : 1 } : {}),
|
||||||
})
|
})
|
||||||
this._units.set(key, unit)
|
this._units.set(key, unit)
|
||||||
}
|
}
|
||||||
|
|
@ -1344,6 +1367,17 @@ export class ClientWorld {
|
||||||
} else if (ev.type === 'PlayerAssign' || ev.type === 'EntitySpawn') {
|
} else if (ev.type === 'PlayerAssign' || ev.type === 'EntitySpawn') {
|
||||||
const uType = ev.type === 'PlayerAssign' ? 'player' : unitTypeFromId(ev.unitType ?? 1)
|
const uType = ev.type === 'PlayerAssign' ? 'player' : unitTypeFromId(ev.unitType ?? 1)
|
||||||
const uId = Number(ev.unitId ?? ev.id ?? 1)
|
const uId = Number(ev.unitId ?? ev.id ?? 1)
|
||||||
|
const lifePct = ev.maxHp
|
||||||
|
? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128)
|
||||||
|
: 128
|
||||||
|
const defaultMode =
|
||||||
|
uType === 'monster'
|
||||||
|
? lifePct <= 0
|
||||||
|
? 12
|
||||||
|
: 1
|
||||||
|
: uType === 'player'
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
ev = {
|
ev = {
|
||||||
type: 'UnitAssign',
|
type: 'UnitAssign',
|
||||||
isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId === 0 || this.selfId === uId)),
|
isSelf: ev.type === 'PlayerAssign' || (uType === 'player' && (this.selfId === 0 || this.selfId === uId)),
|
||||||
|
|
@ -1354,7 +1388,8 @@ export class ClientWorld {
|
||||||
name: ev.name ?? ev.code,
|
name: ev.name ?? ev.code,
|
||||||
x: Number(ev.x ?? 0),
|
x: Number(ev.x ?? 0),
|
||||||
y: Number(ev.y ?? 0),
|
y: Number(ev.y ?? 0),
|
||||||
lifePct: ev.maxHp ? Math.round((Number(ev.hp ?? ev.maxHp) / Number(ev.maxHp)) * 128) : 128,
|
lifePct,
|
||||||
|
mode: ev.mode !== undefined ? Number(ev.mode) : defaultMode,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if (ev.type === 'EntityRemove') {
|
} else if (ev.type === 'EntityRemove') {
|
||||||
|
|
@ -1368,6 +1403,7 @@ export class ClientWorld {
|
||||||
type: 'UnitMove',
|
type: 'UnitMove',
|
||||||
unitType: unitTypeFromId(ev.unitType ?? 0),
|
unitType: unitTypeFromId(ev.unitType ?? 0),
|
||||||
id: Number(ev.unitId ?? ev.id ?? 1),
|
id: Number(ev.unitId ?? ev.id ?? 1),
|
||||||
|
mode: ev.mode ?? 'walk',
|
||||||
from: {
|
from: {
|
||||||
x: Number(ev.currentX ?? ev.x ?? 0),
|
x: Number(ev.currentX ?? ev.x ?? 0),
|
||||||
y: Number(ev.currentY ?? ev.y ?? 0),
|
y: Number(ev.currentY ?? ev.y ?? 0),
|
||||||
|
|
@ -1408,11 +1444,12 @@ export class ClientWorld {
|
||||||
value: Number(ev.value ?? 0),
|
value: Number(ev.value ?? 0),
|
||||||
}
|
}
|
||||||
} else if (ev.type === 'Damage') {
|
} else if (ev.type === 'Damage') {
|
||||||
|
const isPlayerTarget = unitTypeFromId(ev.unitType ?? 1) === 'player'
|
||||||
ev = {
|
ev = {
|
||||||
type: 'UnitMode',
|
type: 'UnitMode',
|
||||||
unitType: ev.unitType ?? 1,
|
unitType: ev.unitType ?? 1,
|
||||||
id: Number(ev.unitId ?? ev.id ?? 0),
|
id: Number(ev.unitId ?? ev.id ?? 0),
|
||||||
mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 5)),
|
mode: Number(ev.mode ?? (ev.lifePct === 0 ? (isPlayerTarget ? 17 : 12) : (isPlayerTarget ? 4 : 3))),
|
||||||
lifePct: Number(ev.lifePct ?? 0),
|
lifePct: Number(ev.lifePct ?? 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1436,6 +1473,7 @@ export class ClientWorld {
|
||||||
if (ev.objectSeed !== undefined) {
|
if (ev.objectSeed !== undefined) {
|
||||||
this.objectSeed = ev.objectSeed >>> 0
|
this.objectSeed = ev.objectSeed >>> 0
|
||||||
}
|
}
|
||||||
|
this.syncPlayerTownModes()
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'LoadComplete':
|
case 'LoadComplete':
|
||||||
|
|
@ -1454,6 +1492,7 @@ export class ClientWorld {
|
||||||
this._revealedRooms.add(`${ev.areaId}:${ev.tileX}:${ev.tileY}`)
|
this._revealedRooms.add(`${ev.areaId}:${ev.tileX}:${ev.tileY}`)
|
||||||
if (this.areaId === 0 && ev.areaId > 0) {
|
if (this.areaId === 0 && ev.areaId > 0) {
|
||||||
this.areaId = ev.areaId
|
this.areaId = ev.areaId
|
||||||
|
this.syncPlayerTownModes()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -1470,6 +1509,13 @@ export class ClientWorld {
|
||||||
} else {
|
} else {
|
||||||
unit.updateFromSnapshot(ev.unit)
|
unit.updateFromSnapshot(ev.unit)
|
||||||
}
|
}
|
||||||
|
if (ev.unit.mode === undefined) {
|
||||||
|
if (unit.unitType === 'player') {
|
||||||
|
unit.mode = isTownLevel(this.areaId) ? 5 : 1
|
||||||
|
} else if (unit.unitType === 'monster') {
|
||||||
|
unit.mode = unit.lifePct <= 0 ? 12 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
if (unit.unitType === 'monster') {
|
if (unit.unitType === 'monster') {
|
||||||
this._monsters.set(unit.id, unit)
|
this._monsters.set(unit.id, unit)
|
||||||
if (this._merc && unit.id === this._merc.mercId) {
|
if (this._merc && unit.id === this._merc.mercId) {
|
||||||
|
|
@ -1532,9 +1578,62 @@ export class ClientWorld {
|
||||||
unit.x = ev.to.x
|
unit.x = ev.to.x
|
||||||
unit.y = ev.to.y
|
unit.y = ev.to.y
|
||||||
}
|
}
|
||||||
unit.targetX = ev.to.x
|
let targetX = ev.to.x
|
||||||
unit.targetY = ev.to.y
|
let targetY = ev.to.y
|
||||||
|
if (
|
||||||
|
ev.target &&
|
||||||
|
((targetX === 0 && targetY === 0) ||
|
||||||
|
(ev.from !== undefined && targetX === ev.from.x && targetY === ev.from.y))
|
||||||
|
) {
|
||||||
|
const targetUnit = this.getUnit(ev.target.unitType, ev.target.id)
|
||||||
|
if (targetUnit && (targetUnit.x !== 0 || targetUnit.y !== 0)) {
|
||||||
|
targetX = targetUnit.x
|
||||||
|
targetY = targetUnit.y
|
||||||
|
} else if (
|
||||||
|
unitTypeFromId(ev.target.unitType) === 'player' &&
|
||||||
|
(ev.target.id === this.selfId || this.selfId === 0) &&
|
||||||
|
(this._self.x !== 0 || this._self.y !== 0)
|
||||||
|
) {
|
||||||
|
targetX = this._self.x
|
||||||
|
targetY = this._self.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit.targetX = targetX
|
||||||
|
unit.targetY = targetY
|
||||||
unit.targetUnit = ev.target
|
unit.targetUnit = ev.target
|
||||||
|
const moveMode = ev.mode ?? 'walk'
|
||||||
|
unit.moveMode = moveMode
|
||||||
|
if (targetX !== unit.x || targetY !== unit.y) {
|
||||||
|
unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction)
|
||||||
|
}
|
||||||
|
if (unit.unitType === 'player' && unit.mode !== 0 && unit.mode !== 16 && unit.mode !== 17) {
|
||||||
|
const inTown = isTownLevel(this.areaId)
|
||||||
|
if (moveMode === 'stop') {
|
||||||
|
unit.mode = inTown ? 5 : 1
|
||||||
|
} else if (moveMode === 'run') {
|
||||||
|
unit.mode = 3
|
||||||
|
} else if (moveMode === 'knockback') {
|
||||||
|
unit.mode = 19
|
||||||
|
} else {
|
||||||
|
unit.mode = inTown ? 6 : 2
|
||||||
|
}
|
||||||
|
if (moveMode !== 'stop') {
|
||||||
|
unit.castState = undefined
|
||||||
|
}
|
||||||
|
} else if (unit.unitType === 'monster' && unit.lifePct > 0 && unit.mode !== 0 && unit.mode !== 12) {
|
||||||
|
if (moveMode === 'stop') {
|
||||||
|
unit.mode = 1
|
||||||
|
} else if (moveMode === 'run') {
|
||||||
|
unit.mode = 15
|
||||||
|
} else if (moveMode === 'knockback') {
|
||||||
|
unit.mode = 9
|
||||||
|
} else {
|
||||||
|
unit.mode = 2
|
||||||
|
}
|
||||||
|
if (moveMode !== 'stop') {
|
||||||
|
unit.castState = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
|
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
|
||||||
if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) {
|
if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) {
|
||||||
this._self.x = ev.from.x
|
this._self.x = ev.from.x
|
||||||
|
|
@ -1543,9 +1642,9 @@ export class ClientWorld {
|
||||||
this._self.x = ev.to.x
|
this._self.x = ev.to.x
|
||||||
this._self.y = ev.to.y
|
this._self.y = ev.to.y
|
||||||
}
|
}
|
||||||
if (ev.to.x !== 0 || ev.to.y !== 0) {
|
if (targetX !== 0 || targetY !== 0) {
|
||||||
this._self.targetX = ev.to.x
|
this._self.targetX = targetX
|
||||||
this._self.targetY = ev.to.y
|
this._self.targetY = targetY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
@ -1576,6 +1675,16 @@ export class ClientWorld {
|
||||||
const uId = Number(ev.id ?? ev.unitId ?? 0)
|
const uId = Number(ev.id ?? ev.unitId ?? 0)
|
||||||
const unit = this.ensureUnit(ev.unitType, uId)
|
const unit = this.ensureUnit(ev.unitType, uId)
|
||||||
unit.mode = ev.mode
|
unit.mode = ev.mode
|
||||||
|
if (
|
||||||
|
ev.mode === 0 ||
|
||||||
|
ev.mode === 1 ||
|
||||||
|
ev.mode === 3 ||
|
||||||
|
ev.mode === 12 ||
|
||||||
|
ev.mode === 16 ||
|
||||||
|
ev.mode === 17
|
||||||
|
) {
|
||||||
|
unit.castState = undefined
|
||||||
|
}
|
||||||
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
|
if (ev.at && (ev.at.x !== 0 || ev.at.y !== 0)) {
|
||||||
unit.x = ev.at.x
|
unit.x = ev.at.x
|
||||||
unit.y = ev.at.y
|
unit.y = ev.at.y
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,27 @@ function clampUnitType(raw: number): UnitType {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' {
|
function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' {
|
||||||
// In D2Client.dll: 0x17 / 0x02 / 0x04 / bit 0x10 = run, 0x20 = knockback
|
// In D2Client.dll 0x6fac9830 (player) & 0x6fb82e68 (monster):
|
||||||
if (rawMoveType === 0x20 || rawMoveType === 0x09) return 'knockback'
|
// - 0x17 (0x0F/0x67 run) / 0x18 (0x10/0x68 run) / 0x02 = run
|
||||||
if (rawMoveType === 0x17 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) return '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'
|
return 'walk'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent {
|
||||||
const r = new ByteReader(pkt)
|
const r = new ByteReader(pkt)
|
||||||
r.u8() // 0x59
|
r.u8() // 0x59
|
||||||
|
|
@ -142,64 +203,72 @@ export function decode0x59AssignPlayer(pkt: Uint8Array): ServerEvent {
|
||||||
name,
|
name,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
mode: 1,
|
||||||
},
|
},
|
||||||
isSelf: x !== 0 || y !== 0,
|
isSelf: x !== 0 || y !== 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 0x69 SetNPCMode (12B, D2Client.dll 0x6fb5f390) */
|
/** 0x69 SetNPCMode (12B, D2Client.dll 0x6fb5f390 -> 0x6fafdc70) */
|
||||||
export function decode0x69SetNpcMode(pkt: Uint8Array): ServerEvent {
|
export function decode0x69SetNpcMode(pkt: Uint8Array): ServerEvent {
|
||||||
const r = new ByteReader(pkt)
|
const r = new ByteReader(pkt)
|
||||||
r.u8() // 0x69
|
r.u8() // 0x69
|
||||||
const id = r.u32LE()
|
const id = r.u32LE()
|
||||||
const mode = r.u8()
|
const rawCmd = r.u8()
|
||||||
const x = r.u16LE()
|
const x = r.u16LE()
|
||||||
const y = r.u16LE()
|
const y = r.u16LE()
|
||||||
const lifePct = r.u8()
|
const paramByte = r.u8()
|
||||||
r.u8()
|
r.u8() // animSpeed
|
||||||
|
const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte)
|
||||||
return {
|
return {
|
||||||
type: 'UnitMode',
|
type: 'UnitMode',
|
||||||
unitType: 1,
|
unitType: 1,
|
||||||
id,
|
id,
|
||||||
mode,
|
mode: resolveMonsterCmdMode(rawCmd),
|
||||||
at: { x, y },
|
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 {
|
export function decode0x6ANpcAction(pkt: Uint8Array): ServerEvent {
|
||||||
const r = new ByteReader(pkt)
|
const r = new ByteReader(pkt)
|
||||||
r.u8() // 0x6A
|
r.u8() // 0x6A
|
||||||
const id = r.u32LE()
|
const id = r.u32LE()
|
||||||
const mode = r.u8()
|
const rawCmd = r.u8()
|
||||||
r.u8()
|
r.u8() // targetType
|
||||||
r.u32LE()
|
r.u32LE() // targetId
|
||||||
r.u8()
|
const paramByte = r.u8()
|
||||||
|
const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte)
|
||||||
return {
|
return {
|
||||||
type: 'UnitMode',
|
type: 'UnitMode',
|
||||||
unitType: 1,
|
unitType: 1,
|
||||||
id,
|
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 {
|
export function decode0x6BNpcAction2(pkt: Uint8Array): ServerEvent {
|
||||||
const r = new ByteReader(pkt)
|
const r = new ByteReader(pkt)
|
||||||
r.u8() // 0x6B
|
r.u8() // 0x6B
|
||||||
const id = r.u32LE()
|
const id = r.u32LE()
|
||||||
const mode = r.u8()
|
const rawCmd = r.u8()
|
||||||
r.u32LE()
|
r.u16LE() // targetX
|
||||||
|
r.u16LE() // targetY
|
||||||
|
const paramByte = r.u8()
|
||||||
|
r.u8() // animSpeed
|
||||||
const x = r.u16LE()
|
const x = r.u16LE()
|
||||||
const y = r.u16LE()
|
const y = r.u16LE()
|
||||||
r.u16LE()
|
const lifePct = resolveMonsterCmdLifePct(rawCmd, paramByte)
|
||||||
return {
|
return {
|
||||||
type: 'UnitMode',
|
type: 'UnitMode',
|
||||||
unitType: 1,
|
unitType: 1,
|
||||||
id,
|
id,
|
||||||
mode,
|
mode: resolveMonsterCmdMode(rawCmd),
|
||||||
at: { x, y },
|
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 {
|
export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent {
|
||||||
const r = new ByteReader(pkt)
|
const r = new ByteReader(pkt)
|
||||||
r.u8() // 0xAC
|
r.u8() // 0xAC
|
||||||
|
|
@ -228,27 +305,42 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent {
|
||||||
const lifePct = r.u8()
|
const lifePct = r.u8()
|
||||||
const packetLen = r.u8()
|
const packetLen = r.u8()
|
||||||
|
|
||||||
|
let rawMode: number | undefined
|
||||||
let flags = 0
|
let flags = 0
|
||||||
if (packetLen > 13 && pkt.byteLength >= 13) {
|
if (packetLen > 13 && pkt.byteLength >= 13) {
|
||||||
const bitReader = new BitReader(pkt.subarray(13, Math.min(pkt.byteLength, packetLen)))
|
const bitReader = new BitReader(pkt.subarray(13, Math.min(pkt.byteLength, packetLen)))
|
||||||
if (bitReader.remainingBits >= 6) {
|
if (bitReader.remainingBits >= 4) {
|
||||||
bitReader.readBits(4) // initial facing direction
|
rawMode = bitReader.readBits(4) // 4-bit initial monster mode (D2Client.dll 0x6fb5e462)
|
||||||
const hasAnimComp = bitReader.readBit()
|
if (bitReader.remainingBits >= 1) {
|
||||||
// Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2).
|
const hasAnimComp = bitReader.readBit()
|
||||||
// If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc:
|
// Note: if hasAnimComp === 1, 16 component values follow (widths depend on MonStats2).
|
||||||
if (hasAnimComp === 0 && bitReader.remainingBits >= 1) {
|
// If hasAnimComp === 0, we can directly read the monster flag bits at 0x6fb5e4fc:
|
||||||
const hasMonsterFlags = bitReader.readBit()
|
if (hasAnimComp === 0 && bitReader.remainingBits >= 1) {
|
||||||
if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) {
|
const hasMonsterFlags = bitReader.readBit()
|
||||||
if (bitReader.readBit()) flags |= 0x04 // champion
|
if (hasMonsterFlags === 1 && bitReader.remainingBits >= 5) {
|
||||||
if (bitReader.readBit()) flags |= 0x08 // unique
|
if (bitReader.readBit()) flags |= 0x04 // champion
|
||||||
if (bitReader.readBit()) flags |= 0x02 // superunique
|
if (bitReader.readBit()) flags |= 0x08 // unique
|
||||||
if (bitReader.readBit()) flags |= 0x10 // minion
|
if (bitReader.readBit()) flags |= 0x02 // superunique
|
||||||
if (bitReader.readBit()) flags |= 0x40 // ghostly
|
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 {
|
return {
|
||||||
type: 'UnitAssign',
|
type: 'UnitAssign',
|
||||||
unit: {
|
unit: {
|
||||||
|
|
@ -257,6 +349,8 @@ export function decode0xACAssignNpc(pkt: Uint8Array): ServerEvent {
|
||||||
classId,
|
classId,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
mode,
|
||||||
|
direction,
|
||||||
lifePct,
|
lifePct,
|
||||||
flags,
|
flags,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -963,5 +963,99 @@ describe('ClientWorld authoritative state mirror', () => {
|
||||||
expect(boneSpear?.hitUnitIds?.has(100)).toBe(true)
|
expect(boneSpear?.hitUnitIds?.has(100)).toBe(true)
|
||||||
expect(boneSpear!.x).toBeGreaterThan(5005)
|
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,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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -141,4 +141,116 @@ describe('D2GS S->C 1.13c packet decoders (M1)', () => {
|
||||||
partnerId: 7,
|
partnerId: 7,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('decodes 0xAC AssignNPC 4-bit bitstream mode (D2Game.dll 0x6fcacac9 / D2Client.dll 0x6fb5e462)', () => {
|
||||||
|
// Alive Rogue Scout (classId=564) with bitstream byte 0x01 (low 4 bits = 1 MONMODE_NEUTRAL)
|
||||||
|
const alivePkt = new Uint8Array([
|
||||||
|
0xac,
|
||||||
|
0x2a, 0x00, 0x00, 0x00, // id = 42
|
||||||
|
0x34, 0x02, // classId = 564 (act1hire / Rogue Scout)
|
||||||
|
0xf6, 0x13, // x = 5110
|
||||||
|
0xc4, 0x13, // y = 5060
|
||||||
|
0x80, // lifePct = 128 (full HP)
|
||||||
|
0x0e, // packet length = 14
|
||||||
|
0x01, // bitstream: low 4 bits = 1 (MONMODE_NEUTRAL)
|
||||||
|
])
|
||||||
|
const aliveEv = decodeD2gsServerPacket(alivePkt)
|
||||||
|
expect(aliveEv.type).toBe('UnitAssign')
|
||||||
|
if (aliveEv.type === 'UnitAssign') {
|
||||||
|
expect(aliveEv.unit.unitType).toBe(1)
|
||||||
|
expect(aliveEv.unit.id).toBe(42)
|
||||||
|
expect(aliveEv.unit.classId).toBe(564)
|
||||||
|
expect(aliveEv.unit.lifePct).toBe(128)
|
||||||
|
expect(aliveEv.unit.mode).toBe(1) // MONMODE_NEUTRAL, never 0 (MONMODE_DEATH)
|
||||||
|
expect(aliveEv.unit.direction).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(aliveEv.unit.direction).toBeLessThan(64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead monster corpse (lifePct = 0, bitstream low 4 bits = 12 MONMODE_DEAD)
|
||||||
|
const corpsePkt = new Uint8Array([
|
||||||
|
0xac,
|
||||||
|
0x99, 0x00, 0x00, 0x00,
|
||||||
|
0x01, 0x00,
|
||||||
|
0x00, 0x14,
|
||||||
|
0x00, 0x14,
|
||||||
|
0x00, // lifePct = 0
|
||||||
|
0x0e,
|
||||||
|
0x0c, // low 4 bits = 12 (MONMODE_DEAD)
|
||||||
|
])
|
||||||
|
const corpseEv = decodeD2gsServerPacket(corpsePkt)
|
||||||
|
expect(corpseEv.type).toBe('UnitAssign')
|
||||||
|
if (corpseEv.type === 'UnitAssign') {
|
||||||
|
expect(corpseEv.unit.lifePct).toBe(0)
|
||||||
|
expect(corpseEv.unit.mode).toBe(12)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps 0x69/0x6A/0x6B state command index via D2Client.dll 0x6fb82e68 (MONSTER_CMD_TO_MODE)', () => {
|
||||||
|
// 0x69 SetNPCMode: [0x69, id:u32, cmd:u8, x:u16, y:u16, lifePct:u8, unk:u8]
|
||||||
|
const make69 = (cmd: number, lifePct: number) => {
|
||||||
|
const w = new ByteWriter(12)
|
||||||
|
w.u8(0x69)
|
||||||
|
w.u32LE(77)
|
||||||
|
w.u8(cmd)
|
||||||
|
w.u16LE(5100)
|
||||||
|
w.u16LE(5200)
|
||||||
|
w.u8(lifePct)
|
||||||
|
w.u8(0)
|
||||||
|
return decodeD2gsServerPacket(w.toUint8Array())
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd 0x07 -> mode 1 (MONMODE_NEUTRAL)
|
||||||
|
expect(make69(0x07, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 1, lifePct: 128 })
|
||||||
|
// cmd 0x06 -> mode 3 (MONMODE_GETHIT)
|
||||||
|
expect(make69(0x06, 96)).toMatchObject({ type: 'UnitMode', id: 77, mode: 3, lifePct: 96 })
|
||||||
|
// cmd 0x08 -> mode 0 (MONMODE_DEATH)
|
||||||
|
expect(make69(0x08, 0)).toMatchObject({ type: 'UnitMode', id: 77, mode: 0, lifePct: 0 })
|
||||||
|
// cmd 0x09 -> mode 12 (MONMODE_DEAD)
|
||||||
|
expect(make69(0x09, 0)).toMatchObject({ type: 'UnitMode', id: 77, mode: 12, lifePct: 0 })
|
||||||
|
// cmd 0x0A -> mode 4 (MONMODE_ATTACK1)
|
||||||
|
expect(make69(0x0a, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 4, lifePct: 128 })
|
||||||
|
// cmd 0x10 -> mode 5 (MONMODE_ATTACK2)
|
||||||
|
expect(make69(0x10, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 5, lifePct: 128 })
|
||||||
|
// cmd 0x04 -> mode 7 (MONMODE_CAST)
|
||||||
|
expect(make69(0x04, 128)).toMatchObject({ type: 'UnitMode', id: 77, mode: 7, lifePct: 128 })
|
||||||
|
|
||||||
|
// Real 1.13c Charsi 0x6B NPCAction2 packet from 02-d2gs-town-walk-chat.d2cap:
|
||||||
|
// 6b 08000000 0c 000000000000 3f16 9a11 (rawCmd=0x0C Sequence, paramByte=0x00 must NOT zero out lifePct)
|
||||||
|
const charsi6b = new Uint8Array([
|
||||||
|
0x6b,
|
||||||
|
0x08, 0x00, 0x00, 0x00, // id = 8 (Charsi)
|
||||||
|
0x0c, // rawCmd = 0x0C -> mode 8 (MONMODE_SEQUENCE)
|
||||||
|
0x00, 0x00, 0x00, 0x00, // targetX=0, targetY=0
|
||||||
|
0x00, 0x00, // paramByte=0, animSpeed=0
|
||||||
|
0x3f, 0x16, // x = 5695
|
||||||
|
0x9a, 0x11, // y = 4506
|
||||||
|
])
|
||||||
|
const charsiEv = decodeD2gsServerPacket(charsi6b)
|
||||||
|
expect(charsiEv).toEqual({
|
||||||
|
type: 'UnitMode',
|
||||||
|
unitType: 1,
|
||||||
|
id: 8,
|
||||||
|
mode: 8,
|
||||||
|
at: { x: 5695, y: 4506 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decodes 0x67 NPCMove and 0x68 NPCMoveToTarget walk/run moveType (0x01/0x17 and 0x00/0x18)', () => {
|
||||||
|
const w68Run = new ByteWriter(21)
|
||||||
|
w68Run.u8(0x68)
|
||||||
|
w68Run.u32LE(88)
|
||||||
|
w68Run.u8(0x18) // 0x18 = run in 0x68 NPCMoveToTarget
|
||||||
|
w68Run.u16LE(5100)
|
||||||
|
w68Run.u16LE(5200)
|
||||||
|
w68Run.u8(0) // targetType = player
|
||||||
|
w68Run.u32LE(1) // targetId = 1
|
||||||
|
w68Run.bytes(new Uint8Array(6))
|
||||||
|
const ev68 = decodeD2gsServerPacket(w68Run.toUint8Array())
|
||||||
|
expect(ev68).toMatchObject({
|
||||||
|
type: 'UnitMove',
|
||||||
|
unitType: 1,
|
||||||
|
id: 88,
|
||||||
|
mode: 'run',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue