Fix automap exploration, viewport geometry, markers, and fail-fast loading (Closes #624, #625, #626, #627, #628, #629, #630, #631, #632, #635)

This commit is contained in:
troytt 2026-10-02 17:20:35 +00:00
parent f1ca3dfc2b
commit 036d36bccb
8 changed files with 483 additions and 377 deletions

View File

@ -1,15 +1,20 @@
import { DRLG_GetActNoFromLevelId } from '../../common/drlg/drlg-drlg.ts'
import type {
PackedDrlgBundleManifest,
PackedTileCatalogIndex,
} from '../../common/pack-contract/index.ts'
import { getMonsterKindByClassId } from '../../common/units/monsters-data.ts'
import { levelDisplayName } from '../../common/world/level-names-zh.ts' import { levelDisplayName } from '../../common/world/level-names-zh.ts'
import { getTblLang, type TblLang } from '../i18n/lang.ts' import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts'
import type { import type {
MinimapLevel, MinimapLevel,
MinimapMarker, MinimapMarker,
} from '../ui/minimap.ts' } from '../ui/minimap.ts'
import { import {
isWalkableWorldSubTile,
type LevelRoomView, type LevelRoomView,
type LevelView, type LevelView,
} from '../map/level-view.ts' } from '../map/level-view.ts'
import type { MapService } from '../map/map-service.ts' import { MapService } from '../map/map-service.ts'
import type { AutomapRevealMode } from '../settings/client-settings.ts' import type { AutomapRevealMode } from '../settings/client-settings.ts'
import type { RenderableUnitInput } from '../view/unit-drawables.ts' import type { RenderableUnitInput } from '../view/unit-drawables.ts'
@ -34,12 +39,41 @@ export interface MinimapStashIcon {
export type ActId = 1 | 2 | 3 | 4 | 5 export type ActId = 1 | 2 | 3 | 4 | 5
export type DifficultyId = 0 | 1 | 2 export type DifficultyId = 0 | 1 | 2
/**
* Resolves the 1-based Act ID (1..5) for a given level areaId using canonical DRLG table logic
* (D2Common.0x6FD75300 #10001 DRLG_GetActNoFromLevelId).
*/
export function getActForArea(areaId: number): ActId { export function getActForArea(areaId: number): ActId {
if (areaId <= 39) return 1 const act0 = DRLG_GetActNoFromLevelId(areaId)
if (areaId <= 74) return 2 return (act0 + 1) as ActId
if (areaId <= 102) return 3 }
if (areaId <= 108) return 4
return 5 /**
* Localized difficulty string ('Normal' | 'Nightmare' | 'Hell' / '普通' | '惡夢' | '地獄')
* wired to .tbl string keys (strNormal, strNightmare, strHell) per Issue #628.
*/
export function getLocalizedDifficulty(
difficulty: DifficultyId,
lang: TblLang = getTblLang(),
): string {
const isZh = lang === 'CHI'
if (difficulty === 1) {
return lookupTbl('strNightmare', lang) ?? (isZh ? '惡夢' : 'Nightmare')
}
if (difficulty === 2) {
return lookupTbl('strHell', lang) ?? (isZh ? '地獄' : 'Hell')
}
return lookupTbl('strNormal', lang) ?? (isZh ? '普通' : 'Normal')
}
/**
* Canonical preset town levels with AutoMap=1 in LvlPrest.txt (DrlgPreset.cpp:1794-1823):
* 1 (Rogue Encampment), 40 (Lut Gholein), 75 (Kurast Docks), 103 (Pandemonium Fortress), 109 (Harrogath).
*/
export const TOWN_LEVEL_IDS: ReadonlySet<number> = new Set([1, 40, 75, 103, 109])
export function isTownLevel(levelId: number): boolean {
return TOWN_LEVEL_IDS.has(levelId)
} }
/** /**
@ -94,106 +128,66 @@ function makeRoomKey(areaId: number, roomX: number, roomY: number): string {
return `${areaId}:${roomX},${roomY}` return `${areaId}:${roomX},${roomY}`
} }
function buildFallbackLevelView( interface NodeFsLike {
seed: number, readFileSync(path: string, encoding: string): string
difficulty: DifficultyId, readFileSync(path: string): { buffer: ArrayBuffer; byteOffset: number; byteLength: number }
areaId: number, existsSync(path: string): boolean
): LevelView {
const actIndex = getActForArea(areaId) - 1
const baseTileX = (areaId * 32) + ((seed >>> 0) % 16)
const baseTileY = (areaId * 24) + (((seed >>> 4) >>> 0) % 16)
const roomGrid = 3
const roomTileW = 8
const roomTileH = 8
const rooms: LevelRoomView[] = []
let idx = 0
for (let ry = 0; ry < roomGrid; ry++) {
for (let rx = 0; rx < roomGrid; rx++) {
const localTileX = rx * roomTileW
const localTileY = ry * roomTileH
const tileX = baseTileX + localTileX
const tileY = baseTileY + localTileY
rooms.push({
index: idx++,
areaId,
tileX,
tileY,
tileW: roomTileW,
tileH: roomTileH,
localTileX,
localTileY,
subX: localTileX * 5,
subY: localTileY * 5,
subW: roomTileW * 5,
subH: roomTileH * 5,
worldSubX: tileX * 5,
worldSubY: tileY * 5,
revealed: true,
})
}
} }
const tileWidth = roomGrid * roomTileW interface NodePathLike {
const tileHeight = roomGrid * roomTileH resolve(...args: string[]): string
const cellsX = tileWidth + 1 join(...args: string[]): string
const cellsY = tileHeight + 1 }
const subWidth = cellsX * 5
const subHeight = cellsY * 5
const totalSub = subWidth * subHeight
const collision = new Uint16Array(totalSub)
const blocked = new Uint8Array(totalSub)
const walkable = new Uint8Array(totalSub)
walkable.fill(1)
const originX = baseTileX * 5 /**
const originY = baseTileY * 5 * Synchronously loads the packed tile bundle in Node/test environments when MapService
* was not injected via AutomapViewOptions.
return { */
id: areaId, function tryLoadPackedBundleNodeSync(): MapService | null {
areaId, const proc = (
levelId: areaId, globalThis as unknown as {
act: actIndex, process?: { versions?: { node?: string }; cwd?: () => string }
seed: seed >>> 0, }
difficulty, ).process
originX, if (!proc || !proc.versions?.node) {
originY, return null
subOriginX: originX, }
subOriginY: originY, try {
width: subWidth, const req =
height: subHeight, typeof (globalThis as unknown as { require?: unknown }).require === 'function'
subWidth, ? (globalThis as unknown as { require: (id: string) => unknown }).require
subHeight, : null
tileOriginX: baseTileX, if (!req) return null
tileOriginY: baseTileY, const fs = req('node:fs') as NodeFsLike | undefined
tileWidth, const path = req('node:path') as NodePathLike | undefined
tileHeight, if (!fs || !path) return null
cellsX, const cwd = typeof proc.cwd === 'function' ? proc.cwd() : '.'
cellsY, const candidates = [
gridWidth: subWidth, path.resolve(cwd, 'public/act-packs/tiles'),
gridHeight: subHeight, path.resolve(cwd, 'public/assets/act-packs/tiles'),
spawnX: Math.floor(tileWidth / 2), path.resolve('/usr/local/google/home/taodao/diablo2-web/public/act-packs/tiles'),
spawnY: Math.floor(tileHeight / 2), ]
isoOriginX: cellsY * 80, let resolvedDir: string | null = null
isoOriginY: 0, for (const dir of candidates) {
widthPx: (cellsX + cellsY) * 80, if (fs.existsSync(path.join(dir, 'drlg-bundle.json'))) {
heightPx: (cellsX + cellsY) * 40, resolvedDir = dir
collision, break
collisionMasks: collision, }
blocked, }
walkable, if (!resolvedDir) return null
walkableCellCount: totalSub, const indexJson = JSON.parse(
dt1Names: [], fs.readFileSync(path.join(resolvedDir, 'index.json'), 'utf8'),
floors: [], ) as PackedTileCatalogIndex
shadows: [], const manifest = JSON.parse(
walls: [], fs.readFileSync(path.join(resolvedDir, 'drlg-bundle.json'), 'utf8'),
roofs: [], ) as PackedDrlgBundleManifest
frames: [], const buf = fs.readFileSync(path.join(resolvedDir, 'drlg-bundle.bin'))
ds1Objects: [], const bundleBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
presetUnits: [], return MapService.fromPackedBundle(manifest, bundleBytes, indexJson)
entrances: [], } catch (err) {
warps: [], throw new Error(
landmarks: [], `[AutomapView] Failed to synchronously load packed tile bundle from public/act-packs/tiles: ${err instanceof Error ? err.message : String(err)}`,
rooms, )
} }
} }
@ -206,7 +200,7 @@ function buildFallbackLevelView(
* - `'act'`: All DRLG levels and rooms in the active Act are generated and revealed with cross-level warp markers. * - `'act'`: All DRLG levels and rooms in the active Act are generated and revealed with cross-level warp markers.
*/ */
export class AutomapView { export class AutomapView {
private readonly mapService: MapService | null private mapService: MapService | null
private readonly actRevealLevelIds: Readonly<Record<ActId, readonly number[]>> private readonly actRevealLevelIds: Readonly<Record<ActId, readonly number[]>>
private mode: AutomapRevealMode private mode: AutomapRevealMode
private mapSeed = 0 private mapSeed = 0
@ -223,6 +217,9 @@ export class AutomapView {
this.mapService = options?.mapService ?? null this.mapService = options?.mapService ?? null
this.mode = options?.initialMode ?? 'off' this.mode = options?.initialMode ?? 'off'
this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS
if (!this.mapService) {
this.mapService = tryLoadPackedBundleNodeSync()
}
} }
getMode(): AutomapRevealMode { getMode(): AutomapRevealMode {
@ -260,8 +257,10 @@ export class AutomapView {
if (seedChanged) { if (seedChanged) {
this.levelViews.clear() this.levelViews.clear()
this.serverRevealedRooms.clear() this.serverRevealedRooms.clear()
if (!this.mapService) {
this.mapService = tryLoadPackedBundleNodeSync()
}
if (this.mapService) { if (this.mapService) {
try {
const loaded = this.mapService.loadAct({ const loaded = this.mapService.loadAct({
act: this.currentActId - 1, act: this.currentActId - 1,
seed: this.mapSeed, seed: this.mapSeed,
@ -272,9 +271,6 @@ export class AutomapView {
if (loaded) { if (loaded) {
this.levelViews.set(areaId, loaded) this.levelViews.set(areaId, loaded)
} }
} catch {
// Fallback to deterministic synthetic LevelView if MapService has no worker/engine
}
} }
} }
@ -317,15 +313,11 @@ export class AutomapView {
roomX, roomX,
roomY, roomY,
}) })
if (this.mapService) { if (this.mapService && typeof this.mapService.onAddRoomDataPacket === 'function') {
try {
this.mapService.onAddRoomDataPacket( this.mapService.onAddRoomDataPacket(
{ areaId, roomX, roomY }, { areaId, roomX, roomY },
{ rebuildView: false }, { rebuildView: false },
) )
} catch {
// Ignore if MapService is not backed by an in-process engine
}
} }
} }
@ -334,15 +326,11 @@ export class AutomapView {
*/ */
onServerRemoveRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void { onServerRemoveRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void {
this.serverRevealedRooms.delete(makeRoomKey(areaId, roomX, roomY)) this.serverRevealedRooms.delete(makeRoomKey(areaId, roomX, roomY))
if (this.mapService) { if (this.mapService && typeof this.mapService.onRemoveRoomDataPacket === 'function') {
try {
this.mapService.onRemoveRoomDataPacket( this.mapService.onRemoveRoomDataPacket(
{ areaId, roomX, roomY }, { areaId, roomX, roomY },
{ rebuildView: false }, { rebuildView: false },
) )
} catch {
// Ignore if MapService is not backed by an in-process engine
}
} }
} }
@ -353,8 +341,14 @@ export class AutomapView {
private ensureLevelView(areaId: number): LevelView { private ensureLevelView(areaId: number): LevelView {
let view = this.levelViews.get(areaId) let view = this.levelViews.get(areaId)
if (!view) { if (!view) {
if (this.mapService) { if (!this.mapService) {
try { this.mapService = tryLoadPackedBundleNodeSync()
}
if (!this.mapService) {
throw new Error(
`[AutomapView] Failed to load LevelView for area ${areaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty}): mapService is not initialized and packed tile bundle is unavailable`,
)
}
if (this.mapService.getActiveAct() !== getActForArea(areaId) - 1) { if (this.mapService.getActiveAct() !== getActForArea(areaId) - 1) {
this.mapService.loadAct({ this.mapService.loadAct({
act: getActForArea(areaId) - 1, act: getActForArea(areaId) - 1,
@ -365,11 +359,10 @@ export class AutomapView {
}) })
} }
view = this.mapService.getLevelView(areaId, 'all') view = this.mapService.getLevelView(areaId, 'all')
} catch { if (!view) {
view = buildFallbackLevelView(this.mapSeed, this.difficulty, areaId) throw new Error(
} `[AutomapView] MapService failed to produce LevelView for area ${areaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty})`,
} else { )
view = buildFallbackLevelView(this.mapSeed, this.difficulty, areaId)
} }
this.levelViews.set(areaId, view) this.levelViews.set(areaId, view)
} }
@ -396,8 +389,10 @@ export class AutomapView {
for (const rev of this.serverRevealedRooms.values()) { for (const rev of this.serverRevealedRooms.values()) {
if (rev.areaId !== areaId) continue if (rev.areaId !== areaId) continue
if ( if (
(rev.roomX >= room.tileX && rev.roomX < room.tileX + room.tileW && rev.roomY >= room.tileY && rev.roomY < room.tileY + room.tileH) || rev.roomX >= room.tileX &&
(rev.roomX >= room.worldSubX && rev.roomX < room.worldSubX + room.subW && rev.roomY >= room.worldSubY && rev.roomY < room.worldSubY + room.subH) rev.roomX < room.tileX + room.tileW &&
rev.roomY >= room.tileY &&
rev.roomY < room.tileY + room.tileH
) { ) {
return true return true
} }
@ -482,14 +477,33 @@ export class AutomapView {
name: u.name, name: u.name,
}) })
} else if (u.kind === 'monster') { } else if (u.kind === 'monster') {
// Diablo II v1.13c Parity: NEVER draw hostile monsters or bosses on the automap!
// Only player corpses and friendly interactable NPCs (interact=1, excluding ancient statues 537..539) are drawn.
const isDead = Boolean(u.isCorpse) || (u.lifePct !== undefined && u.lifePct <= 0) const isDead = Boolean(u.isCorpse) || (u.lifePct !== undefined && u.lifePct <= 0)
if (isDead) {
if (u.isCorpse) {
entityDots.push({ entityDots.push({
worldX, worldX,
worldY, worldY,
kind: isDead ? 'corpse' : 'monster', kind: 'corpse',
name: u.name, name: u.name,
}) })
} }
} else {
const cid = u.classId ?? 0
const isAncientStatue = cid >= 537 && cid <= 539
const mk = typeof u.classId === 'number' ? getMonsterKindByClassId(u.classId) : undefined
const isInteract = mk ? mk.interact : Boolean((u as { isNpc?: boolean }).isNpc)
if (isInteract && !isAncientStatue) {
entityDots.push({
worldX,
worldY,
kind: 'npc',
name: u.name,
})
}
}
}
} }
let serverRevealedCount = 0 let serverRevealedCount = 0
@ -523,58 +537,27 @@ export class AutomapView {
} }
const snap = this.buildSnapshot([], lang) const snap = this.buildSnapshot([], lang)
const view = this.ensureLevelView(this.currentAreaId) const view = this.ensureLevelView(this.currentAreaId)
const allRooms = view.rooms
let minCellX = 0
let minCellY = 0
let cellsX = 40
let cellsY = 40
let subW = 200
let blocked: Uint8Array
let originX = 0
let originY = 0
if ( if (
view.cellsX > 0 && view.cellsX <= 0 ||
view.cellsY > 0 && view.cellsY <= 0 ||
view.blocked && !view.blocked ||
view.blocked.length === view.cellsX * 5 * view.cellsY * 5 view.blocked.length !== view.cellsX * 5 * view.cellsY * 5
) { ) {
minCellX = view.tileOriginX ?? Math.floor((view.subOriginX ?? 0) / 5) throw new Error(
minCellY = view.tileOriginY ?? Math.floor((view.subOriginY ?? 0) / 5) `[AutomapView] Invalid LevelView for area ${this.currentAreaId}: cellsX=${view.cellsX}, cellsY=${view.cellsY}, blockedLength=${view.blocked?.length ?? 0} (expected ${view.cellsX * 5 * view.cellsY * 5})`,
cellsX = view.cellsX )
cellsY = view.cellsY
subW = view.gridWidth || cellsX * 5
blocked = view.blocked
originX = view.isoOriginX ?? (minCellX - minCellY) * 80
originY = view.isoOriginY ?? (minCellX + minCellY) * 40
} else {
let maxCellX = 40
let maxCellY = 40
if (allRooms.length > 0) {
minCellX = Math.min(...allRooms.map(r => r.tileX))
minCellY = Math.min(...allRooms.map(r => r.tileY))
maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW))
maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH))
}
cellsX = Math.max(1, maxCellX - minCellX)
cellsY = Math.max(1, maxCellY - minCellY)
subW = cellsX * 5
const subH = cellsY * 5
blocked = new Uint8Array(subW * subH)
for (let sy = 0; sy < subH; sy++) {
for (let sx = 0; sx < subW; sx++) {
const absSubX = minCellX * 5 + sx
const absSubY = minCellY * 5 + sy
if (!isWalkableWorldSubTile(view, absSubX, absSubY)) {
blocked[sy * subW + sx] = 1
}
}
}
originX = (minCellX - minCellY) * 80
originY = (minCellX + minCellY) * 40
} }
const minCellX = view.tileOriginX ?? Math.floor((view.subOriginX ?? 0) / 5)
const minCellY = view.tileOriginY ?? Math.floor((view.subOriginY ?? 0) / 5)
const cellsX = view.cellsX
const cellsY = view.cellsY
const subW = view.gridWidth || cellsX * 5
const blocked = view.blocked
const originX = view.isoOriginX ?? (minCellX - minCellY) * 80
const originY = view.isoOriginY ?? (minCellX + minCellY) * 40
const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({ const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({
cellX: wm.cellX - minCellX, cellX: wm.cellX - minCellX,
cellY: wm.cellY - minCellY, cellY: wm.cellY - minCellY,
@ -598,7 +581,7 @@ export class AutomapView {
} }
return { return {
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`, key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}`,
cellsX, cellsX,
cellsY, cellsY,
blocked, blocked,
@ -608,6 +591,8 @@ export class AutomapView {
originY, originY,
levelId: this.currentAreaId, levelId: this.currentAreaId,
levelName: levelDisplayName(this.currentAreaId, lang), levelName: levelDisplayName(this.currentAreaId, lang),
difficulty: getLocalizedDifficulty(this.difficulty, lang),
preRevealTown: isTownLevel(this.currentAreaId),
...(view.automap !== undefined ? { automap: view.automap } : {}), ...(view.automap !== undefined ? { automap: view.automap } : {}),
} }
} }

View File

@ -5,25 +5,26 @@
* - `AUTOMAP_LoadSprites` (`0x6fb11cc0`) & `AUTOMAP_DrawCell` (`0x6fb0f240`): * - `AUTOMAP_LoadSprites` (`0x6fb11cc0`) & `AUTOMAP_DrawCell` (`0x6fb0f240`):
* 8 DC6 sprite sheets (`MaxiMap`, `MaxiMapS`, `Act2Map`, `Act2MapS`, `Act4Map`, `Act4MapS`, * 8 DC6 sprite sheets (`MaxiMap`, `MaxiMapS`, `Act2Map`, `Act2MapS`, `Act4Map`, `Act4MapS`,
* `ExTnMap`, `ExTnMapS`) indexed by `AutomapAtlasSlot` (`0..3`) and `bMinimap` (`false`/`true`). * `ExTnMap`, `ExTnMapS`) indexed by `AutomapAtlasSlot` (`0..3`) and `bMinimap` (`false`/`true`).
* - `AUTOMAP_SetupViewportAndRender` (`0x6fb102a0`) & `AUTOMAP_RenderLayer` (`0x6fb10480`): * - `AUTOMAP_SetupViewportAndRender` (`0x6fae9e20`) & `AUTOMAP_RenderLayer` (`0x6fb10489`):
* Isometric projection with `nDivisor = bMinimap ? 20 : 10`, DC6 baseline anchor * Isometric projection with `nDivisor = bMinimap ? 20 : 10`, DC6 baseline anchor
* `drawLeft = screenX + frame.offsetX - (bMinimap ? 4 : 8)`, * `drawLeft = screenX + frame.offsetX - (bMinimap ? 4 : 8)`,
* `drawTop = screenY - frame.height + frame.offsetY`, * `drawTop = screenY - frame.height + frame.offsetY`,
* rendered in strict 4-layer order: `floors -> walls -> objects -> extras`. * rendered in strict 4-layer order: `floors -> walls -> objects -> extras`.
* - Dual Viewport Modes (`0x6fb102a0..0x6fb10378`): * - Dual Viewport Modes (`0x6fae9e20..0x6fb104d1`):
* - Corner Minimap (`bMinimap = true`, `nDivisor = 20`, default on start): * - Corner Minimap (`bMinimap = true`, `nDivisor = 20`, default on start):
* Clipped `279×225` viewport at top-right `(screenWidth - 295, 16)`, shifting to * Clipped `279×225` viewport at top-right `(screenWidth - 281, 57)`, shifting to
* top-left `(16, 16)` when a right-dock panel (Inventory/Skill Tree) is open. * top-left `(0, 75)` when a right-dock panel (Inventory/Skill Tree) is open.
* - Full-Screen Automap (`bMinimap = false`, `nDivisor = 10`): * - Full-Screen Automap (`bMinimap = false`, `nDivisor = 10`):
* Covers `[0, 0, screenWidth, screenHeight - 48]`, shifts center by `+160px` when left * Covers `[0, 0, screenWidth, screenHeight]`, shifts center by `+(screenWidth >> 2)` when left
* panel is open or `-160px` when right panel is open, and supports arrow-key panning * panel is open or `-(screenWidth >> 2)` when right panel is open, and supports arrow-key panning
* (`±8px` per step) + `Home` reset. * (`±8px` per step) + `Home` reset.
* - `AUTOMAP_UpdateVisibility` (`0x6fb12af0`) & `AUTOMAP_RevealRoom` (`0x6fb12580`): * - `AUTOMAP_UpdateVisibility` (`0x6fb12af0`) & `AUTOMAP_RevealRoom` (`0x6fb12580`):
* Octile distance threshold `max(|dx|, |dy|) * 2 + min(|dx|, |dy|) >= 160` sub-units, * Octile distance threshold `max(|dx|, |dy|) * 2 + min(|dx|, |dy|) >= 160` sub-units,
* revealing the player's current room and adjacent 3×3 rooms into fog-of-war state. * revealing the player's current room and adjacent 3×3 rooms into fog-of-war state.
* - `AUTOMAP_DrawCrosshair` (`0x6fb107f0`) & `AUTOMAP_RenderEntitiesAndPortals` (`0x6fb10dc5`): * - `AUTOMAP_DrawCrosshair` (`0x6fb11490`) & `AUTOMAP_CrosshairColors` (`0x6fabd390`):
* 12-segment closed directional polygon crosshairs for Player, Party/NPCs, Mercenaries/Summons, * 12-segment closed directional polygon crosshairs for Player (0x210), Party (0x213),
* and Bosses/SuperUniques, plus active town portal sprites (`306`/`307`/`300`), warp destination * Pets/Mercenaries (0x214/0x215), Friendly NPCs (0x216), and Player Corpses (0x212),
* plus active town portal sprites (`306`/`307`/`300`), warp destination
* `Font6` labels, and top-right right-aligned gold `Font16` Level Name + Difficulty readout. * `Font6` labels, and top-right right-aligned gold `Font16` Level Name + Difficulty readout.
*/ */
@ -43,6 +44,8 @@ import {
computeAutomapTilePixelCoords, computeAutomapTilePixelCoords,
getCrosshairSegments, getCrosshairSegments,
} from '../../common/formats/automap-table.ts' } from '../../common/formats/automap-table.ts'
import { lookupTbl } from '../i18n/lang.ts'
import { reportMissingArt } from '../scene/world-renderer.ts'
import type { D2ColorCode, D2FontRenderer } from './font.ts' import type { D2ColorCode, D2FontRenderer } from './font.ts'
export type AutomapCrosshairSegment = { export type AutomapCrosshairSegment = {
@ -67,25 +70,47 @@ export const AUTOMAP_REVEAL_DISTANCE_THRESHOLD = 160
/** Arrow-key pan step in screen pixels when Full Automap (`bMinimap = false`) is open. */ /** Arrow-key pan step in screen pixels when Full Automap (`bMinimap = false`) is open. */
export const AUTOMAP_PAN_STEP_PX = 8 export const AUTOMAP_PAN_STEP_PX = 8
/** 3-state fade alpha cycle (`F9`: `0 -> 1 -> 2 -> 0`). */ /** 4-state fade alpha cycle (`F9`: `0 -> 1 -> 2 -> 3 -> 0`). */
export const AUTOMAP_FADE_ALPHAS = [1.0, 0.75, 0.5] as const export const AUTOMAP_FADE_ALPHAS = [1.0, 0.75, 0.5, 0.25] as const
const COLOR_PLAYER = [0.0, 0.0, 1.0, 1.0] as const
const COLOR_RED = [1.0, 0.0, 0.0, 1.0] as const
const COLOR_CORPSE = [1.0, 0.0, 1.0, 1.0] as const
const COLOR_PARTY = [0.0, 1.0, 0.0, 1.0] as const
const COLOR_OWN_PET = [68 / 255, 112 / 255, 116 / 255, 1.0] as const
const COLOR_PARTY_PET = [72 / 255, 160 / 255, 52 / 255, 1.0] as const
const COLOR_NPC = [244 / 255, 244 / 255, 244 / 255, 1.0] as const
const COLOR_NEUTRAL = [128 / 255, 128 / 255, 128 / 255, 1.0] as const
const COLOR_PORTAL = [244 / 255, 244 / 255, 0.0, 1.0] as const
/** /**
* Canonical 1.13c crosshair palette colors (`AUTOMAP_DrawCrosshair` @ `0x6fb107f0`, `0x6fb10dc5`, `0x6fb11490`). * Canonical 1.13c crosshair palette colors (`AUTOMAP_DrawCrosshair` @ `0x6fb11490`, `0x6fabd390`).
*/ */
export const AUTOMAP_CROSSHAIR_COLORS = { export const AUTOMAP_CROSSHAIR_COLORS = {
/** Player crosshair: Palette 0x97 (`RGB(0, 0, 255)` / `#0080ff`). */ /** Player crosshair: Palette 0x210 (`RGB(0, 0, 255)`). */
player: [0.0, 0.5, 1.0, 1.0] as const, player: COLOR_PLAYER,
/** Party member / Ally crosshair: Palette 0x7D / 0x84 (`RGB(0, 255, 0)`). */ /** Non-party player crosshair: Palette 0x211 (`RGB(255, 0, 0)`). */
party: [0.0, 1.0, 0.0, 1.0] as const, nonPartyPlayer: COLOR_RED,
/** Friendly Town/Quest NPC crosshair: `0x6fbcc216` (`RGB(244, 244, 244)` in `0x6fb11490`). */ /** Player corpse crosshair: Palette 0x212 (`RGB(255, 0, 255)`). */
npc: [244 / 255, 244 / 255, 244 / 255, 1.0] as const, corpse: COLOR_CORPSE,
/** Mercenary / Summon crosshair: Palette 0x81 (`RGB(72, 160, 52)`). */ /** Party member / Ally crosshair: Palette 0x213 (`RGB(0, 255, 0)`). */
mercenary: [72 / 255, 160 / 255, 52 / 255, 1.0] as const, party: COLOR_PARTY,
/** Valkyrie (357) / Iron Golem (291) crosshair: Palette 0x1F (`RGB(244, 244, 244)`). */ /** Own pet / Mercenary crosshair: Palette 0x214 (`RGB(68, 112, 116)`). */
valkyrieOrIronGolem: [244 / 255, 244 / 255, 244 / 255, 1.0] as const, ownPet: COLOR_OWN_PET,
/** Boss / SuperUnique / Unique monster crosshair: Palette 0x0A (`RGB(255, 0, 0)`). */ /** Mercenary crosshair alias. */
boss: [1.0, 0.0, 0.0, 1.0] as const, mercenary: COLOR_OWN_PET,
/** Party member pet crosshair: Palette 0x215 (`RGB(72, 160, 52)`). */
partyPet: COLOR_PARTY_PET,
/** Friendly Town/Quest NPC crosshair: Palette 0x216 (`RGB(244, 244, 244)`). */
npc: COLOR_NPC,
/** Neutral crosshair: Palette 0x217 (`RGB(128, 128, 128)`). */
neutral: COLOR_NEUTRAL,
/** Portal crosshair: Palette 0x218 (`RGB(244, 244, 0)`). */
portal: COLOR_PORTAL,
/** Valkyrie (357) / Iron Golem (291) crosshair: Palette 0x216 (`RGB(244, 244, 244)`). */
valkyrieOrIronGolem: COLOR_NPC,
/** Boss crosshair retained for test backward compatibility: Palette 0x211 (`RGB(255, 0, 0)`). */
boss: COLOR_RED,
} as const } as const
/** Full-scale (`bMinimap = false`) and half-scale (`bMinimap = true`) sheet names per slot `0..3`. */ /** Full-scale (`bMinimap = false`) and half-scale (`bMinimap = true`) sheet names per slot `0..3`. */
@ -171,7 +196,7 @@ export interface AutomapViewportMetrics {
/** /**
* Computes viewport bounds, divisor, panel shift, and center anchor per * Computes viewport bounds, divisor, panel shift, and center anchor per
* `AUTOMAP_SetupViewportAndRender` (`0x6fb102a0..0x6fb10378`). * `AUTOMAP_SetupViewportAndRender` (`0x6fae9e20..0x6fb104d1`).
*/ */
export function computeAutomapViewport( export function computeAutomapViewport(
screenWidth: number, screenWidth: number,
@ -188,8 +213,10 @@ export function computeAutomapViewport(
if (bMinimap) { if (bMinimap) {
const vpW = 279 const vpW = 279
const vpH = 225 const vpH = 225
const vpX = rightPanelOpen ? 16 : Math.max(0, safeW - 295) const vpX = rightPanelOpen ? 0 : Math.max(0, safeW - 281)
const vpY = 16 const vpY = rightPanelOpen ? 75 : 57
const centerX = rightPanelOpen ? 139 : Math.max(139, safeW - 142)
const centerY = rightPanelOpen ? 187 : 169
return { return {
bMinimap: true, bMinimap: true,
divisor: 20, divisor: 20,
@ -203,8 +230,8 @@ export function computeAutomapViewport(
y: vpY, y: vpY,
width: vpW, width: vpW,
height: vpH, height: vpH,
centerX: vpX + 139, centerX,
centerY: vpY + 112, centerY,
centerShiftX: 0, centerShiftX: 0,
effectivePanX: 0, effectivePanX: 0,
effectivePanY: 0, effectivePanY: 0,
@ -216,11 +243,16 @@ export function computeAutomapViewport(
const vpX = 0 const vpX = 0
const vpY = 0 const vpY = 0
const vpW = safeW const vpW = safeW
const vpH = Math.max(1, safeH - 48) const vpH = safeH
const shiftAmount = safeW >> 2
const centerShiftX = const centerShiftX =
leftPanelOpen && !rightPanelOpen ? 160 : rightPanelOpen && !leftPanelOpen ? -160 : 0 leftPanelOpen && !rightPanelOpen
const centerX = Math.trunc(vpW / 2) + centerShiftX ? shiftAmount
const centerY = Math.trunc(vpH / 2) : rightPanelOpen && !leftPanelOpen
? -shiftAmount
: 0
const centerX = Math.trunc(safeW / 2) + centerShiftX
const centerY = Math.trunc((safeH - 40) / 2)
const effPanX = Math.trunc(panX) const effPanX = Math.trunc(panX)
const effPanY = Math.trunc(panY) const effPanY = Math.trunc(panY)
@ -319,23 +351,35 @@ export interface MinimapMarker {
readonly toLevelId?: number readonly toLevelId?: number
} }
/** Dynamic entity marker drawn on the automap (`0x6fb10dc5`). */ /** Dynamic entity marker drawn on the automap (`0x6fb11490` & `0x6fabd390`). */
export interface AutomapEntityMarker { export interface AutomapEntityMarker {
/** World X coordinate (or relative to grid origin if `isRelativeToOrigin` is true). */ /** World X coordinate (or relative to grid origin if `isRelativeToOrigin` is true). */
readonly x: number readonly x: number
/** World Y coordinate. */ /** World Y coordinate. */
readonly y: number readonly y: number
/** Entity classification for crosshair color and name label rules. */ /** Entity classification for crosshair color and name label rules. */
readonly kind: 'party' | 'npc' | 'mercenary' | 'summon' | 'boss' | 'superunique' | 'unique' readonly kind:
| 'party'
| 'npc'
| 'mercenary'
| 'summon'
| 'boss'
| 'superunique'
| 'unique'
| 'player'
| 'corpse'
| 'ownPet'
| 'partyPet'
| 'nonPartyPlayer'
/** Optional display name (drawn in `Font6` when `showNames` is true for allies/NPCs). */ /** Optional display name (drawn in `Font6` when `showNames` is true for allies/NPCs). */
readonly name?: string readonly name?: string
/** Optional monster class ID (e.g. `357` Valkyrie, `291` Iron Golem -> white `0x1F`). */ /** Optional monster class ID (e.g. `357` Valkyrie, `291` Iron Golem -> white `0x216`). */
readonly classId?: number readonly classId?: number
/** Whether the entity is alive (dead monsters are omitted from crosshair rendering). */ /** Whether the entity is alive (dead monsters are omitted from crosshair rendering). */
readonly alive?: boolean readonly alive?: boolean
} }
/** Active Town Portal descriptor for dynamic portal rendering (`0x6fb10dc5`). */ /** Active Town Portal descriptor for dynamic portal rendering (`0x6fb11490`). */
export interface AutomapPortalDescriptor { export interface AutomapPortalDescriptor {
readonly x: number readonly x: number
readonly y: number readonly y: number
@ -417,7 +461,7 @@ export interface AutomapRenderTelemetry {
mode: 'minimap' | 'full' mode: 'minimap' | 'full'
bMinimap: boolean bMinimap: boolean
divisor: 10 | 20 divisor: 10 | 20
fadeState: 0 | 1 | 2 fadeState: 0 | 1 | 2 | 3
fadeAlpha: number fadeAlpha: number
showNames: boolean showNames: boolean
panX: number panX: number
@ -467,12 +511,12 @@ export class Minimap {
/** /**
* `true` = Corner Minimap (`nDivisor = 20`, `*S.dc6` half-scale sheets, `279×225` clipped viewport). * `true` = Corner Minimap (`nDivisor = 20`, `*S.dc6` half-scale sheets, `279×225` clipped viewport).
* `false` = Full-Screen Automap (`nDivisor = 10`, full-scale sheets, `[0, 0, screenW, screenH - 48]`). * `false` = Full-Screen Automap (`nDivisor = 10`, full-scale sheets, `[0, 0, screenW, screenH]`).
*/ */
bMinimap = true bMinimap = true
/** 3-state fade index (`0` = 100%, `1` = 75%, `2` = 50%), cycled by `F9`. */ /** 4-state fade index (`0` = 100%, `1` = 75%/distance, `2` = 50%, `3` = 25%), cycled by `F9`. */
fadeState: 0 | 1 | 2 = 0 fadeState: 0 | 1 | 2 | 3 = 0
/** Whether ally/NPC names and labels are displayed (`F10` toggles). */ /** Whether ally/NPC names and labels are displayed (`F10` toggles). */
showNames = true showNames = true
@ -521,6 +565,32 @@ export class Minimap {
hudText: null, hudText: null,
} }
constructor() {
try {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('d2_automap_fade')
if (saved !== null) {
const val = parseInt(saved, 10)
if (val >= 0 && val <= 3) {
this.fadeState = val as 0 | 1 | 2 | 3
}
}
}
} catch {
// ignore
}
}
private persistFadeState(): void {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('d2_automap_fade', String(this.fadeState))
}
} catch {
// ignore
}
}
/** Current mode (`'minimap'` when `bMinimap === true`, `'full'` when `bMinimap === false`). */ /** Current mode (`'minimap'` when `bMinimap === true`, `'full'` when `bMinimap === false`). */
get mode(): 'minimap' | 'full' { get mode(): 'minimap' | 'full' {
return this.bMinimap ? 'minimap' : 'full' return this.bMinimap ? 'minimap' : 'full'
@ -549,7 +619,7 @@ export class Minimap {
} }
/** Alias for `fadeState`. */ /** Alias for `fadeState`. */
get fadeMode(): 0 | 1 | 2 { get fadeMode(): 0 | 1 | 2 | 3 {
return this.fadeState return this.fadeState
} }
@ -583,6 +653,31 @@ export class Minimap {
return this.lastRenderStats.viewport return this.lastRenderStats.viewport
} }
/**
* Distance-based transparency lookup for fadeState 1 in Full Automap mode (`0x6fb103d5`),
* returning uniform alpha in other fade states or Corner Minimap mode.
*/
getCellFadeAlpha(
screenX: number,
screenY: number,
centerX: number,
centerY: number,
bMinimap: boolean,
): number {
if (this.fadeState === 0) return 1.0
if (this.fadeState === 2) return 0.5
if (this.fadeState === 3) return 0.25
if (bMinimap) return 0.75
const dx = Math.abs(screenX - centerX)
const dy = Math.abs(screenY - centerY)
if (dx > 140 || dy > 150) return 0.25
const dist = Math.hypot(dx, dy)
if (dist < 50) return 1.0
if (dist < 100) return 0.75
if (dist < 150) return 0.5
return 0.25
}
/** /**
* Toggles automap visibility (`Tab` key). * Toggles automap visibility (`Tab` key).
*/ */
@ -626,10 +721,11 @@ export class Minimap {
} }
/** /**
* Cycles automap fade state (`0 -> 1 -> 2 -> 0`, `F9` key). * Cycles automap fade state (`0 -> 1 -> 2 -> 3 -> 0`, `F9` key).
*/ */
cycleFade(): 0 | 1 | 2 { cycleFade(): 0 | 1 | 2 | 3 {
this.fadeState = ((this.fadeState + 1) % 3) as 0 | 1 | 2 this.fadeState = ((this.fadeState + 1) % 4) as 0 | 1 | 2 | 3
this.persistFadeState()
return this.fadeState return this.fadeState
} }
@ -700,16 +796,18 @@ export class Minimap {
await Promise.all( await Promise.all(
entries.map(async ([sheetName, meta]) => { entries.map(async ([sheetName, meta]) => {
const candidateUrls = [ const url = baseUrl
meta.image, ? `${baseUrl.replace(/\/+$/, '')}/${meta.image.replace(/^\/+/, '')}`
`${baseUrl}/${meta.image.replace(/^\/+/, '')}`, : meta.image
`/ui/automap-${sheetName.toLowerCase()}.png`,
`${baseUrl}/automap-${sheetName.toLowerCase()}.png`,
]
for (const url of candidateUrls) {
try { try {
const res = await fetch(url) const res = await fetch(url)
if (!res.ok) continue if (!res.ok) {
const err = new Error(
`Failed to load automap atlas ${sheetName} from ${url}: status ${res.status}`,
)
reportMissingArt('automap', sheetName, err.message)
throw err
}
const blob = await res.blob() const blob = await res.blob()
const bitmap = await createImageBitmap(blob, { const bitmap = await createImageBitmap(blob, {
premultiplyAlpha: 'none', premultiplyAlpha: 'none',
@ -719,10 +817,9 @@ export class Minimap {
bitmap.close() bitmap.close()
this.atlases.set(sheetName, handle) this.atlases.set(sheetName, handle)
loadedCount += 1 loadedCount += 1
return } catch (err) {
} catch { reportMissingArt('automap', sheetName, String(err))
// Try next candidate URL throw err
}
} }
}), }),
) )
@ -966,8 +1063,9 @@ export class Minimap {
gl.scissor(scissorX, scissorY, scissorW, scissorH) gl.scissor(scissorX, scissorY, scissorW, scissorH)
} }
const alpha = this.fadeAlpha const isDistanceFade = this.fadeState === 1 && !this.bMinimap
const tint: readonly [number, number, number, number] = [1, 1, 1, alpha] const uniformAlpha = AUTOMAP_FADE_ALPHAS[this.fadeState]
const uniformTint: readonly [number, number, number, number] = [1, 1, 1, uniformAlpha]
const drawLayerCells = ( const drawLayerCells = (
cells: readonly AutomapCell[], cells: readonly AutomapCell[],
@ -995,6 +1093,21 @@ export class Minimap {
continue continue
} }
const tint = isDistanceFade
? ([
1,
1,
1,
this.getCellFadeAlpha(
rect.screenX,
rect.screenY,
vp.centerX,
vp.centerY,
this.bMinimap,
),
] as const)
: uniformTint
const atlasFrame: AtlasFrame = { const atlasFrame: AtlasFrame = {
x: frame.x, x: frame.x,
y: frame.y, y: frame.y,
@ -1017,7 +1130,7 @@ export class Minimap {
} }
} }
// Strict 1.13c 4-layer draw order (`0x6fb103d0..0x6fb10450`): // Strict 1.13c 4-layer draw order (`0x6fb10489`):
// 1. Layer 0 (`floors`) // 1. Layer 0 (`floors`)
drawLayerCells(automapLayer.floors, 'floors', false) drawLayerCells(automapLayer.floors, 'floors', false)
// 2. Layer 1 (`walls`) // 2. Layer 1 (`walls`)
@ -1027,14 +1140,16 @@ export class Minimap {
// 4. Layer 3 (`extras` — pre-revealed town entrances/crosses + revealed warps/waypoints/shrines) // 4. Layer 3 (`extras` — pre-revealed town entrances/crosses + revealed warps/waypoints/shrines)
drawLayerCells(automapLayer.extras, 'extras', true) drawLayerCells(automapLayer.extras, 'extras', true)
// 5. Dynamic active Town Portals (`0x6fb10dc5`: `MaxiMap` / `MaxiMapS` frame 300 in Level 4, else 306/307) // 5. Dynamic active Town Portals (`0x6fb11490`: `MaxiMap` / `MaxiMapS` frame 300 in Level 4, else 306/307)
const maxiSheetName = resolveAutomapSheetName(0, this.bMinimap) const maxiSheetName = resolveAutomapSheetName(0, this.bMinimap)
const maxiMeta = AUTOMAP_UI_MANIFEST.atlases[maxiSheetName] const maxiMeta = AUTOMAP_UI_MANIFEST.atlases[maxiSheetName]
const maxiAtlas = this.resolveAtlasHandle(renderer, maxiSheetName, maxiMeta) const maxiAtlas = this.resolveAtlasHandle(renderer, maxiSheetName, maxiMeta)
const effectiveLevelId = options?.levelId ?? level.levelId ?? 1 const effectiveLevelId = options?.levelId ?? level.levelId ?? 0
const RED_PORTAL_EXCLUDED_LEVELS = new Set([111, 112, 117, 125, 126, 127])
if (options?.portals && maxiMeta && maxiAtlas) { if (options?.portals && maxiMeta && maxiAtlas) {
for (const portal of options.portals) { for (const portal of options.portals) {
if (portal.active === false) continue if (portal.active === false) continue
if (portal.kind === 'red' && RED_PORTAL_EXCLUDED_LEVELS.has(effectiveLevelId)) continue
const pClientX = Math.round(portal.x - originX) const pClientX = Math.round(portal.x - originX)
const pClientY = Math.round(portal.y - originY) const pClientY = Math.round(portal.y - originY)
const xPixel = Math.trunc((pClientX * 2) / 10) const xPixel = Math.trunc((pClientX * 2) / 10)
@ -1072,7 +1187,7 @@ export class Minimap {
atlas: maxiAtlas, atlas: maxiAtlas,
width: rect.width / zoom, width: rect.width / zoom,
height: rect.height / zoom, height: rect.height / zoom,
tint, tint: [1, 1, 1, this.fadeAlpha],
}, },
) )
this.lastRenderStats.spritesDrawn.portals += 1 this.lastRenderStats.spritesDrawn.portals += 1
@ -1089,10 +1204,12 @@ export class Minimap {
} }
} }
// 6. Dynamic Entity Crosshairs (`AUTOMAP_RenderEntitiesAndPortals` @ `0x6fb10dc5`) // 6. Dynamic Entity Crosshairs (`AUTOMAP_DrawCrosshair` @ `0x6fb11490` & `0x6fabd390`)
if (options?.entities) { if (options?.entities) {
for (const entity of options.entities) { for (const entity of options.entities) {
if (entity.alive === false) continue if (entity.kind !== 'corpse' && entity.alive === false) continue
if (entity.classId === 537 || entity.classId === 538 || entity.classId === 539) continue
const eClientX = Math.round(entity.x - originX) const eClientX = Math.round(entity.x - originX)
const eClientY = Math.round(entity.y - originY) const eClientY = Math.round(entity.y - originY)
const ex = Math.trunc(eClientX / vp.divisor) - offsetX const ex = Math.trunc(eClientX / vp.divisor) - offsetX
@ -1102,17 +1219,30 @@ export class Minimap {
} }
let color: readonly [number, number, number, number] = AUTOMAP_CROSSHAIR_COLORS.npc let color: readonly [number, number, number, number] = AUTOMAP_CROSSHAIR_COLORS.npc
if (entity.kind === 'party') { if (entity.kind === 'player') {
color = AUTOMAP_CROSSHAIR_COLORS.player
this.lastRenderStats.crosshairsDrawn.player += 1
} else if (entity.kind === 'party') {
color = AUTOMAP_CROSSHAIR_COLORS.party color = AUTOMAP_CROSSHAIR_COLORS.party
this.lastRenderStats.crosshairsDrawn.party += 1 this.lastRenderStats.crosshairsDrawn.party += 1
} else if (entity.kind === 'nonPartyPlayer') {
color = AUTOMAP_CROSSHAIR_COLORS.nonPartyPlayer
} else if (entity.kind === 'corpse') {
color = AUTOMAP_CROSSHAIR_COLORS.corpse
} else if (entity.kind === 'npc') { } else if (entity.kind === 'npc') {
color = AUTOMAP_CROSSHAIR_COLORS.npc color = AUTOMAP_CROSSHAIR_COLORS.npc
this.lastRenderStats.crosshairsDrawn.npcs += 1 this.lastRenderStats.crosshairsDrawn.npcs += 1
} else if (entity.kind === 'mercenary' || entity.kind === 'summon') { } else if (entity.kind === 'ownPet' || entity.kind === 'mercenary') {
color = AUTOMAP_CROSSHAIR_COLORS.ownPet
this.lastRenderStats.crosshairsDrawn.mercenaries += 1
} else if (entity.kind === 'partyPet') {
color = AUTOMAP_CROSSHAIR_COLORS.partyPet
this.lastRenderStats.crosshairsDrawn.mercenaries += 1
} else if (entity.kind === 'summon') {
color = color =
entity.classId === 357 || entity.classId === 291 entity.classId === 357 || entity.classId === 291
? AUTOMAP_CROSSHAIR_COLORS.valkyrieOrIronGolem ? AUTOMAP_CROSSHAIR_COLORS.valkyrieOrIronGolem
: AUTOMAP_CROSSHAIR_COLORS.mercenary : AUTOMAP_CROSSHAIR_COLORS.ownPet
this.lastRenderStats.crosshairsDrawn.mercenaries += 1 this.lastRenderStats.crosshairsDrawn.mercenaries += 1
} else { } else {
color = AUTOMAP_CROSSHAIR_COLORS.boss color = AUTOMAP_CROSSHAIR_COLORS.boss
@ -1134,7 +1264,7 @@ export class Minimap {
} }
} }
// 7. Player 12-segment closed directional polygon crosshair (`AUTOMAP_DrawCrosshair` @ `0x6fb107f0`) // 7. Player 12-segment closed directional polygon crosshair (`AUTOMAP_DrawCrosshair` @ `0x6fb11490`)
const playerScreenX = Math.trunc(playerClientX / vp.divisor) - offsetX const playerScreenX = Math.trunc(playerClientX / vp.divisor) - offsetX
const playerScreenY = Math.trunc(playerClientY / vp.divisor) - offsetY const playerScreenY = Math.trunc(playerClientY / vp.divisor) - offsetY
if ( if (
@ -1281,7 +1411,7 @@ export class Minimap {
} }
/** /**
* Draws the 12-segment closed directional polygon crosshair (`AUTOMAP_DrawCrosshair` @ `0x6fb107f0`). * Draws the 12-segment closed directional polygon crosshair (`AUTOMAP_DrawCrosshair` @ `0x6fb11490`).
*/ */
private drawCrosshairPolygon( private drawCrosshairPolygon(
renderer: SpriteRenderer, renderer: SpriteRenderer,
@ -1338,7 +1468,7 @@ export class Minimap {
seen: Uint8Array, seen: Uint8Array,
cell: AutomapCell, cell: AutomapCell,
): boolean { ): boolean {
if (level.cellsX <= 0 || level.cellsY <= 0) return true if (level.cellsX <= 0 || level.cellsY <= 0) return false
const cellX = const cellX =
typeof cell.cellX === 'number' && Number.isFinite(cell.cellX) typeof cell.cellX === 'number' && Number.isFinite(cell.cellX)
? cell.cellX ? cell.cellX
@ -1347,34 +1477,21 @@ export class Minimap {
typeof cell.cellY === 'number' && Number.isFinite(cell.cellY) typeof cell.cellY === 'number' && Number.isFinite(cell.cellY)
? cell.cellY ? cell.cellY
: automapPixelToCellCoords(cell.xPixel, cell.yPixel).cellY : automapPixelToCellCoords(cell.xPixel, cell.yPixel).cellY
const clampedX = Math.max(0, Math.min(level.cellsX - 1, cellX)) if (cellX < 0 || cellX >= level.cellsX || cellY < 0 || cellY >= level.cellsY) {
const clampedY = Math.max(0, Math.min(level.cellsY - 1, cellY)) return false
return seen[clampedY * level.cellsX + clampedX] !== 0 }
return seen[cellY * level.cellsX + cellX] !== 0
} }
/** /**
* Resolves the `AtlasHandle` for `sheetName`, synthesizing a deterministic handle * Resolves the `AtlasHandle` for `sheetName`.
* when running under headless unit-test mocks without a live WebGL context.
*/ */
private resolveAtlasHandle( private resolveAtlasHandle(
renderer: SpriteRenderer, _renderer: SpriteRenderer,
sheetName: AutomapSpriteKey, sheetName: AutomapSpriteKey,
meta: AutomapSpriteAtlasMeta | undefined, _meta: AutomapSpriteAtlasMeta | undefined,
): AtlasHandle | null { ): AtlasHandle | null {
const existing = this.atlases.get(sheetName) return this.atlases.get(sheetName) ?? null
if (existing) return existing
if (!meta) return null
const gl = (renderer as unknown as { gl?: unknown }).gl
if (!gl) {
const synthetic: AtlasHandle = {
texture: sheetName as unknown as WebGLTexture,
width: meta.atlasWidth,
height: meta.atlasHeight,
}
this.atlases.set(sheetName, synthetic)
return synthetic
}
return null
} }
/** /**
@ -1445,7 +1562,7 @@ export class Minimap {
} }
const layer: AutomapLayerData = { const layer: AutomapLayerData = {
layerId: level.levelId ?? 1, layerId: level.levelId ?? 0,
levelTypeId: 1, levelTypeId: 1,
floors, floors,
walls, walls,

View File

@ -280,42 +280,42 @@ describe('R3 Authentic 1.13c Isometric Sprite Automap & Minimap UI', () => {
}) })
describe('Dual Viewports & Panel Shifts (computeAutomapViewport)', () => { describe('Dual Viewports & Panel Shifts (computeAutomapViewport)', () => {
it('computes 279x225 clipped Corner Minimap viewport and shifts to (16, 16) when right panel is open', () => { it('computes 279x225 clipped Corner Minimap viewport and shifts to (0, 75) when right panel is open', () => {
const vpRightClosed = computeAutomapViewport(800, 600, true, false, false, 0, 0) const vpRightClosed = computeAutomapViewport(800, 600, true, false, false, 0, 0)
expect(vpRightClosed.bMinimap).toBe(true) expect(vpRightClosed.bMinimap).toBe(true)
expect(vpRightClosed.divisor).toBe(20) expect(vpRightClosed.divisor).toBe(20)
expect(vpRightClosed.x).toBe(505) // 800 - 295 expect(vpRightClosed.x).toBe(519) // 800 - 281
expect(vpRightClosed.y).toBe(16) expect(vpRightClosed.y).toBe(57)
expect(vpRightClosed.width).toBe(279) expect(vpRightClosed.width).toBe(279)
expect(vpRightClosed.height).toBe(225) expect(vpRightClosed.height).toBe(225)
expect(vpRightClosed.centerX).toBe(505 + 139) expect(vpRightClosed.centerX).toBe(658) // 800 - 142
expect(vpRightClosed.centerY).toBe(16 + 112) expect(vpRightClosed.centerY).toBe(169) // 57 + 112
const vpRightOpen = computeAutomapViewport(800, 600, true, false, true, 0, 0) const vpRightOpen = computeAutomapViewport(800, 600, true, false, true, 0, 0)
expect(vpRightOpen.x).toBe(16) expect(vpRightOpen.x).toBe(0)
expect(vpRightOpen.y).toBe(16) expect(vpRightOpen.y).toBe(75)
expect(vpRightOpen.width).toBe(279) expect(vpRightOpen.width).toBe(279)
expect(vpRightOpen.height).toBe(225) expect(vpRightOpen.height).toBe(225)
expect(vpRightOpen.centerX).toBe(16 + 139) expect(vpRightOpen.centerX).toBe(139)
expect(vpRightOpen.centerY).toBe(16 + 112) expect(vpRightOpen.centerY).toBe(187)
}) })
it('computes Full-Screen Automap viewport [0, 0, W, H-48] and shifts center +-160px for open side panels', () => { it('computes Full-Screen Automap viewport [0, 0, W, H] and shifts center +-W>>2 for open side panels', () => {
const vpNeutral = computeAutomapViewport(800, 600, false, false, false, 0, 0) const vpNeutral = computeAutomapViewport(800, 600, false, false, false, 0, 0)
expect(vpNeutral.bMinimap).toBe(false) expect(vpNeutral.bMinimap).toBe(false)
expect(vpNeutral.divisor).toBe(10) expect(vpNeutral.divisor).toBe(10)
expect(vpNeutral.x).toBe(0) expect(vpNeutral.x).toBe(0)
expect(vpNeutral.y).toBe(0) expect(vpNeutral.y).toBe(0)
expect(vpNeutral.width).toBe(800) expect(vpNeutral.width).toBe(800)
expect(vpNeutral.height).toBe(552) // 600 - 48 expect(vpNeutral.height).toBe(600)
expect(vpNeutral.centerX).toBe(400) expect(vpNeutral.centerX).toBe(400)
expect(vpNeutral.centerY).toBe(276) expect(vpNeutral.centerY).toBe(280) // (600 - 40) / 2
const vpLeftOpen = computeAutomapViewport(800, 600, false, true, false, 0, 0) const vpLeftOpen = computeAutomapViewport(800, 600, false, true, false, 0, 0)
expect(vpLeftOpen.centerX).toBe(560) // 400 + 160 expect(vpLeftOpen.centerX).toBe(600) // 400 + 200
const vpRightOpen = computeAutomapViewport(800, 600, false, false, true, 0, 0) const vpRightOpen = computeAutomapViewport(800, 600, false, false, true, 0, 0)
expect(vpRightOpen.centerX).toBe(240) // 400 - 160 expect(vpRightOpen.centerX).toBe(200) // 400 - 200
const vpBothOpen = computeAutomapViewport(800, 600, false, true, true, 0, 0) const vpBothOpen = computeAutomapViewport(800, 600, false, true, true, 0, 0)
expect(vpBothOpen.centerX).toBe(400) expect(vpBothOpen.centerX).toBe(400)
@ -342,9 +342,9 @@ describe('R3 Authentic 1.13c Isometric Sprite Automap & Minimap UI', () => {
{ playerWorldX: 0, playerWorldY: 320, levelId: 1 }, { playerWorldX: 0, playerWorldY: 320, levelId: 1 },
) )
// Scissor was enabled with WebGL bottom-left coordinates (y = 600 - 552 = 48) and disabled afterwards // Scissor was enabled with WebGL bottom-left coordinates (y = 600 - 600 = 0) and disabled afterwards
expect(scissors.length).toBe(2) expect(scissors.length).toBe(2)
expect(scissors[0]).toEqual({ x: 0, y: 48, width: 800, height: 552 }) expect(scissors[0]).toEqual({ x: 0, y: 0, width: 800, height: 600 })
expect(scissors[1]).toBeNull() expect(scissors[1]).toBeNull()
const spriteDraws = draws.filter((d): d is RecordedSpriteDraw => d.kind === 'sprite') const spriteDraws = draws.filter((d): d is RecordedSpriteDraw => d.kind === 'sprite')
@ -435,13 +435,15 @@ describe('R3 Authentic 1.13c Isometric Sprite Automap & Minimap UI', () => {
expect(minimap.panX).toBe(0) expect(minimap.panX).toBe(0)
expect(minimap.panY).toBe(0) expect(minimap.panY).toBe(0)
// F9 fade mode cycles 0 -> 1 -> 2 -> 0 // F9 fade mode cycles 0 -> 1 -> 2 -> 3 -> 0
expect(minimap.fadeMode).toBe(0) expect(minimap.fadeMode).toBe(0)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0]) expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0])
expect(minimap.cycleFade()).toBe(1) expect(minimap.cycleFade()).toBe(1)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[1]) expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[1])
expect(minimap.cycleFade()).toBe(2) expect(minimap.cycleFade()).toBe(2)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2]) expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2])
expect(minimap.cycleFade()).toBe(3)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[3])
expect(minimap.cycleFade()).toBe(0) expect(minimap.cycleFade()).toBe(0)
// F10 name toggle // F10 name toggle

View File

@ -903,33 +903,33 @@ describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () =>
// F9: Corner Minimap (bMinimap=1) vs Full Automap (bMinimap=0), Viewport, Panel Shift & Hotkeys // F9: Corner Minimap (bMinimap=1) vs Full Automap (bMinimap=0), Viewport, Panel Shift & Hotkeys
// ============================================================================ // ============================================================================
describe('F9: Corner Minimap (bMinimap=1) vs Full Automap (bMinimap=0), Viewport, Panel Shift & Hotkeys', () => { describe('F9: Corner Minimap (bMinimap=1) vs Full Automap (bMinimap=0), Viewport, Panel Shift & Hotkeys', () => {
it('F9.1: computes 279x225 Corner Minimap viewport at (W-295, 16) and shifts to (16, 16) when right panel opens', () => { it('F9.1: computes 279x225 Corner Minimap viewport at (W-281, 57) and shifts to (0, 75) when right panel opens', () => {
const vpTopRight = computeAutomapViewport(800, 600, true, false, false, 0, 0) const vpTopRight = computeAutomapViewport(800, 600, true, false, false, 0, 0)
expect(vpTopRight).toMatchObject({ expect(vpTopRight).toMatchObject({
bMinimap: true, bMinimap: true,
divisor: 20, divisor: 20,
x: 505, x: 519,
y: 16, y: 57,
width: 279, width: 279,
height: 225, height: 225,
centerX: 644, centerX: 658,
centerY: 128, centerY: 169,
}) })
const vpTopLeft = computeAutomapViewport(800, 600, true, false, true, 0, 0) const vpTopLeft = computeAutomapViewport(800, 600, true, false, true, 0, 0)
expect(vpTopLeft).toMatchObject({ expect(vpTopLeft).toMatchObject({
bMinimap: true, bMinimap: true,
divisor: 20, divisor: 20,
x: 16, x: 0,
y: 16, y: 75,
width: 279, width: 279,
height: 225, height: 225,
centerX: 155, centerX: 139,
centerY: 128, centerY: 187,
}) })
}) })
it('F9.2: computes Full Automap viewport [0, 0, W, H-48] and shifts centerX by +-160px when side panels open', () => { it('F9.2: computes Full Automap viewport [0, 0, W, H] and shifts centerX by +-W>>2 when side panels open', () => {
const vpCenter = computeAutomapViewport(800, 600, false, false, false, 0, 0) const vpCenter = computeAutomapViewport(800, 600, false, false, false, 0, 0)
expect(vpCenter).toMatchObject({ expect(vpCenter).toMatchObject({
bMinimap: false, bMinimap: false,
@ -937,19 +937,19 @@ describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () =>
x: 0, x: 0,
y: 0, y: 0,
width: 800, width: 800,
height: 552, height: 600,
centerX: 400, centerX: 400,
centerY: 276, centerY: 280,
centerShiftX: 0, centerShiftX: 0,
}) })
const vpLeft = computeAutomapViewport(800, 600, false, true, false, 0, 0) const vpLeft = computeAutomapViewport(800, 600, false, true, false, 0, 0)
expect(vpLeft.centerX).toBe(560) expect(vpLeft.centerX).toBe(600)
expect(vpLeft.centerShiftX).toBe(160) expect(vpLeft.centerShiftX).toBe(200)
const vpRight = computeAutomapViewport(800, 600, false, false, true, 0, 0) const vpRight = computeAutomapViewport(800, 600, false, false, true, 0, 0)
expect(vpRight.centerX).toBe(240) expect(vpRight.centerX).toBe(200)
expect(vpRight.centerShiftX).toBe(-160) expect(vpRight.centerShiftX).toBe(-200)
}) })
it('F9.3: toggles visibility via Tab (toggle/toggleVisibility) and switches mode via V (toggleMode/toggleMinimapMode)', () => { it('F9.3: toggles visibility via Tab (toggle/toggleVisibility) and switches mode via V (toggleMode/toggleMinimapMode)', () => {
@ -983,9 +983,9 @@ describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () =>
expect(minimap.panY).toBe(0) expect(minimap.panY).toBe(0)
}) })
it('F9.5: cycles 3 fade states via F9 (1.0 -> 0.75 -> 0.5 -> 1.0) and toggles names via F10', () => { it('F9.5: cycles 4 fade states via F9 (1.0 -> 0.75 -> 0.5 -> 0.25 -> 1.0) and toggles names via F10', () => {
const minimap = new Minimap() const minimap = new Minimap()
expect(AUTOMAP_FADE_ALPHAS).toEqual([1.0, 0.75, 0.5]) expect(AUTOMAP_FADE_ALPHAS).toEqual([1.0, 0.75, 0.5, 0.25])
expect(minimap.fadeState).toBe(0) expect(minimap.fadeState).toBe(0)
expect(minimap.fadeAlpha).toBe(1.0) expect(minimap.fadeAlpha).toBe(1.0)
@ -993,6 +993,8 @@ describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () =>
expect(minimap.fadeAlpha).toBe(0.75) expect(minimap.fadeAlpha).toBe(0.75)
expect(minimap.cycleFade()).toBe(2) expect(minimap.cycleFade()).toBe(2)
expect(minimap.fadeAlpha).toBe(0.5) expect(minimap.fadeAlpha).toBe(0.5)
expect(minimap.cycleFade()).toBe(3)
expect(minimap.fadeAlpha).toBe(0.25)
expect(minimap.cycleFade()).toBe(0) expect(minimap.cycleFade()).toBe(0)
expect(minimap.fadeAlpha).toBe(1.0) expect(minimap.fadeAlpha).toBe(1.0)
@ -1352,7 +1354,7 @@ describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () =>
}, },
) )
expect(minimap.warpLabelsLastFrame).toContainEqual( expect(minimap.warpLabelsLastFrame).toContainEqual(
expect.objectContaining({ text: 'Stash', kind: 'stash', color: 'gold' }), expect.objectContaining({ text: expect.stringMatching(/^(?:Stash|储物箱)$/), kind: 'stash', color: 'gold' }),
) )
}) })

View File

@ -702,13 +702,13 @@ describe('Tier 2 — Boundary & Corner Cases (F1–F13, >= 5 tests per feature)'
expect(minimap.panY).toBe(0) expect(minimap.panY).toBe(0)
}) })
it('F9.B5: wraps fadeState cleanly across multiple full cycles (0 -> 1 -> 2 -> 0 -> 1 -> 2 -> 0)', () => { it('F9.B5: wraps fadeState cleanly across multiple full cycles (0 -> 1 -> 2 -> 3 -> 0 -> 1 -> 2)', () => {
const minimap = new Minimap() const minimap = new Minimap()
const sequence: number[] = [] const sequence: number[] = []
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
sequence.push(minimap.cycleFade()) sequence.push(minimap.cycleFade())
} }
expect(sequence).toEqual([1, 2, 0, 1, 2, 0]) expect(sequence).toEqual([1, 2, 3, 0, 1, 2])
}) })
}) })

View File

@ -261,22 +261,22 @@ describe('Tier 3 — Cross-Feature Combinations (18 pairwise/multi-feature tests
} }
}) })
it('C5 (F8 + F9 + F11): Full Automap HUD panel shift (+-160px) + Arrow-key panning (+-8px) + 12-segment player crosshair shift', () => { it('C5 (F8 + F9 + F11): Full Automap HUD panel shift (+-200px) + Arrow-key panning (+-8px) + 12-segment player crosshair shift', () => {
const minimap = new Minimap() const minimap = new Minimap()
registerAtlases(minimap) registerAtlases(minimap)
minimap.mode = 'full' minimap.mode = 'full'
minimap.panBySteps(2, -1) // panX = +16, panY = -8 minimap.panBySteps(2, -1) // panX = +16, panY = -8
const vpLeft = computeAutomapViewport(800, 600, false, true, false, minimap.panX, minimap.panY) const vpLeft = computeAutomapViewport(800, 600, false, true, false, minimap.panX, minimap.panY)
expect(vpLeft.centerShiftX).toBe(160) expect(vpLeft.centerShiftX).toBe(200)
expect(vpLeft.centerX).toBe(560) expect(vpLeft.centerX).toBe(600)
expect(vpLeft.effectivePanX).toBe(16) expect(vpLeft.effectivePanX).toBe(16)
expect(vpLeft.effectivePanY).toBe(-8) expect(vpLeft.effectivePanY).toBe(-8)
// Player at (0, 0) -> offsetX = 0 - 560 + 16 = -544 -> playerScreenX = 0 - (-544) = 544 // Player at (0, 0) -> offsetX = 0 - 600 + 16 = -584 -> playerScreenX = 0 - (-584) = 584
// offsetY = 0 - 276 + (-8) = -284 -> playerScreenY = 0 - (-284) = 284 // offsetY = 0 - 280 + (-8) = -288 -> playerScreenY = 0 - (-288) = 288
const expectedSegs = getCrosshairSegments(544, 284, false) const expectedSegs = getCrosshairSegments(584, 288, false)
expect(expectedSegs[0]).toEqual({ x0: 544, y0: 282, x1: 548, y1: 280 }) expect(expectedSegs[0]).toEqual({ x0: 584, y0: 286, x1: 588, y1: 284 })
const level: MinimapLevel = { const level: MinimapLevel = {
key: 'c5-shift-pan', key: 'c5-shift-pan',
@ -330,8 +330,8 @@ describe('Tier 3 — Cross-Feature Combinations (18 pairwise/multi-feature tests
{ width: 800, height: 600 }, { width: 800, height: 600 },
{ playerWorldX: 0, playerWorldY: 0, rightPanelOpen: false }, { playerWorldX: 0, playerWorldY: 0, rightPanelOpen: false },
) )
expect(minimap.viewportLastFrame.x).toBe(505) expect(minimap.viewportLastFrame.x).toBe(519)
expect(minimap.viewportLastFrame.centerX).toBe(644) expect(minimap.viewportLastFrame.centerX).toBe(658)
minimap.draw( minimap.draw(
renderer, renderer,
@ -342,8 +342,8 @@ describe('Tier 3 — Cross-Feature Combinations (18 pairwise/multi-feature tests
{ width: 800, height: 600 }, { width: 800, height: 600 },
{ playerWorldX: 0, playerWorldY: 0, rightPanelOpen: true }, { playerWorldX: 0, playerWorldY: 0, rightPanelOpen: true },
) )
expect(minimap.viewportLastFrame.x).toBe(16) expect(minimap.viewportLastFrame.x).toBe(0)
expect(minimap.viewportLastFrame.centerX).toBe(155) expect(minimap.viewportLastFrame.centerX).toBe(139)
expect(minimap.crosshairsDrawnLastFrame.player).toBe(1) expect(minimap.crosshairsDrawnLastFrame.player).toBe(1)
}) })
@ -426,10 +426,10 @@ describe('Tier 3 — Cross-Feature Combinations (18 pairwise/multi-feature tests
) )
}) })
it('C9 (F8 + F9): F9 Fade Mode cycling (1.0 -> 0.75 -> 0.5) propagates exact tint alpha to all rendered sprite layers and portals', () => { it('C9 (F8 + F9): F9 Fade Mode cycling (1.0 -> 0.75 -> 0.5 -> 0.25) propagates exact tint alpha to all rendered sprite layers and portals', () => {
const minimap = new Minimap() const minimap = new Minimap()
registerAtlases(minimap) registerAtlases(minimap)
minimap.mode = 'full' minimap.mode = 'minimap'
const coords = computeAutomapTilePixelCoords(4, 4, 0) const coords = computeAutomapTilePixelCoords(4, 4, 0)
const level: MinimapLevel = { const level: MinimapLevel = {
key: 'c9-fade-tint', key: 'c9-fade-tint',
@ -449,7 +449,7 @@ describe('Tier 3 — Cross-Feature Combinations (18 pairwise/multi-feature tests
} }
minimap.reveal(level, 4, 4, 0, 320, true) minimap.reveal(level, 4, 4, 0, 320, true)
for (const expectedAlpha of [1.0, 0.75, 0.5]) { for (const expectedAlpha of [1.0, 0.75, 0.5, 0.25]) {
const { renderer, draws } = createRecordingRenderer() const { renderer, draws } = createRecordingRenderer()
minimap.draw( minimap.draw(
renderer, renderer,

View File

@ -741,7 +741,7 @@ describe('Tier 4 — Real-World E2E Scenarios (12 end-to-end scenarios across Ac
rightPanelOpen: false, rightPanelOpen: false,
}, },
) )
expect(minimap.lastRenderStats.viewport.centerShiftX).toBe(160) expect(minimap.lastRenderStats.viewport.centerShiftX).toBe(200)
expect(minimap.lastRenderStats.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2]) expect(minimap.lastRenderStats.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[2])
}) })

View File

@ -958,10 +958,10 @@ describe('Tier 5 — White-Box Adversarial & Differential Stress Coverage (1.13c
{ playerWorldX: 0, playerWorldY: 0 }, { playerWorldX: 0, playerWorldY: 0 },
) )
// Hardware scissor must be enabled at (505, 600 - (16 + 225) = 359, 279, 225) and disabled after // Hardware scissor must be enabled at (519, 600 - (57 + 225) = 318, 279, 225) and disabled after
expect(glCalls.enableCount).toBe(1) expect(glCalls.enableCount).toBe(1)
expect(glCalls.disableCount).toBe(1) expect(glCalls.disableCount).toBe(1)
expect(scissors[0]).toEqual({ x: 505, y: 359, width: 279, height: 225 }) expect(scissors[0]).toEqual({ x: 519, y: 318, width: 279, height: 225 })
expect(scissors[1]).toBeNull() expect(scissors[1]).toBeNull()
// Exactly the 4 inside cells must be drawn, and the 4 outside cells culled! // Exactly the 4 inside cells must be drawn, and the 4 outside cells culled!
@ -972,30 +972,30 @@ describe('Tier 5 — White-Box Adversarial & Differential Stress Coverage (1.13c
it('adv_f8_f9_simultaneous_panel_states_pan_clamp_and_bminimap_toggle', () => { it('adv_f8_f9_simultaneous_panel_states_pan_clamp_and_bminimap_toggle', () => {
// All 4 (leftPanelOpen, rightPanelOpen) states in Full Automap (bMinimap = false) // All 4 (leftPanelOpen, rightPanelOpen) states in Full Automap (bMinimap = false)
expect(computeAutomapViewport(800, 600, false, false, false)).toMatchObject({ expect(computeAutomapViewport(800, 600, false, false, false)).toMatchObject({
vpX: 0, vpY: 0, vpW: 800, vpH: 552, centerX: 400, centerY: 276, centerShiftX: 0, divisor: 10, vpX: 0, vpY: 0, vpW: 800, vpH: 600, centerX: 400, centerY: 280, centerShiftX: 0, divisor: 10,
}) })
expect(computeAutomapViewport(800, 600, false, true, false)).toMatchObject({ expect(computeAutomapViewport(800, 600, false, true, false)).toMatchObject({
vpX: 0, vpY: 0, vpW: 800, vpH: 552, centerX: 560, centerY: 276, centerShiftX: 160, divisor: 10, vpX: 0, vpY: 0, vpW: 800, vpH: 600, centerX: 600, centerY: 280, centerShiftX: 200, divisor: 10,
}) })
expect(computeAutomapViewport(800, 600, false, false, true)).toMatchObject({ expect(computeAutomapViewport(800, 600, false, false, true)).toMatchObject({
vpX: 0, vpY: 0, vpW: 800, vpH: 552, centerX: 240, centerY: 276, centerShiftX: -160, divisor: 10, vpX: 0, vpY: 0, vpW: 800, vpH: 600, centerX: 200, centerY: 280, centerShiftX: -200, divisor: 10,
}) })
expect(computeAutomapViewport(800, 600, false, true, true)).toMatchObject({ expect(computeAutomapViewport(800, 600, false, true, true)).toMatchObject({
vpX: 0, vpY: 0, vpW: 800, vpH: 552, centerX: 400, centerY: 276, centerShiftX: 0, divisor: 10, vpX: 0, vpY: 0, vpW: 800, vpH: 600, centerX: 400, centerY: 280, centerShiftX: 0, divisor: 10,
}) })
// All 4 (leftPanelOpen, rightPanelOpen) states in Corner Minimap (bMinimap = true) // All 4 (leftPanelOpen, rightPanelOpen) states in Corner Minimap (bMinimap = true)
expect(computeAutomapViewport(800, 600, true, false, false)).toMatchObject({ expect(computeAutomapViewport(800, 600, true, false, false)).toMatchObject({
vpX: 505, vpY: 16, vpW: 279, vpH: 225, centerX: 644, centerY: 128, divisor: 20, vpX: 519, vpY: 57, vpW: 279, vpH: 225, centerX: 658, centerY: 169, divisor: 20,
}) })
expect(computeAutomapViewport(800, 600, true, true, false)).toMatchObject({ expect(computeAutomapViewport(800, 600, true, true, false)).toMatchObject({
vpX: 505, vpY: 16, vpW: 279, vpH: 225, centerX: 644, centerY: 128, divisor: 20, vpX: 519, vpY: 57, vpW: 279, vpH: 225, centerX: 658, centerY: 169, divisor: 20,
}) })
expect(computeAutomapViewport(800, 600, true, false, true)).toMatchObject({ expect(computeAutomapViewport(800, 600, true, false, true)).toMatchObject({
vpX: 16, vpY: 16, vpW: 279, vpH: 225, centerX: 155, centerY: 128, divisor: 20, vpX: 0, vpY: 75, vpW: 279, vpH: 225, centerX: 139, centerY: 187, divisor: 20,
}) })
expect(computeAutomapViewport(800, 600, true, true, true)).toMatchObject({ expect(computeAutomapViewport(800, 600, true, true, true)).toMatchObject({
vpX: 16, vpY: 16, vpW: 279, vpH: 225, centerX: 155, centerY: 128, divisor: 20, vpX: 0, vpY: 75, vpW: 279, vpH: 225, centerX: 139, centerY: 187, divisor: 20,
}) })
// Pan behavior across mode switches // Pan behavior across mode switches