diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index 868d15c..5fc8e6e 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -6,7 +6,8 @@ import { } from '../../common/units/char-anim-speed.ts' import { dir64ToClipDirection } from '../../common/units/direction.ts' import { PLR_MODES } from '../../common/units/plr-mode.ts' -import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' +import type { LoadedMonsterArt } from '../render/monster-art.ts' +import { CAMERA_Y_OFFSET } from '../view/viewport-profile.ts' import { npcDisplayName } from '../../common/units/npc-table.ts' import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts' import type { AutomapEntityMarker, Minimap, MinimapLevel } from '../ui/minimap.ts' @@ -162,91 +163,6 @@ export function clearMissingArtEntries(): void { } } -const COMMON_HCIDX_TO_MONSTER_ID: Readonly> = { - 0: 'skeleton1', - 1: 'returned1', - 2: 'bonewarrior1', - 3: 'burningdead1', - 4: 'horror1', - 5: 'zombie1', - 6: 'hungrydead1', - 7: 'ghoul1', - 8: 'drownedcarcass1', - 9: 'plaguebearer1', - 10: 'afflicted1', - 15: 'foulcrow1', - 16: 'bloodhawk1', - 19: 'fallen1', - 20: 'carver1', - 21: 'devilkin1', - 22: 'darkone1', - 24: 'brute1', - 25: 'yeti1', - 29: 'sandmaggot1', - 52: 'ghost1', - 58: 'corruptrogue1', - 59: 'darkhunter1', - 63: 'quillrat1', - 64: 'spikefiend1', - 68: 'goatman1', - 69: 'nightclan1', - 73: 'fallenshaman1', - 74: 'carvershaman1', - 146: 'cain1', - 147: 'gheed', - 148: 'akara', - 149: 'chicken', - 150: 'kashya', - 151: 'rat', - 152: 'rogue1', - 154: 'charsi', - 155: 'warriv1', - 175: 'warriv2', - 176: 'atma', - 177: 'drognan', - 178: 'fara', - 198: 'greiz', - 199: 'elzix', - 200: 'geglash', - 201: 'jerhyn', - 202: 'lysander', - 210: 'meshif1', - 244: 'cain2', - 245: 'cain3', - 246: 'cain4', - 252: 'asheara', - 253: 'hratli', - 254: 'alkor', - 255: 'ormus', - 256: 'natalya', - 257: 'halbu', - 367: 'tyrael1', - 405: 'jamella', - 511: 'larzuk', - 512: 'drehya', - 513: 'malah', - 514: 'nihlathak', - 515: 'qual-kehk', - 520: 'cain5', -} - -/** - * Secondary `PlrMode.txt` tokens that share HTH DCC sequences in `d2char.mpq` when no dedicated - * HTH DCC file exists for that mode (`KB`/`BL` -> `gh`, `TH`/`KK` -> `a1`, `S1..S4`/`SQ` -> `sc`). - * Primary modes (`dt`, `nu`, `wl`, `rn`, `gh`, `tn`, `tw`, `a1`, `a2`, `sc`, `dd`) never alias. - */ -const PLAYER_HTH_FALLBACK_CLIP: Readonly> = { - bl: 'gh', - th: 'a1', - kk: 'a1', - s1: 'sc', - s2: 'sc', - s3: 'sc', - s4: 'sc', - sq: 'sc', - kb: 'gh', -} - /** * Compute 0-based animation frame index from elapsed time `nowMs` and 1.13c 8.8 fixed-point * animation speed (`speed256`, where `256 = 1.0` frame per 25Hz / 40ms game tick; @@ -431,7 +347,9 @@ function resolveOnlineMonsterArt( ): LoadedMonsterArt | undefined { if (!monsterArtMap || monsterArtMap.size === 0) return undefined const rawToken = unit.token.trim().toLowerCase() - const direct = monsterArtMap.get(rawToken) + const direct = + monsterArtMap.get(rawToken) ?? + (rawToken ? monsterArtMap.get(`token:${rawToken}`) : undefined) if (direct) return direct const byHcIdxKey = monsterArtMap.get(`hcidx:${unit.classId}`) @@ -449,23 +367,6 @@ function resolveOnlineMonsterArt( } } - const mappedId = COMMON_HCIDX_TO_MONSTER_ID[unit.classId] - if (mappedId) { - const byId = monsterArtMap.get(mappedId) - if (byId) return byId - const spec = MONSTER_ART_MAP[mappedId] - if (spec) { - const byToken = monsterArtMap.get(`token:${spec.token.toLowerCase()}`) - if (byToken) return byToken - } - } - - if (unit.name) { - const lowerName = unit.name.trim().toLowerCase() - const byName = monsterArtMap.get(lowerName) - if (byName) return byName - } - return undefined } @@ -494,11 +395,7 @@ function drawSceneUnitDrawable( ) return } - const fallbackHthKey = PLAYER_HTH_FALLBACK_CLIP[requestedClipKey] - const clip = - charArt.clips?.[requestedClipKey] ?? - (fallbackHthKey !== undefined ? charArt.clips?.[fallbackHthKey] : undefined) ?? - (isMoving ? charArt.clips?.wl : charArt.clips?.nu) + const clip = charArt.clips?.[requestedClipKey] const canonicalClip = getCharAnimClip113c(unit.classId, requestedClipKey) const baseSpeed = (clip?.speed && clip.speed > 0 ? clip.speed : canonicalClip?.speed) ?? 0 @@ -571,9 +468,19 @@ function drawSceneUnitDrawable( frame.anchorY !== undefined ? unit.worldY + frame.anchorY : unit.worldY - frame.height + FEET_HEIGHT / 2 + const tint = unit.tintColor + ? ([ + unit.tintColor[0] / 255, + unit.tintColor[1] / 255, + unit.tintColor[2] / 255, + 1.0, + ] as const) + : undefined renderer.draw(frame, drawX, drawY, { atlas: charArt.handle, elevationY: Math.max(0, frame.height - 16), + ...(tint ? { tint } : {}), + ...(unit.paletteRow !== undefined ? { paletteRow: unit.paletteRow } : {}), }) return } @@ -680,8 +587,7 @@ function drawSceneUnitDrawable( frame.anchorY !== undefined ? unit.worldY + frame.anchorY : unit.worldY - frame.height + FEET_HEIGHT / 2 - const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase()) - const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal') + const baseBlendMode = art.blendMode ?? 'normal' const tint = unit.tintColor ? ([ unit.tintColor[0] / 255, @@ -689,14 +595,13 @@ function drawSceneUnitDrawable( unit.tintColor[2] / 255, 1.0, ] as const) - : unit.isHovered - ? ([1.25, 1.25, 1.25, 1.0] as const) - : undefined + : undefined renderer.draw(frame, drawX, drawY, { atlas: art.handle, blendMode: baseBlendMode, elevationY: Math.max(0, frame.height - 16), ...(tint ? { tint } : {}), + ...(unit.paletteRow !== undefined ? { paletteRow: unit.paletteRow } : {}), }) if (art.glowGroups !== undefined) { const glowClip = art.glowClips?.[requestedClipKey] @@ -802,7 +707,54 @@ function drawSceneUnitDrawable( if (unit.unitKind === 'object') { if (unit.classId === 59 || unit.classId === 60) { if (ctx.overlayArtMap && ctx.overlayArtMap.size > 0) { - drawTownPortal(renderer, unit.worldX, unit.worldY, nowMs, ctx.overlayArtMap) + const portalStartMs = resolveOneShotStartMs( + `portal:${unit.unitId}`, + 'portal', + unit.animStartedAtMs, + nowMs, + ) + const portalElapsedMs = Math.max(0, nowMs - portalStartMs) + if (unit.classId === 60) { + const frameDurationMs = 51.2 + const openDurationMs = 15 * frameDurationMs + const isOpening = portalElapsedMs < openDurationMs + const trArt = ctx.overlayArtMap.get(isOpening ? 'town_portal_op_tr' : 'town_portal_on_tr') + const hdArt = ctx.overlayArtMap.get(isOpening ? 'town_portal_op_hd' : 'town_portal_on_hd') + const rawFrameIdx = isOpening + ? Math.min(14, Math.max(0, Math.floor(portalElapsedMs / frameDurationMs))) + : Math.max(0, Math.floor((portalElapsedMs - openDurationMs) / frameDurationMs)) % 15 + const redTint: readonly [number, number, number, number] = [1.0, 120 / 255, 120 / 255, 1.0] + if (trArt !== undefined) { + const group = trArt.frames[0] + const frame = group?.[rawFrameIdx] ?? group?.[0] + if (frame !== undefined) { + const drawX = frame.anchorX !== undefined ? unit.worldX + frame.anchorX : unit.worldX - frame.width / 2 + const drawY = frame.anchorY !== undefined ? unit.worldY + frame.anchorY : unit.worldY - frame.height / 2 + renderer.draw(frame, drawX, drawY, { + atlas: trArt.handle, + blendMode: 'normal', + tint: redTint, + }) + } + } + if (hdArt !== undefined) { + const group = hdArt.frames[0] + const frame = group?.[rawFrameIdx] ?? group?.[0] + if (frame !== undefined) { + const drawX = frame.anchorX !== undefined ? unit.worldX + frame.anchorX : unit.worldX - frame.width / 2 + const drawY = frame.anchorY !== undefined ? unit.worldY + frame.anchorY : unit.worldY - frame.height / 2 + renderer.draw(frame, drawX, drawY, { + atlas: hdArt.handle, + blendMode: 'additive', + elevationY: Math.max(0, frame.height - 16), + tint: redTint, + }) + renderer.setBlendMode('normal') + } + } + } else { + drawTownPortal(renderer, unit.worldX, unit.worldY, portalElapsedMs, ctx.overlayArtMap) + } return } } @@ -831,28 +783,35 @@ function drawSceneUnitDrawable( const requestedMode = OBJECT_MODE_TO_CLIP[modeIdx] ?? 'nu' const clipKey = objArt.clips[requestedMode] ? requestedMode - : modeIdx === 2 && objArt.clips.op - ? 'op' - : modeIdx === 1 && objArt.clips.on - ? 'on' - : objArt.clips.nu - ? 'nu' - : (Object.keys(objArt.clips)[0] ?? 'nu') + : objArt.clips.nu + ? 'nu' + : (Object.keys(objArt.clips)[0] ?? 'nu') const clip = objArt.clips[clipKey] const dirCount = Math.max(1, clip?.directions ?? objArt.directions) - const direction = ((unit.direction % dirCount) + dirCount) % dirCount + const direction = dir64ToClipDirection(unit.direction, dirCount) const groupIndex = (clip?.group ?? objArt.standOffset) + direction const group = objArt.groups[groupIndex] ?? objArt.groups[objArt.standOffset] ?? objArt.groups[0] const frameCount = Math.max(1, group?.length ?? 1) - const isOpenedFallbackToOp = modeIdx === 2 && clipKey === 'op' && !objArt.clips.on const shouldLoop = - !isOpenedFallbackToOp && (objArt.cycleAnim?.[modeIdx] ?? clip?.loop ?? modeIdx !== 1) let frameIndex = 0 if (frameCount > 1) { if (!shouldLoop) { - frameIndex = frameCount - 1 + const unitKey = `object:${unit.unitId}` + const startedAtMs = resolveOneShotStartMs( + unitKey, + clipKey, + unit.animStartedAtMs, + nowMs, + ) + const stepSpeed256 = objArt.frameDelta?.[modeIdx] || clip?.speed || 256 + const elapsedTicks = Math.max(0, nowMs - startedAtMs) / 40 + const startFrame = (objArt as any).start?.[modeIdx] ?? 0 + 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) } else { const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 0 if (!(speed > 0)) { @@ -876,7 +835,14 @@ function drawSceneUnitDrawable( frame.anchorY !== undefined ? unit.worldY + frame.anchorY : unit.worldY - frame.height + FEET_HEIGHT / 2 - const tint = unit.isHovered ? ([1.25, 1.25, 1.25, 1.0] as const) : undefined + const tint = unit.tintColor + ? ([ + unit.tintColor[0] / 255, + unit.tintColor[1] / 255, + unit.tintColor[2] / 255, + 1.0, + ] as const) + : undefined renderer.draw(frame, drawX, drawY, { atlas: objArt.handle, blendMode: objArt.blendMode ?? 'normal', @@ -1052,7 +1018,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram } const camX = snapshot.playerWorld.x - const camY = snapshot.playerWorld.y - 16 + const camY = snapshot.playerWorld.y + CAMERA_Y_OFFSET if (renderer !== null) { ctx.tileGpuCache?.ensureActPalette(snapshot.actId) @@ -1070,7 +1036,9 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram }, ): void => { const animStep = - ctx.nowMs !== undefined ? Math.floor(ctx.nowMs / 100) : Math.floor(snapshot.tick / 2.5) + ctx.nowMs !== undefined + ? Math.floor((ctx.nowMs * 80) / (40 * 256)) + : Math.floor((snapshot.tick * 80) / 256) const p = q.animatedPlacements && q.animatedPlacements.length > 1 ? (q.animatedPlacements[Math.abs(animStep) % q.animatedPlacements.length] ?? q.placement) @@ -1111,6 +1079,13 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram const w = sortedWalls[wallIdx]! if (w.sortY > maxSortY) break const h = w.placement?.height ?? 40 + if (!w.placement) { + reportMissingArt( + 'tile', + `wall:${w.cellX},${w.cellY}`, + `Missing wall tile placement at cell (${w.cellX}, ${w.cellY})`, + ) + } drawTileQuad(w, { elevationY: Math.max(0, h - 40) }) wallIdx += 1 } @@ -1133,8 +1108,17 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram drawWallsUpToSortY(Infinity) // 4. Roofs - for (const q of snapshot.roofQuads) { - drawTileQuad(q, { tint: [1, 1, 1, 0.85] }) + // Hide roofs when player is inside roof bounding box (playerUnderRoof), otherwise draw fully opaque (no constant 0.85). + const localSub = snapshot.playerLocalSubTile + const playerCellX = Math.floor(localSub.x / 5) + const playerCellY = Math.floor(localSub.y / 5) + const playerUnderRoof = snapshot.roofQuads.some( + (q) => q.cellX === playerCellX && q.cellY === playerCellY, + ) + if (!playerUnderRoof) { + for (const q of snapshot.roofQuads) { + drawTileQuad(q) + } } // Disable world lighting before automap/UI sprites diff --git a/src/client/view/scene-source.ts b/src/client/view/scene-source.ts index 3623c1b..135916f 100644 --- a/src/client/view/scene-source.ts +++ b/src/client/view/scene-source.ts @@ -20,6 +20,7 @@ import { export type UnitState = RenderableUnitInput import { + CAMERA_Y_OFFSET, getViewportProfile, screenToWorld, subTileToWorldOrtho, @@ -319,7 +320,7 @@ export class ClientWorldSceneSource implements SceneSource { ) const cameraTopLeft = { x: playerWorld.x - profile.width / 2, - y: playerWorld.y - profile.height / 2, + y: playerWorld.y + CAMERA_Y_OFFSET - profile.height / 2, } const floorQuads: SceneTileQuad[] = [] @@ -439,7 +440,7 @@ export class ClientWorldSceneSource implements SceneSource { screenX, screenY, playerWorld.x, - playerWorld.y, + playerWorld.y + CAMERA_Y_OFFSET, profile, ) const localUnits = normalizeUnitsToLocalSubTiles(st.units, shiftX, shiftY) @@ -455,7 +456,11 @@ export class ClientWorldSceneSource implements SceneSource { const localPlayerY = st.playerSubY - shiftY for (const u of entities.unitDrawables) { - if (u.isCorpse && u.unitKind === 'monster') continue + if (u.noSel === true) continue + if (u.isSel === false) continue + if (u.isCorpse) { + if (!u.corpseSel) continue + } if ( u.unitKind === 'player' && entities.unitDrawables.length > 1 && @@ -464,6 +469,28 @@ export class ClientWorldSceneSource implements SceneSource { ) { continue } + // If authentic hitbox is present and width > 0, test rectangle bounds + if (u.hitBox && u.hitBox.width > 0 && u.hitBox.height > 0) { + const boxLeft = u.worldX + u.hitBox.left + const boxTop = u.worldY + u.hitBox.top + const boxRight = boxLeft + u.hitBox.width + const boxBottom = boxTop + u.hitBox.height + if ( + clickWorld.x >= boxLeft && + clickWorld.x <= boxRight && + clickWorld.y >= boxTop && + clickWorld.y <= boxBottom + ) { + const centerX = (boxLeft + boxRight) / 2 + const centerY = (boxTop + boxBottom) / 2 + const distSq = (clickWorld.x - centerX) ** 2 + (clickWorld.y - centerY) ** 2 + if (best === null || distSq < bestDistSq) { + bestDistSq = distSq + best = u + } + } + continue + } // Sprite visual center is roughly 24px above foot anchor; warp/tile/item also test foot anchor directly const dx = clickWorld.x - u.worldX const dyBody = clickWorld.y - (u.worldY - 24) diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 0a21dce..2b08331 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -33,6 +33,13 @@ export interface UnitOverlayState { * Structural unit input compatible with both `UnitSnapshot` (from `src/netproto`) * and `ClientUnit` (from `src/client/world/unit-table.ts`). */ +export interface UnitHitBox { + readonly left: number + readonly top: number + readonly width: number + readonly height: number +} + export interface RenderableUnitInput { readonly kind: SceneUnitKind readonly id: number @@ -90,6 +97,11 @@ export interface RenderableUnitInput { readonly portalOwnerName?: string | undefined readonly portalAreaId?: number | undefined readonly isPartyMember?: boolean | undefined + readonly hitBox?: UnitHitBox | undefined + readonly isSel?: boolean | undefined + readonly noSel?: boolean | undefined + readonly corpseSel?: boolean | undefined + readonly paletteRow?: number | undefined } export type RenderableUnit = RenderableUnitInput @@ -137,6 +149,11 @@ export interface SceneUnitDrawable { readonly portalOwnerName?: string | undefined readonly portalAreaId?: number | undefined readonly isPartyMember?: boolean | undefined + readonly hitBox?: UnitHitBox | undefined + readonly isSel?: boolean | undefined + readonly noSel?: boolean | undefined + readonly corpseSel?: boolean | undefined + readonly paletteRow?: number | undefined } export interface SceneMissileDrawable { @@ -228,6 +245,25 @@ export const OVERLAY_METADATA: Readonly> = { 101: { overlayId: 101, token: 'HolyShield', totalFrames: 16, blendMode: 'additive', yOffset: 0, lightRadius: 11, lightColor: [255, 245, 200] }, } +export interface StateColorShift { + readonly stateId: number + readonly priority: number + readonly tintColor: readonly [number, number, number] + readonly paletteRow: number +} + +/** + * 1.13c States.txt color shift priority and visual parameters: + * Freeze (state 1, priority 100, shift 108): Ice blue tint [150, 215, 255], paletteRow: 1 + * Cold (state 11, priority 100, shift 108): Chilled blue tint [150, 215, 255], paletteRow: 1 + * Poison (state 2, priority 95, shift 104): Poison green tint [128, 255, 128], paletteRow: 2 + */ +export const STATE_COLOR_SHIFTS: Readonly> = { + 1: { stateId: 1, priority: 100, tintColor: [150, 215, 255], paletteRow: 1 }, + 11: { stateId: 11, priority: 100, tintColor: [150, 215, 255], paletteRow: 1 }, + 2: { stateId: 2, priority: 95, tintColor: [128, 255, 128], paletteRow: 2 }, +} + const PLAYER_CLASS_TOKENS: readonly string[] = ['AM', 'SO', 'NE', 'PA', 'BA', 'DZ', 'AI'] export function resolveUnitToken(unit: RenderableUnitInput): string { @@ -449,7 +485,7 @@ export function buildSceneDrawables( subY: unit.y, worldX, worldY, - sortY: worldY + 4, + sortY: worldY, blendMode, trans: entry.trans ?? 0, lightRadius: lightUnits > 0 ? Math.max(14, lightUnits * 2) : 0, @@ -510,7 +546,7 @@ export function buildSceneDrawables( loop: !isOneShot, worldX, worldY: worldY + yOffset, - sortY: worldY + 1, + sortY: worldY, blendMode, } unitOverlays.push(ovDrawable) @@ -538,15 +574,16 @@ export function buildSceneDrawables( } } - let tintColor: readonly [number, number, number] | null = null + let tintColor: readonly [number, number, number] | null = (unit as any).tintColor ?? null + let activePaletteRow: number | undefined = unit.paletteRow if (unit.states) { + let highestShift: StateColorShift | null = null for (const stateId of unit.states) { - if (stateId === 1) { - // Freeze / Chill blue tint - tintColor = [90, 155, 255] - } else if (stateId === 2 && !tintColor) { - // Poison green tint - tintColor = [95, 235, 95] + const shift = STATE_COLOR_SHIFTS[stateId] + if (shift) { + if (highestShift === null || shift.priority > highestShift.priority) { + highestShift = shift + } } const meta = OVERLAY_METADATA[stateId] if (meta && !unitOverlays.some((o) => o.overlayId === stateId)) { @@ -560,7 +597,7 @@ export function buildSceneDrawables( loop: true, worldX, worldY: worldY + meta.yOffset, - sortY: worldY + 1, + sortY: worldY, blendMode: meta.blendMode, } unitOverlays.push(ovDrawable) @@ -578,6 +615,17 @@ export function buildSceneDrawables( } } } + if (highestShift) { + tintColor = highestShift.tintColor + activePaletteRow = highestShift.paletteRow + } + } + if (activePaletteRow === undefined) { + if (unit.isUnique) { + activePaletteRow = 3 + } else if (unit.isChampion) { + activePaletteRow = 4 + } } const isCorpse = @@ -586,18 +634,27 @@ export function buildSceneDrawables( const activeCastState = !isCorpse ? unit.castState : undefined const effectiveTargetX = unit.targetX ?? activeCastState?.targetX const effectiveTargetY = unit.targetY ?? activeCastState?.targetY + const defaultCastMode = unit.kind === 'player' ? 10 : 7 const effectiveMode = activeCastState - ? activeCastState.mode || unit.mode || 10 + ? (activeCastState.mode ?? unit.mode ?? defaultCastMode) : (unit.mode ?? (unit.kind === 'player' || unit.kind === 'monster' ? (isCorpse ? 12 : 1) : 0)) - const animMode = - unit.animMode ?? - (!isCorpse && (effectiveMode === 7 || effectiveMode === 4) - ? 'A1' - : !isCorpse && effectiveMode === 8 - ? 'A2' - : !isCorpse && effectiveMode === 10 - ? 'SC' - : undefined) + let derivedAnimMode: string | undefined = undefined + if (!isCorpse) { + if (unit.kind === 'player') { + if (effectiveMode === 7) derivedAnimMode = 'A1' + else if (effectiveMode === 8) derivedAnimMode = 'A2' + else if (effectiveMode === 10) derivedAnimMode = 'SC' + } else if (unit.kind === 'monster') { + if (effectiveMode === 4) derivedAnimMode = 'A1' + else if (effectiveMode === 5) derivedAnimMode = 'A2' + else if (effectiveMode === 7) derivedAnimMode = 'SC' + else if (effectiveMode === 8) derivedAnimMode = 'S1' + else if (effectiveMode === 9) derivedAnimMode = 'S2' + else if (effectiveMode === 10) derivedAnimMode = 'S3' + else if (effectiveMode === 11) derivedAnimMode = 'S4' + } + } + const animMode = unit.animMode ?? derivedAnimMode const animStartedAtMs = unit.animStartedAtMs ?? activeCastState?.startedAtMs ?? @@ -642,7 +699,7 @@ export function buildSceneDrawables( loop: false, worldX, worldY: worldY - (ovTxt.yOffset ?? 0), - sortY: worldY + 1, + sortY: worldY, blendMode: ovTxt.trans === 0 ? 'normal' : 'additive', } unitOverlays.push(ovDrawable) @@ -659,16 +716,6 @@ export function buildSceneDrawables( }) } } - } else if (effectiveMode === 10) { - pointLights.push({ - subTileX: Math.round(unit.x), - subTileY: Math.round(unit.y), - radius: 10, - intensity: 210, - r: 255, - g: 195, - b: 110, - }) } } @@ -678,11 +725,7 @@ export function buildSceneDrawables( const warpId = isWarp ? (unit.warpId ?? unit.classId) : unit.warpId const radiusX = isWarp ? (unit.radiusX ?? 28) : unit.radiusX const radiusY = isWarp ? (unit.radiusY ?? 20) : unit.radiusY - const resolvedName = isWarp - ? unit.name && unit.name.trim() !== '' - ? unit.name - : `Entrance (${warpId})` - : unit.name + const resolvedName = unit.name && unit.name.trim() !== '' ? unit.name : undefined const unitDrawable: SceneUnitDrawable = { drawableKind: 'unit', @@ -701,7 +744,7 @@ export function buildSceneDrawables( subY: unit.y, worldX, worldY, - sortY: isCorpse ? worldY - 16 : worldY, + sortY: worldY, blendMode: 'normal', lifePct: unit.lifePct ?? 100, name: resolvedName, @@ -732,15 +775,34 @@ export function buildSceneDrawables( ...(unit.portalOwnerName !== undefined ? { portalOwnerName: unit.portalOwnerName } : {}), ...(unit.portalAreaId !== undefined ? { portalAreaId: unit.portalAreaId } : {}), ...(unit.isPartyMember !== undefined ? { isPartyMember: unit.isPartyMember } : {}), + ...(unit.hitBox !== undefined ? { hitBox: unit.hitBox } : {}), + ...(unit.isSel !== undefined ? { isSel: unit.isSel } : {}), + ...(unit.noSel !== undefined ? { noSel: unit.noSel } : {}), + ...(unit.corpseSel !== undefined ? { corpseSel: unit.corpseSel } : {}), + ...(activePaletteRow !== undefined ? { paletteRow: activePaletteRow } : {}), } unitDrawables.push(unitDrawable) } + const getEntityLayer = (d: SceneEntityDrawable): number => { + if (d.drawableKind === 'unit') { + return d.isCorpse ? 0 : 1 + } + if (d.drawableKind === 'missile') return 2 + if (d.drawableKind === 'overlay') return 3 + return 1 + } + const sortedDrawables: SceneEntityDrawable[] = [ ...unitDrawables, ...missileDrawables, ...overlayDrawables, - ].sort((a, b) => a.sortY - b.sortY || a.worldX - b.worldX) + ].sort( + (a, b) => + a.sortY - b.sortY || + getEntityLayer(a) - getEntityLayer(b) || + a.worldX - b.worldX, + ) return { unitDrawables, diff --git a/src/client/view/viewport-profile.ts b/src/client/view/viewport-profile.ts index 96a3f89..6065091 100644 --- a/src/client/view/viewport-profile.ts +++ b/src/client/view/viewport-profile.ts @@ -11,6 +11,13 @@ export type { ViewportResolution } export const NATIVE_HUD_WIDTH = 800 export const NATIVE_HUD_HEIGHT = 600 +/** + * Canonical 1.13c viewport camera vertical offset in world orthogonal pixels (-16px). + * In 1.13c isometric projection, centering on the player shifts the camera by -16px vertically + * (one 32x16 subtile height) so the player's ground footprint is centered on screen. + */ +export const CAMERA_Y_OFFSET = -16 + export interface ViewportProfile { readonly id: ViewportResolution readonly width: number diff --git a/tests/client/renderer-animation-parity-m4-p1.test.ts b/tests/client/renderer-animation-parity-m4-p1.test.ts new file mode 100644 index 0000000..78e4d22 --- /dev/null +++ b/tests/client/renderer-animation-parity-m4-p1.test.ts @@ -0,0 +1,775 @@ +/** + * Diablo II v1.13c Parity Verification Suite — Batch 4 Phase 1 (Core Animation & World Renderer) + * Tests for issues #604, #605, #607, #608, #610, #611, #613, #615, #616, #617, #618, #620. + */ +import { describe, expect, it } from 'vitest' +import { + buildSceneDrawables, + STATE_COLOR_SHIFTS, + type RenderableUnitInput, + type SceneUnitDrawable, +} from '../../src/client/view/unit-drawables.ts' +import { + CAMERA_Y_OFFSET, + VIEWPORT_800x600, + subTileToWorldOrtho, + worldToScreen, +} from '../../src/client/view/viewport-profile.ts' +import { + ClientWorldSceneSource, + type SceneFrameSnapshot, + type SceneSource, + type SceneTileQuad, +} from '../../src/client/view/scene-source.ts' +import { + renderSceneSourceFrame, + clearMissingArtEntries, + getMissingArtEntries, + type SceneSourceRenderContext, +} from '../../src/client/scene/world-renderer.ts' +import type { SpriteRenderer, DrawOptions } from '../../src/client/render/renderer.ts' +import type { AtlasFrame } from '../../src/client/render/atlas.ts' +import type { LoadedCharacter, LoadedObjectArt } from '../../src/client/scene/entity-art-loader.ts' +import type { LoadedMonsterArt } from '../../src/client/render/monster-art.ts' +import type { LoadedOverlayArt } from '../../src/client/scene/missile-overlay-renderer.ts' + +const clearMissingArtList = clearMissingArtEntries +const getMissingArtList = getMissingArtEntries + +function renderWorldFrame( + renderer: MockSpriteRenderer, + snapshot: SceneFrameSnapshot, + ctx: Partial, + nowMs = 0, +): SceneFrameSnapshot { + const mockSource: SceneSource = { + isWorldReady: () => snapshot.worldReady, + getPlayerSubTile: () => snapshot.playerSubTile, + getPlayerLocalSubTile: () => snapshot.playerLocalSubTile, + getPlayerWorldOrtho: () => snapshot.playerWorld, + getCameraTopLeft: () => snapshot.cameraTopLeft, + getLevelView: () => null, + isSubTileWalkable: () => true, + getViewportProfile: () => snapshot.viewportProfile, + setViewportProfile: () => snapshot.viewportProfile, + getActId: () => snapshot.actId, + getLevelId: () => snapshot.levelId, + getTick: () => snapshot.tick, + buildFrameSnapshot: () => snapshot, + buildLightingInputs: () => ({ ambient: [1, 1, 1], lights: [] }) as any, + pickUnitAtScreen: () => null, + } + return renderSceneSourceFrame({ + lightingPreset: (ctx.lightingPreset as any) ?? 'noon', + nowMs, + ...ctx, + source: mockSource, + renderer: renderer as unknown as SpriteRenderer, + }) +} + +interface CapturedDrawCall { + readonly frame: AtlasFrame + readonly x: number + readonly y: number + readonly options: DrawOptions +} + +class MockSpriteRenderer { + drawCalls: CapturedDrawCall[] = [] + currentBlendMode: string = 'normal' + + begin(): void { + this.drawCalls = [] + } + flush(): void {} + setLighting(): void {} + setBlendMode(mode: string): void { + this.currentBlendMode = mode + } + addAtlas(): any { + return 1 as any + } + draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void { + this.drawCalls.push({ frame, x, y, options: { ...options } }) + } +} + +describe('Batch 4 Phase 1: Core Animation & World Renderer Parity', () => { + it('Issue #604: State color shifts prioritize freeze/cold over poison and set paletteRow', () => { + // 1. Freeze (state 1, priority 100) and Poison (state 2, priority 95) simultaneously + const unitInput: RenderableUnitInput = { + kind: 'monster', + id: 1, + classId: 10, + x: 100, + y: 100, + states: [2, 1], // poison first, then freeze + } + const result = buildSceneDrawables([unitInput]) + const drawable = result.unitDrawables[0]! + expect(drawable.paletteRow).toBe(1) + expect(drawable.tintColor).toEqual(STATE_COLOR_SHIFTS[1]!.tintColor) + + // 2. Poison alone (state 2) + const poisonOnly: RenderableUnitInput = { + kind: 'player', + id: 2, + classId: 0, + x: 100, + y: 100, + states: [2], + } + const poisonResult = buildSceneDrawables([poisonOnly]) + expect(poisonResult.unitDrawables[0]!.paletteRow).toBe(2) + expect(poisonResult.unitDrawables[0]!.tintColor).toEqual(STATE_COLOR_SHIFTS[2]!.tintColor) + + // 3. Unique and Champion without state + const uniqueUnit: RenderableUnitInput = { + kind: 'monster', + id: 3, + classId: 20, + x: 100, + y: 100, + isUnique: true, + } + const championUnit: RenderableUnitInput = { + kind: 'monster', + id: 4, + classId: 20, + x: 100, + y: 100, + isChampion: true, + } + expect(buildSceneDrawables([uniqueUnit]).unitDrawables[0]!.paletteRow).toBe(3) + expect(buildSceneDrawables([championUnit]).unitDrawables[0]!.paletteRow).toBe(4) + }) + + it('Issue #604: Monsters AND players both receive paletteRow and tint on renderer.draw', () => { + const renderer = new MockSpriteRenderer() + const mockCharArt: LoadedCharacter = { + handle: 1 as any, + groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]], + directions: 1, + walk: 0, + stand: 0, + members: 1, + clips: { + nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] }, + }, + } + const mockMonsterArt: LoadedMonsterArt = { + token: 'ZM', + handle: 2 as any, + groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]], + directions: 1, + standOffset: 0, + standFrames: 1, + walkOffset: 0, + walkFrames: 1, + layerFailures: [], + clips: { + nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] }, + }, + } + + const playerInput: RenderableUnitInput = { + kind: 'player', + id: 10, + classId: 1, // SO + x: 50, + y: 50, + states: [1], // Freeze -> paletteRow: 1 + } + const monsterInput: RenderableUnitInput = { + kind: 'monster', + id: 11, + classId: 5, + token: 'ZM', + x: 60, + y: 60, + states: [2], // Poison -> paletteRow: 2 + } + + const built = buildSceneDrawables([playerInput, monsterInput]) + const snapshot: SceneFrameSnapshot = { + tick: 0, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 50, y: 50 }, + playerLocalSubTile: { x: 50, y: 50 }, + playerWorld: { x: 0, y: 0 }, + cameraTopLeft: { x: -400, y: -300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities: built, + } + + const ctx: Partial = { + lightingPreset: 'noon', + characterArtByToken: new Map([['so', mockCharArt]]), + monsterArtMap: new Map([['token:zm', mockMonsterArt]]), + } + + renderWorldFrame(renderer, snapshot, ctx, 1000) + + expect(renderer.drawCalls.length).toBeGreaterThanOrEqual(2) + const playerDraw = renderer.drawCalls.find(c => c.options.atlas === (1 as any))! + const monsterDraw = renderer.drawCalls.find(c => c.options.atlas === (2 as any))! + + expect(playerDraw.options.paletteRow).toBe(1) + expect(playerDraw.options.tint).toBeDefined() + expect(monsterDraw.options.paletteRow).toBe(2) + expect(monsterDraw.options.tint).toBeDefined() + }) + + it('Issue #605: Eliminates artificial pointLight on activeCastState with mode 10', () => { + const unitWithCast: RenderableUnitInput = { + kind: 'monster', + id: 20, + classId: 5, + x: 40, + y: 40, + castState: { + skillId: 0, + mode: 10, + targetX: 50, + targetY: 50, + startedAtMs: 0, + untilMs: 1000, + }, + } + const result = buildSceneDrawables([unitWithCast]) + // MonMode 10 is S3; must not create artificial yellow pointLight + expect(result.pointLights).toHaveLength(0) + }) + + it('Issue #607: Objects use dir64ToClipDirection and smooth non-looping mode playback without on<->op swap', () => { + const renderer = new MockSpriteRenderer() + const mockObjectArt: LoadedObjectArt = { + token: 'c5', + slug: 'c5', + blendMode: 'normal', + handle: 3 as any, + groups: [ + // 5-frame opening sequence + [ + { x: 0, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -32 }, + { x: 32, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -32 }, + { x: 64, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -32 }, + { x: 96, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -32 }, + { x: 128, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -32 }, + ], + ], + directions: 1, + standOffset: 0, + standFrames: 5, + clips: { + op: { group: 0, directions: 1, frames: 5, speed: 256, loop: false, events: [] }, + }, + frameDelta: [256, 256, 256, 256, 256, 256, 256, 256], + cycleAnim: [true, false, true, true, true, true, true, true], + } + + const chestObject: RenderableUnitInput = { + kind: 'object', + id: 30, + classId: 100, + token: 'C5', + direction: 16, + mode: 1, // OBJMODE_OPERATING (OP) + animStartedAtMs: 1000, + x: 30, + y: 30, + } + + const built = buildSceneDrawables([chestObject]) + const snapshot: SceneFrameSnapshot = { + tick: 25, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 30, y: 30 }, + playerLocalSubTile: { x: 30, y: 30 }, + playerWorld: { x: 0, y: 0 }, + cameraTopLeft: { x: -400, y: -300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities: built, + } + + const ctx: Partial = { + lightingPreset: 'noon', + objectArtMap: new Map([['c5', mockObjectArt]]), + } + + // At nowMs = 1040 (elapsed 1 tick = 40ms, at 256/256 speed = 1 frame progress) + renderWorldFrame(renderer, snapshot, ctx, 1040) + expect(renderer.drawCalls).toHaveLength(1) + expect(renderer.drawCalls[0]!.frame.x).toBe(32) // frame 1, NOT frame 4 (frameCount - 1) + }) + + it('Issue #608: Town portals use oneShot timing and classId 60 renders with red hue', () => { + const renderer = new MockSpriteRenderer() + const mockTrArt: LoadedOverlayArt = { + meta: { name: 'town_portal_on_tr', width: 64, height: 64, groups: [[ [0, 0, 64, 64, -32, -32] ]] } as any, + handle: 4 as any, + frames: [[{ x: 0, y: 0, width: 64, height: 64, anchorX: -32, anchorY: -32 }]], + } + const mockHdArt: LoadedOverlayArt = { + meta: { name: 'town_portal_on_hd', width: 64, height: 64, groups: [[ [0, 0, 64, 64, -32, -32] ]] } as any, + handle: 5 as any, + frames: [[{ x: 0, y: 0, width: 64, height: 64, anchorX: -32, anchorY: -32 }]], + } + const overlayMap = new Map([ + ['town_portal_on_tr', mockTrArt], + ['town_portal_on_hd', mockHdArt], + ]) + + const redPortal: RenderableUnitInput = { + kind: 'object', + id: 40, + classId: 60, // Permanent Portal PP + x: 20, + y: 20, + animStartedAtMs: 0, + } + const built = buildSceneDrawables([redPortal]) + const snapshot: SceneFrameSnapshot = { + tick: 50, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 20, y: 20 }, + playerLocalSubTile: { x: 20, y: 20 }, + playerWorld: { x: 0, y: 0 }, + cameraTopLeft: { x: -400, y: -300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities: built, + } + + const ctx: Partial = { + lightingPreset: 'noon', + overlayArtMap: overlayMap, + } + + renderWorldFrame(renderer, snapshot, ctx, 5000) + expect(renderer.drawCalls.length).toBe(2) + // Red portal must have red tint [1.0, 120/255, 120/255, 1.0] + expect(renderer.drawCalls[0]!.options.tint).toBeDefined() + expect(renderer.drawCalls[0]!.options.tint![0]).toBeCloseTo(1.0, 3) + expect(renderer.drawCalls[0]!.options.tint![1]).toBeCloseTo(120 / 255, 3) + }) + + it('Issue #610: Canonical CAMERA_Y_OFFSET is -16 and shifts cameraTopLeft.y', () => { + expect(CAMERA_Y_OFFSET).toBe(-16) + + const sceneSource = new ClientWorldSceneSource( + () => ({ + tick: 10, + playerSubX: 100, + playerSubY: 100, + units: [], + levelView: null, + }), + VIEWPORT_800x600, + ) + + const snap = sceneSource.buildFrameSnapshot() + expect(snap.cameraTopLeft.y).toBeCloseTo(snap.playerWorld.y + CAMERA_Y_OFFSET - VIEWPORT_800x600.height / 2, 6) + }) + + it('Issue #611: Monster blend mode defaults to normal without arbitrary additive token table', () => { + const renderer = new MockSpriteRenderer() + const mockArt: LoadedMonsterArt = { + token: 'GH', + handle: 6 as any, + groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]], + directions: 1, + standOffset: 0, + standFrames: 1, + walkOffset: 0, + walkFrames: 1, + layerFailures: [], + clips: { + nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] }, + }, + } + const ghostMonster: RenderableUnitInput = { + kind: 'monster', + id: 50, + classId: 52, // Ghost (GH) + token: 'GH', + x: 10, + y: 10, + } + const built = buildSceneDrawables([ghostMonster]) + const snapshot: SceneFrameSnapshot = { + tick: 0, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 10, y: 10 }, + playerLocalSubTile: { x: 10, y: 10 }, + playerWorld: { x: 0, y: 0 }, + cameraTopLeft: { x: -400, y: -300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities: built, + } + const ctx: Partial = { + lightingPreset: 'noon', + monsterArtMap: new Map([['token:gh', mockArt]]), + } + renderWorldFrame(renderer, snapshot, ctx, 0) + expect(renderer.drawCalls).toHaveLength(1) + expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal') + }) + + it('Issue #613: Player clips without registered art report missing art without synthetic aliasing', () => { + clearMissingArtList() + const renderer = new MockSpriteRenderer() + const mockCharArt: LoadedCharacter = { + handle: 7 as any, + groups: [[{ x: 0, y: 0, width: 32, height: 48, anchorX: -16, anchorY: -48 }]], + directions: 1, + walk: 0, + stand: 0, + members: 1, + clips: { + nu: { group: 0, directions: 1, frames: 1, speed: 256, loop: true, events: [] }, + }, + } + const throwingPlayer: RenderableUnitInput = { + kind: 'player', + id: 60, + classId: 4, // BA + mode: 11, // TH (Throw) + x: 10, + y: 10, + } + const built = buildSceneDrawables([throwingPlayer]) + const snapshot: SceneFrameSnapshot = { + tick: 0, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 10, y: 10 }, + playerLocalSubTile: { x: 10, y: 10 }, + playerWorld: { x: 0, y: 0 }, + cameraTopLeft: { x: -400, y: -300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities: built, + } + const ctx: Partial = { + lightingPreset: 'noon', + characterArtByToken: new Map([['ba', mockCharArt]]), + } + renderWorldFrame(renderer, snapshot, ctx, 0) + // Because 'th' is missing, it must NOT fallback to 'a1' or 'nu' + expect(renderer.drawCalls).toHaveLength(0) + const missing = getMissingArtList() + expect(missing.some(m => m.category === 'player-clip' && m.id === 'BA:th')).toBe(true) + }) + + it('Issue #615: Roofs are hidden when player is inside roof bounding box and opaque otherwise', () => { + const renderer = new MockSpriteRenderer() + const roofQuad: SceneTileQuad = { + cellX: 10, + cellY: 10, + worldX: 100, + worldY: 100, + sortY: 800, + act: 1, + library: 1, + tile: 1, + dt1Path: 'roof.dt1', + placement: { + x: 0, + y: 0, + width: 160, + height: 80, + page: 0, + act: 1, + nonEmptyPixels: 100, + hash: 'h1', + }, + } + + const tileGpuCache = { + ensureActPalette: () => {}, + getPageHandle: () => 10 as any, + } + + // Case 1: Player inside roof cell (cellX: 10, cellY: 10 -> localSubTile 50, 50) + const insideSnapshot: SceneFrameSnapshot = { + tick: 0, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 50, y: 50 }, + playerLocalSubTile: { x: 50, y: 50 }, + playerWorld: { x: 100, y: 100 }, + cameraTopLeft: { x: -300, y: -200 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [roofQuad], + entities: { unitDrawables: [], missileDrawables: [], overlayDrawables: [], sortedDrawables: [], pointLights: [] }, + } + const ctx: Partial = { + lightingPreset: 'noon', + tileGpuCache: tileGpuCache as any, + } + + renderWorldFrame(renderer, insideSnapshot, ctx, 0) + // Roof hidden when player under roof! + expect(renderer.drawCalls).toHaveLength(0) + + // Case 2: Player outside roof cell (cellX: 20, cellY: 20 -> localSubTile 100, 100) + const outsideSnapshot: SceneFrameSnapshot = { + ...insideSnapshot, + playerLocalSubTile: { x: 100, y: 100 }, + } + renderWorldFrame(renderer, outsideSnapshot, ctx, 0) + expect(renderer.drawCalls).toHaveLength(1) + // Opaque draw call: no tint! + expect(renderer.drawCalls[0]!.options.tint).toBeUndefined() + }) + + it('Issue #616: AnimMode disentanglement distinguishes player vs monster modes', () => { + // Player mode 10 is SC (Cast) + const playerUnit: RenderableUnitInput = { + kind: 'player', + id: 70, + classId: 0, + mode: 10, + x: 0, + y: 0, + } + expect(buildSceneDrawables([playerUnit]).unitDrawables[0]!.animMode).toBe('SC') + + // Monster mode 10 is S3 (Skill3) + const monsterUnit: RenderableUnitInput = { + kind: 'monster', + id: 71, + classId: 1, + mode: 10, + x: 0, + y: 0, + } + expect(buildSceneDrawables([monsterUnit]).unitDrawables[0]!.animMode).toBe('S3') + + // Monster mode 7 is SC (Cast) + const monsterCast: RenderableUnitInput = { + kind: 'monster', + id: 72, + classId: 1, + mode: 7, + x: 0, + y: 0, + } + expect(buildSceneDrawables([monsterCast]).unitDrawables[0]!.animMode).toBe('SC') + }) + + it('Issue #617: Unit selection hitbox tests authentic rectangular bounds and respects noSel/isSel/corpseSel', () => { + const unselectableUnit: RenderableUnitInput = { + kind: 'monster', + id: 80, + classId: 1, + x: 10, + y: 10, + noSel: true, + } + const corpseNonSel: RenderableUnitInput = { + kind: 'monster', + id: 81, + classId: 1, + x: 20, + y: 20, + isCorpse: true, + corpseSel: false, + } + const corpseSel: RenderableUnitInput = { + kind: 'monster', + id: 82, + classId: 1, + x: 30, + y: 30, + isCorpse: true, + corpseSel: true, + } + const hitboxUnit: RenderableUnitInput = { + kind: 'monster', + id: 83, + classId: 1, + x: 40, + y: 40, + hitBox: { left: -15, top: -40, width: 30, height: 45 }, + } + + const sceneSource = new ClientWorldSceneSource( + () => ({ + tick: 0, + playerSubX: 0, + playerSubY: 0, + units: [unselectableUnit, corpseNonSel, corpseSel, hitboxUnit], + levelView: null, + }), + VIEWPORT_800x600, + ) + + // Unselectable unit cannot be picked + const worldUnsel = subTileToWorldOrtho(10, 10, 0, 0) + const screenUnsel = worldToScreen(worldUnsel.x, worldUnsel.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600) + expect(sceneSource.pickUnitAtScreen(screenUnsel.x, screenUnsel.y, 50)).toBeNull() + + // Non-selectable corpse cannot be picked + const worldCorpseNonSel = subTileToWorldOrtho(20, 20, 0, 0) + const screenCorpseNonSel = worldToScreen(worldCorpseNonSel.x, worldCorpseNonSel.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600) + expect(sceneSource.pickUnitAtScreen(screenCorpseNonSel.x, screenCorpseNonSel.y, 50)).toBeNull() + + // Selectable corpse CAN be picked + const worldCorpseSel = subTileToWorldOrtho(30, 30, 0, 0) + const screenCorpseSel = worldToScreen(worldCorpseSel.x, worldCorpseSel.y, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600) + const pickedCorpse = sceneSource.pickUnitAtScreen(screenCorpseSel.x, screenCorpseSel.y, 50) + expect(pickedCorpse?.unitId).toBe(82) + + // Hitbox unit inside bounds + const worldHitbox = subTileToWorldOrtho(40, 40, 0, 0) + const screenHitbox = worldToScreen(worldHitbox.x, worldHitbox.y - 20, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600) + const pickedHitbox = sceneSource.pickUnitAtScreen(screenHitbox.x, screenHitbox.y, 50) + expect(pickedHitbox?.unitId).toBe(83) + }) + + it('Issue #618: Monster art resolves strictly without COMMON_HCIDX_TO_MONSTER_ID heuristic', () => { + const unknownClassMonster: RenderableUnitInput = { + kind: 'monster', + id: 90, + classId: 0, // In old code, 0 mapped heuristically to 'skeleton1' + token: 'UNKNOWN_TOK', + x: 0, + y: 0, + } + const built = buildSceneDrawables([unknownClassMonster]) + const snapshot: SceneFrameSnapshot = { + tick: 0, + actId: 1, + levelId: 1, + viewportProfile: VIEWPORT_800x600, + worldReady: true, + playerSubTile: { x: 0, y: 0 }, + playerLocalSubTile: { x: 0, y: 0 }, + playerWorld: { x: 0, y: 0 }, + cameraTopLeft: { x: -400, y: -300 }, + floorQuads: [], + shadowQuads: [], + lowerWallQuads: [], + upperWallQuads: [], + roofQuads: [], + entities: built, + } + clearMissingArtList() + const renderer = new MockSpriteRenderer() + const ctx: Partial = { + lightingPreset: 'noon', + monsterArtMap: new Map(), // empty map + } + renderWorldFrame(renderer, snapshot, ctx, 0) + expect(renderer.drawCalls).toHaveLength(0) + const missing = getMissingArtList() + expect(missing.some(m => m.category === 'monster' && m.id === '0')).toBe(true) + }) + + it('Issue #620: SortY removes arbitrary biases and layer sorting keeps corpse under unit under missile under overlay', () => { + const corpse: RenderableUnitInput = { + kind: 'monster', + id: 100, + classId: 1, + x: 10, + y: 10, + isCorpse: true, + } + const alive: RenderableUnitInput = { + kind: 'monster', + id: 101, + classId: 1, + x: 10, + y: 10, + } + const missile: RenderableUnitInput = { + kind: 'missile', + id: 102, + classId: 1, // arrow + x: 10, + y: 10, + } + const overlay: RenderableUnitInput = { + kind: 'player', + id: 103, + classId: 0, + x: 10, + y: 10, + overlays: [{ overlayId: 1, token: 'Freeze' }], + } + const unnamedWarp: RenderableUnitInput = { + kind: 'warp', + id: 104, + classId: 1, + warpId: 5, + x: 10, + y: 10, + } + + const built = buildSceneDrawables([corpse, alive, missile, overlay, unnamedWarp]) + // Unnamed warp must have undefined name, not synthetic "Entrance (5)" + const warpDrawable = built.unitDrawables.find(u => u.unitId === 104)! + expect(warpDrawable.name).toBeUndefined() + + // All entities at the same sub-tile must share the exact same sortY (no +4, +1, -16) + const corpseDrawable = built.unitDrawables.find(u => u.unitId === 100)! + const aliveDrawable = built.unitDrawables.find(u => u.unitId === 101)! + const missileDrawable = built.missileDrawables[0]! + const overlayDrawable = built.overlayDrawables[0]! + + expect(corpseDrawable.sortY).toBe(aliveDrawable.sortY) + expect(missileDrawable.sortY).toBe(aliveDrawable.sortY) + expect(overlayDrawable.sortY).toBe(aliveDrawable.sortY) + + // Sorted order: corpse (0) -> alive unit (1) -> missile (2) -> overlay (3) + const sorted = built.sortedDrawables + const corpseIdx = sorted.findIndex(d => (d as SceneUnitDrawable).unitId === 100) + const aliveIdx = sorted.findIndex(d => (d as SceneUnitDrawable).unitId === 101) + const missileIdx = sorted.findIndex(d => d.drawableKind === 'missile') + const overlayIdx = sorted.findIndex(d => d.drawableKind === 'overlay') + + expect(corpseIdx).toBeLessThan(aliveIdx) + expect(aliveIdx).toBeLessThan(missileIdx) + expect(missileIdx).toBeLessThan(overlayIdx) + }) +}) diff --git a/tests/client/spawn-position-539.test.ts b/tests/client/spawn-position-539.test.ts index b4eadac..e3caff6 100644 --- a/tests/client/spawn-position-539.test.ts +++ b/tests/client/spawn-position-539.test.ts @@ -30,6 +30,7 @@ import { import { OnlineSession } from '../../src/client/session/online-session.ts' import { createSettingsStore } from '../../src/client/settings/settings-store.ts' import { + CAMERA_Y_OFFSET, VIEWPORT_800x600, screenToSubTile, subTileToScreen, @@ -186,7 +187,7 @@ describe('Issue #539: player spawn is rendered at the server position inside the expect(selfDrawable!.worldX).toBeCloseTo(snap.playerWorld.x, 6) expect(selfDrawable!.worldY).toBeCloseTo(snap.playerWorld.y, 6) expect(snap.cameraTopLeft.x).toBeCloseTo(snap.playerWorld.x - snap.viewportProfile.width / 2, 6) - expect(snap.cameraTopLeft.y).toBeCloseTo(snap.playerWorld.y - snap.viewportProfile.height / 2, 6) + expect(snap.cameraTopLeft.y).toBeCloseTo(snap.playerWorld.y + CAMERA_Y_OFFSET - snap.viewportProfile.height / 2, 6) // 3. The server sub-tile is walkable camp floor in the client DRLG collision. const sx = Math.floor(truth.self.x)