diff --git a/src/client/automap/automap-view.ts b/src/client/automap/automap-view.ts index 50fe585..9092af4 100644 --- a/src/client/automap/automap-view.ts +++ b/src/client/automap/automap-view.ts @@ -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 = 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> 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 } : {}), } } diff --git a/src/client/ui/minimap.ts b/src/client/ui/minimap.ts index 71e38c6..e61d3b7 100644 --- a/src/client/ui/minimap.ts +++ b/src/client/ui/minimap.ts @@ -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, diff --git a/tests/automap-renderer.test.ts b/tests/automap-renderer.test.ts index 5f54810..e81a628 100644 --- a/tests/automap-renderer.test.ts +++ b/tests/automap-renderer.test.ts @@ -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 diff --git a/tests/e2e-automap-113c/tier1-feature-coverage.test.ts b/tests/e2e-automap-113c/tier1-feature-coverage.test.ts index ef2e735..9cfea74 100644 --- a/tests/e2e-automap-113c/tier1-feature-coverage.test.ts +++ b/tests/e2e-automap-113c/tier1-feature-coverage.test.ts @@ -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' }), ) }) diff --git a/tests/e2e-automap-113c/tier2-boundary-corner.test.ts b/tests/e2e-automap-113c/tier2-boundary-corner.test.ts index f737b0a..486995d 100644 --- a/tests/e2e-automap-113c/tier2-boundary-corner.test.ts +++ b/tests/e2e-automap-113c/tier2-boundary-corner.test.ts @@ -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]) }) }) diff --git a/tests/e2e-automap-113c/tier3-cross-feature-combinations.test.ts b/tests/e2e-automap-113c/tier3-cross-feature-combinations.test.ts index 869013c..042a4c0 100644 --- a/tests/e2e-automap-113c/tier3-cross-feature-combinations.test.ts +++ b/tests/e2e-automap-113c/tier3-cross-feature-combinations.test.ts @@ -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, diff --git a/tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts b/tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts index 8c6a16f..be888bd 100644 --- a/tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts +++ b/tests/e2e-automap-113c/tier4-real-world-scenarios.test.ts @@ -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]) }) diff --git a/tests/e2e-automap-113c/tier5-adversarial-coverage.test.ts b/tests/e2e-automap-113c/tier5-adversarial-coverage.test.ts index ca5ba31..e981118 100644 --- a/tests/e2e-automap-113c/tier5-adversarial-coverage.test.ts +++ b/tests/e2e-automap-113c/tier5-adversarial-coverage.test.ts @@ -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