Merge branch 'fix/issue-549-minimap-level-switch' into main (Fixes #549)
This commit is contained in:
commit
47d18fce72
|
|
@ -288,6 +288,25 @@ export class AutomapView {
|
||||||
return this.levelViews.get(areaId)!
|
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.levelViews.has(targetAreaId)) {
|
||||||
|
this.ensureLevelView(targetAreaId)
|
||||||
|
}
|
||||||
|
this.applyRevealModeToViews()
|
||||||
|
return this.levelViews.get(targetAreaId)!
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a server `0x07 AddMapRoom` event.
|
* Record a server `0x07 AddMapRoom` event.
|
||||||
*/
|
*/
|
||||||
|
|
@ -498,36 +517,62 @@ export class AutomapView {
|
||||||
/**
|
/**
|
||||||
* Build a `MinimapLevel` descriptor suitable for `Minimap.draw(...)` (`src/ui/minimap.ts`).
|
* Build a `MinimapLevel` descriptor suitable for `Minimap.draw(...)` (`src/ui/minimap.ts`).
|
||||||
*/
|
*/
|
||||||
buildMinimapLevel(lang: TblLang = getTblLang()): MinimapLevel {
|
buildMinimapLevel(lang: TblLang = getTblLang(), areaId?: number): MinimapLevel {
|
||||||
|
if (areaId !== undefined && areaId > 0 && areaId !== this.currentAreaId) {
|
||||||
|
this.syncActiveLevel(areaId)
|
||||||
|
}
|
||||||
const snap = this.buildSnapshot([], lang)
|
const snap = this.buildSnapshot([], lang)
|
||||||
const view = this.ensureLevelView(this.currentAreaId)
|
const view = this.ensureLevelView(this.currentAreaId)
|
||||||
const allRooms = view.rooms
|
const allRooms = view.rooms
|
||||||
|
|
||||||
let minCellX = 0
|
let minCellX = 0
|
||||||
let minCellY = 0
|
let minCellY = 0
|
||||||
let maxCellX = 40
|
let cellsX = 40
|
||||||
let maxCellY = 40
|
let cellsY = 40
|
||||||
if (allRooms.length > 0) {
|
let subW = 200
|
||||||
minCellX = Math.min(...allRooms.map(r => r.tileX))
|
let blocked: Uint8Array
|
||||||
minCellY = Math.min(...allRooms.map(r => r.tileY))
|
let originX = 0
|
||||||
maxCellX = Math.max(...allRooms.map(r => r.tileX + r.tileW))
|
let originY = 0
|
||||||
maxCellY = Math.max(...allRooms.map(r => r.tileY + r.tileH))
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellsX = Math.max(1, maxCellX - minCellX)
|
if (
|
||||||
const cellsY = Math.max(1, maxCellY - minCellY)
|
view.cellsX > 0 &&
|
||||||
const subW = cellsX * 5
|
view.cellsY > 0 &&
|
||||||
const subH = cellsY * 5
|
view.blocked &&
|
||||||
const blocked = new Uint8Array(subW * subH)
|
view.blocked.length === view.cellsX * 5 * view.cellsY * 5
|
||||||
|
) {
|
||||||
for (let sy = 0; sy < subH; sy++) {
|
minCellX = view.tileOriginX ?? Math.floor((view.subOriginX ?? 0) / 5)
|
||||||
for (let sx = 0; sx < subW; sx++) {
|
minCellY = view.tileOriginY ?? Math.floor((view.subOriginY ?? 0) / 5)
|
||||||
const absSubX = minCellX * 5 + sx
|
cellsX = view.cellsX
|
||||||
const absSubY = minCellY * 5 + sy
|
cellsY = view.cellsY
|
||||||
if (!isWalkableWorldSubTile(view, absSubX, absSubY)) {
|
subW = view.gridWidth || cellsX * 5
|
||||||
blocked[sy * subW + sx] = 1
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({
|
const markers: MinimapMarker[] = snap.warpMarkers.map(wm => ({
|
||||||
|
|
@ -538,6 +583,20 @@ export class AutomapView {
|
||||||
targetName: wm.label,
|
targetName: wm.label,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`,
|
key: `automap_${this.mapSeed}_${this.difficulty}_${this.currentAreaId}_${this.mode}`,
|
||||||
cellsX,
|
cellsX,
|
||||||
|
|
@ -545,8 +604,8 @@ export class AutomapView {
|
||||||
blocked,
|
blocked,
|
||||||
gridWidth: subW,
|
gridWidth: subW,
|
||||||
markers,
|
markers,
|
||||||
originX: (minCellX - minCellY) * 80,
|
originX,
|
||||||
originY: (minCellX + minCellY) * 40,
|
originY,
|
||||||
levelId: this.currentAreaId,
|
levelId: this.currentAreaId,
|
||||||
levelName: levelDisplayName(this.currentAreaId, lang),
|
levelName: levelDisplayName(this.currentAreaId, lang),
|
||||||
...(view.automap !== undefined ? { automap: view.automap } : {}),
|
...(view.automap !== undefined ? { automap: view.automap } : {}),
|
||||||
|
|
|
||||||
|
|
@ -897,13 +897,22 @@ export class CommandMapper {
|
||||||
return this.lastPath
|
return this.lastPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snap = this.predictor.getSnapshot()
|
||||||
|
const sx = Math.round(snap.logicalX)
|
||||||
|
const sy = Math.round(snap.logicalY)
|
||||||
const levelView =
|
const levelView =
|
||||||
this.mapService?.getActiveLevelView() ?? this.automapView?.getLevelView() ?? null
|
(this.mapService && this.world.areaId > 0 && (sx !== 0 || sy !== 0)
|
||||||
|
? this.mapService.findLevelViewAtWorldSubTile(sx, sy, [
|
||||||
|
this.world.areaId,
|
||||||
|
...this.world.getRevealedAreaIds(),
|
||||||
|
])
|
||||||
|
: null) ??
|
||||||
|
(this.world.areaId > 0 ? this.automapView?.getLevelView(this.world.areaId) : undefined) ??
|
||||||
|
this.mapService?.getActiveLevelView() ??
|
||||||
|
this.automapView?.getLevelView() ??
|
||||||
|
null
|
||||||
|
|
||||||
if (levelView && levelView.walkable.length > 0) {
|
if (levelView && levelView.walkable.length > 0) {
|
||||||
const snap = this.predictor.getSnapshot()
|
|
||||||
const sx = Math.round(snap.logicalX)
|
|
||||||
const sy = Math.round(snap.logicalY)
|
|
||||||
const ox = levelView.subOriginX ?? 0
|
const ox = levelView.subOriginX ?? 0
|
||||||
const oy = levelView.subOriginY ?? 0
|
const oy = levelView.subOriginY ?? 0
|
||||||
const inBounds =
|
const inBounds =
|
||||||
|
|
|
||||||
|
|
@ -927,11 +927,19 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
ctx.minimap.visible = false
|
ctx.minimap.visible = false
|
||||||
} else {
|
} else {
|
||||||
ctx.minimap.visible = true
|
ctx.minimap.visible = true
|
||||||
const baseMinimapLevel = ctx.automapView.buildMinimapLevel()
|
if (levelView && levelView.areaId > 0) {
|
||||||
|
ctx.automapView.syncActiveLevel(levelView.areaId, levelView)
|
||||||
|
} else if (snapshot.levelId > 0 && ctx.automapView.getCurrentAreaId() !== snapshot.levelId) {
|
||||||
|
ctx.automapView.syncActiveLevel(snapshot.levelId)
|
||||||
|
}
|
||||||
|
const baseMinimapLevel = ctx.automapView.buildMinimapLevel(
|
||||||
|
undefined,
|
||||||
|
levelView?.areaId ?? snapshot.levelId,
|
||||||
|
)
|
||||||
const effectiveMinimapLevel: MinimapLevel = {
|
const effectiveMinimapLevel: MinimapLevel = {
|
||||||
...baseMinimapLevel,
|
...baseMinimapLevel,
|
||||||
originX: levelView?.isoOriginX ?? 0,
|
originX: levelView?.isoOriginX ?? baseMinimapLevel.originX ?? 0,
|
||||||
originY: levelView?.isoOriginY ?? 0,
|
originY: levelView?.isoOriginY ?? baseMinimapLevel.originY ?? 0,
|
||||||
preRevealTown: isTownLevel(snapshot.levelId),
|
preRevealTown: isTownLevel(snapshot.levelId),
|
||||||
}
|
}
|
||||||
const localSub = snapshot.playerLocalSubTile
|
const localSub = snapshot.playerLocalSubTile
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,7 @@ export class OnlineSession {
|
||||||
private disconnectReasonValue: string | null = null
|
private disconnectReasonValue: string | null = null
|
||||||
private pingMsValue = 0
|
private pingMsValue = 0
|
||||||
private tickCounter = 0
|
private tickCounter = 0
|
||||||
|
private readonly knownAreaIds = new Set<number>()
|
||||||
|
|
||||||
private readonly outboundCommandsLog: ClientCommand[] = []
|
private readonly outboundCommandsLog: ClientCommand[] = []
|
||||||
private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
|
private readonly stateListeners = new Set<(snapshot: OnlineSessionLobbySnapshot) => void>()
|
||||||
|
|
@ -373,17 +374,11 @@ export class OnlineSession {
|
||||||
// such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG
|
// such as Blood Moor as a side effect of 0x07). The rendered level is the one whose DRLG
|
||||||
// canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a
|
// canvas contains the player's world sub-tile (1.13c: unit → pRoom → pLevel); until such a
|
||||||
// level exists the scene reports not-ready.
|
// level exists the scene reports not-ready.
|
||||||
|
// #549: resolving the active levelView also keeps `world.areaId` and `automapView` in lockstep.
|
||||||
const predSnap = this.predictor.getSnapshot()
|
const predSnap = this.predictor.getSnapshot()
|
||||||
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
|
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
|
||||||
const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
||||||
const levelView =
|
const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
|
||||||
this.levelView ??
|
|
||||||
(this.mapService && this.world.areaId > 0 && (playerSubX !== 0 || playerSubY !== 0)
|
|
||||||
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, [
|
|
||||||
this.world.areaId,
|
|
||||||
...this.world.getRevealedAreaIds(),
|
|
||||||
])
|
|
||||||
: null)
|
|
||||||
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
|
||||||
return {
|
return {
|
||||||
tick: this.tickCounter,
|
tick: this.tickCounter,
|
||||||
|
|
@ -694,6 +689,7 @@ export class OnlineSession {
|
||||||
const savedName = this.world.self.name
|
const savedName = this.world.self.name
|
||||||
const savedClass = this.world.self.charClass
|
const savedClass = this.world.self.charClass
|
||||||
this.world.reset()
|
this.world.reset()
|
||||||
|
this.knownAreaIds.clear()
|
||||||
this.world.self.name = savedName
|
this.world.self.name = savedName
|
||||||
this.world.self.charClass = savedClass
|
this.world.self.charClass = savedClass
|
||||||
this.predictor.setPosition(0, 0)
|
this.predictor.setPosition(0, 0)
|
||||||
|
|
@ -921,6 +917,66 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCandidateAreaIds(): number[] {
|
||||||
|
const out: number[] = []
|
||||||
|
if (this.world.areaId > 0) {
|
||||||
|
out.push(this.world.areaId)
|
||||||
|
}
|
||||||
|
for (const id of this.world.getRevealedAreaIds()) {
|
||||||
|
if (id > 0 && !out.includes(id)) {
|
||||||
|
out.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of this.knownAreaIds) {
|
||||||
|
if (id > 0 && !out.includes(id)) {
|
||||||
|
out.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the `LevelView` containing the local player's current world sub-tile and synchronize
|
||||||
|
* `world.areaId` and `automapView` whenever the player crosses into a different area (#549).
|
||||||
|
*/
|
||||||
|
private syncActiveAreaFromPlayerPosition(options?: {
|
||||||
|
readonly emitHudSync?: boolean
|
||||||
|
}): LevelView | null {
|
||||||
|
const predSnap = this.predictor.getSnapshot()
|
||||||
|
const playerSubX = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
|
||||||
|
const playerSubY = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
|
||||||
|
const candidateIds = this.getCandidateAreaIds()
|
||||||
|
const levelView =
|
||||||
|
this.levelView ??
|
||||||
|
(this.mapService && candidateIds.length > 0 && (playerSubX !== 0 || playerSubY !== 0)
|
||||||
|
? this.mapService.findLevelViewAtWorldSubTile(playerSubX, playerSubY, candidateIds)
|
||||||
|
: null)
|
||||||
|
|
||||||
|
if (levelView && levelView.areaId > 0) {
|
||||||
|
if (this.world.areaId > 0) {
|
||||||
|
this.knownAreaIds.add(this.world.areaId)
|
||||||
|
}
|
||||||
|
this.knownAreaIds.add(levelView.areaId)
|
||||||
|
const areaChanged = this.world.areaId !== levelView.areaId
|
||||||
|
const automapOutOfSync =
|
||||||
|
this.automapView.getCurrentAreaId() !== levelView.areaId ||
|
||||||
|
this.automapView.getLevelView(levelView.areaId) === undefined
|
||||||
|
if (areaChanged) {
|
||||||
|
this.world.areaId = levelView.areaId
|
||||||
|
}
|
||||||
|
if (areaChanged || automapOutOfSync) {
|
||||||
|
this.automapView.syncActiveLevel(levelView.areaId, levelView)
|
||||||
|
}
|
||||||
|
if (areaChanged) {
|
||||||
|
void this.soundService.onEnterArea(levelView.areaId)
|
||||||
|
if (options?.emitHudSync) {
|
||||||
|
this.hudModel.syncFromWorld()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return levelView
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a
|
* `DUNGEON_IsRoomInTown` for the local player: whether the level containing its sub-tile is a
|
||||||
* town level, or `null` while no loaded level contains it. The sub-tile is the one the gate
|
* town level, or `null` while no loaded level contains it. The sub-tile is the one the gate
|
||||||
|
|
@ -932,7 +988,7 @@ export class OnlineSession {
|
||||||
const levelView = this.mapService.findLevelViewAtWorldSubTile(
|
const levelView = this.mapService.findLevelViewAtWorldSubTile(
|
||||||
Math.round(snap.logicalX),
|
Math.round(snap.logicalX),
|
||||||
Math.round(snap.logicalY),
|
Math.round(snap.logicalY),
|
||||||
[this.world.areaId, ...this.world.getRevealedAreaIds()],
|
this.getCandidateAreaIds(),
|
||||||
)
|
)
|
||||||
return levelView === null ? null : isTownLevel(levelView.areaId)
|
return levelView === null ? null : isTownLevel(levelView.areaId)
|
||||||
}
|
}
|
||||||
|
|
@ -1070,7 +1126,11 @@ export class OnlineSession {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const activeView = this.mapService?.getActiveLevelView() ?? undefined
|
this.knownAreaIds.clear()
|
||||||
|
this.knownAreaIds.add(ev.areaId)
|
||||||
|
const activeView =
|
||||||
|
this.mapService?.getActiveLevelView() ??
|
||||||
|
(this.levelView && this.levelView.areaId === ev.areaId ? this.levelView : undefined)
|
||||||
this.automapView.setContext(
|
this.automapView.setContext(
|
||||||
ev.mapSeed,
|
ev.mapSeed,
|
||||||
this.world.difficulty,
|
this.world.difficulty,
|
||||||
|
|
@ -1082,6 +1142,9 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'RoomReveal': {
|
case 'RoomReveal': {
|
||||||
|
if (ev.areaId > 0) {
|
||||||
|
this.knownAreaIds.add(ev.areaId)
|
||||||
|
}
|
||||||
if (this.mapService) {
|
if (this.mapService) {
|
||||||
try {
|
try {
|
||||||
this.mapService.onAddRoomDataPacket({
|
this.mapService.onAddRoomDataPacket({
|
||||||
|
|
@ -1271,6 +1334,9 @@ export class OnlineSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synchronize active area and AutomapView from current player position (#549)
|
||||||
|
this.syncActiveAreaFromPlayerPosition({ emitHudSync: false })
|
||||||
|
|
||||||
// Synchronize HudManager via HudModel
|
// Synchronize HudManager via HudModel
|
||||||
this.hudModel.syncFromWorld()
|
this.hudModel.syncFromWorld()
|
||||||
|
|
||||||
|
|
@ -1305,6 +1371,8 @@ export class OnlineSession {
|
||||||
unit.direction = state.dir64
|
unit.direction = state.dir64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1359,10 +1427,7 @@ export class OnlineSession {
|
||||||
const activeLevelView =
|
const activeLevelView =
|
||||||
this.levelView ??
|
this.levelView ??
|
||||||
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
|
(this.mapService && this.world.areaId > 0 && (selfX !== 0 || selfY !== 0)
|
||||||
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, [
|
? this.mapService.findLevelViewAtWorldSubTile(selfX, selfY, this.getCandidateAreaIds())
|
||||||
this.world.areaId,
|
|
||||||
...this.world.getRevealedAreaIds(),
|
|
||||||
])
|
|
||||||
: null) ??
|
: null) ??
|
||||||
this.mapService?.getActiveLevelView() ??
|
this.mapService?.getActiveLevelView() ??
|
||||||
this.automapView.getLevelView()
|
this.automapView.getLevelView()
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,9 @@ export class Minimap {
|
||||||
/** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */
|
/** level key -> last player sub-unit coordinates where `AUTOMAP_UpdateVisibility` triggered. */
|
||||||
private readonly lastRevealSubPos = new Map<string, { x: number; y: number }>()
|
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. */
|
/** Diagnostic telemetry from the most recent `draw()` invocation. */
|
||||||
readonly lastRenderStats: AutomapRenderTelemetry = {
|
readonly lastRenderStats: AutomapRenderTelemetry = {
|
||||||
mode: 'minimap',
|
mode: 'minimap',
|
||||||
|
|
@ -869,6 +872,11 @@ export class Minimap {
|
||||||
viewport: { readonly width: number; readonly height: number },
|
viewport: { readonly width: number; readonly height: number },
|
||||||
options?: MinimapDrawOptions,
|
options?: MinimapDrawOptions,
|
||||||
): void {
|
): 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 screenW = Math.max(1, Math.trunc(viewport.width))
|
||||||
const screenH = Math.max(1, Math.trunc(viewport.height))
|
const screenH = Math.max(1, Math.trunc(viewport.height))
|
||||||
const vp = computeAutomapViewport(
|
const vp = computeAutomapViewport(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
/**
|
||||||
|
* Issue #549 regression: Minimap / Automap must switch synchronously with the main map
|
||||||
|
* when the player transitions between levels/areas within an Act (outdoor border crossing,
|
||||||
|
* dungeon warp, town portal, or waypoint) without a new `0x03 LoadAct` packet.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import '../../tools/lib/node-map-loader.ts'
|
||||||
|
import { AutomapView } from '../../src/client/automap/automap-view.ts'
|
||||||
|
import { MapService } from '../../src/client/map/map-service.ts'
|
||||||
|
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
|
||||||
|
import { OnlineSession } from '../../src/client/session/online-session.ts'
|
||||||
|
import { createSettingsStore } from '../../src/client/settings/settings-store.ts'
|
||||||
|
import { Minimap } from '../../src/client/ui/minimap.ts'
|
||||||
|
import { levelDisplayName } from '../../src/common/world/level-names-zh.ts'
|
||||||
|
|
||||||
|
const FIXTURE_DIR = resolve('tests/fixtures/netproto')
|
||||||
|
const ROGUE_ENCAMPMENT = 1
|
||||||
|
const BLOOD_MOOR = 2
|
||||||
|
const DEN_OF_EVIL = 8
|
||||||
|
|
||||||
|
function createNoopSpriteRenderer() {
|
||||||
|
return {
|
||||||
|
begin() {},
|
||||||
|
draw() {},
|
||||||
|
drawSolidQuad() {},
|
||||||
|
setLighting() {},
|
||||||
|
setPalette() {},
|
||||||
|
flush() {},
|
||||||
|
getGl() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeTileGpuCache() {
|
||||||
|
return {
|
||||||
|
loadedPageCount: 1,
|
||||||
|
ensureActPalette() {},
|
||||||
|
getPageHandle() {
|
||||||
|
return { index: 1, width: 2048, height: 1932 }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Issue #549: Minimap / Automap switches synchronously with main map on area transitions', () => {
|
||||||
|
let mapService: MapService
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mapService = await MapService.fromPackedDir(resolve('public/act-packs/tiles'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AutomapView.syncActiveLevel switches currentAreaId without wiping serverRevealedRooms across the Act', () => {
|
||||||
|
const automap = new AutomapView({ mapService, initialMode: 'off' })
|
||||||
|
const townView = automap.setContext(0x52e4e6ff, 0, ROGUE_ENCAMPMENT)
|
||||||
|
const townRoom = townView.rooms[0]!
|
||||||
|
automap.onServerAddRoom(townRoom.tileX, townRoom.tileY, ROGUE_ENCAMPMENT)
|
||||||
|
expect(automap.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
|
||||||
|
|
||||||
|
const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all')
|
||||||
|
const bmRoom = bloodMoorView.rooms[0]!
|
||||||
|
automap.onServerAddRoom(bmRoom.tileX, bmRoom.tileY, BLOOD_MOOR)
|
||||||
|
|
||||||
|
// Switch to Blood Moor via syncActiveLevel: preserves revealed rooms in both Area 1 and Area 2
|
||||||
|
automap.syncActiveLevel(BLOOD_MOOR, bloodMoorView)
|
||||||
|
expect(automap.getCurrentAreaId()).toBe(BLOOD_MOOR)
|
||||||
|
expect(automap.buildSnapshot().currentAreaId).toBe(BLOOD_MOOR)
|
||||||
|
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
|
||||||
|
|
||||||
|
const bmMinimapLevel = automap.buildMinimapLevel('ENG')
|
||||||
|
expect(bmMinimapLevel.levelId).toBe(BLOOD_MOOR)
|
||||||
|
expect(bmMinimapLevel.levelName).toBe(levelDisplayName(BLOOD_MOOR, 'ENG'))
|
||||||
|
expect(bmMinimapLevel.cellsX).toBe(bloodMoorView.cellsX)
|
||||||
|
expect(bmMinimapLevel.cellsY).toBe(bloodMoorView.cellsY)
|
||||||
|
expect(bmMinimapLevel.markers.some((m) => m.kind === 'exit')).toBe(true)
|
||||||
|
|
||||||
|
// Switch back to Rogue Encampment: Area 1 revealed rooms are still intact
|
||||||
|
automap.syncActiveLevel(ROGUE_ENCAMPMENT, townView)
|
||||||
|
expect(automap.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(automap.buildSnapshot().serverRevealedRoomCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('OnlineSession keeps automapView and world.areaId in lockstep across Area 1 -> Area 2 -> Area 8 -> Area 1 transitions', () => {
|
||||||
|
const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8')
|
||||||
|
const settingsStore = createSettingsStore()
|
||||||
|
settingsStore.set({ automapReveal: 'level' })
|
||||||
|
const session = new OnlineSession({ settingsStore, mapService })
|
||||||
|
session.loadD2CapReplay(text)
|
||||||
|
session.replayAllD2Cap()
|
||||||
|
|
||||||
|
// Initial state: standing in Rogue Encampment (Area 1), even though Area 2 rooms were pre-revealed by 0x07
|
||||||
|
expect(session.world.getRevealedAreaIds()).toContain(BLOOD_MOOR)
|
||||||
|
expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(session.automapView.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
|
||||||
|
// 1. Player walks/moves across border into Blood Moor (Area 2) without a new LoadAct packet
|
||||||
|
const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all')
|
||||||
|
const bmSubX = bloodMoorView.subOriginX + bloodMoorView.spawnX * 5 + 2
|
||||||
|
const bmSubY = bloodMoorView.subOriginY + bloodMoorView.spawnY * 5 + 2
|
||||||
|
session.handleServerEvent({
|
||||||
|
type: 'UnitReassign',
|
||||||
|
unitType: 'player',
|
||||||
|
id: session.world.selfId,
|
||||||
|
at: { x: bmSubX, y: bmSubY },
|
||||||
|
snap: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(session.sceneSource.getLevelView()?.areaId).toBe(BLOOD_MOOR)
|
||||||
|
expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(BLOOD_MOOR)
|
||||||
|
expect(session.world.areaId).toBe(BLOOD_MOOR)
|
||||||
|
expect(session.automapView.getCurrentAreaId()).toBe(BLOOD_MOOR)
|
||||||
|
expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(BLOOD_MOOR)
|
||||||
|
expect(session.automapView.buildMinimapLevel('ENG').levelName).toBe(
|
||||||
|
levelDisplayName(BLOOD_MOOR, 'ENG'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Player enters Den of Evil (Area 8): server reveals Area 8 room via 0x07 and reassigns player
|
||||||
|
const denView = mapService.getLevelView(DEN_OF_EVIL, 'all')
|
||||||
|
const denRoom = denView.rooms[0]!
|
||||||
|
session.handleServerEvent({
|
||||||
|
type: 'RoomReveal',
|
||||||
|
areaId: DEN_OF_EVIL,
|
||||||
|
tileX: denRoom.tileX,
|
||||||
|
tileY: denRoom.tileY,
|
||||||
|
})
|
||||||
|
const denSubX = denView.subOriginX + denView.spawnX * 5 + 2
|
||||||
|
const denSubY = denView.subOriginY + denView.spawnY * 5 + 2
|
||||||
|
session.handleServerEvent({
|
||||||
|
type: 'UnitReassign',
|
||||||
|
unitType: 'player',
|
||||||
|
id: session.world.selfId,
|
||||||
|
at: { x: denSubX, y: denSubY },
|
||||||
|
snap: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(session.sceneSource.getLevelView()?.areaId).toBe(DEN_OF_EVIL)
|
||||||
|
expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(DEN_OF_EVIL)
|
||||||
|
expect(session.world.areaId).toBe(DEN_OF_EVIL)
|
||||||
|
expect(session.automapView.getCurrentAreaId()).toBe(DEN_OF_EVIL)
|
||||||
|
expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(DEN_OF_EVIL)
|
||||||
|
expect(session.automapView.buildMinimapLevel('ENG').levelName).toBe(
|
||||||
|
levelDisplayName(DEN_OF_EVIL, 'ENG'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Player returns to Rogue Encampment (Area 1) via Town Portal / walk back
|
||||||
|
const townView = mapService.getLevelView(ROGUE_ENCAMPMENT, 'all')
|
||||||
|
const townSubX = townView.subOriginX + townView.spawnX * 5 + 2
|
||||||
|
const townSubY = townView.subOriginY + townView.spawnY * 5 + 2
|
||||||
|
session.handleServerEvent({
|
||||||
|
type: 'UnitReassign',
|
||||||
|
unitType: 'player',
|
||||||
|
id: session.world.selfId,
|
||||||
|
at: { x: townSubX, y: townSubY },
|
||||||
|
snap: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(session.sceneSource.getLevelView()?.areaId).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(session.sceneSource.buildFrameSnapshot().levelId).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(session.automapView.getCurrentAreaId()).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
expect(session.automapView.buildMinimapLevel('ENG').levelId).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renderSceneSourceFrame + Minimap switches level geometry, HUD title, and resets manual pan when moving across areas', () => {
|
||||||
|
const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8')
|
||||||
|
const settingsStore = createSettingsStore()
|
||||||
|
settingsStore.set({ automapReveal: 'level' })
|
||||||
|
const session = new OnlineSession({ settingsStore, mapService })
|
||||||
|
session.loadD2CapReplay(text)
|
||||||
|
session.replayAllD2Cap()
|
||||||
|
|
||||||
|
const minimap = new Minimap()
|
||||||
|
minimap.mode = 'full'
|
||||||
|
const renderer = createNoopSpriteRenderer()
|
||||||
|
const tileGpuCache = createFakeTileGpuCache()
|
||||||
|
|
||||||
|
// Frame 1: in Rogue Encampment (Area 1)
|
||||||
|
renderSceneSourceFrame({
|
||||||
|
source: session.sceneSource,
|
||||||
|
lightingPreset: 'fullbright',
|
||||||
|
renderer: renderer as never,
|
||||||
|
tileGpuCache: tileGpuCache as never,
|
||||||
|
minimap,
|
||||||
|
automapView: session.automapView,
|
||||||
|
})
|
||||||
|
expect(minimap.hudTextLastFrame?.levelName).toBe(levelDisplayName(ROGUE_ENCAMPMENT))
|
||||||
|
|
||||||
|
// User manually pans the full automap in Area 1
|
||||||
|
minimap.pan(32, -24)
|
||||||
|
expect(minimap.panX).toBe(32)
|
||||||
|
expect(minimap.panY).toBe(-24)
|
||||||
|
|
||||||
|
// Local predictor moves player into Blood Moor (Area 2) via session.tick()
|
||||||
|
const bloodMoorView = mapService.getLevelView(BLOOD_MOOR, 'all')
|
||||||
|
const bmSubX = bloodMoorView.subOriginX + bloodMoorView.spawnX * 5 + 2
|
||||||
|
const bmSubY = bloodMoorView.subOriginY + bloodMoorView.spawnY * 5 + 2
|
||||||
|
session.predictor.setPosition(bmSubX, bmSubY)
|
||||||
|
session.tick(40, 2000)
|
||||||
|
|
||||||
|
// Frame 2: in Blood Moor (Area 2)
|
||||||
|
const snap2 = renderSceneSourceFrame({
|
||||||
|
source: session.sceneSource,
|
||||||
|
lightingPreset: 'fullbright',
|
||||||
|
renderer: renderer as never,
|
||||||
|
tileGpuCache: tileGpuCache as never,
|
||||||
|
minimap,
|
||||||
|
automapView: session.automapView,
|
||||||
|
})
|
||||||
|
expect(snap2.levelId).toBe(BLOOD_MOOR)
|
||||||
|
expect(session.automapView.getCurrentAreaId()).toBe(BLOOD_MOOR)
|
||||||
|
expect(minimap.hudTextLastFrame?.levelName).toBe(levelDisplayName(BLOOD_MOOR))
|
||||||
|
// Manual pan from Area 1 is automatically reset to (0, 0) upon switching to Area 2
|
||||||
|
expect(minimap.panX).toBe(0)
|
||||||
|
expect(minimap.panY).toBe(0)
|
||||||
|
expect(minimap.revealedRoomsCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CommandMapper resolves local pathfinding against the level containing the player even when 0x07 revealed a neighbouring level', () => {
|
||||||
|
const text = readFileSync(resolve(FIXTURE_DIR, '04-d2gs-idle-60s.d2cap'), 'utf8')
|
||||||
|
const settingsStore = createSettingsStore()
|
||||||
|
settingsStore.set({ automapReveal: 'level' })
|
||||||
|
const session = new OnlineSession({ settingsStore, mapService })
|
||||||
|
session.loadD2CapReplay(text)
|
||||||
|
session.replayAllD2Cap()
|
||||||
|
|
||||||
|
// Precondition: MapService.getActiveLevelView() was switched to Area 2 by 0x07 RoomReveal,
|
||||||
|
// while player is standing in Area 1 (Rogue Encampment).
|
||||||
|
expect(mapService.getActiveLevelView()?.areaId).toBe(BLOOD_MOOR)
|
||||||
|
expect(session.world.areaId).toBe(ROGUE_ENCAMPMENT)
|
||||||
|
|
||||||
|
const playerX = Math.round(session.predictor.getSnapshot().logicalX)
|
||||||
|
const playerY = Math.round(session.predictor.getSnapshot().logicalY)
|
||||||
|
const targetScreen = session.camera.subtileToScreen(playerX + 3, playerY)
|
||||||
|
session.commandMapper.handlePointerDown(targetScreen.x, targetScreen.y, 0, 5000)
|
||||||
|
session.commandMapper.handlePointerUp(0)
|
||||||
|
|
||||||
|
const predAfterClick = session.predictor.getSnapshot()
|
||||||
|
expect(predAfterClick.moving).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue