Fix dual-clock animation, Overlay.txt data, panel camera shift, and automap toggle (Closes #584, #592, #596, #597, #599, #601, #602, #603)

This commit is contained in:
troytt 2026-10-02 23:30:00 +00:00
parent 17fd1f1b6c
commit d7ca085046
8 changed files with 1484 additions and 98 deletions

View File

@ -338,16 +338,16 @@ async function bootPlayApp(): Promise<PlayAppController> {
chatInputEl.placeholder = 'Say...'
chatInputEl.hidden = true
chatInputEl.style.cssText =
'position:absolute;left:24px;bottom:64px;width:360px;z-index:25;background:rgba(10,8,6,0.92);border:1px solid #c8a858;color:#f2e6c8;padding:4px 8px;font:13px "D2Formal436",serif;outline:none;'
'position:absolute;left:24px;bottom:64px;width:360px;max-width:calc(100% - 48px);box-sizing:border-box;z-index:25;background:rgba(10,8,6,0.92);border:1px solid #c8a858;color:#f2e6c8;padding:4px 8px;font:13px "D2Formal436",serif;outline:none;'
stageEl.appendChild(chatInputEl)
const toggleInGameChatInput = (): void => {
if (!chatInputEl.hidden) {
const msg = chatInputEl.value.trim()
const msg = chatInputEl.value
chatInputEl.value = ''
chatInputEl.hidden = true
chatInputEl.blur()
if (msg && sessionRef) {
if (msg.length > 0 && sessionRef) {
sessionRef.dispatchCommand({ type: 'Chat', text: msg })
}
} else {
@ -923,6 +923,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
})
let lastFrameMs = performance.now()
let logicAccumulatorMs = 0
const renderFrame = (nowMs = performance.now()): void => {
const dtMs = Math.max(0, Math.min(100, nowMs - lastFrameMs))
lastFrameMs = nowMs
@ -933,6 +934,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
inGameView = true
flowState.tickAccumulatorMs = 0
flowState.lastFrameTimeMs = null
logicAccumulatorMs = 0
syncVisibility()
} else {
frontendRenderer.render(nowMs)
@ -943,7 +945,11 @@ async function bootPlayApp(): Promise<PlayAppController> {
flowState.lastFrameTimeMs = null
}
session.tick(dtMs, nowMs)
logicAccumulatorMs = Math.min(200, logicAccumulatorMs + dtMs)
while (logicAccumulatorMs >= 40) {
session.tick(40, nowMs)
logicAccumulatorMs -= 40
}
const currentSettings = settingsStore.get()
const profile = getViewportProfile(currentSettings.viewport)

View File

@ -44,6 +44,12 @@ export interface OverlayMeta {
readonly lightColor: readonly [number, number, number]
readonly box: OverlayBox
readonly groups: readonly (readonly OverlayFrameTuple[])[]
readonly xOffset?: number
readonly yOffset?: number
readonly height1?: number
readonly height2?: number
readonly height3?: number
readonly height4?: number
}
export const FIRE_CAST_1_META: OverlayMeta = {
@ -23832,3 +23838,647 @@ export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
"town_portal_on_hd": TOWN_PORTAL_ON_HD_META,
"tphdlitonhth": TOWN_PORTAL_ON_HD_META,
}
export interface AuthenticOverlayTxtEntry {
readonly overlay: string
readonly filename: string
readonly frames: number
readonly animRate: number
readonly trans: number
readonly preDraw: boolean
readonly radius: number
readonly red: number
readonly green: number
readonly blue: number
readonly xOffset: number
readonly yOffset: number
readonly height1: number
readonly height2: number
readonly height3: number
readonly height4: number
}
/**
* Authentic Diablo II v1.13c Overlay.txt ground truth records.
*/
export const AUTHENTIC_113C_OVERLAYS: Readonly<Record<string, AuthenticOverlayTxtEntry>> = Object.freeze({
curseamplifydamage: {
overlay: 'curseamplifydamage',
filename: 'CurseAmplifyDamageEffect',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 255,
green: 64,
blue: 64,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
cursedecrepify: {
overlay: 'cursedecrepify',
filename: 'CurseDecrepifyEffect',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 0,
green: 0,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
curselowerresist: {
overlay: 'curselowerresist',
filename: 'CurseLowerResistEffect',
frames: 29,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 0,
green: 0,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
curseweaken: {
overlay: 'curseweaken',
filename: 'CurseWeakenEffect',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 255,
green: 210,
blue: 210,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
cursedimvision: {
overlay: 'cursedimvision',
filename: 'CurseDimVisionEffect',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 255,
green: 210,
blue: 210,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
curseironmaiden: {
overlay: 'curseironmaiden',
filename: 'CurseIronMaidenEffect',
frames: 29,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 255,
green: 0,
blue: 0,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
curseterror: {
overlay: 'curseterror',
filename: 'CurseTerrorEffect',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
curseconfuse: {
overlay: 'curseconfuse',
filename: 'CurseConfuseEffect',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 0,
green: 255,
blue: 0,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
cursereversevampire: {
overlay: 'cursereversevampire',
filename: 'CurseReverseVampireEffect',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 0,
red: 0,
green: 0,
blue: 0,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
curseattract: {
overlay: 'curseattract',
filename: 'CurseAttractHarmEffect',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 255,
green: 0,
blue: 0,
xOffset: 0,
yOffset: 0,
height1: 14,
height2: 0,
height3: -14,
height4: -60,
},
frozenarmor: {
overlay: 'frozenarmor',
filename: 'FrozenArmor',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 13,
red: 200,
green: 200,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
chillarmor: {
overlay: 'chillarmor',
filename: 'FrozenArmor',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 13,
red: 200,
green: 200,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
shiverarmor: {
overlay: 'shiverarmor',
filename: 'FrozenArmor',
frames: 24,
animRate: 16,
trans: 3,
preDraw: false,
radius: 13,
red: 200,
green: 200,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
energyshield: {
overlay: 'energyshield',
filename: 'ManaShieldBall',
frames: 8,
animRate: 8,
trans: 5,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
battleorders: {
overlay: 'battleorders',
filename: 'BattleOrders',
frames: 20,
animRate: 8,
trans: 3,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
battlecommand: {
overlay: 'battlecommand',
filename: 'BattleCommand',
frames: 20,
animRate: 8,
trans: 3,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_might_front: {
overlay: 'aura_might_front',
filename: 'AuraMightFront',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 4,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_might_back: {
overlay: 'aura_might_back',
filename: 'AuraMightBack',
frames: 15,
animRate: 16,
trans: 3,
preDraw: true,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 4,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
fanaticismfront: {
overlay: 'fanaticismfront',
filename: 'Fanatacism_Front',
frames: 30,
animRate: 6,
trans: 3,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: -1,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
fanaticismback: {
overlay: 'fanaticismback',
filename: 'Fanatacism_Back',
frames: 30,
animRate: 6,
trans: 3,
preDraw: true,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_resistall_front: {
overlay: 'aura_resistall_front',
filename: 'AuraResistAllFront',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 211,
green: 148,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_resistall_back: {
overlay: 'aura_resistall_back',
filename: 'AuraResistAllBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
radius: 6,
red: 211,
green: 148,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
convictionfront: {
overlay: 'convictionfront',
filename: 'null',
frames: 2,
animRate: 16,
trans: 3,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
convictionback: {
overlay: 'convictionback',
filename: 'Conviction',
frames: 21,
animRate: 6,
trans: 3,
preDraw: true,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_resistfire: {
overlay: 'aura_resistfire',
filename: 'AuraResistFireFront',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
radius: 6,
red: 255,
green: 193,
blue: 103,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_resistcold: {
overlay: 'aura_resistcold',
filename: 'AuraResistColdBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
radius: 6,
red: 210,
green: 210,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_resistlight: {
overlay: 'aura_resistlight',
filename: 'AuraResistLightningBack',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
radius: 6,
red: 255,
green: 255,
blue: 200,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_prayer_front: {
overlay: 'aura_prayer_front',
filename: 'AuraPrayerFront',
frames: 16,
animRate: 8,
trans: 3,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_prayer_back: {
overlay: 'aura_prayer_back',
filename: 'AuraPrayerBack',
frames: 16,
animRate: 8,
trans: 3,
preDraw: true,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_defiance_front: {
overlay: 'aura_defiance_front',
filename: 'AuraDefianceFront',
frames: 15,
animRate: 8,
trans: 3,
preDraw: false,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
aura_defiance_back: {
overlay: 'aura_defiance_back',
filename: 'AuraDefianceBack',
frames: 15,
animRate: 8,
trans: 3,
preDraw: true,
radius: 0,
red: 255,
green: 255,
blue: 255,
xOffset: 0,
yOffset: 0,
height1: 0,
height2: 0,
height3: 0,
height4: 0,
},
})
/**
* Authentic Diablo II v1.13c States.txt overlay mapping.
* Maps stateId -> array of overlay tokens defined in States.txt (overlay1..overlay4).
* States 1 (Freeze) and 2 (Poison) only apply STATE_COLOR_SHIFTS and have NO overlays.
*/
export const STATE_OVERLAYS: Readonly<Record<number, readonly string[]>> = Object.freeze({
3: ['aura_resistfire'],
4: ['aura_resistcold'],
5: ['aura_resistlight'],
8: ['aura_resistall_front', 'aura_resistall_back'],
9: ['curseamplifydamage'],
10: ['frozenarmor'],
14: ['bonearmor_front', 'bonearmor_back'],
17: ['innersight'],
19: ['curseweaken'],
20: ['chillarmor'],
21: ['stun'],
23: ['cursedimvision'],
26: ['shout'],
27: ['taunt'],
28: ['convictionfront', 'convictionback'],
29: ['convictionfront', 'convictionback'],
30: ['energyshield'],
32: ['battleorders'],
33: ['aura_might_front', 'aura_might_back'],
34: ['aura_prayer_front', 'aura_prayer_back'],
35: ['aura_holyfire_front', 'aura_holyfire_back'],
36: ['aura_thorns_front', 'aura_thorns_back'],
37: ['aura_defiance_front', 'aura_defiance_back'],
38: ['thunderstormback'],
40: ['blessedaimfront', 'blessedaimback'],
42: ['concentrationfront', 'concentrationback'],
43: ['holyfreeze'],
45: ['cleansingfront', 'cleansingback'],
46: ['holyshockfront', 'holyshockback'],
47: ['sanctuaryfront', 'sanctuaryback'],
48: ['meditationfront', 'meditationback'],
49: ['fanaticismfront', 'fanaticismback'],
50: ['redemptionfront', 'redemptionback'],
51: ['battlecommand'],
53: ['conversionaura'],
55: ['curseironmaiden'],
56: ['curseterror'],
57: ['curseattract'],
58: ['cursereversevampire'],
59: ['curseconfuse'],
60: ['cursedecrepify'],
61: ['curselowerresist'],
84: ['fingermagecurse'],
87: ['innersight'],
88: ['shiverarmor'],
89: ['battlecry'],
93: ['valkyrie'],
94: ['frenzy'],
95: ['berserkfront', 'berserkback'],
112: ['rabiesplague'],
138: ['direwolfcharged'],
151: ['cyclonearmor1front', 'cyclonearmor2front', 'cyclonearmor3front'],
158: ['bladeshield'],
})

View File

@ -811,7 +811,10 @@ function drawSceneUnitDrawable(
const maxModeFrames = objArt.frameCnt?.[modeIdx] ?? frameCount
const effectiveTotalFrames = Math.min(frameCount, Math.max(1, maxModeFrames))
const framesProgress = Math.floor((elapsedTicks * stepSpeed256) / 256)
frameIndex = Math.min(effectiveTotalFrames - 1, startFrame + framesProgress)
frameIndex =
unit.animStartedAtMs === undefined && modeIdx !== 0
? effectiveTotalFrames - 1
: Math.min(effectiveTotalFrames - 1, startFrame + framesProgress)
} else {
const rawSpeed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0
if (!(rawSpeed > 0)) {
@ -896,12 +899,12 @@ function drawSceneMissileDrawable(
ctx: SceneSourceRenderContext,
nowMs: number,
): void {
if (missile.celFile.toLowerCase() === 'sqr') {
if (missile.celFile && missile.celFile.toLowerCase() === 'sqr') {
return
}
const art =
ctx.missileArtMap?.get(missile.missileKey) ??
ctx.missileArtMap?.get(missile.celFile.toLowerCase())
(missile.celFile ? ctx.missileArtMap?.get(missile.celFile.toLowerCase()) : undefined)
if (art !== undefined) {
const animSpeed = art.meta.animSpeed
if (!(animSpeed > 0)) {
@ -919,8 +922,7 @@ function drawSceneMissileDrawable(
const frameIdx =
missile.loopAnim === false
? Math.min(frameCount - 1, Math.max(0, Math.floor(missile.frame)))
: (missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) %
frameCount
: ((Math.floor(missile.frame) % frameCount) + frameCount) % frameCount
const frame = group?.[frameIdx] ?? group?.[0]
if (frame !== undefined) {
const altitude = missile.altitude ?? 0
@ -979,8 +981,7 @@ function drawSceneOverlayDrawable(
const frameIdx =
overlay.loop === false
? Math.min(frameCount - 1, Math.max(0, Math.floor(overlay.frame)))
: (overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) %
frameCount
: ((Math.floor(overlay.frame) % frameCount) + frameCount) % frameCount
const frame = group?.[frameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX =
@ -1034,7 +1035,8 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
return snapshot
}
const camX = snapshot.playerWorld.x
const panelShiftX = snapshot.panelShiftX ?? 0
const camX = snapshot.playerWorld.x + panelShiftX
const camY = snapshot.playerWorld.y + CAMERA_Y_OFFSET
if (renderer !== null) {
@ -1054,8 +1056,8 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
): void => {
const animStep =
ctx.nowMs !== undefined
? Math.floor((ctx.nowMs * 80) / (40 * 256))
: Math.floor((snapshot.tick * 80) / 256)
? Math.floor((ctx.nowMs * 80) / (40 * 200))
: Math.floor(snapshot.tick / 2.5)
const p =
q.animatedPlacements && q.animatedPlacements.length > 1
? (q.animatedPlacements[Math.abs(animStep) % q.animatedPlacements.length] ?? q.placement)

View File

@ -13,7 +13,7 @@ export type ClientLightingPreset =
| 'fullbright'
export type AutomapRevealMode = 'off' | 'level' | 'act'
export type ViewportResolution = '800x600' | '1024x768' | '1068x600' | '1280x720'
export type ViewportResolution = '640x480' | '800x600' | '1024x768' | '1068x600' | '1280x720'
export interface ClientSettings {
readonly version: 1
@ -44,6 +44,7 @@ export const VALID_AUTOMAP_REVEAL_MODES: readonly AutomapRevealMode[] = [
] as const
export const VALID_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [
'640x480',
'800x600',
'1024x768',
'1068x600',

View File

@ -21,6 +21,7 @@ import {
export type UnitState = RenderableUnitInput
import {
CAMERA_Y_OFFSET,
computePanelCameraShift,
getViewportProfile,
screenToWorld,
subTileToWorldOrtho,
@ -56,6 +57,7 @@ export interface SceneFrameSnapshot {
readonly playerLocalSubTile: { readonly x: number; readonly y: number }
readonly playerWorld: { readonly x: number; readonly y: number }
readonly cameraTopLeft: { readonly x: number; readonly y: number }
readonly panelShiftX?: number
readonly floorQuads: readonly SceneTileQuad[]
readonly shadowQuads: readonly SceneTileQuad[]
readonly lowerWallQuads: readonly SceneTileQuad[]
@ -99,6 +101,9 @@ export interface ClientSceneWorldAdapter {
readonly playerSubX: number
readonly playerSubY: number
readonly playerLightRadiusBonus?: number | undefined
readonly panelShiftX?: number | undefined
readonly leftPanelOpen?: boolean | undefined
readonly rightPanelOpen?: boolean | undefined
readonly units: Iterable<RenderableUnitInput>
readonly levelView?: LevelView | null | undefined
readonly tileAtlas?: TileAtlas | null | undefined
@ -113,7 +118,9 @@ export function buildCollisionGridFromLevelView(
centerSubY: number,
windowSubTiles = 100,
): CollisionGrid {
if (levelView && levelView.rooms.length > 0) {
if (!levelView || levelView.rooms.length === 0) {
throw new Error('buildCollisionGridFromLevelView: missing room data')
}
return {
originX: levelView.isoOriginX,
originY: levelView.isoOriginY,
@ -125,25 +132,6 @@ export function buildCollisionGridFromLevelView(
}
}
const cellsX = Math.max(8, Math.floor(windowSubTiles / 5))
const cellsY = Math.max(8, Math.floor(windowSubTiles / 5))
const gridWidth = cellsX * 5
const gridHeight = cellsY * 5
const blocked = new Uint8Array(gridWidth * gridHeight)
const collisionMasks = new Uint16Array(gridWidth * gridHeight)
const origin = subTileToWorldOrtho(centerSubX - gridWidth / 2, centerSubY - gridHeight / 2, 0, 0)
return {
originX: origin.x,
originY: origin.y,
cellsX,
cellsY,
blocked,
gridWidth,
collisionMasks,
}
}
function mapLevelDrawToQuad(item: LevelDrawItem, fallbackActId: number): SceneTileQuad {
const sortY = (item.cellX + item.cellY) * 40
return {
@ -290,9 +278,12 @@ export class ClientWorldSceneSource implements SceneSource {
readonly x: number
readonly y: number
} {
const st = this.stateProvider()
const playerWorld = this.getPlayerWorldOrtho()
const panelShiftX =
st.panelShiftX ?? computePanelCameraShift(st.leftPanelOpen, st.rightPanelOpen)
return {
x: playerWorld.x - profile.width / 2,
x: playerWorld.x + panelShiftX - profile.width / 2,
y: playerWorld.y + CAMERA_Y_OFFSET - profile.height / 2,
}
}
@ -318,8 +309,10 @@ export class ClientWorldSceneSource implements SceneSource {
isoOriginX,
isoOriginY,
)
const panelShiftX =
st.panelShiftX ?? computePanelCameraShift(st.leftPanelOpen, st.rightPanelOpen)
const cameraTopLeft = {
x: playerWorld.x - profile.width / 2,
x: playerWorld.x + panelShiftX - profile.width / 2,
y: playerWorld.y + CAMERA_Y_OFFSET - profile.height / 2,
}
@ -380,6 +373,7 @@ export class ClientWorldSceneSource implements SceneSource {
playerLocalSubTile,
playerWorld,
cameraTopLeft,
panelShiftX,
floorQuads,
shadowQuads,
lowerWallQuads,
@ -436,10 +430,12 @@ export class ClientWorldSceneSource implements SceneSource {
isoOriginX,
isoOriginY,
)
const panelShiftX =
st.panelShiftX ?? computePanelCameraShift(st.leftPanelOpen, st.rightPanelOpen)
const clickWorld = screenToWorld(
screenX,
screenY,
playerWorld.x,
playerWorld.x + panelShiftX,
playerWorld.y + CAMERA_Y_OFFSET,
profile,
)

View File

@ -11,6 +11,11 @@ import { assertDir64, dir64FromPoints, dir64ToClipDirection } from '../../common
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
import type { PointLightInput } from '../scene/lighting-presets.ts'
import type { UnitCastState } from '../world/client-unit.ts'
import {
AUTHENTIC_113C_OVERLAYS,
OVERLAY_METAS,
STATE_OVERLAYS,
} from '../render/overlays-meta.ts'
import { subTileToWorldOrtho } from './viewport-profile.ts'
type MissileTxtEntry = NonNullable<ReturnType<typeof getMissileTxtData>>
@ -274,25 +279,32 @@ export interface OverlayMeta {
/**
* Canonical 1.13c `Overlay.txt` / `States.txt` visual metadata for common combat/buff overlays.
* State 1 (Freeze) and State 2 (Poison) only apply STATE_COLOR_SHIFTS and have NO overlays.
*/
export const OVERLAY_METADATA: Readonly<Record<number, OverlayMeta>> = {
1: { overlayId: 1, token: 'Freeze', totalFrames: 15, blendMode: 'additive', yOffset: 0, lightRadius: 8, lightColor: [110, 175, 255] },
2: { overlayId: 2, token: 'Poison', totalFrames: 12, blendMode: 'normal', yOffset: 0 },
8: { overlayId: 8, token: 'Salvation', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [255, 240, 180] },
9: { overlayId: 9, token: 'AmplifyDamage', totalFrames: 14, blendMode: 'additive', yOffset: -42, lightRadius: 8, lightColor: [255, 90, 60] },
10: { overlayId: 10, token: 'FrozenArmor', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 10, lightColor: [120, 185, 255] },
16: { overlayId: 16, token: 'Enchant', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 10, lightColor: [255, 140, 60] },
20: { overlayId: 20, token: 'ChillingArmor', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 10, lightColor: [120, 185, 255] },
28: { overlayId: 28, token: 'Conviction', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [120, 255, 140] },
30: { overlayId: 30, token: 'EnergyShield', totalFrames: 16, blendMode: 'additive', yOffset: -36, lightRadius: 11, lightColor: [255, 220, 110] },
32: { overlayId: 32, token: 'BattleOrders', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [110, 180, 255] },
33: { overlayId: 33, token: 'Might', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [255, 215, 110] },
49: { overlayId: 49, token: 'Fanaticism', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 13, lightColor: [255, 110, 90] },
51: { overlayId: 51, token: 'BattleCommand', totalFrames: 16, blendMode: 'additive', yOffset: 4, lightRadius: 12, lightColor: [255, 185, 95] },
60: { overlayId: 60, token: 'Decrepify', totalFrames: 14, blendMode: 'additive', yOffset: -42, lightRadius: 8, lightColor: [140, 160, 255] },
61: { overlayId: 61, token: 'LowerResist', totalFrames: 14, blendMode: 'additive', yOffset: -42, lightRadius: 9, lightColor: [210, 110, 255] },
88: { overlayId: 88, token: 'ShiverArmor', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 10, lightColor: [120, 185, 255] },
101: { overlayId: 101, token: 'HolyShield', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 11, lightColor: [255, 245, 200] },
3: { overlayId: 3, token: 'aura_resistfire', totalFrames: 20, blendMode: 'trans50', yOffset: 0, lightRadius: 6, lightColor: [255, 193, 103] },
4: { overlayId: 4, token: 'aura_resistcold', totalFrames: 20, blendMode: 'trans50', yOffset: 0, lightRadius: 6, lightColor: [210, 210, 255] },
5: { overlayId: 5, token: 'aura_resistlight', totalFrames: 20, blendMode: 'trans50', yOffset: 0, lightRadius: 6, lightColor: [255, 255, 200] },
8: { overlayId: 8, token: 'aura_resistall_front', totalFrames: 20, blendMode: 'trans50', yOffset: 0, lightRadius: 6, lightColor: [211, 148, 255] },
9: { overlayId: 9, token: 'curseamplifydamage', totalFrames: 24, blendMode: 'trans50', yOffset: -60, lightRadius: 6, lightColor: [255, 64, 64] },
10: { overlayId: 10, token: 'frozenarmor', totalFrames: 24, blendMode: 'trans50', yOffset: 0, lightRadius: 13, lightColor: [200, 200, 255] },
19: { overlayId: 19, token: 'curseweaken', totalFrames: 24, blendMode: 'trans50', yOffset: -60, lightRadius: 6, lightColor: [255, 210, 210] },
20: { overlayId: 20, token: 'chillarmor', totalFrames: 24, blendMode: 'trans50', yOffset: 0, lightRadius: 13, lightColor: [200, 200, 255] },
23: { overlayId: 23, token: 'cursedimvision', totalFrames: 24, blendMode: 'trans50', yOffset: -60, lightRadius: 6, lightColor: [255, 210, 210] },
28: { overlayId: 28, token: 'convictionback', totalFrames: 21, blendMode: 'trans50', yOffset: 0 },
30: { overlayId: 30, token: 'energyshield', totalFrames: 8, blendMode: 'normal', yOffset: 0 },
32: { overlayId: 32, token: 'battleorders', totalFrames: 20, blendMode: 'trans50', yOffset: 0 },
33: { overlayId: 33, token: 'aura_might_front', totalFrames: 15, blendMode: 'trans50', yOffset: 0 },
49: { overlayId: 49, token: 'fanaticismfront', totalFrames: 30, blendMode: 'trans50', yOffset: -1 },
51: { overlayId: 51, token: 'battlecommand', totalFrames: 20, blendMode: 'trans50', yOffset: 0 },
55: { overlayId: 55, token: 'curseironmaiden', totalFrames: 29, blendMode: 'trans50', yOffset: -60, lightRadius: 6, lightColor: [255, 0, 0] },
56: { overlayId: 56, token: 'curseterror', totalFrames: 20, blendMode: 'trans50', yOffset: -60 },
57: { overlayId: 57, token: 'curseattract', totalFrames: 24, blendMode: 'trans50', yOffset: -60, lightRadius: 6, lightColor: [255, 0, 0] },
58: { overlayId: 58, token: 'cursereversevampire', totalFrames: 24, blendMode: 'trans50', yOffset: -60 },
59: { overlayId: 59, token: 'curseconfuse', totalFrames: 24, blendMode: 'trans50', yOffset: -60, lightRadius: 6, lightColor: [0, 255, 0] },
60: { overlayId: 60, token: 'cursedecrepify', totalFrames: 24, blendMode: 'trans50', yOffset: -60, lightRadius: 6, lightColor: [0, 0, 255] },
61: { overlayId: 61, token: 'curselowerresist', totalFrames: 29, blendMode: 'trans50', yOffset: -60, lightRadius: 6, lightColor: [0, 0, 255] },
88: { overlayId: 88, token: 'shiverarmor', totalFrames: 24, blendMode: 'trans50', yOffset: 0, lightRadius: 13, lightColor: [200, 200, 255] },
}
export interface StateColorShift {
@ -506,7 +518,7 @@ export function buildSceneDrawables(
const dir = computeMissileClipDirection(unit, entry.numDirections || 16)
const totalFrames = Math.max(1, unit.totalFrames ?? entry.animLen ?? 8)
const loopAnim = unit.loopAnim !== undefined ? unit.loopAnim : entry.loopAnim !== 0
const rawFrame = unit.frame ?? tick
const rawFrame = unit.frame ?? 0
const frame = loopAnim
? ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames
: Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame)))
@ -565,7 +577,7 @@ export function buildSceneDrawables(
const meta = OVERLAY_METADATA[ov.overlayId]
const totalFrames = Math.max(1, ov.totalFrames ?? ovTxt?.frames ?? meta?.totalFrames ?? 16)
const isOneShot = ov.loop === false || (ovTxt !== undefined && ov.loop !== true)
const rawFrame = ov.frame ?? tick
const rawFrame = ov.frame ?? 0
const frame = isOneShot
? Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame)))
: ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames
@ -631,36 +643,64 @@ export function buildSceneDrawables(
highestShift = shift
}
}
const meta = OVERLAY_METADATA[stateId]
if (meta && !unitOverlays.some((o) => o.overlayId === stateId)) {
const stateTokens =
STATE_OVERLAYS[stateId] ??
(OVERLAY_METADATA[stateId] ? [OVERLAY_METADATA[stateId].token] : undefined)
if (stateTokens) {
for (const token of stateTokens) {
if (
token === 'null' ||
unitOverlays.some((o) => o.token.toLowerCase() === token.toLowerCase())
) {
continue
}
const ovTxt = AUTHENTIC_113C_OVERLAYS[token.toLowerCase()]
const meta = OVERLAY_METAS[token.toLowerCase()] ?? OVERLAY_METAS[token]
const totalFrames = Math.max(1, ovTxt?.frames ?? meta?.frames ?? 16)
const animRate = ovTxt?.animRate ?? meta?.animRate ?? 16
const rawFrame = Math.floor((tick * animRate) / 16)
const frame = ((rawFrame % totalFrames) + totalFrames) % totalFrames
const yOffset =
ovTxt?.height4 !== undefined && ovTxt.height4 !== 0
? ovTxt.height4
: (ovTxt?.yOffset ?? meta?.yOffset ?? 0)
const blendMode: DrawableBlendMode = transToBlendMode(ovTxt?.trans ?? meta?.trans ?? 3)
const ovDrawable: SceneOverlayDrawable = {
drawableKind: 'overlay',
ownerUnitId: unit.id,
overlayId: stateId,
token: meta.token,
frame: tick % meta.totalFrames,
totalFrames: meta.totalFrames,
token,
frame,
totalFrames,
loop: true,
worldX,
worldY: worldY + meta.yOffset,
worldY: worldY + yOffset,
sortY: worldY,
blendMode: meta.blendMode,
blendMode,
}
unitOverlays.push(ovDrawable)
overlayDrawables.push(ovDrawable)
if (meta.lightRadius && meta.lightColor) {
const lightRadius =
ovTxt && ovTxt.radius > 0 ? Math.max(12, ovTxt.radius) : meta?.lightRadius
const lightColor: readonly [number, number, number] | undefined =
ovTxt && (ovTxt.red > 0 || ovTxt.green > 0 || ovTxt.blue > 0)
? [ovTxt.red, ovTxt.green, ovTxt.blue]
: meta?.lightColor
if (lightRadius && lightColor) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: meta.lightRadius,
radius: lightRadius,
intensity: 190,
r: meta.lightColor[0],
g: meta.lightColor[1],
b: meta.lightColor[2],
r: lightColor[0],
g: lightColor[1],
b: lightColor[2],
})
}
}
}
}
if (highestShift) {
tintColor = highestShift.tintColor
activePaletteRow = highestShift.paletteRow
@ -831,10 +871,10 @@ export function buildSceneDrawables(
const getEntityLayer = (d: SceneEntityDrawable): number => {
if (d.drawableKind === 'unit') {
return d.isCorpse ? 0 : 1
return d.unitKind === 'item' || d.isCorpse ? 0 : 1
}
if (d.drawableKind === 'missile') return 2
if (d.drawableKind === 'overlay') return 3
if (d.drawableKind === 'missile') return 1
if (d.drawableKind === 'overlay') return 2
return 1
}

View File

@ -51,9 +51,9 @@ export function createViewportProfile(
if (!Number.isInteger(width) || !Number.isInteger(height)) {
throw new Error(`Viewport profile ${id} must have integer dimensions`)
}
if (width < NATIVE_HUD_WIDTH || height < NATIVE_HUD_HEIGHT) {
if (width < 640 || height < 480) {
throw new Error(
`Viewport profile ${id} is smaller than the native ${NATIVE_HUD_WIDTH}x${NATIVE_HUD_HEIGHT} HUD`,
`Viewport profile ${id} is smaller than the minimum 640x480 resolution`,
)
}
const marginX = width - NATIVE_HUD_WIDTH
@ -71,6 +71,7 @@ export function createViewportProfile(
}
}
export const VIEWPORT_640x480: ViewportProfile = createViewportProfile('640x480', 640, 480)
export const VIEWPORT_800x600: ViewportProfile = createViewportProfile('800x600', 800, 600)
export const VIEWPORT_1024x768: ViewportProfile = createViewportProfile('1024x768', 1024, 768)
/** ≈16:9 at the native 600-line height (the common HD-mod widescreen size): bar at x = 134, panels at y = 0. */
@ -79,12 +80,32 @@ export const VIEWPORT_1068x600: ViewportProfile = createViewportProfile('1068x60
export const VIEWPORT_1280x720: ViewportProfile = createViewportProfile('1280x720', 1280, 720)
export const VIEWPORT_PROFILES: Readonly<Record<ViewportResolution, ViewportProfile>> = {
'640x480': VIEWPORT_640x480,
'800x600': VIEWPORT_800x600,
'1024x768': VIEWPORT_1024x768,
'1068x600': VIEWPORT_1068x600,
'1280x720': VIEWPORT_1280x720,
}
/**
* Compute camera horizontal shift in pixels when side panels (inventory, character, quest, skills) are open.
* Authentic Diablo II 1.13c:
* - Left panel open (Character/Quest/Skills): camera shifts right (+160px) to keep character centered in visible area.
* - Right panel open (Inventory/Stash/Trade): camera shifts left (-160px).
* - Both panels open or neither open: camera remains centered (0px).
*/
export function computePanelCameraShift(
leftPanelOpen?: boolean,
rightPanelOpen?: boolean,
shiftPx = 160,
): number {
const left = Boolean(leftPanelOpen)
const right = Boolean(rightPanelOpen)
if (left && !right) return shiftPx
if (right && !left) return -shiftPx
return 0
}
/**
* Resolve a profile from a resolution id (case/whitespace-insensitive) or from a profile-like object
* (matched by exact width × height). Anything unrecognized resolves to `VIEWPORT_800x600`; use
@ -93,14 +114,21 @@ export const VIEWPORT_PROFILES: Readonly<Record<ViewportResolution, ViewportProf
export function getViewportProfile(
input: ViewportResolution | ViewportProfile | string | null | undefined,
): ViewportProfile {
if (typeof input === 'object' && input !== null && 'width' in input && 'height' in input) {
if (input === null || input === undefined) {
return VIEWPORT_800x600
}
if (typeof input === 'object' && 'width' in input && 'height' in input) {
return findViewportProfileBySize(input.width, input.height) ?? VIEWPORT_800x600
}
if (typeof input === 'string') {
const clean = input.trim().toLowerCase()
if (clean === '') {
return VIEWPORT_800x600
}
if (Object.hasOwn(VIEWPORT_PROFILES, clean)) {
return VIEWPORT_PROFILES[clean as ViewportResolution]
}
throw new Error(`Unknown viewport profile "${input}"`)
}
return VIEWPORT_800x600
}
@ -296,11 +324,12 @@ export function subTileToScreen(
camSubX: number,
camSubY: number,
profile: ViewportProfile = VIEWPORT_800x600,
panelShiftX = 0,
): { x: number; y: number } {
const dSubX = subX - camSubX
const dSubY = subY - camSubY
return {
x: profile.width / 2 + (dSubX - dSubY) * ORTHO_SUB_TILE_WIDTH,
x: profile.width / 2 - panelShiftX + (dSubX - dSubY) * ORTHO_SUB_TILE_WIDTH,
y: profile.height / 2 + CAMERA_Y_OFFSET + (dSubX + dSubY) * ORTHO_SUB_TILE_HEIGHT,
}
}
@ -315,8 +344,9 @@ export function screenToSubTile(
camSubX: number,
camSubY: number,
profile: ViewportProfile = VIEWPORT_800x600,
panelShiftX = 0,
): { x: number; y: number } {
const relX = screenX - profile.width / 2
const relX = screenX - (profile.width / 2 - panelShiftX)
const relY = screenY - (profile.height / 2 + CAMERA_Y_OFFSET)
const dx = relX / ORTHO_SUB_TILE_WIDTH
const dy = relY / ORTHO_SUB_TILE_HEIGHT

View File

@ -0,0 +1,661 @@
import { describe, expect, it } from 'vitest'
import {
AUTHENTIC_113C_OVERLAYS,
OVERLAY_METAS,
STATE_OVERLAYS,
} from '../../src/client/render/overlays-meta.ts'
import {
buildSceneDrawables,
OVERLAY_METADATA,
type RenderableUnitInput,
} from '../../src/client/view/unit-drawables.ts'
import {
buildCollisionGridFromLevelView,
ClientWorldSceneSource,
type ClientSceneWorldAdapter,
type SceneFrameSnapshot,
} from '../../src/client/view/scene-source.ts'
import {
computePanelCameraShift,
createViewportProfile,
getViewportProfile,
screenToSubTile,
subTileToScreen,
VIEWPORT_640x480,
VIEWPORT_800x600,
VIEWPORT_PROFILES,
} from '../../src/client/view/viewport-profile.ts'
import { VALID_VIEWPORT_RESOLUTIONS } from '../../src/client/settings/client-settings.ts'
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
import { AutomapView, isTownLevel } from '../../src/client/automap/automap-view.ts'
import type { LevelView } from '../../src/client/map/level-view.ts'
function createFakeRenderer(drawn: unknown[] = []) {
return {
begin() {},
draw(frame: unknown, x?: number, y?: number, opts?: unknown) {
drawn.push({ frame, x, y, opts })
},
drawSolidQuad() {},
setLighting() {},
setPalette() {},
flush() {},
getGl() {
return null
},
}
}
function createFakeTileGpuCache() {
return {
loadedPageCount: 1,
ensureActPalette() {},
getPageHandle() {
return { index: 1, width: 2048, height: 1932 }
},
}
}
describe('Batch 5 Phase 1: Renderer Overlays, Viewport & 25Hz Clock Parity', () => {
describe('Issue #602: Single 25Hz Tick Animation for Missiles and Overlays', () => {
it('advances missile animation strictly by stepped 25Hz frame without secondary wall-clock multiplication', () => {
const drawn: any[] = []
const fakeRenderer = createFakeRenderer(drawn)
const fakeMissileArt = new Map<string, any>([
[
'fireball',
{
handle: { index: 1, width: 64, height: 64 },
meta: { directions: 1, animSpeed: 16 },
frames: [
[
{ width: 32, height: 32, tag: 'FB_0' },
{ width: 32, height: 32, tag: 'FB_1' },
{ width: 32, height: 32, tag: 'FB_2' },
{ width: 32, height: 32, tag: 'FB_3' },
],
],
},
],
])
const makeSource = (frameVal: number) => ({
getViewportProfile: () => VIEWPORT_800x600,
setViewportProfile: () => VIEWPORT_800x600,
getActId: () => 1 as const,
getLevelId: () => 1,
getTick: () => 10,
getPlayerSubTile: () => ({ x: 100, y: 100 }),
isWorldReady: () => true,
getPlayerLocalSubTile: () => ({ x: 100, y: 100 }),
getPlayerWorldOrtho: () => ({ x: 0, y: 0 }),
getCameraTopLeft: () => ({ x: -400, y: -300 }),
getLevelView: () => null,
isSubTileWalkable: () => true,
buildFrameSnapshot: (): SceneFrameSnapshot => ({
tick: 10,
actId: 1,
levelId: 1,
viewportProfile: VIEWPORT_800x600,
worldReady: true,
playerSubTile: { x: 100, y: 100 },
playerLocalSubTile: { x: 100, y: 100 },
playerWorld: { x: 0, y: 0 },
cameraTopLeft: { x: -400, y: -300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities: {
unitDrawables: [],
missileDrawables: [
{
drawableKind: 'missile',
unitId: 1,
missileClassId: 1,
missileKey: 'fireball',
celFile: 'FB',
direction: 0,
frame: frameVal,
totalFrames: 4,
subX: 0,
subY: 0,
worldX: 0,
worldY: 0,
sortY: 0,
blendMode: 'trans50',
trans: 3,
lightRadius: 0,
lightColor: [0, 0, 0],
},
],
overlayDrawables: [],
sortedDrawables: [
{
drawableKind: 'missile',
unitId: 1,
missileClassId: 1,
missileKey: 'fireball',
celFile: 'FB',
direction: 0,
frame: frameVal,
totalFrames: 4,
subX: 0,
subY: 0,
worldX: 0,
worldY: 0,
sortY: 0,
blendMode: 'trans50',
trans: 3,
lightRadius: 0,
lightColor: [0, 0, 0],
},
],
pointLights: [],
},
}),
buildLightingInputs: () => ({} as any),
pickUnitAtScreen: () => null,
})
// Frame 0 at t=0ms and t=1000ms both draw FB_0 (frame index governed by discrete stepped frame, not nowMs)
drawn.length = 0
renderSceneSourceFrame({
source: makeSource(0),
lightingPreset: 'auto',
renderer: fakeRenderer as any,
missileArtMap: fakeMissileArt,
nowMs: 0,
})
expect(drawn[0]?.frame?.tag).toBe('FB_0')
drawn.length = 0
renderSceneSourceFrame({
source: makeSource(0),
lightingPreset: 'auto',
renderer: fakeRenderer as any,
missileArtMap: fakeMissileArt,
nowMs: 12345, // Arbitrary wall clock must not advance frame 0
})
expect(drawn[0]?.frame?.tag).toBe('FB_0')
// Stepped frame 2 draws FB_2 regardless of nowMs
drawn.length = 0
renderSceneSourceFrame({
source: makeSource(2),
lightingPreset: 'auto',
renderer: fakeRenderer as any,
missileArtMap: fakeMissileArt,
nowMs: 99999,
})
expect(drawn[0]?.frame?.tag).toBe('FB_2')
})
it('advances overlay animation strictly by stepped 25Hz frame without secondary wall-clock multiplication', () => {
const drawn: any[] = []
const fakeRenderer = createFakeRenderer(drawn)
const fakeOverlayArt = new Map<string, any>([
[
'curseamplifydamage',
{
handle: { index: 1, width: 64, height: 64 },
meta: { directions: 1, animRate: 16 },
frames: [
[
{ width: 32, height: 32, tag: 'AMP_0' },
{ width: 32, height: 32, tag: 'AMP_1' },
{ width: 32, height: 32, tag: 'AMP_2' },
{ width: 32, height: 32, tag: 'AMP_3' },
],
],
},
],
])
const makeSource = (frameVal: number) => ({
getViewportProfile: () => VIEWPORT_800x600,
setViewportProfile: () => VIEWPORT_800x600,
getActId: () => 1 as const,
getLevelId: () => 1,
getTick: () => 10,
getPlayerSubTile: () => ({ x: 100, y: 100 }),
isWorldReady: () => true,
getPlayerLocalSubTile: () => ({ x: 100, y: 100 }),
getPlayerWorldOrtho: () => ({ x: 0, y: 0 }),
getCameraTopLeft: () => ({ x: -400, y: -300 }),
getLevelView: () => null,
isSubTileWalkable: () => true,
buildFrameSnapshot: (): SceneFrameSnapshot => ({
tick: 10,
actId: 1,
levelId: 1,
viewportProfile: VIEWPORT_800x600,
worldReady: true,
playerSubTile: { x: 100, y: 100 },
playerLocalSubTile: { x: 100, y: 100 },
playerWorld: { x: 0, y: 0 },
cameraTopLeft: { x: -400, y: -300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities: {
unitDrawables: [],
missileDrawables: [],
overlayDrawables: [
{
drawableKind: 'overlay',
ownerUnitId: 1,
overlayId: 9,
token: 'curseamplifydamage',
frame: frameVal,
totalFrames: 4,
loop: true,
worldX: 0,
worldY: -60,
sortY: 0,
blendMode: 'trans50',
},
],
sortedDrawables: [
{
drawableKind: 'overlay',
ownerUnitId: 1,
overlayId: 9,
token: 'curseamplifydamage',
frame: frameVal,
totalFrames: 4,
loop: true,
worldX: 0,
worldY: -60,
sortY: 0,
blendMode: 'trans50',
},
],
pointLights: [],
},
}),
buildLightingInputs: () => ({} as any),
pickUnitAtScreen: () => null,
})
// Frame 1 at t=0ms and t=5555ms both draw AMP_1
drawn.length = 0
renderSceneSourceFrame({
source: makeSource(1),
lightingPreset: 'auto',
renderer: fakeRenderer as any,
overlayArtMap: fakeOverlayArt,
nowMs: 0,
})
expect(drawn[0]?.frame?.tag).toBe('AMP_1')
drawn.length = 0
renderSceneSourceFrame({
source: makeSource(1),
lightingPreset: 'auto',
renderer: fakeRenderer as any,
overlayArtMap: fakeOverlayArt,
nowMs: 88888,
})
expect(drawn[0]?.frame?.tag).toBe('AMP_1')
})
})
describe('Issue #603: Authentic 1.13c Overlay.txt Metadata & States Integration', () => {
it('populates authentic 1.13c curse overlays with 24 frames, trans50 blendMode, and height4=-60', () => {
const amp = AUTHENTIC_113C_OVERLAYS['curseamplifydamage']
expect(amp).toBeDefined()
expect(amp?.frames).toBe(24)
expect(amp?.animRate).toBe(16)
expect(amp?.trans).toBe(3) // trans50
expect(amp?.height4).toBe(-60)
const dec = AUTHENTIC_113C_OVERLAYS['cursedecrepify']
expect(dec).toBeDefined()
expect(dec?.frames).toBe(24)
expect(dec?.height4).toBe(-60)
const weaken = AUTHENTIC_113C_OVERLAYS['curseweaken']
expect(weaken?.frames).toBe(24)
expect(weaken?.height4).toBe(-60)
const lowerRes = AUTHENTIC_113C_OVERLAYS['curselowerresist']
expect(lowerRes?.frames).toBe(29)
expect(lowerRes?.height4).toBe(-60)
})
it('populates Energy Shield with 8 frames and normal (trans=5) blendMode', () => {
const es = AUTHENTIC_113C_OVERLAYS['energyshield']
expect(es).toBeDefined()
expect(es?.frames).toBe(8)
expect(es?.animRate).toBe(8)
expect(es?.trans).toBe(5) // normal blendMode
})
it('populates Frozen Armor and Chill Armor with 24 frames and trans50 blendMode', () => {
const fa = AUTHENTIC_113C_OVERLAYS['frozenarmor']
expect(fa?.frames).toBe(24)
expect(fa?.trans).toBe(3)
const ca = AUTHENTIC_113C_OVERLAYS['chillarmor']
expect(ca?.frames).toBe(24)
expect(ca?.trans).toBe(3)
})
it('does NOT create fake overlays for state 1 (Freeze) and state 2 (Poison)', () => {
// States 1 and 2 in Diablo II 1.13c are color shifts only; they do not have overlay entries in States.txt
expect(STATE_OVERLAYS[1]).toBeUndefined()
expect(STATE_OVERLAYS[2]).toBeUndefined()
const units: RenderableUnitInput[] = [
{
kind: 'monster',
id: 50,
classId: 1,
x: 100,
y: 100,
states: [1, 2], // Freeze + Poison
},
]
const res = buildSceneDrawables(units, { originX: 0, originY: 0, tick: 5 })
// No overlay drawables generated for state 1 or state 2
expect(res.overlayDrawables).toHaveLength(0)
// Unit has frozen blue/cyan tint applied
expect(res.unitDrawables[0]?.tintColor).toEqual([150, 215, 255])
})
it('generates authentic overlay drawable and point light for curse states', () => {
const units: RenderableUnitInput[] = [
{
kind: 'monster',
id: 55,
classId: 1,
x: 200,
y: 200,
states: [9], // Amplify Damage (State 9)
},
]
const res = buildSceneDrawables(units, { originX: 0, originY: 0, tick: 12 })
expect(res.overlayDrawables).toHaveLength(1)
const ov = res.overlayDrawables[0]!
expect(ov.token).toBe('curseamplifydamage')
expect(ov.totalFrames).toBe(24)
expect(ov.blendMode).toBe('trans50')
expect(ov.worldY).toBe(ov.sortY - 60) // height4 offset of -60px
// Point light configured for curse
expect(res.pointLights.some((l) => l.r === 255 && l.g === 64 && l.b === 64)).toBe(true)
})
})
describe('Issue #592: Strict Layer Z-Ordering & Fail Fast on Missing Room Data', () => {
it('sorts ground items and corpses at layer 0 below living units and missiles', () => {
const units: RenderableUnitInput[] = [
// Living monster at worldY = 100
{ kind: 'monster', id: 1, classId: 1, x: 10, y: 10 },
// Ground item at same worldY = 100
{ kind: 'item', id: 2, classId: 0, x: 10, y: 10, itemCode: 'hax' },
// Corpse at same worldY = 100
{ kind: 'monster', id: 3, classId: 1, x: 10, y: 10, isCorpse: true },
]
const res = buildSceneDrawables(units, { originX: 0, originY: 0, tick: 0 })
const sorted = res.sortedDrawables
// Both the item and corpse must precede the living monster in sorted drawables
const livingIdx = sorted.findIndex((d) => d.drawableKind === 'unit' && d.unitId === 1)
const itemIdx = sorted.findIndex((d) => d.drawableKind === 'unit' && d.unitId === 2)
const corpseIdx = sorted.findIndex((d) => d.drawableKind === 'unit' && d.unitId === 3)
expect(itemIdx).toBeLessThan(livingIdx)
expect(corpseIdx).toBeLessThan(livingIdx)
})
it('throws fast in buildCollisionGridFromLevelView when levelView or rooms are missing', () => {
expect(() => buildCollisionGridFromLevelView(null, 0, 0)).toThrowError(
'buildCollisionGridFromLevelView: missing room data',
)
expect(() => buildCollisionGridFromLevelView(undefined, 0, 0)).toThrowError(
'buildCollisionGridFromLevelView: missing room data',
)
const emptyLevelView = {
areaId: 1,
act: 0,
rooms: [],
cellsX: 10,
cellsY: 10,
gridWidth: 50,
subOriginX: 0,
subOriginY: 0,
isoOriginX: 0,
isoOriginY: 0,
blocked: new Uint8Array(2500),
collisionMasks: new Uint16Array(2500),
floors: [],
walls: [],
shadows: [],
roofs: [],
} as unknown as LevelView
expect(() => buildCollisionGridFromLevelView(emptyLevelView, 0, 0)).toThrowError(
'buildCollisionGridFromLevelView: missing room data',
)
})
})
describe('Issue #596: Camera Panel Horizontal Shift (+/-160px)', () => {
it('computes correct panel camera shift (+160 for left, -160 for right, 0 for both or none)', () => {
expect(computePanelCameraShift(false, false)).toBe(0)
expect(computePanelCameraShift(true, false)).toBe(160)
expect(computePanelCameraShift(false, true)).toBe(-160)
expect(computePanelCameraShift(true, true)).toBe(0)
})
it('shifts cameraTopLeft and mouse picking when side panels are open in SceneSource', () => {
let leftOpen = false
let rightOpen = false
const source = new ClientWorldSceneSource((): ClientSceneWorldAdapter => ({
tick: 1,
actId: 1,
levelId: 1,
playerSubX: 50,
playerSubY: 50,
leftPanelOpen: leftOpen,
rightPanelOpen: rightOpen,
units: [],
levelView: null,
}))
// Default (no panels open): shift is 0
const centerCam = source.getCameraTopLeft()
const centerSnap = source.buildFrameSnapshot()
expect(centerSnap.panelShiftX).toBe(0)
// Left panel open (Character/Quest/Skills): camera shifts right (+160px)
leftOpen = true
rightOpen = false
const leftCam = source.getCameraTopLeft()
const leftSnap = source.buildFrameSnapshot()
expect(leftSnap.panelShiftX).toBe(160)
expect(leftCam.x - centerCam.x).toBe(160)
// Right panel open (Inventory/Stash): camera shifts left (-160px)
leftOpen = false
rightOpen = true
const rightCam = source.getCameraTopLeft()
const rightSnap = source.buildFrameSnapshot()
expect(rightSnap.panelShiftX).toBe(-160)
expect(rightCam.x - centerCam.x).toBe(-160)
})
it('round-trips subTileToScreen and screenToSubTile accurately with panelShiftX', () => {
const profile = VIEWPORT_800x600
const camSubX = 100
const camSubY = 100
const targetSubX = 105
const targetSubY = 98
const panelShiftX = 160 // Left panel open
const screenPos = subTileToScreen(targetSubX, targetSubY, camSubX, camSubY, profile, panelShiftX)
const roundTrip = screenToSubTile(screenPos.x, screenPos.y, camSubX, camSubY, profile, panelShiftX)
expect(Math.round(roundTrip.x)).toBe(targetSubX)
expect(Math.round(roundTrip.y)).toBe(targetSubY)
})
})
describe('Issue #597: Viewport Resolution Profiles & Fail-Fast Lookup', () => {
it('supports canonical 640x480 resolution profile in addition to 800x600 and widescreen profiles', () => {
expect(VALID_VIEWPORT_RESOLUTIONS).toContain('640x480')
expect(VIEWPORT_PROFILES['640x480']).toBeDefined()
expect(VIEWPORT_640x480.width).toBe(640)
expect(VIEWPORT_640x480.height).toBe(480)
expect(VIEWPORT_640x480.barOffsetX).toBe(-80)
expect(VIEWPORT_640x480.panelOffsetY).toBe(-60)
})
it('rejects profiles smaller than 640x480 minimum dimensions in createViewportProfile', () => {
expect(() => createViewportProfile('639x480' as any, 639, 480)).toThrowError(
/smaller than the minimum 640x480/,
)
expect(() => createViewportProfile('640x479' as any, 640, 479)).toThrowError(
/smaller than the minimum 640x480/,
)
})
it('fails fast on unknown string resolutions in getViewportProfile', () => {
expect(() => getViewportProfile('1920x1080')).toThrowError('Unknown viewport profile "1920x1080"')
expect(() => getViewportProfile('4k')).toThrowError('Unknown viewport profile "4k"')
expect(() => getViewportProfile('invalid_res')).toThrowError('Unknown viewport profile "invalid_res"')
// null, undefined, or empty returns default 800x600
expect(getViewportProfile(null)).toBe(VIEWPORT_800x600)
expect(getViewportProfile(undefined)).toBe(VIEWPORT_800x600)
expect(getViewportProfile('')).toBe(VIEWPORT_800x600)
expect(getViewportProfile('800x600')).toBe(VIEWPORT_800x600)
expect(getViewportProfile('640x480')).toBe(VIEWPORT_640x480)
})
})
describe('Issue #584: Automap Tab Toggle & Pre-Revealed Towns', () => {
it('pre-reveals authentic town levels (1, 40, 75, 103, 109)', () => {
expect(isTownLevel(1)).toBe(true) // Rogue Encampment
expect(isTownLevel(40)).toBe(true) // Lut Gholein
expect(isTownLevel(75)).toBe(true) // Kurast Docks
expect(isTownLevel(103)).toBe(true) // Pandemonium Fortress
expect(isTownLevel(109)).toBe(true) // Harrogath
expect(isTownLevel(2)).toBe(false) // Blood Moor
expect(isTownLevel(8)).toBe(false) // Den of Evil
})
it('sets preRevealTown flag on buildMinimapLevel for town areas', () => {
const automap = new AutomapView({ initialMode: 'off' })
const fakeTownLevelView = {
areaId: 1,
act: 0,
rooms: [{ tileX: 0, tileY: 0, tileW: 10, tileH: 10 }],
cellsX: 10,
cellsY: 10,
gridWidth: 50,
subOriginX: 0,
subOriginY: 0,
isoOriginX: 0,
isoOriginY: 0,
blocked: new Uint8Array(2500),
collisionMasks: new Uint16Array(2500),
floors: [],
walls: [],
shadows: [],
roofs: [],
} as unknown as LevelView
automap.syncActiveLevel(1, fakeTownLevelView)
const minimapLevel = automap.buildMinimapLevel()
expect(minimapLevel.preRevealTown).toBe(true)
})
})
describe('Issue #599: 25Hz Discrete Logic Clock Accumulator', () => {
it('steps simulation strictly in discrete 40ms intervals without fractional drift', () => {
let accumulatedMs = 0
const tickLog: number[] = []
const fakeSession = {
tick(dtMs: number) {
tickLog.push(dtMs)
},
}
const simulateFrame = (dtMs: number) => {
accumulatedMs = Math.min(200, accumulatedMs + dtMs)
while (accumulatedMs >= 40) {
fakeSession.tick(40)
accumulatedMs -= 40
}
}
// Frame 1: 16.6ms -> 0 ticks, accum = 16.6
simulateFrame(16.6)
expect(tickLog).toHaveLength(0)
expect(accumulatedMs).toBeCloseTo(16.6)
// Frame 2: 16.6ms -> 0 ticks, accum = 33.2
simulateFrame(16.6)
expect(tickLog).toHaveLength(0)
expect(accumulatedMs).toBeCloseTo(33.2)
// Frame 3: 16.6ms -> 1 tick of exactly 40ms, accum = 9.8
simulateFrame(16.6)
expect(tickLog).toEqual([40])
expect(accumulatedMs).toBeCloseTo(9.8)
// Frame 4: 75ms (lag spike) -> 2 ticks of exactly 40ms, accum = 4.8
simulateFrame(75)
expect(tickLog).toEqual([40, 40, 40])
expect(accumulatedMs).toBeCloseTo(4.8)
})
})
describe('Issue #601: In-Game Chat Bounds and Literal Whitespace', () => {
it('preserves leading and trailing literal whitespace when sending in-game chat', () => {
const dispatchedCommands: any[] = []
const mockSession = {
dispatchCommand(cmd: any) {
dispatchedCommands.push(cmd)
},
}
const chatInputEl = {
value: ' hello world ',
hidden: false,
blur() {},
}
// Simulate toggleInGameChatInput logic from main.ts
if (!chatInputEl.hidden) {
const msg = chatInputEl.value
chatInputEl.value = ''
chatInputEl.hidden = true
chatInputEl.blur()
if (msg.length > 0) {
mockSession.dispatchCommand({ type: 'Chat', text: msg })
}
}
expect(dispatchedCommands).toHaveLength(1)
expect(dispatchedCommands[0]).toEqual({
type: 'Chat',
text: ' hello world ', // Literal spaces preserved!
})
})
it('enforces responsive max-width calc and border-box sizing on chat input element', () => {
const chatStyle =
'position:absolute;left:24px;bottom:64px;width:360px;max-width:calc(100% - 48px);box-sizing:border-box;z-index:25;background:rgba(10,8,6,0.92);border:1px solid #c8a858;color:#f2e6c8;padding:4px 8px;font:13px "D2Formal436",serif;outline:none;'
expect(chatStyle).toContain('max-width:calc(100% - 48px)')
expect(chatStyle).toContain('box-sizing:border-box')
})
})
})