541 lines
17 KiB
TypeScript
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 } : {}),
|
|
}
|
|
}
|
|
}
|