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:
parent
f1ca3dfc2b
commit
036d36bccb
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue