diff --git a/src/client/main.ts b/src/client/main.ts index 2051e61..8c3c94c 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -338,16 +338,16 @@ async function bootPlayApp(): Promise { 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 { }) 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 { inGameView = true flowState.tickAccumulatorMs = 0 flowState.lastFrameTimeMs = null + logicAccumulatorMs = 0 syncVisibility() } else { frontendRenderer.render(nowMs) @@ -943,7 +945,11 @@ async function bootPlayApp(): Promise { 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) diff --git a/src/client/render/overlays-meta.ts b/src/client/render/overlays-meta.ts index 290aa46..3dfe424 100644 --- a/src/client/render/overlays-meta.ts +++ b/src/client/render/overlays-meta.ts @@ -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> = { "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> = 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> = 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'], +}) diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index bb165f5..f6d4686 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -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) diff --git a/src/client/settings/client-settings.ts b/src/client/settings/client-settings.ts index b992db7..27925cc 100644 --- a/src/client/settings/client-settings.ts +++ b/src/client/settings/client-settings.ts @@ -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', diff --git a/src/client/view/scene-source.ts b/src/client/view/scene-source.ts index 7b903bb..648a604 100644 --- a/src/client/view/scene-source.ts +++ b/src/client/view/scene-source.ts @@ -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 readonly levelView?: LevelView | null | undefined readonly tileAtlas?: TileAtlas | null | undefined @@ -113,34 +118,17 @@ export function buildCollisionGridFromLevelView( centerSubY: number, windowSubTiles = 100, ): CollisionGrid { - if (levelView && levelView.rooms.length > 0) { - return { - originX: levelView.isoOriginX, - originY: levelView.isoOriginY, - cellsX: levelView.cellsX, - cellsY: levelView.cellsY, - blocked: levelView.blocked, - gridWidth: levelView.gridWidth, - collisionMasks: levelView.collisionMasks, - } + if (!levelView || levelView.rooms.length === 0) { + throw new Error('buildCollisionGridFromLevelView: missing room data') } - - 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, + originX: levelView.isoOriginX, + originY: levelView.isoOriginY, + cellsX: levelView.cellsX, + cellsY: levelView.cellsY, + blocked: levelView.blocked, + gridWidth: levelView.gridWidth, + collisionMasks: levelView.collisionMasks, } } @@ -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, ) diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 3df8a07..5638cab 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -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> @@ -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> = { - 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,33 +643,61 @@ export function buildSceneDrawables( highestShift = shift } } - const meta = OVERLAY_METADATA[stateId] - if (meta && !unitOverlays.some((o) => o.overlayId === stateId)) { - const ovDrawable: SceneOverlayDrawable = { - drawableKind: 'overlay', - ownerUnitId: unit.id, - overlayId: stateId, - token: meta.token, - frame: tick % meta.totalFrames, - totalFrames: meta.totalFrames, - loop: true, - worldX, - worldY: worldY + meta.yOffset, - sortY: worldY, - blendMode: meta.blendMode, - } - unitOverlays.push(ovDrawable) - overlayDrawables.push(ovDrawable) - if (meta.lightRadius && meta.lightColor) { - pointLights.push({ - subTileX: Math.round(unit.x), - subTileY: Math.round(unit.y), - radius: meta.lightRadius, - intensity: 190, - r: meta.lightColor[0], - g: meta.lightColor[1], - b: meta.lightColor[2], - }) + 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, + frame, + totalFrames, + loop: true, + worldX, + worldY: worldY + yOffset, + sortY: worldY, + blendMode, + } + unitOverlays.push(ovDrawable) + overlayDrawables.push(ovDrawable) + 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: lightRadius, + intensity: 190, + r: lightColor[0], + g: lightColor[1], + b: lightColor[2], + }) + } } } } @@ -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 } diff --git a/src/client/view/viewport-profile.ts b/src/client/view/viewport-profile.ts index 45b8a5e..e6749aa 100644 --- a/src/client/view/viewport-profile.ts +++ b/src/client/view/viewport-profile.ts @@ -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> = { + '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 { + 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([ + [ + '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([ + [ + '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') + }) + }) +})