Fix unit colormaps, object animation modes, portal palettes, camera centering, and DCC HTH parity (Closes #604, #605, #607, #608, #610, #611, #613, #615, #616, #617, #618, #620)

This commit is contained in:
troytt 2026-10-02 21:35:56 +00:00
parent 6af87acc13
commit a4d514cbec
6 changed files with 1029 additions and 173 deletions

View File

@ -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<Record<number, string>> = {
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<Record<string, string>> = {
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

View File

@ -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)

View File

@ -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<Record<number, OverlayMeta>> = {
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<Record<number, StateColorShift>> = {
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,

View File

@ -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

View File

@ -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<SceneSourceRenderContext>,
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<SceneSourceRenderContext> = {
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<SceneSourceRenderContext> = {
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<string, LoadedOverlayArt>([
['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<SceneSourceRenderContext> = {
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<SceneSourceRenderContext> = {
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<SceneSourceRenderContext> = {
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<SceneSourceRenderContext> = {
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<SceneSourceRenderContext> = {
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)
})
})

View File

@ -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)