diff --git a/src/client/main.ts b/src/client/main.ts index 49700ac..66238f5 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -958,7 +958,6 @@ async function bootPlayApp(): Promise { automapView: session.automapView, hudManager: session.hudManager, nowMs, - playerMoving: session.predictor.getSnapshot().moving, }) } diff --git a/src/client/motion/interpolate.ts b/src/client/motion/interpolate.ts index 614d085..3a67336 100644 --- a/src/client/motion/interpolate.ts +++ b/src/client/motion/interpolate.ts @@ -88,7 +88,7 @@ export function resolveRemoteMoveSpeed(options: { const running = options.running ?? (options.moveType !== undefined - ? options.moveType === 0x17 || (options.moveType & 0x02) !== 0 + ? options.moveType === 0x17 || options.moveType === 0x18 || (options.moveType & 0x02) !== 0 : false) if (options.speedSubTilesPerSec !== undefined && options.speedSubTilesPerSec > 0) { return { running, speedSubTilesPerSec: options.speedSubTilesPerSec } diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 20b45b0..b7f51be 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1,4 +1,5 @@ import { dir64ToClipDirection } from '../../common/units/direction.ts' +import { PLR_MODES } from '../../common/units/plr-mode.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' import { npcDisplayName } from '../../common/units/npc-table.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' @@ -175,19 +176,41 @@ const COMMON_HCIDX_TO_MONSTER_ID: Readonly> = { 520: 'cain5', } -const PLAYER_MODE_TO_CLIP: Readonly> = { - 0: 'dt', - 1: 'nu', - 2: 'wl', - 3: 'rn', - 4: 'gh', - 5: 'tn', - 6: 'tw', - 7: 'a1', - 8: 'a2', - 9: 'bl', - 10: 'sc', - 17: 'dd', +/** + * Secondary `PlrMode.txt` tokens that share HTH DCC sequences in `d2char.mpq` when no dedicated + * HTH DCC file exists for that mode (`KB`/`BL` -> `gh`, `TH`/`KK` -> `a1`, `S1..S4`/`SQ` -> `sc`). + * Primary modes (`dt`, `nu`, `wl`, `rn`, `gh`, `tn`, `tw`, `a1`, `a2`, `sc`, `dd`) never alias. + */ +const PLAYER_HTH_FALLBACK_CLIP: Readonly> = { + bl: 'gh', + th: 'a1', + kk: 'a1', + s1: 'sc', + s2: 'sc', + s3: 'sc', + s4: 'sc', + sq: 'sc', + kb: 'gh', +} + +/** + * Compute 0-based animation frame index from elapsed time `nowMs` and 1.13c 8.8 fixed-point + * animation speed (`speed256`, where `256 = 1.0` frame per 25Hz / 40ms game tick; + * `D2Common.dll` `0x6FDBE510` / `0x6FDBEFF0`). + */ +export function computeAnimDataFrameIndex( + nowMs: number, + speed256: number, + frameCount: number, + loop = true, +): number { + if (frameCount <= 1) return 0 + const safeTicks = Math.max(0, nowMs) / 40 + const rawFrame = Math.floor((safeTicks * speed256) / 256) + if (!loop) { + return Math.min(frameCount - 1, Math.max(0, rawFrame)) + } + return ((rawFrame % frameCount) + frameCount) % frameCount } const MONSTER_MODE_TO_CLIP: Readonly> = { @@ -345,7 +368,6 @@ export interface SceneSourceRenderContext { readonly automapView?: AutomapView | null | undefined readonly hudManager?: HudManager | null | undefined readonly nowMs?: number | undefined - readonly playerMoving?: boolean | undefined readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined } @@ -407,32 +429,42 @@ function drawSceneUnitDrawable( ctx.characterArt ?? null if (charArt !== null) { - const isMoving = - Boolean(ctx.playerMoving) || unit.mode === 2 || unit.mode === 3 || unit.mode === 6 - const requestedClipKey = - Boolean(ctx.playerMoving) && (unit.mode === 1 || unit.mode === 5) - ? unit.mode === 5 && charArt.clips?.tw - ? 'tw' - : 'wl' - : (PLAYER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu')) + const modeToken = PLR_MODES[unit.mode]?.token.toLowerCase() + const requestedClipKey = unit.isCorpse && modeToken !== 'dt' ? 'dd' : modeToken + if (!requestedClipKey) { + reportMissingArt( + 'player-mode', + `${unit.token}:${unit.mode}`, + `Invalid player mode=${unit.mode} for token=${unit.token}`, + ) + return + } + const fallbackHthKey = PLAYER_HTH_FALLBACK_CLIP[requestedClipKey] const clip = charArt.clips?.[requestedClipKey] ?? - (isMoving ? charArt.clips?.wl : charArt.clips?.nu) - const dirCount = Math.max(1, clip?.directions ?? charArt.directions) + (fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) + if (!clip || !(clip.speed > 0)) { + reportMissingArt( + 'player-clip', + `${unit.token}:${requestedClipKey}`, + `Missing player clip or AnimData.d2 speed for token=${unit.token} clip=${requestedClipKey} mode=${unit.mode}`, + ) + return + } + const dirCount = Math.max(1, clip.directions || charArt.directions) const direction = dir64ToClipDirection(unit.direction, dirCount) - const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand - const groupIndex = baseGroup + direction - const group = - charArt.groups[groupIndex] ?? - charArt.groups[charArt.stand + direction] ?? - charArt.groups[0] + const groupIndex = clip.group + direction + const group = charArt.groups[groupIndex] const frameCount = Math.max(1, group?.length ?? 1) + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + clip.loop !== false const frameIndex = - unit.isCorpse || requestedClipKey === 'dd' + unit.isCorpse && requestedClipKey !== 'dt' ? frameCount - 1 - : isMoving - ? Math.floor(nowMs / 65) % frameCount - : Math.floor(nowMs / 140) % frameCount + : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -485,19 +517,29 @@ function drawSceneUnitDrawable( art.clips?.[requestedClipKey] ?? (isMoving ? art.clips?.wl : undefined) ?? art.clips?.nu - const dirCount = Math.max(1, clip?.directions ?? art.directions) + if (!clip || !(clip.speed > 0)) { + reportMissingArt( + 'monster-clip', + `${unit.classId}:${requestedClipKey}`, + `Missing monster clip or AnimData.d2 speed for classId=${unit.classId} token=${unit.token} clip=${requestedClipKey}`, + ) + return + } + const dirCount = Math.max(1, clip.directions || art.directions) const direction = dir64ToClipDirection(unit.direction, dirCount) - const baseGroup = - clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset + const baseGroup = clip.group const groupIndex = baseGroup + direction const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0] const frameCount = Math.max(1, group?.length ?? 1) + const shouldLoop = + !unit.isCorpse && + requestedClipKey !== 'dd' && + requestedClipKey !== 'dt' && + clip.loop !== false const frameIndex = - unit.isCorpse || requestedClipKey === 'dd' + unit.isCorpse && requestedClipKey !== 'dt' ? frameCount - 1 - : isMoving - ? Math.floor(nowMs / 75) % frameCount - : Math.floor(nowMs / 130) % frameCount + : computeAnimDataFrameIndex(nowMs, clip.speed, frameCount, shouldLoop) const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -682,9 +724,16 @@ function drawSceneUnitDrawable( if (!shouldLoop) { frameIndex = frameCount - 1 } else { - const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 128 - const frameDurationMs = speed > 0 ? Math.max(40, Math.round((256 * 40) / speed)) : 100 - frameIndex = Math.abs(Math.floor(nowMs / frameDurationMs)) % frameCount + const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0 + if (!(speed > 0)) { + reportMissingArt( + 'object-clip', + `${unit.classId}:${clipKey}`, + `Missing frameDelta/speed for object classId=${unit.classId} mode=${modeIdx}`, + ) + return + } + frameIndex = computeAnimDataFrameIndex(nowMs, speed, frameCount, true) } } const frame = group?.[frameIndex] ?? group?.[0] @@ -738,11 +787,22 @@ function drawSceneMissileDrawable( ctx.missileArtMap?.get(missile.missileKey) ?? ctx.missileArtMap?.get(missile.celFile.toLowerCase()) if (art !== undefined) { + const animSpeed = art.meta.animSpeed + if (!(animSpeed > 0)) { + reportMissingArt( + 'missile-speed', + missile.missileKey || missile.celFile || String(missile.missileClassId), + `Missing animSpeed for missile key=${missile.missileKey}`, + ) + return + } const dirCount = Math.max(1, art.meta.directions) const dir = ((missile.direction % dirCount) + dirCount) % dirCount const group = art.frames[dir] ?? art.frames[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIdx = (missile.frame + Math.floor(nowMs / 50)) % frameCount + const frameIdx = + (missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) % + frameCount const frame = group?.[frameIdx] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -777,9 +837,20 @@ function drawSceneOverlayDrawable( ctx.overlayArtMap?.get(overlay.token) ?? ctx.overlayArtMap?.get(overlay.token.toLowerCase()) if (art !== undefined) { + const animRate = art.meta.animRate + if (!(animRate > 0)) { + reportMissingArt( + 'overlay-speed', + overlay.token || String(overlay.overlayId), + `Missing animRate for overlay token=${overlay.token}`, + ) + return + } const group = art.frames[0] const frameCount = Math.max(1, group?.length ?? 1) - const frameIdx = (overlay.frame + Math.floor(nowMs / 60)) % frameCount + const frameIdx = + (overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) % + frameCount const frame = group?.[frameIdx] ?? group?.[0] if (frame !== undefined) { const drawX = @@ -826,7 +897,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram renderer.flush() } if (ctx.hudManager) { - ctx.hudManager.render(nowMs, ctx.playerMoving ?? false) + ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) return snapshot @@ -1019,7 +1090,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram } if (ctx.hudManager) { - ctx.hudManager.render(nowMs, ctx.playerMoving ?? false) + ctx.hudManager.render(nowMs) } ctx.onFrameSnapshot?.(snapshot) return snapshot diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index abc30c7..654bfe6 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -993,6 +993,32 @@ export class OnlineSession { return levelView === null ? null : isTownLevel(levelView.areaId) } + /** + * Resolve whether `(subX, subY)` is in a town level (`DUNGEON_IsRoomInTown` equivalent): + * prefers the exact `LevelView` containing `(subX, subY)` when `mapService` is loaded, + * and falls back to the active `world.areaId` / `levelView.areaId`. + */ + private isSubTileInTown(subX: number, subY: number): boolean { + if (this.mapService && this.world.areaId > 0 && (subX !== 0 || subY !== 0)) { + const levelView = this.mapService.findLevelViewAtWorldSubTile( + Math.round(subX), + Math.round(subY), + [this.world.areaId, ...this.world.getRevealedAreaIds()], + ) + if (levelView !== null) { + return isTownLevel(levelView.areaId) + } + } + const activeAreaId = + this.world.areaId > 0 + ? this.world.areaId + : (this.levelView?.areaId ?? + this.mapService?.getActiveLevelView()?.areaId ?? + this.automapView.getLevelView()?.areaId ?? + 0) + return isTownLevel(activeAreaId) + } + // ─── Outbound ClientCommand Dispatch ───────────────────────── /** @@ -1249,12 +1275,29 @@ export class OnlineSession { const evAny = ev as any const evTypeId = typeof evAny.unitType === 'number' ? evAny.unitType : unitTypeToId(ev.unitType) const evUnitId = Number(evAny.id ?? evAny.unitId ?? 1) - const toX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) - const toY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0) + const unit = this.world.getUnit(evTypeId as UnitType, evUnitId) + const rawToX = Number(evAny.to?.x ?? evAny.targetX ?? evAny.x ?? 0) + const rawToY = Number(evAny.to?.y ?? evAny.targetY ?? evAny.y ?? 0) + const toX = unit?.targetX !== undefined && (rawToX === 0 || evAny.target !== undefined) ? unit.targetX : rawToX + const toY = unit?.targetY !== undefined && (rawToY === 0 || evAny.target !== undefined) ? unit.targetY : rawToY const fromX = evAny.from?.x ?? evAny.currentX ?? evAny.x const fromY = evAny.from?.y ?? evAny.currentY ?? evAny.y const isLocalPlayer = evTypeId === 0 && evUnitId === this.world.selfId && this.world.selfId !== 0 + if (unit && unit.unitType === 'player') { + const posForTownX = ev.mode === 'stop' ? toX : Number(fromX ?? unit.x ?? toX) + const posForTownY = ev.mode === 'stop' ? toY : Number(fromY ?? unit.y ?? toY) + const inTown = this.isSubTileInTown(posForTownX, posForTownY) + if (ev.mode === 'stop') { + unit.mode = inTown ? 5 : 1 + } else if (ev.mode === 'run') { + unit.mode = 3 + } else if (ev.mode === 'knockback') { + unit.mode = 19 + } else { + unit.mode = inTown ? 6 : 2 + } + } if (isLocalPlayer) { if (ev.mode === 'stop') { this.verifyLocalPlayerPosition(toX, toY, { kind: 'player-stop' }) @@ -1369,6 +1412,24 @@ export class OnlineSession { unit.x = state.x unit.y = state.y unit.direction = state.dir64 + if (unit.unitType === 'player' && unit.id !== this.world.selfId) { + const unitInTown = this.isSubTileInTown(state.x, state.y) + if (state.moving && unit.moveMode !== 'stop') { + if (state.running || unit.mode === 3) { + unit.mode = 3 + } else if (unit.mode === 2 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { + unit.mode = unitInTown ? 6 : 2 + } + } else if (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 1 || unit.mode === 5) { + unit.mode = unitInTown ? 5 : 1 + unit.moveMode = 'stop' + } + } else if (unit.unitType === 'monster' && !state.moving) { + if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { + unit.mode = 1 + unit.moveMode = 'stop' + } + } } } @@ -1390,17 +1451,31 @@ export class OnlineSession { const selfUnit = this.world.getUnit('player', this.world.selfId) const isSelfDead = this.world.isSelfDead || this.world.isDead const selfCastState = !isSelfDead ? (selfUnit?.castState ?? this.world.self.castState) : undefined + const selfInTown = this.isSubTileInTown(selfX, selfY) + const neutralMode = selfInTown ? 5 : 1 + const walkMode = selfInTown ? 6 : 2 + const unitExplicitMode = + selfUnit?.mode !== undefined && + selfUnit.mode !== 0 && + selfUnit.mode !== 1 && + selfUnit.mode !== 2 && + selfUnit.mode !== 3 && + selfUnit.mode !== 5 && + selfUnit.mode !== 6 + ? selfUnit.mode + : undefined const selfMode = isSelfDead - ? 12 + ? (selfUnit?.mode === 0 ? 0 : 17) : selfCastState ? selfCastState.mode || 10 : predSnap.moving ? predSnap.running ? 3 - : 2 - : selfUnit?.mode && selfUnit.mode > 1 - ? selfUnit.mode - : 1 + : walkMode + : (unitExplicitMode ?? neutralMode) + if (selfUnit !== undefined && !selfCastState) { + selfUnit.mode = selfMode + } const selfDirection = selfCastState && selfUnit !== undefined ? selfUnit.direction : predSnap.dir64 out.push({ @@ -1500,9 +1575,26 @@ export class OnlineSession { continue } - const isCorpse = u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0) + const isCorpse = + (u.unitType === 'monster' && (u.mode === 0 || u.mode === 12 || u.lifePct <= 0)) || + (u.unitType === 'player' && (u.mode === 0 || u.mode === 17 || u.lifePct <= 0)) const castState = !isCorpse ? u.castState : undefined - const mode = castState ? castState.mode || u.mode : u.mode + let mode = castState ? castState.mode || u.mode : u.mode + if (u.unitType === 'player' && !isCorpse && !castState) { + const unitInTown = this.isSubTileInTown(ux, uy) + if (interp?.moving && u.moveMode !== 'stop') { + mode = interp.running || u.mode === 3 ? 3 : unitInTown ? 6 : 2 + } else if ( + u.mode === 1 || + u.mode === 5 || + (interp !== null && interp !== undefined && !interp.moving && (u.mode === 2 || u.mode === 3 || u.mode === 6)) + ) { + mode = unitInTown ? 5 : 1 + } else if (u.mode === 2 || u.mode === 6) { + mode = unitInTown ? 6 : 2 + } + u.mode = mode + } const targetX = castState?.targetX ?? u.targetX const targetY = castState?.targetY ?? u.targetY diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 26e927c..7ea2a28 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -73,7 +73,7 @@ export class ClientUnit { this.name = snapshot.name this.x = snapshot.x this.y = snapshot.y - this.mode = snapshot.mode ?? 0 + this.mode = snapshot.mode ?? (this.unitType === 'player' || this.unitType === 'monster' ? 1 : 0) this.direction = snapshot.direction ?? 0 this.lifePct = snapshot.lifePct ?? 128 this.flags = snapshot.flags ?? 0 diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index e1e2a12..1c70e00 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -19,6 +19,7 @@ import { } from '../../netproto/index.ts' import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts' import { dir64FromPoints } from '../../common/units/direction.ts' +import { isTownLevel } from '../../common/world/scene-population.ts' import { ClientUnit, makeUnitKey, type UnitCastState } from './client-unit.ts' import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts' import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts' @@ -430,9 +431,22 @@ export class ClientWorld { return this._soundQueue.splice(0, this._soundQueue.length) } + private syncPlayerTownModes(): void { + const inTown = isTownLevel(this.areaId) + for (const unit of this._units.values()) { + if (unit.unitType !== 'player') continue + if (unit.mode === 1 || unit.mode === 5) { + unit.mode = inTown ? 5 : 1 + } else if (unit.mode === 2 || unit.mode === 6) { + unit.mode = inTown ? 6 : 2 + } + } + } + tick(dtMs = 40): void { this.frameCount++ const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10) + const inTown = isTownLevel(this.areaId) for (const unit of this._units.values()) { if (unit.dead) continue if (unit.targetX === undefined || unit.targetY === undefined) continue @@ -443,6 +457,13 @@ export class ClientWorld { if (dist <= step) { unit.x = unit.targetX unit.y = unit.targetY + if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6)) { + unit.mode = inTown ? 5 : 1 + unit.moveMode = 'stop' + } else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 13 || unit.mode === 15)) { + unit.mode = 1 + unit.moveMode = 'stop' + } } else { unit.x += (dx / dist) * step unit.y += (dy / dist) * step @@ -469,12 +490,14 @@ export class ClientWorld { const key = makeUnitKey(unitType, id) let unit = this._units.get(key) if (!unit) { + const typeName = unitTypeFromId(unitType) unit = new ClientUnit({ - unitType: unitTypeFromId(unitType), + unitType: typeName, id, classId: 0, x: 0, y: 0, + ...(typeName === 'player' ? { mode: isTownLevel(this.areaId) ? 5 : 1 } : {}), }) this._units.set(key, unit) } @@ -527,6 +550,7 @@ export class ClientWorld { type: 'UnitMove', unitType: unitTypeFromId(ev.unitType ?? 0), id: Number(ev.unitId ?? ev.id ?? 1), + mode: ev.mode ?? 'walk', from: { x: Number(ev.currentX ?? ev.x ?? 0), y: Number(ev.currentY ?? ev.y ?? 0), @@ -567,11 +591,12 @@ export class ClientWorld { value: Number(ev.value ?? 0), } } else if (ev.type === 'Damage') { + const isPlayerTarget = unitTypeFromId(ev.unitType ?? 1) === 'player' ev = { type: 'UnitMode', unitType: ev.unitType ?? 1, id: Number(ev.unitId ?? ev.id ?? 0), - mode: Number(ev.mode ?? (ev.lifePct === 0 ? 12 : 5)), + mode: Number(ev.mode ?? (ev.lifePct === 0 ? (isPlayerTarget ? 17 : 12) : (isPlayerTarget ? 4 : 3))), lifePct: Number(ev.lifePct ?? 0), } } @@ -595,6 +620,7 @@ export class ClientWorld { if (ev.objectSeed !== undefined) { this.objectSeed = ev.objectSeed >>> 0 } + this.syncPlayerTownModes() break case 'LoadComplete': @@ -613,6 +639,7 @@ export class ClientWorld { this._revealedRooms.add(`${ev.areaId}:${ev.tileX}:${ev.tileY}`) if (this.areaId === 0 && ev.areaId > 0) { this.areaId = ev.areaId + this.syncPlayerTownModes() } break @@ -629,6 +656,13 @@ export class ClientWorld { } else { unit.updateFromSnapshot(ev.unit) } + if (ev.unit.mode === undefined) { + if (unit.unitType === 'player') { + unit.mode = isTownLevel(this.areaId) ? 5 : 1 + } else if (unit.unitType === 'monster') { + unit.mode = unit.lifePct <= 0 ? 12 : 1 + } + } if (unit.unitType === 'monster') { this._monsters.set(unit.id, unit) if (this._merc && unit.id === this._merc.mercId) { @@ -691,9 +725,56 @@ export class ClientWorld { unit.x = ev.to.x unit.y = ev.to.y } - unit.targetX = ev.to.x - unit.targetY = ev.to.y + let targetX = ev.to.x + let targetY = ev.to.y + if ( + ev.target && + ((targetX === 0 && targetY === 0) || + (ev.from !== undefined && targetX === ev.from.x && targetY === ev.from.y)) + ) { + const targetUnit = this.getUnit(ev.target.unitType, ev.target.id) + if (targetUnit && (targetUnit.x !== 0 || targetUnit.y !== 0)) { + targetX = targetUnit.x + targetY = targetUnit.y + } else if ( + unitTypeFromId(ev.target.unitType) === 'player' && + (ev.target.id === this.selfId || this.selfId === 0) && + (this._self.x !== 0 || this._self.y !== 0) + ) { + targetX = this._self.x + targetY = this._self.y + } + } + unit.targetX = targetX + unit.targetY = targetY unit.targetUnit = ev.target + const moveMode = ev.mode ?? 'walk' + unit.moveMode = moveMode + if (targetX !== unit.x || targetY !== unit.y) { + unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction) + } + if (unit.unitType === 'player') { + const inTown = isTownLevel(this.areaId) + if (moveMode === 'stop') { + unit.mode = inTown ? 5 : 1 + } else if (moveMode === 'run') { + unit.mode = 3 + } else if (moveMode === 'knockback') { + unit.mode = 19 + } else { + unit.mode = inTown ? 6 : 2 + } + } else if (unit.unitType === 'monster') { + if (moveMode === 'stop') { + if (unit.mode === 2 || unit.mode === 13 || unit.mode === 15) { + unit.mode = 1 + } + } else if (moveMode === 'run') { + unit.mode = 15 + } else { + unit.mode = 2 + } + } if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { if (ev.from && (ev.from.x !== 0 || ev.from.y !== 0)) { this._self.x = ev.from.x @@ -702,9 +783,9 @@ export class ClientWorld { this._self.x = ev.to.x this._self.y = ev.to.y } - if (ev.to.x !== 0 || ev.to.y !== 0) { - this._self.targetX = ev.to.x - this._self.targetY = ev.to.y + if (targetX !== 0 || targetY !== 0) { + this._self.targetX = targetX + this._self.targetY = targetY } } break diff --git a/src/netproto/d2gs/s2c/movement.ts b/src/netproto/d2gs/s2c/movement.ts index cdc03e0..dbc74dc 100644 --- a/src/netproto/d2gs/s2c/movement.ts +++ b/src/netproto/d2gs/s2c/movement.ts @@ -14,9 +14,13 @@ function clampUnitType(raw: number): UnitType { } function resolveMoveMode(rawMoveType: number): 'walk' | 'run' | 'knockback' | 'stop' { - // In D2Client.dll: 0x17 / 0x02 / 0x04 / bit 0x10 = run, 0x20 = knockback - if (rawMoveType === 0x20 || rawMoveType === 0x09) return 'knockback' - if (rawMoveType === 0x17 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) return 'run' + // In D2Client.dll 0x6fac9830: 0x17 (0x0F run) / 0x18 (0x10 run) / 0x02 = run, 0x13/0x14/0x20/0x09 = knockback + if (rawMoveType === 0x20 || rawMoveType === 0x09 || rawMoveType === 0x13 || rawMoveType === 0x14) { + return 'knockback' + } + if (rawMoveType === 0x17 || rawMoveType === 0x18 || rawMoveType === 0x02 || (rawMoveType & 0x02) !== 0) { + return 'run' + } return 'walk' } diff --git a/tests/client/player-anim-modes-544.test.ts b/tests/client/player-anim-modes-544.test.ts new file mode 100644 index 0000000..1f215e7 --- /dev/null +++ b/tests/client/player-anim-modes-544.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from 'vitest' +import { AutomapView } from '../../src/client/automap/automap-view.ts' +import { + clearMissingArtEntries, + computeAnimDataFrameIndex, + getMissingArtEntries, + renderSceneSourceFrame, +} from '../../src/client/scene/world-renderer.ts' +import type { LoadedCharacter } from '../../src/client/scene/entity-art-loader.ts' +import { OnlineSession } from '../../src/client/session/online-session.ts' +import { ClientWorldSceneSource, type ClientSceneWorldAdapter } from '../../src/client/view/scene-source.ts' +import { decode0x0DPlayerStop, decode0x0FPlayerMove, decode0x10PlayerMoveToTarget } from '../../src/netproto/d2gs/s2c/movement.ts' +import { PLR_MODES } from '../../src/common/units/plr-mode.ts' + +function makeTaggedCharArt(): LoadedCharacter { + const modes: Array<{ key: string; group: number; frames: number; speed: number; loop: boolean }> = [ + { key: 'dt', group: 0, frames: 4, speed: 256, loop: false }, + { key: 'nu', group: 1, frames: 8, speed: 128, loop: true }, + { key: 'wl', group: 2, frames: 8, speed: 256, loop: true }, + { key: 'rn', group: 3, frames: 8, speed: 256, loop: true }, + { key: 'gh', group: 4, frames: 4, speed: 256, loop: false }, + { key: 'tn', group: 5, frames: 16, speed: 80, loop: true }, + { key: 'tw', group: 6, frames: 8, speed: 256, loop: true }, + { key: 'a1', group: 7, frames: 8, speed: 256, loop: false }, + { key: 'a2', group: 8, frames: 8, speed: 256, loop: false }, + { key: 'sc', group: 9, frames: 8, speed: 256, loop: false }, + { key: 'dd', group: 10, frames: 1, speed: 256, loop: false }, + ] + const groups = modes.map((m) => + Array.from({ length: m.frames }, (_, fIdx) => ({ + x: m.group * 100 + fIdx, + y: 0, + width: 32, + height: 64, + anchorX: -16, + anchorY: -56, + tag: `${m.key}_${fIdx}`, + })), + ) + const clips = Object.fromEntries( + modes.map((m) => [ + m.key, + { + group: m.group, + directions: 1, + frames: m.frames, + speed: m.speed, + loop: m.loop, + events: [], + }, + ]), + ) + return { + handle: { index: 1, width: 512, height: 512 } as never, + groups: groups as never, + directions: 1, + walk: 2, + stand: 1, + members: 1, + clips, + } +} + +describe('Issue #544: 1.13c Player Animation Modes (TN/TW/RN/DD), Per-Unit Isolation & AnimData.d2 Speed', () => { + it('1. isolates local player movement from remote standing players in the same scene', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Rogue Encampment (Town) + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { + unitType: 0, + id: 1, + classId: 1, // Sorceress ('so') + name: 'LocalSorc', + x: 5100, + y: 5050, + }, + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: false, + unit: { + unitType: 0, + id: 2, + classId: 1, // Remote Sorceress ('so') + name: 'RemoteSorc', + x: 5104, + y: 5050, + }, + }) + + // Start local player walking in town (mode = 6 TW) + session.predictor.setPath([{ x: 5120, y: 5050 }], false) + session.tick(40, 100) + + const renderables = session.buildRenderableUnits() + const localPlayer = renderables.find((u) => u.id === 1)! + const remotePlayer = renderables.find((u) => u.id === 2)! + expect(localPlayer.mode).toBe(6) // PLRMODE_TOWNWALK (TW) + expect(remotePlayer.mode).toBe(5) // PLRMODE_TOWNNEUTRAL (TN) + + // Render scene frame and inspect drawn sprite tags for both players + const automap = new AutomapView({ initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1) + const baseX = levelView.subOriginX + 50 + const baseY = levelView.subOriginY + 50 + + const drawnTags: string[] = [] + const fakeRenderer = { + begin() {}, + draw(frame: { tag?: string }) { + if (frame.tag) drawnTags.push(frame.tag) + }, + setLighting() {}, + setPalette() {}, + flush() {}, + } + const fakeTileGpuCache = { + loadedPageCount: 1, + ensureActPalette() {}, + getPageHandle() { + return undefined + }, + } + + const source = new ClientWorldSceneSource( + (): ClientSceneWorldAdapter => ({ + tick: 1, + actId: 1, + levelId: 1, + playerSubX: baseX, + playerSubY: baseY, + units: [ + { + kind: 'player', + id: 1, + classId: 1, + x: baseX, + y: baseY, + mode: localPlayer.mode, + direction: 0, + isLocalPlayer: true, + }, + { + kind: 'player', + id: 2, + classId: 1, + x: baseX + 4, + y: baseY, + mode: remotePlayer.mode, + direction: 0, + isLocalPlayer: false, + }, + ], + levelView, + }), + '800x600', + ) + + const charArt = makeTaggedCharArt() + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + tileGpuCache: fakeTileGpuCache as never, + characterArtByToken: new Map([['so', charArt]]), + nowMs: 80, + }) + + // Local player drew TW frame (`tw_2`), while remote standing player drew TN frame (`tn_0`) + expect(drawnTags).toContain('tw_2') + expect(drawnTags).toContain('tn_0') + expect(drawnTags.some((t) => t.startsWith('wl_'))).toBe(false) + }) + + it('2. transitions remote player mode on 0x0F PlayerMove, 0x10 PlayerMoveToTarget, 0x0D PlayerStop, and segment completion', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Town + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 }, + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: false, + unit: { unitType: 0, id: 2, classId: 3, name: 'RemotePal', x: 5105, y: 5050 }, + }) + + // Initially standing in town -> mode 5 (TN) + expect(session.world.getUnit('player', 2)?.mode).toBe(5) + + // 0x0F PlayerMove (walk: moveType = 0x01) from (5105, 5050) to (5115, 5050) + const pkt0x0FWalk = new Uint8Array(16) + const dvWalk = new DataView(pkt0x0FWalk.buffer) + dvWalk.setUint8(0, 0x0f) + dvWalk.setUint8(1, 0) // unitType = player + dvWalk.setUint32(2, 2, true) // unitId = 2 + dvWalk.setUint8(6, 0x01) // moveType = walk + dvWalk.setUint16(7, 5115, true) // targetX + dvWalk.setUint16(9, 5050, true) // targetY + dvWalk.setUint8(11, 0) + dvWalk.setUint16(12, 5105, true) // currentX + dvWalk.setUint16(14, 5050, true) // currentY + session.handleServerEvent(decode0x0FPlayerMove(pkt0x0FWalk)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(6) // TW in town + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(6) + + // 0x10 PlayerMoveToTarget (run: moveType = 0x18) targeting local player id = 1 at (5100, 5050) + const pkt0x10Run = new Uint8Array(16) + const dvRun = new DataView(pkt0x10Run.buffer) + dvRun.setUint8(0, 0x10) + dvRun.setUint8(1, 0) // unitType = player + dvRun.setUint32(2, 2, true) // unitId = 2 + dvRun.setUint8(6, 0x18) // moveType = 0x18 (run to target in 1.13c D2Client 0x6fac9830) + dvRun.setUint8(7, 0) // targetType = player + dvRun.setUint32(8, 1, true) // targetId = 1 + dvRun.setUint16(12, 5110, true) // currentX + dvRun.setUint16(14, 5050, true) // currentY + session.handleServerEvent(decode0x10PlayerMoveToTarget(pkt0x10Run)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(3) // RN (even in town) + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(3) + + // Advance interpolation until remote player reaches target (5100, 5050) -> returns to TN (5) + for (let i = 0; i < 30; i++) { + session.tick(100, i * 100) + } + expect(session.world.getUnit('player', 2)?.mode).toBe(5) + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5) + + // Start another run, then stop immediately via 0x0D PlayerStop + session.handleServerEvent({ + type: 'UnitMove', + unitType: 0, + id: 2, + mode: 'run', + from: { x: 5100, y: 5050 }, + to: { x: 5120, y: 5050 }, + }) + expect(session.world.getUnit('player', 2)?.mode).toBe(3) + + const pkt0x0DStop = new Uint8Array(13) + const dvStop = new DataView(pkt0x0DStop.buffer) + dvStop.setUint8(0, 0x0d) + dvStop.setUint8(1, 0) + dvStop.setUint32(2, 2, true) + dvStop.setUint8(6, 0x07) // actionCode = neutral + dvStop.setUint16(7, 5102, true) + dvStop.setUint16(9, 5050, true) + dvStop.setUint8(11, 0) + dvStop.setUint8(12, 128) + session.handleServerEvent(decode0x0DPlayerStop(pkt0x0DStop)) + + expect(session.world.getUnit('player', 2)?.mode).toBe(5) // Back to TN in town + expect(session.buildRenderableUnits().find((u) => u.id === 2)?.mode).toBe(5) + }) + + it('3. switches local player between town (TN=5, TW=6, RN=3) and wilderness (NU=1, WL=2, RN=3) and uses DD=17 (never KK=12) on death', () => { + const session = new OnlineSession() + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 1, // Rogue Encampment (Town) + }) + session.handleServerEvent({ + type: 'UnitAssign', + isSelf: true, + unit: { unitType: 0, id: 1, classId: 1, name: 'Self', x: 5100, y: 5050 }, + }) + + // Town Idle -> TN (5) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(5) + expect(PLR_MODES[5]?.token).toBe('TN') + + // Town Walk -> TW (6) + session.predictor.setPath([{ x: 5120, y: 5050 }], false) + session.tick(40, 40) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(6) + expect(PLR_MODES[6]?.token).toBe('TW') + + // Town Run -> RN (3) (1.13c D2Game sub_6FC7F600: running in town still uses PLRMODE_RUN=3) + session.predictor.setPath([{ x: 5140, y: 5050 }], true) + session.tick(40, 80) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3) + expect(PLR_MODES[3]?.token).toBe('RN') + + // Enter Blood Moor (areaId = 2, non-town) while running -> stays RN (3) + session.handleServerEvent({ + type: 'LoadAct', + act: 0, + mapSeed: 0x11223344, + areaId: 2, + }) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(3) + + // Switch to walking in Blood Moor -> WL (2) + session.predictor.setPath([{ x: 5160, y: 5050 }], false) + session.tick(40, 120) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(2) + expect(PLR_MODES[2]?.token).toBe('WL') + + // Stop in Blood Moor -> NU (1) + session.predictor.setPosition(5160, 5050) + expect(session.buildRenderableUnits().find((u) => u.id === 1)?.mode).toBe(1) + expect(PLR_MODES[1]?.token).toBe('NU') + + // Local player dies -> mode must be 17 (DD = Dead/Corpse), never 12 (KK = Kick) + expect(PLR_MODES[12]?.token).toBe('KK') + expect(PLR_MODES[17]?.token).toBe('DD') + session.handleServerEvent({ + type: 'LifeMana', + life: 0, + mana: 0, + stamina: 0, + at: { x: 5160, y: 5050 }, + vx: 0, + vy: 0, + }) + const deadSelf = session.buildRenderableUnits().find((u) => u.id === 1)! + expect(deadSelf.isCorpse).toBe(true) + expect(deadSelf.mode).toBe(17) + expect(deadSelf.mode).not.toBe(12) + }) + + it('4. advances frames at 1.13c 25Hz (40ms tick) 8.8 fixed-point AnimData.d2 speed and reports missing clip speed', () => { + // speed = 256 -> 1 frame per 40ms tick (25 fps) + expect(computeAnimDataFrameIndex(0, 256, 8)).toBe(0) + expect(computeAnimDataFrameIndex(39, 256, 8)).toBe(0) + expect(computeAnimDataFrameIndex(40, 256, 8)).toBe(1) + expect(computeAnimDataFrameIndex(80, 256, 8)).toBe(2) + expect(computeAnimDataFrameIndex(320, 256, 8)).toBe(0) // loops at 8 frames + + // speed = 128 (SONUHTH) -> 1 frame per 80ms (12.5 fps) + expect(computeAnimDataFrameIndex(0, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(40, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(79, 128, 8)).toBe(0) + expect(computeAnimDataFrameIndex(80, 128, 8)).toBe(1) + expect(computeAnimDataFrameIndex(160, 128, 8)).toBe(2) + + // speed = 80 (SOTNHTH) -> 1 frame per 128ms (7.8125 fps) + expect(computeAnimDataFrameIndex(0, 80, 16)).toBe(0) + expect(computeAnimDataFrameIndex(127, 80, 16)).toBe(0) + expect(computeAnimDataFrameIndex(128, 80, 16)).toBe(1) + expect(computeAnimDataFrameIndex(256, 80, 16)).toBe(2) + + // Non-looping death animation (DT, loop = false) clamps to frameCount - 1 + expect(computeAnimDataFrameIndex(10000, 256, 4, false)).toBe(3) + + // Fail-fast diagnostic when clip speed metadata is missing + clearMissingArtEntries() + const automap = new AutomapView({ initialMode: 'level' }) + const levelView = automap.setContext(0x11223344, 0, 1) + const baseX = levelView.subOriginX + 50 + const baseY = levelView.subOriginY + 50 + const source = new ClientWorldSceneSource( + (): ClientSceneWorldAdapter => ({ + tick: 1, + actId: 1, + levelId: 1, + playerSubX: baseX, + playerSubY: baseY, + units: [ + { + kind: 'player', + id: 1, + classId: 1, + x: baseX, + y: baseY, + mode: 5, // TN + direction: 0, + isLocalPlayer: true, + }, + ], + levelView, + }), + '800x600', + ) + const brokenCharArt: LoadedCharacter = { + handle: { index: 1, width: 64, height: 64 } as never, + groups: [[{ x: 0, y: 0, width: 32, height: 64 }]], + directions: 1, + walk: 0, + stand: 0, + members: 1, + // Missing 'tn' clip! + clips: {}, + } + const fakeRenderer = { + begin() {}, + draw() {}, + setLighting() {}, + setPalette() {}, + flush() {}, + } + renderSceneSourceFrame({ + source, + lightingPreset: 'fullbright', + renderer: fakeRenderer as never, + characterArtByToken: new Map([['so', brokenCharArt]]), + nowMs: 0, + }) + expect( + getMissingArtEntries().some((e) => e.category === 'player-clip' && e.id.toLowerCase() === 'so:tn'), + ).toBe(true) + clearMissingArtEntries() + }) +})