1037 lines
32 KiB
TypeScript
1037 lines
32 KiB
TypeScript
/**
|
|
* 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,
|
|
transToBlendMode,
|
|
type RenderableUnitInput,
|
|
type SceneUnitDrawable,
|
|
} from '../../src/client/view/unit-drawables.ts'
|
|
import {
|
|
CAMERA_Y_OFFSET,
|
|
screenToSubTile,
|
|
subTileToScreen,
|
|
subTileToWorldOrtho,
|
|
VIEWPORT_800x600,
|
|
VIEWPORT_1024x768,
|
|
VIEWPORT_1280x720,
|
|
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)
|
|
})
|
|
|
|
it('Issue #611: transToBlendMode authentic D2Gfx DrawMode mapping and SpriteRenderer blendMode verification', () => {
|
|
// 0 -> trans25
|
|
expect(transToBlendMode(0)).toBe('trans25')
|
|
// 1 -> highlight
|
|
expect(transToBlendMode(1)).toBe('highlight')
|
|
// 2 -> trans75
|
|
expect(transToBlendMode(2)).toBe('trans75')
|
|
// 3 -> trans50
|
|
expect(transToBlendMode(3)).toBe('trans50')
|
|
// 4 -> burn
|
|
expect(transToBlendMode(4)).toBe('burn')
|
|
// 5 -> normal
|
|
expect(transToBlendMode(5)).toBe('normal')
|
|
// 6 -> modulate
|
|
expect(transToBlendMode(6)).toBe('modulate')
|
|
// 7 -> transhighlight
|
|
expect(transToBlendMode(7)).toBe('transhighlight')
|
|
// default/undefined
|
|
expect(transToBlendMode(undefined)).toBe('normal')
|
|
expect(transToBlendMode(99)).toBe('normal')
|
|
|
|
const renderer = new MockSpriteRenderer()
|
|
const modes = [
|
|
'normal',
|
|
'additive',
|
|
'trans25',
|
|
'trans50',
|
|
'trans75',
|
|
'modulate',
|
|
'burn',
|
|
'transhighlight',
|
|
'highlight',
|
|
] as const
|
|
for (const m of modes) {
|
|
renderer.setBlendMode(m)
|
|
expect(renderer.currentBlendMode).toBe(m)
|
|
}
|
|
})
|
|
|
|
it('Issue #607: Looping map objects evaluate Sync (Sync==0 applies jitter, Sync==1 runs in lockstep)', () => {
|
|
const mockTorchArt: LoadedObjectArt = {
|
|
token: 'tr',
|
|
slug: 'torch',
|
|
blendMode: 'normal',
|
|
handle: 1 as any,
|
|
groups: [
|
|
Array.from({ length: 8 }, (_, i) => ({
|
|
x: i * 32,
|
|
y: 0,
|
|
width: 32,
|
|
height: 64,
|
|
anchorX: -16,
|
|
anchorY: -64,
|
|
})),
|
|
],
|
|
directions: 1,
|
|
standOffset: 0,
|
|
standFrames: 8,
|
|
clips: {
|
|
nu: { group: 0, directions: 1, frames: 8, speed: 256, loop: true, events: [] },
|
|
},
|
|
frameDelta: [256, 256, 256, 256, 256, 256, 256, 256],
|
|
cycleAnim: [true, true, true, true, true, true, true, true],
|
|
}
|
|
|
|
// Two torch objects with Sync == 0 and different IDs: must NOT animate in lockstep
|
|
const torch1: RenderableUnitInput = {
|
|
kind: 'object',
|
|
id: 101,
|
|
classId: 50,
|
|
token: 'TR',
|
|
mode: 0,
|
|
sync: 0,
|
|
x: 10,
|
|
y: 10,
|
|
}
|
|
const torch2: RenderableUnitInput = {
|
|
kind: 'object',
|
|
id: 102,
|
|
classId: 50,
|
|
token: 'TR',
|
|
mode: 0,
|
|
sync: 0,
|
|
x: 20,
|
|
y: 20,
|
|
}
|
|
|
|
const builtJitter = buildSceneDrawables([torch1, torch2])
|
|
const snapshotJitter: SceneFrameSnapshot = {
|
|
tick: 100,
|
|
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: builtJitter,
|
|
}
|
|
const ctx: Partial<SceneSourceRenderContext> = {
|
|
lightingPreset: 'noon',
|
|
objectArtMap: new Map([['tr', mockTorchArt]]),
|
|
}
|
|
|
|
// Check across multiple ticks that jittered torches differ in frame
|
|
let observedDifference = false
|
|
for (let t = 200; t <= 1200; t += 120) {
|
|
const rend = new MockSpriteRenderer()
|
|
renderWorldFrame(rend, snapshotJitter, ctx, t)
|
|
expect(rend.drawCalls).toHaveLength(2)
|
|
if (rend.drawCalls[0]!.frame.x !== rend.drawCalls[1]!.frame.x) {
|
|
observedDifference = true
|
|
break
|
|
}
|
|
}
|
|
expect(observedDifference).toBe(true)
|
|
|
|
// Two synchronized objects with Sync == 1: MUST animate in exact lockstep
|
|
const syncObj1: RenderableUnitInput = {
|
|
kind: 'object',
|
|
id: 201,
|
|
classId: 50,
|
|
token: 'TR',
|
|
mode: 0,
|
|
sync: 1,
|
|
x: 10,
|
|
y: 10,
|
|
}
|
|
const syncObj2: RenderableUnitInput = {
|
|
kind: 'object',
|
|
id: 202,
|
|
classId: 50,
|
|
token: 'TR',
|
|
mode: 0,
|
|
sync: 1,
|
|
x: 20,
|
|
y: 20,
|
|
}
|
|
const builtSync = buildSceneDrawables([syncObj1, syncObj2])
|
|
const snapshotSync: SceneFrameSnapshot = {
|
|
...snapshotJitter,
|
|
entities: builtSync,
|
|
}
|
|
for (let t = 0; t <= 1000; t += 100) {
|
|
const rend = new MockSpriteRenderer()
|
|
renderWorldFrame(rend, snapshotSync, ctx, t)
|
|
expect(rend.drawCalls).toHaveLength(2)
|
|
expect(rend.drawCalls[0]!.frame.x).toBe(rend.drawCalls[1]!.frame.x)
|
|
}
|
|
})
|
|
|
|
it('Issue #610: CAMERA_Y_OFFSET = -16 eliminates ground click raycasting parallax across viewports', () => {
|
|
const profiles = [VIEWPORT_800x600, VIEWPORT_1024x768, VIEWPORT_1280x720]
|
|
for (const profile of profiles) {
|
|
const camSubX = 100
|
|
const camSubY = 100
|
|
|
|
// Screen center (with camera offset) maps back to exact camera position
|
|
const screenCenter = {
|
|
x: profile.width / 2,
|
|
y: profile.height / 2 + CAMERA_Y_OFFSET,
|
|
}
|
|
const unprojected = screenToSubTile(screenCenter.x, screenCenter.y, camSubX, camSubY, profile)
|
|
expect(unprojected.x).toBeCloseTo(camSubX, 6)
|
|
expect(unprojected.y).toBeCloseTo(camSubY, 6)
|
|
|
|
// Inverse round-trip holds for all sub-tiles
|
|
for (let dx = -20; dx <= 20; dx += 10) {
|
|
for (let dy = -20; dy <= 20; dy += 10) {
|
|
const sX = camSubX + dx
|
|
const sY = camSubY + dy
|
|
const screen = subTileToScreen(sX, sY, camSubX, camSubY, profile)
|
|
const back = screenToSubTile(screen.x, screen.y, camSubX, camSubY, profile)
|
|
expect(back.x).toBeCloseTo(sX, 6)
|
|
expect(back.y).toBeCloseTo(sY, 6)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('Issue #608: activeCastState derives elapsed time strictly from elapsedMs without performance.now()', () => {
|
|
const castUnit: RenderableUnitInput = {
|
|
kind: 'player',
|
|
id: 301,
|
|
classId: 0,
|
|
x: 10,
|
|
y: 10,
|
|
castState: {
|
|
skillId: 1,
|
|
mode: 10,
|
|
targetX: 10,
|
|
targetY: 10,
|
|
startedAtMs: 0,
|
|
untilMs: 1000,
|
|
elapsedMs: 200,
|
|
castOverlayToken: 'ShrineShimmer00',
|
|
},
|
|
}
|
|
const built = buildSceneDrawables([castUnit])
|
|
const overlay = built.overlayDrawables.find(o => o.token.toLowerCase() === 'shrineshimmer00')
|
|
expect(overlay).toBeDefined()
|
|
// elapsedMs = 200ms -> elapsedTicks = 5 -> (5 * 16) / 16 = 5
|
|
expect(overlay!.frame).toBe(5)
|
|
// overlay blendMode uses transToBlendMode
|
|
expect(overlay!.blendMode).toBeDefined()
|
|
})
|
|
|
|
it('Hitbox Selection: Cursor inside unit bounding box takes precedence over closer unit evaluated via radial fallback', () => {
|
|
// Unit 401 has an authentic bounding box: 100x100 around (x: 10, y: 10)
|
|
const boxedMonster: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 401,
|
|
classId: 10,
|
|
x: 10,
|
|
y: 10,
|
|
hitBox: { left: -50, top: -50, width: 100, height: 100 },
|
|
}
|
|
// Unit 402 has NO bounding box, positioned slightly offset (x: 12, y: 10)
|
|
const fallbackMonster: RenderableUnitInput = {
|
|
kind: 'monster',
|
|
id: 402,
|
|
classId: 10,
|
|
x: 12,
|
|
y: 10,
|
|
}
|
|
|
|
const sceneSource = new ClientWorldSceneSource(
|
|
() => ({
|
|
tick: 0,
|
|
playerSubX: 0,
|
|
playerSubY: 0,
|
|
units: [boxedMonster, fallbackMonster],
|
|
levelView: null,
|
|
}),
|
|
VIEWPORT_800x600,
|
|
)
|
|
|
|
// Compute click world position that is inside Unit 401's bounding box,
|
|
// but geometrically closer to Unit 402's center
|
|
const w1 = subTileToWorldOrtho(10, 10, 0, 0)
|
|
const clickWorldX = w1.x + 40
|
|
const clickWorldY = w1.y
|
|
|
|
const screen = worldToScreen(clickWorldX, clickWorldY, 0, CAMERA_Y_OFFSET, VIEWPORT_800x600)
|
|
const picked = sceneSource.pickUnitAtScreen(screen.x, screen.y, 50)
|
|
|
|
expect(picked).not.toBeNull()
|
|
expect(picked?.unitId).toBe(401) // Unit 401 MUST be picked due to hitbox precedence!
|
|
})
|
|
})
|