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 { getTblLang, type TblLang } from '../i18n/lang.ts'
import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts'
import type {
MinimapLevel,
MinimapMarker,
} from '../ui/minimap.ts'
import {
isWalkableWorldSubTile,
type LevelRoomView,
type LevelView,
} 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 { RenderableUnitInput } from '../view/unit-drawables.ts'
@ -34,12 +39,41 @@ export interface MinimapStashIcon {
export type ActId = 1 | 2 | 3 | 4 | 5
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 {
if (areaId <= 39) return 1
if (areaId <= 74) return 2
if (areaId <= 102) return 3
if (areaId <= 108) return 4
return 5
const act0 = DRLG_GetActNoFromLevelId(areaId)
return (act0 + 1) as ActId
}
/**
* 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}`
}
function buildFallbackLevelView(
seed: number,
difficulty: DifficultyId,
areaId: number,
): 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,
})
interface NodeFsLike {
readFileSync(path: string, encoding: string): string
readFileSync(path: string): { buffer: ArrayBuffer; byteOffset: number; byteLength: number }
existsSync(path: string): boolean
}
interface NodePathLike {
resolve(...args: string[]): string
join(...args: string[]): string
}
/**
* Synchronously loads the packed tile bundle in Node/test environments when MapService
* was not injected via AutomapViewOptions.
*/
function tryLoadPackedBundleNodeSync(): MapService | null {
const proc = (
globalThis as unknown as {
process?: { versions?: { node?: string }; cwd?: () => string }
}
).process
if (!proc || !proc.versions?.node) {
return null
}
const tileWidth = roomGrid * roomTileW
const tileHeight = roomGrid * roomTileH
const cellsX = tileWidth + 1
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
return {
id: areaId,
areaId,
levelId: areaId,
act: actIndex,
seed: seed >>> 0,
difficulty,
originX,
originY,
subOriginX: originX,
subOriginY: originY,
width: subWidth,
height: subHeight,
subWidth,
subHeight,
tileOriginX: baseTileX,
tileOriginY: baseTileY,
tileWidth,
tileHeight,
cellsX,
cellsY,
gridWidth: subWidth,
gridHeight: subHeight,
spawnX: Math.floor(tileWidth / 2),
spawnY: Math.floor(tileHeight / 2),
isoOriginX: cellsY * 80,
isoOriginY: 0,
widthPx: (cellsX + cellsY) * 80,
heightPx: (cellsX + cellsY) * 40,
collision,
collisionMasks: collision,
blocked,
walkable,
walkableCellCount: totalSub,
dt1Names: [],
floors: [],
shadows: [],
walls: [],
roofs: [],
frames: [],
ds1Objects: [],
presetUnits: [],
entrances: [],
warps: [],
landmarks: [],
rooms,
try {
const req =
typeof (globalThis as unknown as { require?: unknown }).require === 'function'
? (globalThis as unknown as { require: (id: string) => unknown }).require
: null
if (!req) return null
const fs = req('node:fs') as NodeFsLike | undefined
const path = req('node:path') as NodePathLike | undefined
if (!fs || !path) return null
const cwd = typeof proc.cwd === 'function' ? proc.cwd() : '.'
const candidates = [
path.resolve(cwd, 'public/act-packs/tiles'),
path.resolve(cwd, 'public/assets/act-packs/tiles'),
path.resolve('/usr/local/google/home/taodao/diablo2-web/public/act-packs/tiles'),
]
let resolvedDir: string | null = null
for (const dir of candidates) {
if (fs.existsSync(path.join(dir, 'drlg-bundle.json'))) {
resolvedDir = dir
break
}
}
if (!resolvedDir) return null
const indexJson = JSON.parse(
fs.readFileSync(path.join(resolvedDir, 'index.json'), 'utf8'),
) as PackedTileCatalogIndex
const manifest = JSON.parse(
fs.readFileSync(path.join(resolvedDir, 'drlg-bundle.json'), 'utf8'),
) as PackedDrlgBundleManifest
const buf = fs.readFileSync(path.join(resolvedDir, 'drlg-bundle.bin'))
const bundleBytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
return MapService.fromPackedBundle(manifest, bundleBytes, indexJson)
} catch (err) {
throw new Error(
`[AutomapView] Failed to synchronously load packed tile bundle from public/act-packs/tiles: ${err instanceof Error ? err.message : String(err)}`,
)
}
}
@ -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.
*/
export class AutomapView {
private readonly mapService: MapService | null
private mapService: MapService | null
private readonly actRevealLevelIds: Readonly<Record<ActId, readonly number[]>>
private mode: AutomapRevealMode
private mapSeed = 0
@ -223,6 +217,9 @@ export class AutomapView {
this.mapService = options?.mapService ?? null
this.mode = options?.initialMode ?? 'off'
this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS
if (!this.mapService) {
this.mapService = tryLoadPackedBundleNodeSync()
}
}
getMode(): AutomapRevealMode {
@ -260,20 +257,19 @@ export class AutomapView {
if (seedChanged) {
this.levelViews.clear()
this.serverRevealedRooms.clear()
if (!this.mapService) {
this.mapService = tryLoadPackedBundleNodeSync()
}
if (this.mapService) {
try {
const loaded = this.mapService.loadAct({
act: this.currentActId - 1,
seed: this.mapSeed,
difficulty: this.difficulty,
areaId,
revealMode: 'all',
})
if (loaded) {
this.levelViews.set(areaId, loaded)
}
} catch {
// Fallback to deterministic synthetic LevelView if MapService has no worker/engine
const loaded = this.mapService.loadAct({
act: this.currentActId - 1,
seed: this.mapSeed,
difficulty: this.difficulty,
areaId,
revealMode: 'all',
})
if (loaded) {
this.levelViews.set(areaId, loaded)
}
}
}
@ -317,15 +313,11 @@ export class AutomapView {
roomX,
roomY,
})
if (this.mapService) {
try {
this.mapService.onAddRoomDataPacket(
{ areaId, roomX, roomY },
{ rebuildView: false },
)
} catch {
// Ignore if MapService is not backed by an in-process engine
}
if (this.mapService && typeof this.mapService.onAddRoomDataPacket === 'function') {
this.mapService.onAddRoomDataPacket(
{ areaId, roomX, roomY },
{ rebuildView: false },
)
}
}
@ -334,15 +326,11 @@ export class AutomapView {
*/
onServerRemoveRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void {
this.serverRevealedRooms.delete(makeRoomKey(areaId, roomX, roomY))
if (this.mapService) {
try {
this.mapService.onRemoveRoomDataPacket(
{ areaId, roomX, roomY },
{ rebuildView: false },
)
} catch {
// Ignore if MapService is not backed by an in-process engine
}
if (this.mapService && typeof this.mapService.onRemoveRoomDataPacket === 'function') {
this.mapService.onRemoveRoomDataPacket(
{ areaId, roomX, roomY },
{ rebuildView: false },
)
}
}
@ -353,23 +341,28 @@ export class AutomapView {
private ensureLevelView(areaId: number): LevelView {
let view = this.levelViews.get(areaId)
if (!view) {
if (this.mapService) {
try {
if (this.mapService.getActiveAct() !== getActForArea(areaId) - 1) {
this.mapService.loadAct({
act: getActForArea(areaId) - 1,
seed: this.mapSeed,
difficulty: this.difficulty,
areaId,
revealMode: 'all',
})
}
view = this.mapService.getLevelView(areaId, 'all')
} catch {
view = buildFallbackLevelView(this.mapSeed, this.difficulty, areaId)
}
} else {
view = buildFallbackLevelView(this.mapSeed, this.difficulty, areaId)
if (!this.mapService) {
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) {
this.mapService.loadAct({
act: getActForArea(areaId) - 1,
seed: this.mapSeed,
difficulty: this.difficulty,
areaId,
revealMode: 'all',
})
}
view = this.mapService.getLevelView(areaId, 'all')
if (!view) {
throw new Error(
`[AutomapView] MapService failed to produce LevelView for area ${areaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty})`,
)
}
this.levelViews.set(areaId, view)
}
@ -396,8 +389,10 @@ export class AutomapView {
for (const rev of this.serverRevealedRooms.values()) {
if (rev.areaId !== areaId) continue
if (
(rev.roomX >= room.tileX && rev.roomX < room.tileX + room.tileW && rev.roomY >= room.tileY && rev.roomY < room.tileY + room.tileH) ||
(rev.roomX >= room.worldSubX && rev.roomX < room.worldSubX + room.subW && rev.roomY >= room.worldSubY && rev.roomY < room.worldSubY + room.subH)
rev.roomX >= room.tileX &&
rev.roomX < room.tileX + room.tileW &&
rev.roomY >= room.tileY &&
rev.roomY < room.tileY + room.tileH
) {
return true
}
@ -482,13 +477,32 @@ export class AutomapView {
name: u.name,
})
} 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)
entityDots.push({
worldX,
worldY,
kind: isDead ? 'corpse' : 'monster',
name: u.name,
})
if (isDead) {
if (u.isCorpse) {
entityDots.push({
worldX,
worldY,
kind: 'corpse',
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,
})
}
}
}
}
@ -523,58 +537,27 @@ export class AutomapView {
}
const snap = this.buildSnapshot([], lang)
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 (
view.cellsX > 0 &&
view.cellsY > 0 &&
view.blocked &&
view.blocked.length === view.cellsX * 5 * view.cellsY * 5
view.cellsX <= 0 ||
view.cellsY <= 0 ||
!view.blocked ||
view.blocked.length !== view.cellsX * 5 * view.cellsY * 5
) {
minCellX = view.tileOriginX ?? Math.floor((view.subOriginX ?? 0) / 5)
minCellY = view.tileOriginY ?? Math.floor((view.subOriginY ?? 0) / 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
throw new Error(
`[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})`,
)
}
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 => ({
cellX: wm.cellX - minCellX,
cellY: wm.cellY - minCellY,
@ -598,7 +581,7 @@ export class AutomapView {
}
return {
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`,
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}`,
cellsX,
cellsY,
blocked,
@ -608,6 +591,8 @@ export class AutomapView {
originY,
levelId: this.currentAreaId,
levelName: levelDisplayName(this.currentAreaId, lang),
difficulty: getLocalizedDifficulty(this.difficulty, lang),
preRevealTown: isTownLevel(this.currentAreaId),
...(view.automap !== undefined ? { automap: view.automap } : {}),
}
}

View File

@ -5,25 +5,26 @@
* - `AUTOMAP_LoadSprites` (`0x6fb11cc0`) & `AUTOMAP_DrawCell` (`0x6fb0f240`):
* 8 DC6 sprite sheets (`MaxiMap`, `MaxiMapS`, `Act2Map`, `Act2MapS`, `Act4Map`, `Act4MapS`,
* `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
* `drawLeft = screenX + frame.offsetX - (bMinimap ? 4 : 8)`,
* `drawTop = screenY - frame.height + frame.offsetY`,
* 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):
* Clipped `279×225` viewport at top-right `(screenWidth - 295, 16)`, shifting to
* top-left `(16, 16)` when a right-dock panel (Inventory/Skill Tree) is open.
* Clipped `279×225` viewport at top-right `(screenWidth - 281, 57)`, shifting to
* top-left `(0, 75)` when a right-dock panel (Inventory/Skill Tree) is open.
* - Full-Screen Automap (`bMinimap = false`, `nDivisor = 10`):
* Covers `[0, 0, screenWidth, screenHeight - 48]`, shifts center by `+160px` when left
* panel is open or `-160px` when right panel is open, and supports arrow-key panning
* Covers `[0, 0, screenWidth, screenHeight]`, shifts center by `+(screenWidth >> 2)` when left
* panel is open or `-(screenWidth >> 2)` when right panel is open, and supports arrow-key panning
* (`±8px` per step) + `Home` reset.
* - `AUTOMAP_UpdateVisibility` (`0x6fb12af0`) & `AUTOMAP_RevealRoom` (`0x6fb12580`):
* 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.
* - `AUTOMAP_DrawCrosshair` (`0x6fb107f0`) & `AUTOMAP_RenderEntitiesAndPortals` (`0x6fb10dc5`):
* 12-segment closed directional polygon crosshairs for Player, Party/NPCs, Mercenaries/Summons,
* and Bosses/SuperUniques, plus active town portal sprites (`306`/`307`/`300`), warp destination
* - `AUTOMAP_DrawCrosshair` (`0x6fb11490`) & `AUTOMAP_CrosshairColors` (`0x6fabd390`):
* 12-segment closed directional polygon crosshairs for Player (0x210), Party (0x213),
* 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.
*/
@ -43,6 +44,8 @@ import {
computeAutomapTilePixelCoords,
getCrosshairSegments,
} 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'
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. */
export const AUTOMAP_PAN_STEP_PX = 8
/** 3-state fade alpha cycle (`F9`: `0 -> 1 -> 2 -> 0`). */
export const AUTOMAP_FADE_ALPHAS = [1.0, 0.75, 0.5] as const
/** 4-state fade alpha cycle (`F9`: `0 -> 1 -> 2 -> 3 -> 0`). */
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 = {
/** Player crosshair: Palette 0x97 (`RGB(0, 0, 255)` / `#0080ff`). */
player: [0.0, 0.5, 1.0, 1.0] as const,
/** Party member / Ally crosshair: Palette 0x7D / 0x84 (`RGB(0, 255, 0)`). */
party: [0.0, 1.0, 0.0, 1.0] as const,
/** Friendly Town/Quest NPC crosshair: `0x6fbcc216` (`RGB(244, 244, 244)` in `0x6fb11490`). */
npc: [244 / 255, 244 / 255, 244 / 255, 1.0] as const,
/** Mercenary / Summon crosshair: Palette 0x81 (`RGB(72, 160, 52)`). */
mercenary: [72 / 255, 160 / 255, 52 / 255, 1.0] as const,
/** Valkyrie (357) / Iron Golem (291) crosshair: Palette 0x1F (`RGB(244, 244, 244)`). */
valkyrieOrIronGolem: [244 / 255, 244 / 255, 244 / 255, 1.0] as const,
/** Boss / SuperUnique / Unique monster crosshair: Palette 0x0A (`RGB(255, 0, 0)`). */
boss: [1.0, 0.0, 0.0, 1.0] as const,
/** Player crosshair: Palette 0x210 (`RGB(0, 0, 255)`). */
player: COLOR_PLAYER,
/** Non-party player crosshair: Palette 0x211 (`RGB(255, 0, 0)`). */
nonPartyPlayer: COLOR_RED,
/** Player corpse crosshair: Palette 0x212 (`RGB(255, 0, 255)`). */
corpse: COLOR_CORPSE,
/** Party member / Ally crosshair: Palette 0x213 (`RGB(0, 255, 0)`). */
party: COLOR_PARTY,
/** Own pet / Mercenary crosshair: Palette 0x214 (`RGB(68, 112, 116)`). */
ownPet: COLOR_OWN_PET,
/** Mercenary crosshair alias. */
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
/** 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
* `AUTOMAP_SetupViewportAndRender` (`0x6fb102a0..0x6fb10378`).
* `AUTOMAP_SetupViewportAndRender` (`0x6fae9e20..0x6fb104d1`).
*/
export function computeAutomapViewport(
screenWidth: number,
@ -188,8 +213,10 @@ export function computeAutomapViewport(
if (bMinimap) {
const vpW = 279
const vpH = 225
const vpX = rightPanelOpen ? 16 : Math.max(0, safeW - 295)
const vpY = 16
const vpX = rightPanelOpen ? 0 : Math.max(0, safeW - 281)
const vpY = rightPanelOpen ? 75 : 57
const centerX = rightPanelOpen ? 139 : Math.max(139, safeW - 142)
const centerY = rightPanelOpen ? 187 : 169
return {
bMinimap: true,
divisor: 20,
@ -203,8 +230,8 @@ export function computeAutomapViewport(
y: vpY,
width: vpW,
height: vpH,
centerX: vpX + 139,
centerY: vpY + 112,
centerX,
centerY,
centerShiftX: 0,
effectivePanX: 0,
effectivePanY: 0,
@ -216,11 +243,16 @@ export function computeAutomapViewport(
const vpX = 0
const vpY = 0
const vpW = safeW
const vpH = Math.max(1, safeH - 48)
const vpH = safeH
const shiftAmount = safeW >> 2
const centerShiftX =
leftPanelOpen && !rightPanelOpen ? 160 : rightPanelOpen && !leftPanelOpen ? -160 : 0
const centerX = Math.trunc(vpW / 2) + centerShiftX
const centerY = Math.trunc(vpH / 2)
leftPanelOpen && !rightPanelOpen
? shiftAmount
: rightPanelOpen && !leftPanelOpen
? -shiftAmount
: 0
const centerX = Math.trunc(safeW / 2) + centerShiftX
const centerY = Math.trunc((safeH - 40) / 2)
const effPanX = Math.trunc(panX)
const effPanY = Math.trunc(panY)
@ -319,23 +351,35 @@ export interface MinimapMarker {
readonly toLevelId?: number
}
/** Dynamic entity marker drawn on the automap (`0x6fb10dc5`). */
/** Dynamic entity marker drawn on the automap (`0x6fb11490` & `0x6fabd390`). */
export interface AutomapEntityMarker {
/** World X coordinate (or relative to grid origin if `isRelativeToOrigin` is true). */
readonly x: number
/** World Y coordinate. */
readonly y: number
/** 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). */
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
/** Whether the entity is alive (dead monsters are omitted from crosshair rendering). */
readonly alive?: boolean
}
/** Active Town Portal descriptor for dynamic portal rendering (`0x6fb10dc5`). */
/** Active Town Portal descriptor for dynamic portal rendering (`0x6fb11490`). */
export interface AutomapPortalDescriptor {
readonly x: number
readonly y: number
@ -417,7 +461,7 @@ export interface AutomapRenderTelemetry {
mode: 'minimap' | 'full'
bMinimap: boolean
divisor: 10 | 20
fadeState: 0 | 1 | 2
fadeState: 0 | 1 | 2 | 3
fadeAlpha: number
showNames: boolean
panX: number
@ -467,12 +511,12 @@ export class Minimap {
/**
* `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
/** 3-state fade index (`0` = 100%, `1` = 75%, `2` = 50%), cycled by `F9`. */
fadeState: 0 | 1 | 2 = 0
/** 4-state fade index (`0` = 100%, `1` = 75%/distance, `2` = 50%, `3` = 25%), cycled by `F9`. */
fadeState: 0 | 1 | 2 | 3 = 0
/** Whether ally/NPC names and labels are displayed (`F10` toggles). */
showNames = true
@ -521,6 +565,32 @@ export class Minimap {
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`). */
get mode(): 'minimap' | 'full' {
return this.bMinimap ? 'minimap' : 'full'
@ -549,7 +619,7 @@ export class Minimap {
}
/** Alias for `fadeState`. */
get fadeMode(): 0 | 1 | 2 {
get fadeMode(): 0 | 1 | 2 | 3 {
return this.fadeState
}
@ -583,6 +653,31 @@ export class Minimap {
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).
*/
@ -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 {
this.fadeState = ((this.fadeState + 1) % 3) as 0 | 1 | 2
cycleFade(): 0 | 1 | 2 | 3 {
this.fadeState = ((this.fadeState + 1) % 4) as 0 | 1 | 2 | 3
this.persistFadeState()
return this.fadeState
}
@ -700,29 +796,30 @@ export class Minimap {
await Promise.all(
entries.map(async ([sheetName, meta]) => {
const candidateUrls = [
meta.image,
`${baseUrl}/${meta.image.replace(/^\/+/, '')}`,
`/ui/automap-${sheetName.toLowerCase()}.png`,
`${baseUrl}/automap-${sheetName.toLowerCase()}.png`,
]
for (const url of candidateUrls) {
try {
const res = await fetch(url)
if (!res.ok) continue
const blob = await res.blob()
const bitmap = await createImageBitmap(blob, {
premultiplyAlpha: 'none',
colorSpaceConversion: 'none',
})
const handle = renderer.addAtlas(bitmap, meta.atlasWidth, meta.atlasHeight)
bitmap.close()
this.atlases.set(sheetName, handle)
loadedCount += 1
return
} catch {
// Try next candidate URL
const url = baseUrl
? `${baseUrl.replace(/\/+$/, '')}/${meta.image.replace(/^\/+/, '')}`
: meta.image
try {
const res = await fetch(url)
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 bitmap = await createImageBitmap(blob, {
premultiplyAlpha: 'none',
colorSpaceConversion: 'none',
})
const handle = renderer.addAtlas(bitmap, meta.atlasWidth, meta.atlasHeight)
bitmap.close()
this.atlases.set(sheetName, handle)
loadedCount += 1
} catch (err) {
reportMissingArt('automap', sheetName, String(err))
throw err
}
}),
)
@ -966,8 +1063,9 @@ export class Minimap {
gl.scissor(scissorX, scissorY, scissorW, scissorH)
}
const alpha = this.fadeAlpha
const tint: readonly [number, number, number, number] = [1, 1, 1, alpha]
const isDistanceFade = this.fadeState === 1 && !this.bMinimap
const uniformAlpha = AUTOMAP_FADE_ALPHAS[this.fadeState]
const uniformTint: readonly [number, number, number, number] = [1, 1, 1, uniformAlpha]
const drawLayerCells = (
cells: readonly AutomapCell[],
@ -995,6 +1093,21 @@ export class Minimap {
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 = {
x: frame.x,
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`)
drawLayerCells(automapLayer.floors, 'floors', false)
// 2. Layer 1 (`walls`)
@ -1027,14 +1140,16 @@ export class Minimap {
// 4. Layer 3 (`extras` — pre-revealed town entrances/crosses + revealed warps/waypoints/shrines)
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 maxiMeta = AUTOMAP_UI_MANIFEST.atlases[maxiSheetName]
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) {
for (const portal of options.portals) {
if (portal.active === false) continue
if (portal.kind === 'red' && RED_PORTAL_EXCLUDED_LEVELS.has(effectiveLevelId)) continue
const pClientX = Math.round(portal.x - originX)
const pClientY = Math.round(portal.y - originY)
const xPixel = Math.trunc((pClientX * 2) / 10)
@ -1072,7 +1187,7 @@ export class Minimap {
atlas: maxiAtlas,
width: rect.width / zoom,
height: rect.height / zoom,
tint,
tint: [1, 1, 1, this.fadeAlpha],
},
)
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) {
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 eClientY = Math.round(entity.y - originY)
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
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
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') {
color = AUTOMAP_CROSSHAIR_COLORS.npc
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 =
entity.classId === 357 || entity.classId === 291
? AUTOMAP_CROSSHAIR_COLORS.valkyrieOrIronGolem
: AUTOMAP_CROSSHAIR_COLORS.mercenary
: AUTOMAP_CROSSHAIR_COLORS.ownPet
this.lastRenderStats.crosshairsDrawn.mercenaries += 1
} else {
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 playerScreenY = Math.trunc(playerClientY / vp.divisor) - offsetY
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(
renderer: SpriteRenderer,
@ -1338,7 +1468,7 @@ export class Minimap {
seen: Uint8Array,
cell: AutomapCell,
): boolean {
if (level.cellsX <= 0 || level.cellsY <= 0) return true
if (level.cellsX <= 0 || level.cellsY <= 0) return false
const cellX =
typeof cell.cellX === 'number' && Number.isFinite(cell.cellX)
? cell.cellX
@ -1347,34 +1477,21 @@ export class Minimap {
typeof cell.cellY === 'number' && Number.isFinite(cell.cellY)
? cell.cellY
: automapPixelToCellCoords(cell.xPixel, cell.yPixel).cellY
const clampedX = Math.max(0, Math.min(level.cellsX - 1, cellX))
const clampedY = Math.max(0, Math.min(level.cellsY - 1, cellY))
return seen[clampedY * level.cellsX + clampedX] !== 0
if (cellX < 0 || cellX >= level.cellsX || cellY < 0 || cellY >= level.cellsY) {
return false
}
return seen[cellY * level.cellsX + cellX] !== 0
}
/**
* Resolves the `AtlasHandle` for `sheetName`, synthesizing a deterministic handle
* when running under headless unit-test mocks without a live WebGL context.
* Resolves the `AtlasHandle` for `sheetName`.
*/
private resolveAtlasHandle(
renderer: SpriteRenderer,
_renderer: SpriteRenderer,
sheetName: AutomapSpriteKey,
meta: AutomapSpriteAtlasMeta | undefined,
_meta: AutomapSpriteAtlasMeta | undefined,
): AtlasHandle | null {
const existing = this.atlases.get(sheetName)
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
return this.atlases.get(sheetName) ?? null
}
/**
@ -1445,7 +1562,7 @@ export class Minimap {
}
const layer: AutomapLayerData = {
layerId: level.levelId ?? 1,
layerId: level.levelId ?? 0,
levelTypeId: 1,
floors,
walls,

View File

@ -280,42 +280,42 @@ describe('R3 Authentic 1.13c Isometric Sprite Automap & Minimap UI', () => {
})
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)
expect(vpRightClosed.bMinimap).toBe(true)
expect(vpRightClosed.divisor).toBe(20)
expect(vpRightClosed.x).toBe(505) // 800 - 295
expect(vpRightClosed.y).toBe(16)
expect(vpRightClosed.x).toBe(519) // 800 - 281
expect(vpRightClosed.y).toBe(57)
expect(vpRightClosed.width).toBe(279)
expect(vpRightClosed.height).toBe(225)
expect(vpRightClosed.centerX).toBe(505 + 139)
expect(vpRightClosed.centerY).toBe(16 + 112)
expect(vpRightClosed.centerX).toBe(658) // 800 - 142
expect(vpRightClosed.centerY).toBe(169) // 57 + 112
const vpRightOpen = computeAutomapViewport(800, 600, true, false, true, 0, 0)
expect(vpRightOpen.x).toBe(16)
expect(vpRightOpen.y).toBe(16)
expect(vpRightOpen.x).toBe(0)
expect(vpRightOpen.y).toBe(75)
expect(vpRightOpen.width).toBe(279)
expect(vpRightOpen.height).toBe(225)
expect(vpRightOpen.centerX).toBe(16 + 139)
expect(vpRightOpen.centerY).toBe(16 + 112)
expect(vpRightOpen.centerX).toBe(139)
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)
expect(vpNeutral.bMinimap).toBe(false)
expect(vpNeutral.divisor).toBe(10)
expect(vpNeutral.x).toBe(0)
expect(vpNeutral.y).toBe(0)
expect(vpNeutral.width).toBe(800)
expect(vpNeutral.height).toBe(552) // 600 - 48
expect(vpNeutral.height).toBe(600)
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)
expect(vpLeftOpen.centerX).toBe(560) // 400 + 160
expect(vpLeftOpen.centerX).toBe(600) // 400 + 200
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)
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 },
)
// 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[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()
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.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.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[0])
expect(minimap.cycleFade()).toBe(1)
expect(minimap.fadeAlpha).toBe(AUTOMAP_FADE_ALPHAS[1])
expect(minimap.cycleFade()).toBe(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)
// 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
// ============================================================================
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)
expect(vpTopRight).toMatchObject({
bMinimap: true,
divisor: 20,
x: 505,
y: 16,
x: 519,
y: 57,
width: 279,
height: 225,
centerX: 644,
centerY: 128,
centerX: 658,
centerY: 169,
})
const vpTopLeft = computeAutomapViewport(800, 600, true, false, true, 0, 0)
expect(vpTopLeft).toMatchObject({
bMinimap: true,
divisor: 20,
x: 16,
y: 16,
x: 0,
y: 75,
width: 279,
height: 225,
centerX: 155,
centerY: 128,
centerX: 139,
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)
expect(vpCenter).toMatchObject({
bMinimap: false,
@ -937,19 +937,19 @@ describe('Tier 1 — Feature Coverage (F1–F13, >= 5 tests per feature)', () =>
x: 0,
y: 0,
width: 800,
height: 552,
height: 600,
centerX: 400,
centerY: 276,
centerY: 280,
centerShiftX: 0,
})
const vpLeft = computeAutomapViewport(800, 600, false, true, false, 0, 0)
expect(vpLeft.centerX).toBe(560)
expect(vpLeft.centerShiftX).toBe(160)
expect(vpLeft.centerX).toBe(600)
expect(vpLeft.centerShiftX).toBe(200)
const vpRight = computeAutomapViewport(800, 600, false, false, true, 0, 0)
expect(vpRight.centerX).toBe(240)
expect(vpRight.centerShiftX).toBe(-160)
expect(vpRight.centerX).toBe(200)
expect(vpRight.centerShiftX).toBe(-200)
})
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)
})
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()
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.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.cycleFade()).toBe(2)
expect(minimap.fadeAlpha).toBe(0.5)
expect(minimap.cycleFade()).toBe(3)
expect(minimap.fadeAlpha).toBe(0.25)
expect(minimap.cycleFade()).toBe(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.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)
})
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 sequence: number[] = []
for (let i = 0; i < 6; i++) {
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()
registerAtlases(minimap)
minimap.mode = 'full'
minimap.panBySteps(2, -1) // panX = +16, panY = -8
const vpLeft = computeAutomapViewport(800, 600, false, true, false, minimap.panX, minimap.panY)
expect(vpLeft.centerShiftX).toBe(160)
expect(vpLeft.centerX).toBe(560)
expect(vpLeft.centerShiftX).toBe(200)
expect(vpLeft.centerX).toBe(600)
expect(vpLeft.effectivePanX).toBe(16)
expect(vpLeft.effectivePanY).toBe(-8)
// Player at (0, 0) -> offsetX = 0 - 560 + 16 = -544 -> playerScreenX = 0 - (-544) = 544
// offsetY = 0 - 276 + (-8) = -284 -> playerScreenY = 0 - (-284) = 284
const expectedSegs = getCrosshairSegments(544, 284, false)
expect(expectedSegs[0]).toEqual({ x0: 544, y0: 282, x1: 548, y1: 280 })
// Player at (0, 0) -> offsetX = 0 - 600 + 16 = -584 -> playerScreenX = 0 - (-584) = 584
// offsetY = 0 - 280 + (-8) = -288 -> playerScreenY = 0 - (-288) = 288
const expectedSegs = getCrosshairSegments(584, 288, false)
expect(expectedSegs[0]).toEqual({ x0: 584, y0: 286, x1: 588, y1: 284 })
const level: MinimapLevel = {
key: 'c5-shift-pan',
@ -330,8 +330,8 @@ describe('Tier 3 — Cross-Feature Combinations (18 pairwise/multi-feature tests
{ width: 800, height: 600 },
{ playerWorldX: 0, playerWorldY: 0, rightPanelOpen: false },
)
expect(minimap.viewportLastFrame.x).toBe(505)
expect(minimap.viewportLastFrame.centerX).toBe(644)
expect(minimap.viewportLastFrame.x).toBe(519)
expect(minimap.viewportLastFrame.centerX).toBe(658)
minimap.draw(
renderer,
@ -342,8 +342,8 @@ describe('Tier 3 — Cross-Feature Combinations (18 pairwise/multi-feature tests
{ width: 800, height: 600 },
{ playerWorldX: 0, playerWorldY: 0, rightPanelOpen: true },
)
expect(minimap.viewportLastFrame.x).toBe(16)
expect(minimap.viewportLastFrame.centerX).toBe(155)
expect(minimap.viewportLastFrame.x).toBe(0)
expect(minimap.viewportLastFrame.centerX).toBe(139)
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()
registerAtlases(minimap)
minimap.mode = 'full'
minimap.mode = 'minimap'
const coords = computeAutomapTilePixelCoords(4, 4, 0)
const level: MinimapLevel = {
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)
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()
minimap.draw(
renderer,

View File

@ -741,7 +741,7 @@ describe('Tier 4 — Real-World E2E Scenarios (12 end-to-end scenarios across Ac
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])
})

View File

@ -958,10 +958,10 @@ describe('Tier 5 — White-Box Adversarial & Differential Stress Coverage (1.13c
{ 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.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()
// 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', () => {
// All 4 (leftPanelOpen, rightPanelOpen) states in Full Automap (bMinimap = false)
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({
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({
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({
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)
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({
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({
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({
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