diablo2-web/src/client/automap/automap-view.ts

541 lines
17 KiB
TypeScript

import { DRLG_GetActNoFromLevelId } from '../../common/drlg/drlg-drlg.ts'
import { getMonsterKindByClassId } from '../../common/units/monsters-data.ts'
import { NPC_HCIDX_TO_DESCRIPTOR_ID } from '../../common/units/npc-table.ts'
import { levelDisplayName } from '../../common/world/level-names-zh.ts'
import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.ts'
import type {
MinimapLevel,
MinimapMarker,
} from '../ui/minimap.ts'
import {
type LevelRoomView,
type LevelView,
} from '../map/level-view.ts'
import { MapService } from '../map/map-service.ts'
import type { AutomapRevealMode } from '../settings/client-settings.ts'
import type { RenderableUnitInput } from '../view/unit-drawables.ts'
export interface MinimapEntityDot {
readonly worldX: number
readonly worldY: number
readonly kind: 'ally' | 'monster' | 'corpse' | 'npc'
readonly name?: string | undefined
}
export interface MinimapPortalIcon {
readonly worldX: number
readonly worldY: number
readonly active: boolean
}
export interface MinimapStashIcon {
readonly worldX: number
readonly worldY: number
}
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 {
const act0 = DRLG_GetActNoFromLevelId(areaId)
return (act0 + 1) as ActId
}
/**
* Localized difficulty string ('Normal' | 'Nightmare' | 'Hell' / '普通' | '惡夢' | '地獄')
* wired to .tbl string keys (strNormal, strNightmare, strHell) per Issue #628.
*/
export function getLocalizedDifficulty(
difficulty: DifficultyId,
lang: TblLang = getTblLang(),
): string {
const isZh = lang === 'CHI'
if (difficulty === 1) {
return lookupTbl('strNightmare', lang) ?? (isZh ? '惡夢' : 'Nightmare')
}
if (difficulty === 2) {
return lookupTbl('strHell', lang) ?? (isZh ? '地獄' : 'Hell')
}
return lookupTbl('strNormal', lang) ?? (isZh ? '普通' : 'Normal')
}
/**
* Canonical preset town levels with AutoMap=1 in LvlPrest.txt (DrlgPreset.cpp:1794-1823):
* 1 (Rogue Encampment), 40 (Lut Gholein), 75 (Kurast Docks), 103 (Pandemonium Fortress), 109 (Harrogath).
*/
export const TOWN_LEVEL_IDS: ReadonlySet<number> = new Set([1, 40, 75, 103, 109])
export function isTownLevel(levelId: number): boolean {
return TOWN_LEVEL_IDS.has(levelId)
}
/**
* Primary outdoor/dungeon progression level IDs per Act for `'act'` reveal mode.
*/
export const ACT_PRIMARY_LEVEL_IDS: Readonly<Record<ActId, readonly number[]>> = {
1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 20, 26, 27, 28, 29, 32, 34, 35, 36, 37],
2: [40, 41, 42, 43, 44, 45, 46, 47, 50, 56, 58, 62, 65, 66, 74],
3: [75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 88, 92, 100, 101, 102],
4: [103, 104, 105, 106, 107, 108],
5: [109, 110, 111, 112, 113, 115, 117, 118, 120, 121, 128, 129, 130, 131, 132],
}
export interface AutomapRoomSummary {
readonly areaId: number
readonly roomX: number
readonly roomY: number
readonly roomW: number
readonly roomH: number
readonly revealedByServer: boolean
}
export interface AutomapWarpMarker {
readonly fromAreaId: number
readonly toAreaId: number
readonly cellX: number
readonly cellY: number
readonly label: string
}
export interface AutomapSnapshot {
readonly mode: AutomapRevealMode
readonly actId: ActId
readonly currentAreaId: number
readonly visibleAreaIds: readonly number[]
readonly visibleRoomCount: number
readonly serverRevealedRoomCount: number
readonly visibleRooms: readonly AutomapRoomSummary[]
readonly warpMarkers: readonly AutomapWarpMarker[]
readonly entityDots: readonly MinimapEntityDot[]
readonly portalIcons: readonly MinimapPortalIcon[]
readonly stashIcons: readonly MinimapStashIcon[]
}
export interface AutomapViewOptions {
readonly mapService?: MapService | null | undefined
readonly initialMode?: AutomapRevealMode | undefined
readonly actRevealLevelIds?: Readonly<Record<ActId, readonly number[]>> | undefined
}
function makeRoomKey(areaId: number, roomX: number, roomY: number): string {
return `${areaId}:${roomX},${roomY}`
}
/**
* Online & offline Automap Reveal controller (`off` | `level` | `act`) consuming
* `LevelView` and `MapService` (`src/client/map/`).
*
* - `'off'`: Only rooms explicitly revealed by D2GS `0x07 AddMapRoom` (minus `0x08 RemoveMapRoom`) are visible.
* - `'level'`: All DRLG rooms in the active `areaId` are revealed.
* - `'act'`: All DRLG levels and rooms in the active Act are generated and revealed with cross-level warp markers.
*/
export class AutomapView {
private mapService: MapService | null
private readonly actRevealLevelIds: Readonly<Record<ActId, readonly number[]>>
private mode: AutomapRevealMode
private mapSeed = 0
private difficulty: DifficultyId = 0
private currentAreaId = 1
private currentActId: ActId = 1
private readonly levelViews = new Map<number, LevelView>()
private readonly serverRevealedRooms = new Map<
string,
{ readonly areaId: number; readonly roomX: number; readonly roomY: number }
>()
constructor(options?: AutomapViewOptions) {
this.mapService = options?.mapService ?? null
this.mode = options?.initialMode ?? 'off'
this.actRevealLevelIds = options?.actRevealLevelIds ?? ACT_PRIMARY_LEVEL_IDS
}
getMode(): AutomapRevealMode {
return this.mode
}
setMode(mode: AutomapRevealMode): void {
this.mode = mode
this.applyRevealModeToViews()
}
getCurrentAreaId(): number {
return this.currentAreaId
}
getCurrentActId(): ActId {
return this.currentActId
}
/**
* Bind or update the active map seed, difficulty, and current `areaId`.
*/
setContext(
mapSeed: number,
difficulty: DifficultyId,
areaId: number,
existingLevelView?: LevelView,
): LevelView {
const seedChanged = this.mapSeed !== (mapSeed >>> 0) || this.difficulty !== difficulty
this.mapSeed = mapSeed >>> 0
this.difficulty = difficulty
this.currentAreaId = areaId
this.currentActId = getActForArea(areaId)
if (seedChanged) {
this.levelViews.clear()
this.serverRevealedRooms.clear()
if (this.mapService && typeof (this.mapService as Partial<MapService>).loadAct === 'function') {
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 {
// Ignore if mapService fails synchronously
}
}
}
if (existingLevelView) {
this.levelViews.set(areaId, existingLevelView)
} else if (this.mapService && !this.levelViews.has(areaId)) {
this.ensureLevelView(areaId)
}
this.applyRevealModeToViews()
return this.levelViews.get(areaId)!
}
/**
* Switch the active level (`currentAreaId` / `currentActId`) within the current game session
* without clearing `levelViews` or `serverRevealedRooms` (#549).
*/
syncActiveLevel(areaId: number, existingLevelView?: LevelView): LevelView {
if (areaId > 0) {
this.currentAreaId = areaId
this.currentActId = getActForArea(areaId)
}
const targetAreaId = this.currentAreaId
if (existingLevelView && existingLevelView.areaId === targetAreaId) {
this.levelViews.set(targetAreaId, existingLevelView)
} else if (this.mapService && !this.levelViews.has(targetAreaId)) {
this.ensureLevelView(targetAreaId)
}
this.applyRevealModeToViews()
return this.levelViews.get(targetAreaId)!
}
/**
* Record a server `0x07 AddMapRoom` event.
*/
onServerAddRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void {
if (this.mapService) {
this.ensureLevelView(areaId)
}
this.serverRevealedRooms.set(makeRoomKey(areaId, roomX, roomY), {
areaId,
roomX,
roomY,
})
if (this.mapService && typeof this.mapService.onAddRoomDataPacket === 'function') {
this.mapService.onAddRoomDataPacket(
{ areaId, roomX, roomY },
{ rebuildView: false },
)
}
}
/**
* Record a server `0x08 RemoveMapRoom` event.
*/
onServerRemoveRoom(roomX: number, roomY: number, areaId: number = this.currentAreaId): void {
this.serverRevealedRooms.delete(makeRoomKey(areaId, roomX, roomY))
if (this.mapService && typeof this.mapService.onRemoveRoomDataPacket === 'function') {
this.mapService.onRemoveRoomDataPacket(
{ areaId, roomX, roomY },
{ rebuildView: false },
)
}
}
getLevelView(areaId: number = this.currentAreaId): LevelView | undefined {
return this.levelViews.get(areaId)
}
private ensureLevelView(areaId: number): LevelView | undefined {
let view = this.levelViews.get(areaId)
if (!view) {
if (!this.mapService) {
return undefined
}
try {
if (typeof (this.mapService as Partial<MapService>).loadAct === 'function') {
const activeAct =
typeof (this.mapService as Partial<MapService>).getActiveAct === 'function'
? this.mapService.getActiveAct()
: null
if (activeAct !== getActForArea(areaId) - 1) {
this.mapService.loadAct({
act: getActForArea(areaId) - 1,
seed: this.mapSeed,
difficulty: this.difficulty,
areaId,
revealMode: 'all',
})
}
}
if (typeof (this.mapService as Partial<MapService>).getLevelView === 'function') {
view = this.mapService.getLevelView(areaId, 'all') ?? undefined
}
if (view) {
this.levelViews.set(areaId, view)
}
} catch {
return undefined
}
}
return view
}
private applyRevealModeToViews(): void {
if (!this.mapService) {
return
}
this.ensureLevelView(this.currentAreaId)
if (this.mode === 'act' && this.mapService) {
const actLevels = this.actRevealLevelIds[this.currentActId] ?? [this.currentAreaId]
const idsToReveal = actLevels.includes(this.currentAreaId)
? actLevels
: [this.currentAreaId, ...actLevels]
for (const lvlId of idsToReveal) {
this.ensureLevelView(lvlId)
}
}
}
private isRoomRevealedByServer(areaId: number, room: LevelRoomView): boolean {
if (this.serverRevealedRooms.has(makeRoomKey(areaId, room.tileX, room.tileY))) {
return true
}
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
) {
return true
}
}
return false
}
/**
* Build a complete `AutomapSnapshot` reflecting the active reveal mode (`off` | `level` | `act`),
* visible rooms, cross-level warp markers, and entity/portal/stash icons.
*/
buildSnapshot(
units: Iterable<RenderableUnitInput> = [],
lang: TblLang = getTblLang(),
): AutomapSnapshot {
this.applyRevealModeToViews()
const visibleAreaIds: number[] =
this.mode === 'act'
? Array.from(
new Set([
this.currentAreaId,
...(this.actRevealLevelIds[this.currentActId] ?? []),
]),
)
: [this.currentAreaId]
const visibleRooms: AutomapRoomSummary[] = []
const warpMarkers: AutomapWarpMarker[] = []
for (const areaId of visibleAreaIds) {
const view = this.levelViews.get(areaId)
if (!view) continue
const revealedRooms =
this.mode === 'off'
? view.rooms.filter(r => this.isRoomRevealedByServer(areaId, r))
: view.rooms
for (const room of revealedRooms) {
visibleRooms.push({
areaId,
roomX: room.tileX,
roomY: room.tileY,
roomW: room.tileW,
roomH: room.tileH,
revealedByServer: this.isRoomRevealedByServer(areaId, room),
})
}
if (revealedRooms.length > 0) {
const first = revealedRooms[0]!
warpMarkers.push({
fromAreaId: this.currentAreaId,
toAreaId: areaId,
cellX: first.tileX + Math.floor(first.tileW / 2),
cellY: first.tileY + Math.floor(first.tileH / 2),
label: levelDisplayName(areaId, lang),
})
}
}
const entityDots: MinimapEntityDot[] = []
const portalIcons: MinimapPortalIcon[] = []
const stashIcons: MinimapStashIcon[] = []
for (const u of units) {
if (u.kind === 'missile' || u.kind === 'item') continue
const worldX = (u.x - u.y) * 16
const worldY = (u.x + u.y) * 8
if (u.kind === 'object') {
const tok = (u.token ?? '').toUpperCase()
if (tok === 'PF' || u.classId === 59 || u.classId === 60) {
portalIcons.push({ worldX, worldY, active: true })
} else if (tok === 'B6' || u.classId === 267) {
stashIcons.push({ worldX, worldY })
}
continue
}
if (u.kind === 'player') {
entityDots.push({
worldX,
worldY,
kind: 'ally',
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)
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 isTownNpc = Boolean(NPC_HCIDX_TO_DESCRIPTOR_ID.has(cid) || NPC_HCIDX_TO_DESCRIPTOR_ID[cid])
const isInteract = Boolean(mk?.interact || (u as { isNpc?: boolean }).isNpc || isTownNpc)
if (isInteract && !isAncientStatue) {
entityDots.push({
worldX,
worldY,
kind: 'npc',
name: u.name,
})
}
}
}
}
let serverRevealedCount = 0
for (const b of this.serverRevealedRooms.values()) {
if (this.mode === 'act' || b.areaId === this.currentAreaId) {
serverRevealedCount++
}
}
return {
mode: this.mode,
actId: this.currentActId,
currentAreaId: this.currentAreaId,
visibleAreaIds,
visibleRoomCount: visibleRooms.length,
serverRevealedRoomCount: serverRevealedCount,
visibleRooms,
warpMarkers,
entityDots,
portalIcons,
stashIcons,
}
}
/**
* Build a `MinimapLevel` descriptor suitable for `Minimap.draw(...)` (`src/ui/minimap.ts`).
*/
buildMinimapLevel(lang: TblLang = getTblLang(), areaId?: number): MinimapLevel {
if (areaId !== undefined && areaId > 0 && areaId !== this.currentAreaId) {
this.syncActiveLevel(areaId)
}
const snap = this.buildSnapshot([], lang)
const view = this.ensureLevelView(this.currentAreaId)
if (!view) {
throw new Error(
`[AutomapView] Failed to load LevelView for area ${this.currentAreaId} (seed: ${this.mapSeed}, difficulty: ${this.difficulty}): mapService is not initialized and packed tile bundle is unavailable`,
)
}
if (
view.cellsX <= 0 ||
view.cellsY <= 0 ||
!view.blocked ||
view.blocked.length !== view.cellsX * 5 * view.cellsY * 5
) {
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[] = []
if (Array.isArray(view.entrances)) {
for (const ent of view.entrances) {
if (ent.toLevelId > 0 && ent.toLevelId !== this.currentAreaId) {
markers.push({
cellX: ent.x,
cellY: ent.y,
kind: 'exit',
toLevelId: ent.toLevelId,
targetName: levelDisplayName(ent.toLevelId, lang),
})
}
}
}
return {
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}`,
cellsX,
cellsY,
blocked,
gridWidth: subW,
markers,
originX,
originY,
levelId: this.currentAreaId,
levelName: levelDisplayName(this.currentAreaId, lang),
difficulty: getLocalizedDifficulty(this.difficulty, lang),
preRevealTown: isTownLevel(this.currentAreaId),
...(view.automap !== undefined ? { automap: view.automap } : {}),
}
}
}