1598 lines
53 KiB
TypeScript
1598 lines
53 KiB
TypeScript
/**
|
||
* Diablo II v1.13c Isometric Sprite Automap & Minimap Renderer (`src/ui/minimap.ts`).
|
||
*
|
||
* Ground-truth implementation of `D2Client.dll` `0x6fb0f080..0x6fb12c28`:
|
||
* - `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` (`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 (`0x6fae9e20..0x6fb104d1`):
|
||
* - Corner Minimap (`bMinimap = true`, `nDivisor = 20`, default on start):
|
||
* 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]`, 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` (`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.
|
||
*/
|
||
|
||
import type { AtlasFrame } from '../render/atlas.ts'
|
||
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
||
import {
|
||
AUTOMAP_UI_MANIFEST,
|
||
type AutomapSpriteAtlasMeta,
|
||
type AutomapSpriteFrameMeta,
|
||
type AutomapSpriteKey,
|
||
} from '../../common/items/baked-ui-meta.ts'
|
||
import {
|
||
type AutomapAtlasSlot,
|
||
type AutomapCell,
|
||
type AutomapLayerData,
|
||
computeAutomapObjectPixelCoords,
|
||
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 = {
|
||
readonly x0: number
|
||
readonly y0: number
|
||
readonly x1: number
|
||
readonly y1: number
|
||
}
|
||
|
||
/** Backward-compatible cell radius around the player. */
|
||
export const REVEAL_RADIUS = 9
|
||
|
||
/** DRLG room size in cells (8×8 tiles = 40×40 sub-tiles). */
|
||
export const ROOM_SIZE_CELLS = 8
|
||
|
||
/**
|
||
* Octile distance threshold in sub-units before `AUTOMAP_UpdateVisibility` (`0x6fb12af0`)
|
||
* triggers a room reveal pass (`>= 160` sub-units, where 1 tile = 5 sub-tiles = 80 sub-units).
|
||
*/
|
||
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
|
||
|
||
/** 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` @ `0x6fb11490`, `0x6fabd390`).
|
||
*/
|
||
export const AUTOMAP_CROSSHAIR_COLORS = {
|
||
/** 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`. */
|
||
export const AUTOMAP_SHEET_NAMES: Readonly<
|
||
Record<AutomapAtlasSlot, { readonly full: AutomapSpriteKey; readonly half: AutomapSpriteKey }>
|
||
> = {
|
||
0: { full: 'MaxiMap', half: 'MaxiMapS' },
|
||
1: { full: 'Act2Map', half: 'Act2MapS' },
|
||
2: { full: 'Act4Map', half: 'Act4MapS' },
|
||
3: { full: 'ExTnMap', half: 'ExTnMapS' },
|
||
}
|
||
|
||
/**
|
||
* Resolves the exact automap sprite sheet name (`MaxiMap`, `MaxiMapS`, etc.)
|
||
* for an `AutomapAtlasSlot` (`0..3`), sheet name, or special level ID (`47..49`, `52`, `103`, `>= 109`)
|
||
* and viewport mode (`bMinimap`).
|
||
*/
|
||
export function resolveAutomapSheetName(
|
||
slotOrSheet: AutomapAtlasSlot | AutomapSpriteKey | undefined,
|
||
bMinimap: boolean,
|
||
levelId?: number,
|
||
): AutomapSpriteKey {
|
||
if (typeof slotOrSheet === 'number' && slotOrSheet in AUTOMAP_SHEET_NAMES) {
|
||
if (slotOrSheet !== 0) {
|
||
const entry = AUTOMAP_SHEET_NAMES[slotOrSheet]
|
||
return bMinimap ? entry.half : entry.full
|
||
}
|
||
} else if (typeof slotOrSheet === 'string') {
|
||
if (slotOrSheet === 'Act2Map' || slotOrSheet === 'Act2MapS') {
|
||
return bMinimap ? 'Act2MapS' : 'Act2Map'
|
||
}
|
||
if (slotOrSheet === 'Act4Map' || slotOrSheet === 'Act4MapS') {
|
||
return bMinimap ? 'Act4MapS' : 'Act4Map'
|
||
}
|
||
if (slotOrSheet === 'ExTnMap' || slotOrSheet === 'ExTnMapS') {
|
||
return bMinimap ? 'ExTnMapS' : 'ExTnMap'
|
||
}
|
||
}
|
||
if (levelId === 47 || levelId === 48 || levelId === 49) {
|
||
return bMinimap ? 'Act2MapS' : 'Act2Map'
|
||
}
|
||
if (levelId === 52 || (levelId !== undefined && levelId >= 109)) {
|
||
return bMinimap ? 'ExTnMapS' : 'ExTnMap'
|
||
}
|
||
if (levelId === 103) {
|
||
return bMinimap ? 'Act4MapS' : 'Act4Map'
|
||
}
|
||
const entry = AUTOMAP_SHEET_NAMES[0]
|
||
return bMinimap ? entry.half : entry.full
|
||
}
|
||
|
||
/**
|
||
* Computes the octile movement distance `max(|dx|, |dy|) * 2 + min(|dx|, |dy|)`
|
||
* used by `AUTOMAP_UpdateVisibility` (`0x6fb12af0`).
|
||
*/
|
||
export function computeAutomapRevealDistance(dx: number, dy: number): number {
|
||
const adx = Math.abs(Math.trunc(dx))
|
||
const ady = Math.abs(Math.trunc(dy))
|
||
return Math.max(adx, ady) * 2 + Math.min(adx, ady)
|
||
}
|
||
|
||
export interface AutomapViewportMetrics {
|
||
readonly bMinimap: boolean
|
||
readonly divisor: 10 | 20
|
||
readonly screenWidth: number
|
||
readonly screenHeight: number
|
||
readonly vpX: number
|
||
readonly vpY: number
|
||
readonly vpW: number
|
||
readonly vpH: number
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly centerX: number
|
||
readonly centerY: number
|
||
readonly centerShiftX: number
|
||
readonly effectivePanX: number
|
||
readonly effectivePanY: number
|
||
readonly panX: number
|
||
readonly panY: number
|
||
}
|
||
|
||
/**
|
||
* Computes viewport bounds, divisor, panel shift, and center anchor per
|
||
* `AUTOMAP_SetupViewportAndRender` (`0x6fae9e20..0x6fb104d1`).
|
||
*/
|
||
export function computeAutomapViewport(
|
||
screenWidth: number,
|
||
screenHeight: number,
|
||
bMinimap: boolean,
|
||
leftPanelOpen = false,
|
||
rightPanelOpen = false,
|
||
panX = 0,
|
||
panY = 0,
|
||
): AutomapViewportMetrics {
|
||
const safeW = Math.max(1, Math.trunc(screenWidth))
|
||
const safeH = Math.max(1, Math.trunc(screenHeight))
|
||
|
||
if (bMinimap) {
|
||
const vpW = 279
|
||
const vpH = 225
|
||
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,
|
||
screenWidth: safeW,
|
||
screenHeight: safeH,
|
||
vpX,
|
||
vpY,
|
||
vpW,
|
||
vpH,
|
||
x: vpX,
|
||
y: vpY,
|
||
width: vpW,
|
||
height: vpH,
|
||
centerX,
|
||
centerY,
|
||
centerShiftX: 0,
|
||
effectivePanX: 0,
|
||
effectivePanY: 0,
|
||
panX: 0,
|
||
panY: 0,
|
||
}
|
||
}
|
||
|
||
const vpX = 0
|
||
const vpY = 0
|
||
const vpW = safeW
|
||
const vpH = safeH
|
||
const shiftAmount = safeW >> 2
|
||
const centerShiftX =
|
||
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)
|
||
|
||
return {
|
||
bMinimap: false,
|
||
divisor: 10,
|
||
screenWidth: safeW,
|
||
screenHeight: safeH,
|
||
vpX,
|
||
vpY,
|
||
vpW,
|
||
vpH,
|
||
x: vpX,
|
||
y: vpY,
|
||
width: vpW,
|
||
height: vpH,
|
||
centerX,
|
||
centerY,
|
||
centerShiftX,
|
||
effectivePanX: effPanX,
|
||
effectivePanY: effPanY,
|
||
panX: effPanX,
|
||
panY: effPanY,
|
||
}
|
||
}
|
||
|
||
export interface ProjectedAutomapSpriteRect {
|
||
readonly screenX: number
|
||
readonly screenY: number
|
||
readonly drawLeft: number
|
||
readonly drawTop: number
|
||
readonly width: number
|
||
readonly height: number
|
||
}
|
||
|
||
/**
|
||
* Projects a pre-divided `(xPixel, yPixel)` automap cell and its DC6 frame metadata
|
||
* into screen coordinates per `AUTOMAP_RenderLayer` (`0x6fb10480`) and `AUTOMAP_DrawCell` (`0x6fb0f240`).
|
||
*/
|
||
export function projectAutomapCellScreenRect(
|
||
cell: { readonly xPixel: number; readonly yPixel: number },
|
||
frame: {
|
||
readonly width: number
|
||
readonly height: number
|
||
readonly offsetX: number
|
||
readonly offsetY: number
|
||
},
|
||
offsetX: number,
|
||
offsetY: number,
|
||
bMinimap: boolean,
|
||
): ProjectedAutomapSpriteRect {
|
||
const divisor = bMinimap ? 20 : 10
|
||
const screenX = Math.trunc((cell.xPixel * 10) / divisor) - offsetX
|
||
const screenY = Math.trunc((cell.yPixel * 10) / divisor) - offsetY
|
||
const drawLeft = screenX + frame.offsetX - (bMinimap ? 4 : 8)
|
||
const drawTop = screenY - frame.height + frame.offsetY
|
||
return {
|
||
screenX,
|
||
screenY,
|
||
drawLeft,
|
||
drawTop,
|
||
width: frame.width,
|
||
height: frame.height,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Recovers the tile coordinate `(cellX, cellY)` from an `AutomapCell`'s pre-divided `(xPixel, yPixel)`.
|
||
*/
|
||
export function automapPixelToCellCoords(
|
||
xPixel: number,
|
||
yPixel: number,
|
||
): { readonly cellX: number; readonly cellY: number } {
|
||
const isoX = (xPixel * 10) / 16
|
||
const isoY = (yPixel * 10) / 8
|
||
const subTileX = Math.round((isoX + isoY) / 2)
|
||
const subTileY = Math.round((isoY - isoX) / 2)
|
||
return {
|
||
cellX: Math.floor(subTileX / 5),
|
||
cellY: Math.floor(subTileY / 5),
|
||
}
|
||
}
|
||
|
||
/** Something worth showing on the map even before or after the player reaches it. */
|
||
export interface MinimapMarker {
|
||
/** Position in cells. */
|
||
readonly cellX: number
|
||
readonly cellY: number
|
||
/** Which landmark kind. */
|
||
readonly kind: 'exit' | 'waypoint'
|
||
/** Optional destination area name for entrance/warp labels (`Font6`). */
|
||
readonly targetName?: string
|
||
/** Optional destination level ID (`1..136`). */
|
||
readonly targetLevelId?: number
|
||
/** Optional alias for `targetLevelId`. */
|
||
readonly toLevelId?: number
|
||
}
|
||
|
||
/** 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'
|
||
| '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 `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 (`0x6fb11490`). */
|
||
export interface AutomapPortalDescriptor {
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly active?: boolean
|
||
readonly isLocalPlayerOwner?: boolean
|
||
readonly kind?: 'blue' | 'red'
|
||
readonly label?: string
|
||
}
|
||
|
||
/** Stash position descriptor for `"Stash"` label and icon rendering. */
|
||
export interface AutomapStashDescriptor {
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly name?: string
|
||
readonly label?: string
|
||
}
|
||
|
||
/** What the minimap needs to know about the level under it. */
|
||
export interface MinimapLevel {
|
||
/** A key that changes when the level does; exploration is kept per key. */
|
||
readonly key: string
|
||
readonly cellsX: number
|
||
readonly cellsY: number
|
||
/** One byte per sub-tile, non-zero meaning impassable. */
|
||
readonly blocked: Uint8Array
|
||
/** Sub-tiles across. */
|
||
readonly gridWidth: number
|
||
/** Landmarks to draw once their cell is explored. */
|
||
readonly markers: readonly MinimapMarker[]
|
||
/** Optional world origin X of tile `(0, 0)` (defaults to `0`). */
|
||
readonly originX?: number
|
||
/** Optional world origin Y of tile `(0, 0)` (defaults to `0`). */
|
||
readonly originY?: number
|
||
/** Optional pre-built 4-layer isometric automap data (`floors`, `walls`, `objects`, `extras`). */
|
||
readonly automap?: AutomapLayerData
|
||
/** Optional level ID (`1..136`) for portal sprite selection (`300` in Level 4 vs `306`/`307`). */
|
||
readonly levelId?: number
|
||
/** Optional localized or English level name for top-right `Font16` HUD display. */
|
||
readonly levelName?: string
|
||
/** Optional difficulty label (`Normal` / `Nightmare` / `Hell`) for top-right `Font16` HUD display. */
|
||
readonly difficulty?: string
|
||
/** Optional flag for preset town levels (`DRLGPRESET_GenerateLevel` @ `0x6FD886F0`) where all rooms are revealed upon generation. */
|
||
readonly preRevealTown?: boolean
|
||
}
|
||
|
||
/** Optional extended draw parameters for full 1.13c entity, portal, panel, and HUD integration. */
|
||
export interface MinimapDrawOptions {
|
||
/** Exact player world X coordinate (for smooth sub-tile centering). */
|
||
readonly playerWorldX?: number
|
||
/** Exact player world Y coordinate (for smooth sub-tile centering). */
|
||
readonly playerWorldY?: number
|
||
/** Whether a left-dock panel (`char`, `quest`, `waypoint`, `stash`, `cube`, `vendor`) is open. */
|
||
readonly leftPanelOpen?: boolean
|
||
/** Whether a right-dock panel (`inv`, `skill`) is open. */
|
||
readonly rightPanelOpen?: boolean
|
||
/** Dynamic entities (NPCs, party members, mercenaries, summons, bosses/SuperUniques). */
|
||
readonly entities?: readonly AutomapEntityMarker[]
|
||
/** Active town portals in the current level. */
|
||
readonly portals?: readonly AutomapPortalDescriptor[]
|
||
/** Stash entities in the current level. */
|
||
readonly stashes?: readonly AutomapStashDescriptor[]
|
||
/** Override level ID (`1..136`). */
|
||
readonly levelId?: number
|
||
/** Override level display name for top-right `Font16` HUD readout. */
|
||
readonly levelName?: string
|
||
/** Override difficulty string (`Normal` / `Nightmare` / `Hell`) for top-right `Font16` HUD readout. */
|
||
readonly difficulty?: string
|
||
}
|
||
|
||
export interface AutomapOverlayLabel {
|
||
readonly text: string
|
||
readonly x: number
|
||
readonly y: number
|
||
readonly color: D2ColorCode
|
||
readonly kind: 'warp' | 'stash' | 'entity' | 'portal'
|
||
}
|
||
|
||
export interface AutomapRenderTelemetry {
|
||
mode: 'minimap' | 'full'
|
||
bMinimap: boolean
|
||
divisor: 10 | 20
|
||
fadeState: 0 | 1 | 2 | 3
|
||
fadeAlpha: number
|
||
showNames: boolean
|
||
panX: number
|
||
panY: number
|
||
viewport: AutomapViewportMetrics
|
||
cellCounts: {
|
||
floors: number
|
||
walls: number
|
||
objects: number
|
||
extras: number
|
||
total: number
|
||
}
|
||
spritesDrawn: {
|
||
floors: number
|
||
walls: number
|
||
objects: number
|
||
extras: number
|
||
portals: number
|
||
total: number
|
||
}
|
||
crosshairsDrawn: {
|
||
player: number
|
||
party: number
|
||
npcs: number
|
||
mercenaries: number
|
||
bosses: number
|
||
total: number
|
||
}
|
||
revealedRooms: number
|
||
warpLabels: AutomapOverlayLabel[]
|
||
entityLabels: AutomapOverlayLabel[]
|
||
hudText: {
|
||
levelName: string
|
||
difficulty: string
|
||
x: number
|
||
y1: number
|
||
y2: number
|
||
} | null
|
||
}
|
||
|
||
/**
|
||
* The Diablo II v1.13c Isometric Sprite Automap & Minimap renderer.
|
||
*/
|
||
export class Minimap {
|
||
/** Whether the automap/minimap overlay is visible (`Tab` toggles visibility). */
|
||
visible = true
|
||
|
||
/**
|
||
* `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]`).
|
||
*/
|
||
bMinimap = true
|
||
|
||
/** 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
|
||
|
||
/** Manual pan offset X in screen pixels (active only when `!bMinimap`). */
|
||
panX = 0
|
||
|
||
/** Manual pan offset Y in screen pixels (active only when `!bMinimap`). */
|
||
panY = 0
|
||
|
||
/** Loaded WebGL atlas handles keyed by sheet name (`MaxiMap`, `MaxiMapS`, etc.). */
|
||
private readonly atlases = new Map<AutomapSpriteKey, AtlasHandle>()
|
||
|
||
/** Cached synthesized `AutomapLayerData` for minimal test `MinimapLevel` objects lacking `level.automap`. */
|
||
private readonly synthesizedLayers = new Map<string, AutomapLayerData>()
|
||
|
||
/** level key -> one byte per cell, non-zero meaning explored. */
|
||
private readonly explored = new Map<string, Uint8Array>()
|
||
|
||
/** level key -> set of revealed room keys (`"${roomX},${roomY}"`). */
|
||
private readonly revealedRoomsByLevel = new Map<string, Set<string>>()
|
||
|
||
/** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */
|
||
private readonly lastRevealSubPos = new Map<string, { x: number; y: number }>()
|
||
|
||
/** Last drawn level key so manual pan resets when switching levels (#549). */
|
||
private lastLevelKey: string | null = null
|
||
|
||
/** Diagnostic telemetry from the most recent `draw()` invocation. */
|
||
readonly lastRenderStats: AutomapRenderTelemetry = {
|
||
mode: 'minimap',
|
||
bMinimap: true,
|
||
divisor: 20,
|
||
fadeState: 0,
|
||
fadeAlpha: 1.0,
|
||
showNames: true,
|
||
panX: 0,
|
||
panY: 0,
|
||
viewport: computeAutomapViewport(800, 600, true),
|
||
cellCounts: { floors: 0, walls: 0, objects: 0, extras: 0, total: 0 },
|
||
spritesDrawn: { floors: 0, walls: 0, objects: 0, extras: 0, portals: 0, total: 0 },
|
||
crosshairsDrawn: { player: 0, party: 0, npcs: 0, mercenaries: 0, bosses: 0, total: 0 },
|
||
revealedRooms: 0,
|
||
warpLabels: [],
|
||
entityLabels: [],
|
||
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'
|
||
}
|
||
|
||
set mode(value: 'minimap' | 'full') {
|
||
this.bMinimap = value === 'minimap'
|
||
if (this.bMinimap) {
|
||
this.resetPan()
|
||
}
|
||
}
|
||
|
||
/** Current projection divisor (`20` for Corner Minimap, `10` for Full Automap). */
|
||
get divisor(): 10 | 20 {
|
||
return this.bMinimap ? 20 : 10
|
||
}
|
||
|
||
/** Number of loaded automap sprite sheet atlases (`0..8`). */
|
||
get atlasesLoadedCount(): number {
|
||
return this.atlases.size
|
||
}
|
||
|
||
/** Current sprite alpha multiplier from `fadeState`. */
|
||
get fadeAlpha(): number {
|
||
return AUTOMAP_FADE_ALPHAS[this.fadeState]
|
||
}
|
||
|
||
/** Alias for `fadeState`. */
|
||
get fadeMode(): 0 | 1 | 2 | 3 {
|
||
return this.fadeState
|
||
}
|
||
|
||
/** Revealed DRLG room count for the most recently revealed or drawn level. */
|
||
get revealedRoomsCount(): number {
|
||
return this.lastRenderStats.revealedRooms
|
||
}
|
||
|
||
/** Sprites drawn per layer on the most recent frame. */
|
||
get spritesDrawnLastFrame(): AutomapRenderTelemetry['spritesDrawn'] {
|
||
return this.lastRenderStats.spritesDrawn
|
||
}
|
||
|
||
/** 12-segment crosshairs drawn on the most recent frame. */
|
||
get crosshairsDrawnLastFrame(): AutomapRenderTelemetry['crosshairsDrawn'] {
|
||
return this.lastRenderStats.crosshairsDrawn
|
||
}
|
||
|
||
/** Revealed warp/stash labels on the most recent frame. */
|
||
get warpLabelsLastFrame(): AutomapOverlayLabel[] {
|
||
return this.lastRenderStats.warpLabels
|
||
}
|
||
|
||
/** Top-right gold Font16 HUD text on the most recent frame. */
|
||
get hudTextLastFrame(): AutomapRenderTelemetry['hudText'] {
|
||
return this.lastRenderStats.hudText
|
||
}
|
||
|
||
/** Active automap viewport metrics on the most recent frame. */
|
||
get viewportLastFrame(): AutomapViewportMetrics {
|
||
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).
|
||
*/
|
||
toggleVisibility(): boolean {
|
||
this.visible = !this.visible
|
||
if (!this.visible) {
|
||
this.resetPan()
|
||
}
|
||
return this.visible
|
||
}
|
||
|
||
/**
|
||
* Alias for `toggleVisibility()` (`Tab` key).
|
||
*/
|
||
toggle(): boolean {
|
||
return this.toggleVisibility()
|
||
}
|
||
|
||
/**
|
||
* Toggles between Corner Minimap (`bMinimap = true`, `nDivisor = 20`) and
|
||
* Full-Screen Automap (`bMinimap = false`, `nDivisor = 10`) (`V` key or MiniPanel Automap button).
|
||
*/
|
||
toggleMode(): 'minimap' | 'full' {
|
||
if (!this.visible) {
|
||
this.visible = true
|
||
return this.mode
|
||
}
|
||
this.bMinimap = !this.bMinimap
|
||
if (this.bMinimap) {
|
||
this.resetPan()
|
||
}
|
||
return this.mode
|
||
}
|
||
|
||
/**
|
||
* Alias for `toggleMode()` returning the updated `bMinimap` boolean.
|
||
*/
|
||
toggleMinimapMode(): boolean {
|
||
this.toggleMode()
|
||
return this.bMinimap
|
||
}
|
||
|
||
/**
|
||
* Cycles automap fade state (`0 -> 1 -> 2 -> 3 -> 0`, `F9` key).
|
||
*/
|
||
cycleFade(): 0 | 1 | 2 | 3 {
|
||
this.fadeState = ((this.fadeState + 1) % 4) as 0 | 1 | 2 | 3
|
||
this.persistFadeState()
|
||
return this.fadeState
|
||
}
|
||
|
||
/**
|
||
* Toggles party/ally/NPC name display on the automap (`F10` key).
|
||
*/
|
||
toggleShowNames(): boolean {
|
||
this.showNames = !this.showNames
|
||
return this.showNames
|
||
}
|
||
|
||
/**
|
||
* Alias for `toggleShowNames()`.
|
||
*/
|
||
toggleNames(): boolean {
|
||
return this.toggleShowNames()
|
||
}
|
||
|
||
/**
|
||
* Pans the Full-Screen Automap by `(dx, dy)` pixels (`ArrowUp`/`ArrowDown`/`ArrowLeft`/`ArrowRight`).
|
||
* Returns `true` if the pan was applied (`visible && !bMinimap`), `false` otherwise.
|
||
*/
|
||
pan(dx: number, dy: number): boolean {
|
||
if (!this.visible || this.bMinimap) return false
|
||
this.panX += Math.trunc(dx)
|
||
this.panY += Math.trunc(dy)
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* Pans the Full-Screen Automap by `(stepX * AUTOMAP_PAN_STEP_PX, stepY * AUTOMAP_PAN_STEP_PX)`.
|
||
*/
|
||
panBySteps(stepX: number, stepY: number): boolean {
|
||
return this.pan(stepX * AUTOMAP_PAN_STEP_PX, stepY * AUTOMAP_PAN_STEP_PX)
|
||
}
|
||
|
||
/**
|
||
* Resets the manual pan offset to `(0, 0)` (`Home` key).
|
||
*/
|
||
resetPan(): void {
|
||
this.panX = 0
|
||
this.panY = 0
|
||
}
|
||
|
||
/**
|
||
* Registers an `AtlasHandle` for a specific automap sheet name (`MaxiMap`, `MaxiMapS`, etc.).
|
||
*/
|
||
setAtlasHandle(sheetName: AutomapSpriteKey, handle: AtlasHandle): void {
|
||
this.atlases.set(sheetName, handle)
|
||
}
|
||
|
||
/**
|
||
* Returns the metadata for a specific automap sprite sheet (`MaxiMap`, `MaxiMapS`, etc.).
|
||
*/
|
||
getSheetMeta(sheetName: AutomapSpriteKey): AutomapSpriteAtlasMeta {
|
||
return AUTOMAP_UI_MANIFEST.atlases[sheetName]
|
||
}
|
||
|
||
/**
|
||
* Loads all 8 baked automap PNG sprite sheets (`AUTOMAP_UI_MANIFEST`) into the `SpriteRenderer`.
|
||
*/
|
||
async loadAtlases(renderer: SpriteRenderer, baseUrl = ''): Promise<number> {
|
||
const entries = Object.entries(AUTOMAP_UI_MANIFEST.atlases) as [
|
||
AutomapSpriteKey,
|
||
AutomapSpriteAtlasMeta,
|
||
][]
|
||
let loadedCount = 0
|
||
|
||
await Promise.all(
|
||
entries.map(async ([sheetName, meta]) => {
|
||
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
|
||
}
|
||
}),
|
||
)
|
||
|
||
return loadedCount
|
||
}
|
||
|
||
/**
|
||
* Returns the number of revealed DRLG rooms for the given level key.
|
||
*/
|
||
revealedRoomCount(levelKey: string): number {
|
||
return this.revealedRoomsByLevel.get(levelKey)?.size ?? 0
|
||
}
|
||
|
||
/**
|
||
* Returns whether a specific DRLG room `(roomX, roomY)` in `levelKey` has been revealed.
|
||
*/
|
||
isRoomRevealed(levelKey: string, roomX: number, roomY: number): boolean {
|
||
return this.revealedRoomsByLevel.get(levelKey)?.has(`${roomX},${roomY}`) ?? false
|
||
}
|
||
|
||
/**
|
||
* Returns whether a specific cell `(cellX, cellY)` in `level` has been revealed.
|
||
*/
|
||
isCellExplored(level: MinimapLevel, cellX: number, cellY: number): boolean {
|
||
if (cellX < 0 || cellY < 0 || cellX >= level.cellsX || cellY >= level.cellsY) return false
|
||
const seen = this.seenFor(level)
|
||
return seen[cellY * level.cellsX + cellX] !== 0
|
||
}
|
||
|
||
/**
|
||
* Marks the player's current room and adjacent 3×3 rooms as explored once the player
|
||
* has moved at least `AUTOMAP_REVEAL_DISTANCE_THRESHOLD` (`160` sub-units) in octile distance
|
||
* (`AUTOMAP_UpdateVisibility` @ `0x6fb12af0` & `AUTOMAP_RevealRoom` @ `0x6fb12580`).
|
||
*
|
||
* @param level - the level being walked.
|
||
* @param cellX - the player's cell X.
|
||
* @param cellY - the player's cell Y.
|
||
* @param playerWorldX - optional exact sub-unit X (defaults to `cellX * 80`).
|
||
* @param playerWorldY - optional exact sub-unit Y (defaults to `cellY * 80`).
|
||
* @param force - if true, bypasses the octile distance threshold check.
|
||
* @returns `true` if a reveal pass was executed, `false` if skipped by the octile threshold.
|
||
*/
|
||
reveal(
|
||
level: MinimapLevel,
|
||
cellX: number,
|
||
cellY: number,
|
||
playerWorldX?: number,
|
||
playerWorldY?: number,
|
||
force = false,
|
||
): boolean {
|
||
const subX = playerWorldX !== undefined ? Math.trunc(playerWorldX) : Math.trunc(cellX * 80)
|
||
const subY = playerWorldY !== undefined ? Math.trunc(playerWorldY) : Math.trunc(cellY * 80)
|
||
|
||
const lastPos = this.lastRevealSubPos.get(level.key)
|
||
if (!force && lastPos !== undefined) {
|
||
const dist = computeAutomapRevealDistance(subX - lastPos.x, subY - lastPos.y)
|
||
if (dist < AUTOMAP_REVEAL_DISTANCE_THRESHOLD) {
|
||
return false
|
||
}
|
||
}
|
||
this.lastRevealSubPos.set(level.key, { x: subX, y: subY })
|
||
|
||
const seen = this.seenFor(level)
|
||
let roomSet = this.revealedRoomsByLevel.get(level.key)
|
||
if (!roomSet) {
|
||
roomSet = new Set<string>()
|
||
this.revealedRoomsByLevel.set(level.key, roomSet)
|
||
}
|
||
|
||
const roomX = Math.floor(cellX / ROOM_SIZE_CELLS)
|
||
const roomY = Math.floor(cellY / ROOM_SIZE_CELLS)
|
||
const maxRoomX = Math.max(0, Math.ceil(level.cellsX / ROOM_SIZE_CELLS) - 1)
|
||
const maxRoomY = Math.max(0, Math.ceil(level.cellsY / ROOM_SIZE_CELLS) - 1)
|
||
|
||
// Per D2Common.dll DRLGPRESET_GenerateLevel (0x6FD886F0 lines 1803-1823),
|
||
// preset town levels (1, 40, 75, 103, 109) invoke pfAutomap across all rooms upon generation.
|
||
if (level.preRevealTown === true) {
|
||
seen.fill(1)
|
||
for (let ry = 0; ry <= maxRoomY; ry += 1) {
|
||
for (let rx = 0; rx <= maxRoomX; rx += 1) {
|
||
roomSet.add(`${rx},${ry}`)
|
||
}
|
||
}
|
||
this.lastRenderStats.revealedRooms = roomSet.size
|
||
return true
|
||
}
|
||
|
||
// Reveal the player's room and adjacent 3×3 rooms (`AUTOMAP_RevealRoom` @ `0x6fb12580`)
|
||
for (let ry = Math.max(0, roomY - 1); ry <= Math.min(maxRoomY, roomY + 1); ry += 1) {
|
||
for (let rx = Math.max(0, roomX - 1); rx <= Math.min(maxRoomX, roomX + 1); rx += 1) {
|
||
roomSet.add(`${rx},${ry}`)
|
||
const startX = rx * ROOM_SIZE_CELLS
|
||
const endX = Math.min(level.cellsX, startX + ROOM_SIZE_CELLS)
|
||
const startY = ry * ROOM_SIZE_CELLS
|
||
const endY = Math.min(level.cellsY, startY + ROOM_SIZE_CELLS)
|
||
for (let y = startY; y < endY; y += 1) {
|
||
const rowOffset = y * level.cellsX
|
||
for (let x = startX; x < endX; x += 1) {
|
||
seen[rowOffset + x] = 1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Also ensure the circular REVEAL_RADIUS neighborhood is marked for boundary continuity
|
||
for (let dy = -REVEAL_RADIUS; dy <= REVEAL_RADIUS; dy += 1) {
|
||
const y = cellY + dy
|
||
if (y < 0 || y >= level.cellsY) continue
|
||
for (let dx = -REVEAL_RADIUS; dx <= REVEAL_RADIUS; dx += 1) {
|
||
const x = cellX + dx
|
||
if (x < 0 || x >= level.cellsX) continue
|
||
if (dx * dx + dy * dy > REVEAL_RADIUS * REVEAL_RADIUS) continue
|
||
seen[y * level.cellsX + x] = 1
|
||
}
|
||
}
|
||
|
||
this.lastRenderStats.revealedRooms = roomSet.size
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* How much of a level has been seen (`0..1`).
|
||
*/
|
||
exploredFraction(level: MinimapLevel): number {
|
||
const seen = this.seenFor(level)
|
||
let count = 0
|
||
for (const value of seen) count += value
|
||
return seen.length === 0 ? 0 : count / seen.length
|
||
}
|
||
|
||
/**
|
||
* Draws the authentic 1.13c Isometric Sprite Automap or Corner Minimap.
|
||
*
|
||
* Call between `renderer.begin` and `renderer.flush`.
|
||
* Applies hardware WebGL scissor clipping (`gl.SCISSOR_TEST`) for the active viewport,
|
||
* renders the 4 isometric sprite layers in strict order (`floors -> walls -> objects -> extras`),
|
||
* renders dynamic town portals (`MaxiMap` / `MaxiMapS` `306`/`307`/`300`), and draws the
|
||
* 12-segment closed directional crosshair polygons (`0x6fb107f0`).
|
||
*/
|
||
draw(
|
||
renderer: SpriteRenderer,
|
||
level: MinimapLevel,
|
||
playerCellX: number,
|
||
playerCellY: number,
|
||
view: { readonly x: number; readonly y: number; readonly zoom: number },
|
||
viewport: { readonly width: number; readonly height: number },
|
||
options?: MinimapDrawOptions,
|
||
): void {
|
||
if (this.lastLevelKey !== null && this.lastLevelKey !== level.key) {
|
||
this.resetPan()
|
||
}
|
||
this.lastLevelKey = level.key
|
||
|
||
const screenW = Math.max(1, Math.trunc(viewport.width))
|
||
const screenH = Math.max(1, Math.trunc(viewport.height))
|
||
const vp = computeAutomapViewport(
|
||
screenW,
|
||
screenH,
|
||
this.bMinimap,
|
||
options?.leftPanelOpen ?? false,
|
||
options?.rightPanelOpen ?? false,
|
||
this.panX,
|
||
this.panY,
|
||
)
|
||
|
||
const automapLayer = level.automap ?? this.getOrSynthesizeAutomapLayer(level)
|
||
this.lastRenderStats.mode = this.mode
|
||
this.lastRenderStats.bMinimap = this.bMinimap
|
||
this.lastRenderStats.divisor = vp.divisor
|
||
this.lastRenderStats.fadeState = this.fadeState
|
||
this.lastRenderStats.fadeAlpha = this.fadeAlpha
|
||
this.lastRenderStats.showNames = this.showNames
|
||
this.lastRenderStats.panX = this.panX
|
||
this.lastRenderStats.panY = this.panY
|
||
this.lastRenderStats.viewport = vp
|
||
this.lastRenderStats.cellCounts = {
|
||
floors: automapLayer.floors.length,
|
||
walls: automapLayer.walls.length,
|
||
objects: automapLayer.objects.length,
|
||
extras: automapLayer.extras.length,
|
||
total:
|
||
automapLayer.floors.length +
|
||
automapLayer.walls.length +
|
||
automapLayer.objects.length +
|
||
automapLayer.extras.length,
|
||
}
|
||
this.lastRenderStats.spritesDrawn = {
|
||
floors: 0,
|
||
walls: 0,
|
||
objects: 0,
|
||
extras: 0,
|
||
portals: 0,
|
||
total: 0,
|
||
}
|
||
this.lastRenderStats.crosshairsDrawn = {
|
||
player: 0,
|
||
party: 0,
|
||
npcs: 0,
|
||
mercenaries: 0,
|
||
bosses: 0,
|
||
total: 0,
|
||
}
|
||
this.lastRenderStats.revealedRooms = this.revealedRoomCount(level.key)
|
||
this.lastRenderStats.warpLabels = []
|
||
this.lastRenderStats.entityLabels = []
|
||
this.lastRenderStats.hudText = null
|
||
|
||
if (!this.visible) return
|
||
|
||
const seen = this.seenFor(level)
|
||
const originX = level.originX ?? 0
|
||
const originY = level.originY ?? 0
|
||
const playerClientX =
|
||
options?.playerWorldX !== undefined
|
||
? Math.round(options.playerWorldX - originX)
|
||
: Math.round((playerCellX - playerCellY) * 80)
|
||
const playerClientY =
|
||
options?.playerWorldY !== undefined
|
||
? Math.round(options.playerWorldY - originY)
|
||
: Math.round((playerCellX + playerCellY) * 40)
|
||
|
||
const offsetX = Math.trunc(playerClientX / vp.divisor) - vp.centerX + vp.effectivePanX
|
||
const offsetY = Math.trunc(playerClientY / vp.divisor) - vp.centerY + vp.effectivePanY
|
||
|
||
const zoom = view.zoom > 0 ? view.zoom : 1
|
||
const halfScreenW = screenW / 2
|
||
const halfScreenH = screenH / 2
|
||
const toWorldX = (sx: number): number => view.x + (sx - halfScreenW) / zoom
|
||
const toWorldY = (sy: number): number => view.y + (sy - halfScreenH) / zoom
|
||
|
||
// Enable hardware WebGL scissor clipping for the automap viewport (`0x6fb102a0`)
|
||
const gl = (renderer as unknown as { gl?: WebGL2RenderingContext | null }).gl
|
||
if (gl && typeof gl.enable === 'function' && typeof gl.scissor === 'function') {
|
||
renderer.flush()
|
||
gl.enable(gl.SCISSOR_TEST)
|
||
const scissorX = Math.max(0, Math.round(vp.vpX))
|
||
const scissorY = Math.max(0, Math.round(screenH - (vp.vpY + vp.vpH)))
|
||
const scissorW = Math.max(1, Math.round(vp.vpW))
|
||
const scissorH = Math.max(1, Math.round(vp.vpH))
|
||
gl.scissor(scissorX, scissorY, scissorW, scissorH)
|
||
}
|
||
|
||
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[],
|
||
layerKey: 'floors' | 'walls' | 'objects' | 'extras',
|
||
ignoreFogOfWar: boolean,
|
||
): void => {
|
||
for (const cell of cells) {
|
||
const sheetName = resolveAutomapSheetName(cell.fSaved ?? 0, this.bMinimap)
|
||
const sheetMeta = AUTOMAP_UI_MANIFEST.atlases[sheetName]
|
||
const atlasHandle = this.resolveAtlasHandle(renderer, sheetName, sheetMeta)
|
||
if (!sheetMeta || !atlasHandle) continue
|
||
if (cell.cellNo < 0 || cell.cellNo >= sheetMeta.frames.length) continue
|
||
if (!ignoreFogOfWar && !this.isAutomapCellRevealed(level, seen, cell)) continue
|
||
const frame = sheetMeta.frames[cell.cellNo]
|
||
if (!frame || frame.width <= 0 || frame.height <= 0) continue
|
||
|
||
const rect = projectAutomapCellScreenRect(cell, frame, offsetX, offsetY, this.bMinimap)
|
||
// Frustum cull against active viewport `[vpX, vpY, vpX + vpW, vpY + vpH]`
|
||
if (
|
||
rect.drawLeft + rect.width < vp.vpX ||
|
||
rect.drawLeft > vp.vpX + vp.vpW ||
|
||
rect.drawTop + rect.height < vp.vpY ||
|
||
rect.drawTop > vp.vpY + vp.vpH
|
||
) {
|
||
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,
|
||
width: frame.width,
|
||
height: frame.height,
|
||
}
|
||
renderer.draw(
|
||
atlasFrame,
|
||
toWorldX(rect.drawLeft),
|
||
toWorldY(rect.drawTop),
|
||
{
|
||
atlas: atlasHandle,
|
||
width: rect.width / zoom,
|
||
height: rect.height / zoom,
|
||
tint,
|
||
},
|
||
)
|
||
this.lastRenderStats.spritesDrawn[layerKey] += 1
|
||
this.lastRenderStats.spritesDrawn.total += 1
|
||
}
|
||
}
|
||
|
||
// Strict 1.13c 4-layer draw order (`0x6fb10489`):
|
||
// 1. Layer 0 (`floors`)
|
||
drawLayerCells(automapLayer.floors, 'floors', false)
|
||
// 2. Layer 1 (`walls`)
|
||
drawLayerCells(automapLayer.walls, 'walls', false)
|
||
// 3. Layer 2 (`objects`)
|
||
drawLayerCells(automapLayer.objects, 'objects', false)
|
||
// 4. Layer 3 (`extras` — pre-revealed town entrances/crosses + revealed warps/waypoints/shrines)
|
||
drawLayerCells(automapLayer.extras, 'extras', true)
|
||
|
||
// 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 ?? 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)
|
||
const yPixel = Math.trunc((pClientY * 2) / 10)
|
||
const cellNo =
|
||
effectiveLevelId === 4 ? 300 : portal.isLocalPlayerOwner === false ? 307 : 306
|
||
const frame = maxiMeta.frames[cellNo]
|
||
if (!frame || frame.width <= 0 || frame.height <= 0) continue
|
||
const rect = projectAutomapCellScreenRect(
|
||
{ xPixel, yPixel },
|
||
frame,
|
||
offsetX,
|
||
offsetY,
|
||
this.bMinimap,
|
||
)
|
||
if (
|
||
rect.drawLeft + rect.width < vp.vpX ||
|
||
rect.drawLeft > vp.vpX + vp.vpW ||
|
||
rect.drawTop + rect.height < vp.vpY ||
|
||
rect.drawTop > vp.vpY + vp.vpH
|
||
) {
|
||
continue
|
||
}
|
||
const atlasFrame: AtlasFrame = {
|
||
x: frame.x,
|
||
y: frame.y,
|
||
width: frame.width,
|
||
height: frame.height,
|
||
}
|
||
renderer.draw(
|
||
atlasFrame,
|
||
toWorldX(rect.drawLeft),
|
||
toWorldY(rect.drawTop),
|
||
{
|
||
atlas: maxiAtlas,
|
||
width: rect.width / zoom,
|
||
height: rect.height / zoom,
|
||
tint: [1, 1, 1, this.fadeAlpha],
|
||
},
|
||
)
|
||
this.lastRenderStats.spritesDrawn.portals += 1
|
||
this.lastRenderStats.spritesDrawn.total += 1
|
||
if (portal.label) {
|
||
this.lastRenderStats.warpLabels.push({
|
||
text: portal.label,
|
||
x: rect.screenX,
|
||
y: rect.screenY - 8,
|
||
color: 'blue',
|
||
kind: 'portal',
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. Dynamic Entity Crosshairs (`AUTOMAP_DrawCrosshair` @ `0x6fb11490` & `0x6fabd390`)
|
||
if (options?.entities) {
|
||
for (const entity of options.entities) {
|
||
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
|
||
const ey = Math.trunc(eClientY / vp.divisor) - offsetY
|
||
if (ex < vp.vpX || ex > vp.vpX + vp.vpW || ey < vp.vpY || ey > vp.vpY + vp.vpH) {
|
||
continue
|
||
}
|
||
|
||
let color: readonly [number, number, number, number] = AUTOMAP_CROSSHAIR_COLORS.npc
|
||
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 === '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.ownPet
|
||
this.lastRenderStats.crosshairsDrawn.mercenaries += 1
|
||
} else {
|
||
color = AUTOMAP_CROSSHAIR_COLORS.boss
|
||
this.lastRenderStats.crosshairsDrawn.bosses += 1
|
||
}
|
||
|
||
this.drawCrosshairPolygon(renderer, ex, ey, this.bMinimap, color, toWorldX, toWorldY, zoom)
|
||
this.lastRenderStats.crosshairsDrawn.total += 1
|
||
|
||
if (this.showNames && entity.name && (entity.kind === 'party' || entity.kind === 'npc')) {
|
||
this.lastRenderStats.entityLabels.push({
|
||
text: entity.name,
|
||
x: ex,
|
||
y: ey - 10,
|
||
color: entity.kind === 'npc' ? 'gold' : 'green',
|
||
kind: 'entity',
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 (
|
||
playerScreenX >= vp.vpX &&
|
||
playerScreenX <= vp.vpX + vp.vpW &&
|
||
playerScreenY >= vp.vpY &&
|
||
playerScreenY <= vp.vpY + vp.vpH
|
||
) {
|
||
this.drawCrosshairPolygon(
|
||
renderer,
|
||
playerScreenX,
|
||
playerScreenY,
|
||
this.bMinimap,
|
||
AUTOMAP_CROSSHAIR_COLORS.player,
|
||
toWorldX,
|
||
toWorldY,
|
||
zoom,
|
||
)
|
||
this.lastRenderStats.crosshairsDrawn.player += 1
|
||
this.lastRenderStats.crosshairsDrawn.total += 1
|
||
}
|
||
|
||
// Disable WebGL scissor test after flushing the automap batch
|
||
if (gl && typeof gl.disable === 'function') {
|
||
renderer.flush()
|
||
gl.disable(gl.SCISSOR_TEST)
|
||
}
|
||
|
||
// 8. Collect revealed Warp destination labels & Stash labels (`Font6`)
|
||
for (const marker of level.markers) {
|
||
if (marker.kind !== 'exit' || !marker.targetName) continue
|
||
if (!this.isCellExplored(level, marker.cellX, marker.cellY)) continue
|
||
const subTileX = marker.cellX * 5 + 2
|
||
const subTileY = marker.cellY * 5 + 2
|
||
const coords = computeAutomapObjectPixelCoords(subTileX, subTileY)
|
||
const lx = Math.trunc((coords.xPixel * 10) / vp.divisor) - offsetX
|
||
const ly = Math.trunc((coords.yPixel * 10) / vp.divisor) - offsetY - 8
|
||
if (lx >= vp.vpX && lx <= vp.vpX + vp.vpW && ly >= vp.vpY && ly <= vp.vpY + vp.vpH) {
|
||
this.lastRenderStats.warpLabels.push({
|
||
text: marker.targetName,
|
||
x: lx,
|
||
y: ly,
|
||
color: 'white',
|
||
kind: 'warp',
|
||
})
|
||
}
|
||
}
|
||
|
||
if (options?.stashes) {
|
||
for (const stash of options.stashes) {
|
||
const sClientX = Math.round(stash.x - originX)
|
||
const sClientY = Math.round(stash.y - originY)
|
||
const sx = Math.trunc(sClientX / vp.divisor) - offsetX
|
||
const sy = Math.trunc(sClientY / vp.divisor) - offsetY - 8
|
||
if (sx >= vp.vpX && sx <= vp.vpX + vp.vpW && sy >= vp.vpY && sy <= vp.vpY + vp.vpH) {
|
||
this.lastRenderStats.warpLabels.push({
|
||
text: stash.label || stash.name || 'Stash',
|
||
x: sx,
|
||
y: sy,
|
||
color: 'gold',
|
||
kind: 'stash',
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 9. Top-Right right-aligned gold `Font16` HUD text (Line 1: Level Name, Line 2: Difficulty)
|
||
const levelName = options?.levelName ?? level.levelName ?? ''
|
||
const difficulty = options?.difficulty ?? level.difficulty ?? 'Normal'
|
||
if (levelName) {
|
||
this.lastRenderStats.hudText = {
|
||
levelName,
|
||
difficulty,
|
||
x: 800 - 16,
|
||
y1: 16,
|
||
y2: 34,
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Renders the automap 2D text overlays onto the HUD canvas:
|
||
* - Revealed warp destination area names and `"Stash"` labels (`Font6`, centered)
|
||
* - Friendly NPC / party member names when `showNames` is true (`Font6`, centered at `(x, y - 6)`)
|
||
* - Top-right right-aligned gold `Font16` Level Name (Line 1) and Difficulty (Line 2)
|
||
*/
|
||
drawHudOverlay(
|
||
ctx: CanvasRenderingContext2D,
|
||
font: D2FontRenderer,
|
||
logicalWidth = 800,
|
||
logicalHeight = 600,
|
||
): void {
|
||
if (!this.visible) return
|
||
|
||
const vp = this.lastRenderStats.viewport
|
||
const scaleX = vp.screenWidth > 0 && logicalWidth > 0 ? logicalWidth / vp.screenWidth : 1
|
||
const scaleY = vp.screenHeight > 0 && logicalHeight > 0 ? logicalHeight / vp.screenHeight : 1
|
||
|
||
// 1. Warp & Stash labels (`Font6`)
|
||
for (const label of this.lastRenderStats.warpLabels) {
|
||
const lx = Math.round(label.x * scaleX)
|
||
const ly = Math.round(label.y * scaleY)
|
||
font.drawText(ctx, label.text, lx, ly, {
|
||
font: 'font6',
|
||
color: label.color,
|
||
align: 'center',
|
||
shadow: true,
|
||
})
|
||
}
|
||
|
||
// 2. Friendly NPC / Party member name labels (`Font6`) when `showNames` is enabled
|
||
if (this.showNames) {
|
||
for (const label of this.lastRenderStats.entityLabels) {
|
||
const lx = Math.round(label.x * scaleX)
|
||
const ly = Math.round(label.y * scaleY)
|
||
font.drawText(ctx, label.text, lx, ly, {
|
||
font: 'font6',
|
||
color: label.color,
|
||
align: 'center',
|
||
shadow: true,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 3. Top-right right-aligned gold `Font16` Level Name (Line 1) & Difficulty (Line 2)
|
||
const hudText = this.lastRenderStats.hudText
|
||
if (hudText && hudText.levelName) {
|
||
const rightX = logicalWidth - 16
|
||
font.drawText(ctx, hudText.levelName, rightX, hudText.y1, {
|
||
font: 'font16',
|
||
color: 'gold',
|
||
align: 'right',
|
||
shadow: true,
|
||
})
|
||
if (hudText.difficulty) {
|
||
font.drawText(ctx, hudText.difficulty, rightX, hudText.y2, {
|
||
font: 'font16',
|
||
color: 'gold',
|
||
align: 'right',
|
||
shadow: true,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Draws the 12-segment closed directional polygon crosshair (`AUTOMAP_DrawCrosshair` @ `0x6fb11490`).
|
||
*/
|
||
private drawCrosshairPolygon(
|
||
renderer: SpriteRenderer,
|
||
cx: number,
|
||
cy: number,
|
||
bMinimap: boolean,
|
||
color: readonly [number, number, number, number],
|
||
toWorldX: (sx: number) => number,
|
||
toWorldY: (sy: number) => number,
|
||
zoom: number,
|
||
): readonly AutomapCrosshairSegment[] {
|
||
const segments = getCrosshairSegments(cx, cy, bMinimap)
|
||
const hasQuad = typeof (renderer as { drawSolidQuad?: unknown }).drawSolidQuad === 'function'
|
||
const halfThickness = 0.75 / zoom
|
||
|
||
for (const seg of segments) {
|
||
const x1 = toWorldX(seg.x0)
|
||
const y1 = toWorldY(seg.y0)
|
||
const x2 = toWorldX(seg.x1)
|
||
const y2 = toWorldY(seg.y1)
|
||
if (hasQuad) {
|
||
const dx = x2 - x1
|
||
const dy = y2 - y1
|
||
const len = Math.hypot(dx, dy) || 1
|
||
const nx = (-dy / len) * halfThickness
|
||
const ny = (dx / len) * halfThickness
|
||
renderer.drawSolidQuad(
|
||
x1 + nx,
|
||
y1 + ny,
|
||
x2 + nx,
|
||
y2 + ny,
|
||
x2 - nx,
|
||
y2 - ny,
|
||
x1 - nx,
|
||
y1 - ny,
|
||
color,
|
||
)
|
||
} else {
|
||
const left = Math.min(x1, x2)
|
||
const top = Math.min(y1, y2)
|
||
const w = Math.max(1 / zoom, Math.abs(x2 - x1))
|
||
const h = Math.max(1 / zoom, Math.abs(y2 - y1))
|
||
renderer.drawSolid(left, top, w, h, color)
|
||
}
|
||
}
|
||
return segments
|
||
}
|
||
|
||
/**
|
||
* Checks whether an `AutomapCell` lies in an explored tile of `level`.
|
||
*/
|
||
private isAutomapCellRevealed(
|
||
level: MinimapLevel,
|
||
seen: Uint8Array,
|
||
cell: AutomapCell,
|
||
): boolean {
|
||
if (level.cellsX <= 0 || level.cellsY <= 0) return false
|
||
const cellX =
|
||
typeof cell.cellX === 'number' && Number.isFinite(cell.cellX)
|
||
? cell.cellX
|
||
: automapPixelToCellCoords(cell.xPixel, cell.yPixel).cellX
|
||
const cellY =
|
||
typeof cell.cellY === 'number' && Number.isFinite(cell.cellY)
|
||
? cell.cellY
|
||
: automapPixelToCellCoords(cell.xPixel, cell.yPixel).cellY
|
||
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`.
|
||
*/
|
||
private resolveAtlasHandle(
|
||
_renderer: SpriteRenderer,
|
||
sheetName: AutomapSpriteKey,
|
||
_meta: AutomapSpriteAtlasMeta | undefined,
|
||
): AtlasHandle | null {
|
||
return this.atlases.get(sheetName) ?? null
|
||
}
|
||
|
||
/**
|
||
* Synthesizes a 4-layer `AutomapLayerData` from a minimal `MinimapLevel` (when `level.automap`
|
||
* is not explicitly passed, e.g. in standalone unit tests).
|
||
*/
|
||
private getOrSynthesizeAutomapLayer(level: MinimapLevel): AutomapLayerData {
|
||
const cached = this.synthesizedLayers.get(level.key)
|
||
if (cached) return cached
|
||
|
||
const floors: AutomapCell[] = []
|
||
const walls: AutomapCell[] = []
|
||
const objects: AutomapCell[] = []
|
||
const extras: AutomapCell[] = []
|
||
|
||
for (let y = 0; y < level.cellsY; y += 1) {
|
||
for (let x = 0; x < level.cellsX; x += 1) {
|
||
const coords = computeAutomapTilePixelCoords(x, y, 0)
|
||
if (this.cellBlocked(level, x, y)) {
|
||
walls.push({
|
||
cellNo: 9,
|
||
xPixel: coords.xPixel,
|
||
yPixel: coords.yPixel,
|
||
cellX: x,
|
||
cellY: y,
|
||
fSaved: 0,
|
||
weight: 0,
|
||
})
|
||
} else {
|
||
floors.push({
|
||
cellNo: 0,
|
||
xPixel: coords.xPixel,
|
||
yPixel: coords.yPixel,
|
||
cellX: x,
|
||
cellY: y,
|
||
fSaved: 0,
|
||
weight: 0,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const marker of level.markers) {
|
||
const subTileX = marker.cellX * 5 + 2
|
||
const subTileY = marker.cellY * 5 + 2
|
||
const coords = computeAutomapObjectPixelCoords(subTileX, subTileY)
|
||
if (marker.kind === 'waypoint') {
|
||
extras.push({
|
||
cellNo: 309,
|
||
xPixel: coords.xPixel,
|
||
yPixel: coords.yPixel,
|
||
cellX: marker.cellX,
|
||
cellY: marker.cellY,
|
||
fSaved: 0,
|
||
weight: 0,
|
||
})
|
||
} else {
|
||
objects.push({
|
||
cellNo: 306,
|
||
xPixel: coords.xPixel,
|
||
yPixel: coords.yPixel,
|
||
cellX: marker.cellX,
|
||
cellY: marker.cellY,
|
||
fSaved: 0,
|
||
weight: 0,
|
||
})
|
||
}
|
||
}
|
||
|
||
const layer: AutomapLayerData = {
|
||
layerId: level.levelId ?? 0,
|
||
levelTypeId: 1,
|
||
floors,
|
||
walls,
|
||
objects,
|
||
extras,
|
||
}
|
||
this.synthesizedLayers.set(level.key, layer)
|
||
return layer
|
||
}
|
||
|
||
/**
|
||
* Whether a cell is blocked in the sub-tile walkability grid.
|
||
*/
|
||
private cellBlocked(level: MinimapLevel, cellX: number, cellY: number): boolean {
|
||
const sx = cellX * 5 + 2
|
||
const sy = cellY * 5 + 2
|
||
const idx = sy * level.gridWidth + sx
|
||
return idx >= 0 && idx < level.blocked.length && level.blocked[idx] !== 0
|
||
}
|
||
|
||
/**
|
||
* The explored bytes for a level, created on first use.
|
||
*/
|
||
private seenFor(level: MinimapLevel): Uint8Array {
|
||
let seen = this.explored.get(level.key)
|
||
if (seen === undefined || seen.length !== level.cellsX * level.cellsY) {
|
||
seen = new Uint8Array(level.cellsX * level.cellsY)
|
||
this.explored.set(level.key, seen)
|
||
}
|
||
return seen
|
||
}
|
||
}
|