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:
parent
6af87acc13
commit
a4d514cbec
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue