fix(client): render on the 1.13c 32x16 sub-tile lattice and pick the LevelView by server position (#539)

Root cause 1: subTileToWorldOrtho/subTileToScreen (and their inverses) projected
unit sub-tiles with ORTHO_SUB_TILE_*/2, i.e. a 16x8 px lattice, while DT1 floor
tiles use 160x80 px = 5x5 sub-tiles of 32x16 px. Player, camera, units, minimap
marker and click picking were drawn at half their distance from the level iso
origin: e.g. seed 0x31595789 server (5737,4523) = local (137,123) camp floor was
drawn over local (68.5,61.5), a palisade wall cell (coll 0x1).

Root cause 2: the scene rendered MapService.getActiveLevelView() ("last view
built"); AutomapView building Blood Moor for 0x07 reveals near the spawn swapped
the rendered level. The scene now resolves the level whose DRLG canvas contains
the player's world sub-tile among the LoadAct area + revealed areas.

Anti-silent-failure: remove the player<origin*0.5 shift heuristic and renderer
re-normalisation, the || 1 town defaults and the LoadAct defaultTownArea
fallback / swallowed catch (fail fast), isSubTileWalkable permissive true;
render a not-ready frame (HUD only) until a LevelView and a server position exist.
This commit is contained in:
troytt 2026-10-01 10:13:09 +00:00
parent 3c1ed16bf9
commit 87b074d04d
6 changed files with 188 additions and 120 deletions

View File

@ -19,6 +19,7 @@ import {
type PackedDrlgBundleManifest, type PackedDrlgBundleManifest,
type PackedTileCatalogIndex, type PackedTileCatalogIndex,
} from '../../common/pack-contract/index.ts' } from '../../common/pack-contract/index.ts'
import { COLLIDE_MASK_INVALID } from '../../common/world/d2map.ts'
import { import {
getCollisionLocalSubTile, getCollisionLocalSubTile,
getCollisionWorldSubTile, getCollisionWorldSubTile,
@ -443,6 +444,43 @@ export class MapService {
return getCollisionWorldSubTile(this.activeView, worldSubX, worldSubY) return getCollisionWorldSubTile(this.activeView, worldSubX, worldSubY)
} }
/**
* Resolve the full-level `LevelView` (among `areaIds`, in order) whose DRLG sub-tile canvas
* contains world sub-tile `(worldSubX, worldSubY)` on a non-void cell.
*
* This is how the renderer picks the level to draw around a server-positioned unit (1.13c: the
* unit's room → `pRoom->pLevel`, levels of an act occupy disjoint DRLG coordinate rectangles).
* It deliberately does NOT use {@link getActiveLevelView}: that is "the last view built", which
* `AutomapView` changes as a side effect when it builds neighbouring levels for `0x07` reveals
* (e.g. Blood Moor rooms next to the Rogue Encampment spawn) — issue #539.
*
* Returns `null` when no candidate level has real floor/wall data at that sub-tile (DRLG mismatch
* or not yet loaded); callers must treat that as not-ready rather than guessing a level.
*/
findLevelViewAtWorldSubTile(
worldSubX: number,
worldSubY: number,
areaIds: Iterable<number>,
): LevelView | null {
if (!this.engine) {
throw new Error('MapService.findLevelViewAtWorldSubTile: synchronous call requires in-process engine')
}
if (this.activeSeed === null) return null
const seen = new Set<number>()
for (const areaId of areaIds) {
if (areaId <= 0 || seen.has(areaId)) continue
seen.add(areaId)
const view = this.engine.getLevelView(areaId, 'all')
const lx = Math.floor(worldSubX) - view.subOriginX
const ly = Math.floor(worldSubY) - view.subOriginY
if (lx < 0 || ly < 0 || lx >= view.subWidth || ly >= view.subHeight) continue
const mask = view.collision[ly * view.subWidth + lx] ?? COLLIDE_MASK_INVALID
if ((mask & COLLIDE_MASK_INVALID) === COLLIDE_MASK_INVALID) continue
return view
}
return null
}
private sendWorkerRequest( private sendWorkerRequest(
req: MapWorkerRequest, req: MapWorkerRequest,
): Promise<Extract<MapWorkerResponse, { readonly ok: true }>> { ): Promise<Extract<MapWorkerResponse, { readonly ok: true }>> {

View File

@ -38,7 +38,6 @@ import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
import { resolveGroundItemSpriteRect } from '../ui/inventory.ts' import { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
import { import {
applyLightingInputs, applyLightingInputs,
type LightingInputs,
type LightingPassTargets, type LightingPassTargets,
} from './lighting-presets.ts' } from './lighting-presets.ts'
@ -501,56 +500,34 @@ function drawSceneOverlayDrawable(
} }
} }
function normalizeSceneLightingInputs(
lightingInputs: LightingInputs,
subShiftX: number,
subShiftY: number,
): LightingInputs {
if (
(subShiftX === 0 && subShiftY === 0) ||
!(lightingInputs.playerSubTile.x >= subShiftX && subShiftX > 0)
) {
return lightingInputs
}
const normalizedPlayerSubTile = {
x: lightingInputs.playerSubTile.x - subShiftX,
y: lightingInputs.playerSubTile.y - subShiftY,
}
const normalizedPointLights = lightingInputs.pointLights.map((light) => ({
...light,
subTileX:
light.subTileX >= subShiftX && subShiftX > 0 ? light.subTileX - subShiftX : light.subTileX,
subTileY:
light.subTileY >= subShiftY && subShiftY > 0 ? light.subTileY - subShiftY : light.subTileY,
}))
return {
...lightingInputs,
playerSubTile: normalizedPlayerSubTile,
pointLights: normalizedPointLights,
}
}
/** /**
* Render one frame driven by an engine-decoupled `SceneSource` (e.g. online `ClientWorld` + `LevelView`). * Render one frame driven by an engine-decoupled `SceneSource` (e.g. online `ClientWorld` + `LevelView`).
*
* Coordinate contract (#539): `SceneSource` already converts absolute D2GS world sub-tiles to
* level-local sub-tiles (`world - levelView.subOrigin`) for drawables, lighting and the player;
* this function must not re-normalise them with value-range heuristics. Until the source reports
* `isWorldReady()` (LevelView loaded + server-assigned player position) only a cleared frame and
* the HUD are drawn — the camera is never anchored on a guessed position.
*/ */
export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFrameSnapshot { export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFrameSnapshot {
const rawSnapshot = ctx.source.buildFrameSnapshot() const snapshot = ctx.source.buildFrameSnapshot()
const levelView = ctx.source.getLevelView() const levelView = ctx.source.getLevelView()
const subShiftX = levelView ? (levelView.subOriginX ?? levelView.originX * 5) : 0
const subShiftY = levelView ? (levelView.subOriginY ?? levelView.originY * 5) : 0
const snapshot: SceneFrameSnapshot =
rawSnapshot.playerSubTile.x >= subShiftX && subShiftX > 0
? {
...rawSnapshot,
playerSubTile: {
x: rawSnapshot.playerSubTile.x - subShiftX,
y: rawSnapshot.playerSubTile.y - subShiftY,
},
}
: rawSnapshot
const nowMs = ctx.nowMs ?? performance.now() const nowMs = ctx.nowMs ?? performance.now()
const renderer = ctx.renderer ?? null const renderer = ctx.renderer ?? null
if (!snapshot.worldReady) {
if (renderer !== null) {
renderer.begin({ x: 0, y: 0, zoom: 1 }, [0, 0, 0])
renderer.setLighting(null)
renderer.flush()
}
if (ctx.hudManager) {
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
}
ctx.onFrameSnapshot?.(snapshot)
return snapshot
}
if (renderer !== null) { if (renderer !== null) {
ctx.tileGpuCache?.ensureActPalette(snapshot.actId) ctx.tileGpuCache?.ensureActPalette(snapshot.actId)
const camX = snapshot.playerWorld.x const camX = snapshot.playerWorld.x
@ -558,13 +535,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
renderer.begin({ x: camX, y: camY, zoom: 1 }, [0.02, 0.02, 0.03]) renderer.begin({ x: camX, y: camY, zoom: 1 }, [0.02, 0.02, 0.03])
if (ctx.lightingTargets) { if (ctx.lightingTargets) {
const lightingInputs = ctx.source.buildLightingInputs(ctx.lightingPreset) applyLightingInputs(ctx.source.buildLightingInputs(ctx.lightingPreset), ctx.lightingTargets)
const normalizedLightingInputs = normalizeSceneLightingInputs(
lightingInputs,
subShiftX,
subShiftY,
)
applyLightingInputs(normalizedLightingInputs, ctx.lightingTargets)
} }
const drawTileQuad = ( const drawTileQuad = (
@ -654,13 +625,9 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
originY: levelView?.isoOriginY ?? 0, originY: levelView?.isoOriginY ?? 0,
preRevealTown: isTownLevel(snapshot.levelId), preRevealTown: isTownLevel(snapshot.levelId),
} }
const localSub = ctx.source.getPlayerLocalSubTile() const localSub = snapshot.playerLocalSubTile
const normSubX = const playerCellX = Math.floor(localSub.x / 5)
localSub.x >= subShiftX && subShiftX > 0 ? localSub.x - subShiftX : localSub.x const playerCellY = Math.floor(localSub.y / 5)
const normSubY =
localSub.y >= subShiftY && subShiftY > 0 ? localSub.y - subShiftY : localSub.y
const playerCellX = Math.floor(normSubX / 5)
const playerCellY = Math.floor(normSubY / 5)
ctx.minimap.reveal( ctx.minimap.reveal(
effectiveMinimapLevel, effectiveMinimapLevel,
playerCellX, playerCellX,
@ -707,13 +674,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
renderer.flush() renderer.flush()
} else if (ctx.lightingTargets) { } else if (ctx.lightingTargets) {
const lightingInputs = ctx.source.buildLightingInputs(ctx.lightingPreset) applyLightingInputs(ctx.source.buildLightingInputs(ctx.lightingPreset), ctx.lightingTargets)
const normalizedLightingInputs = normalizeSceneLightingInputs(
lightingInputs,
subShiftX,
subShiftY,
)
applyLightingInputs(normalizedLightingInputs, ctx.lightingTargets)
} }
if (ctx.hudManager) { if (ctx.hudManager) {

View File

@ -269,19 +269,32 @@ export class OnlineSession {
}) })
this.sceneSource = new ClientWorldSceneSource( this.sceneSource = new ClientWorldSceneSource(
() => ({ () => {
// #539: the scene is anchored only on server-provided state (0x03 LoadAct area + 0x07
// revealed rooms + server player position). No `|| 1` Rogue Encampment default and no
// "last LevelView MapService happened to build" (AutomapView builds neighbouring levels
// 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
// level exists the scene reports not-ready.
const predSnap = this.predictor.getSnapshot()
const levelView =
this.mapService && this.world.areaId > 0 && (predSnap.x !== 0 || predSnap.y !== 0)
? this.mapService.findLevelViewAtWorldSubTile(predSnap.x, predSnap.y, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null
const levelId = levelView?.areaId ?? 0
return {
tick: this.tickCounter, tick: this.tickCounter,
actId: getActForArea(this.world.areaId || 1), ...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
levelId: this.world.areaId || 1, playerSubX: predSnap.x,
playerSubX: this.predictor.getSnapshot().x, playerSubY: predSnap.y,
playerSubY: this.predictor.getSnapshot().y,
units: this.buildRenderableUnits(), units: this.buildRenderableUnits(),
levelView: levelView,
this.mapService?.getActiveLevelView() ??
this.automapView.getLevelView(this.world.areaId || 1) ??
null,
tileAtlas: this.mapService?.getTileAtlas() ?? null, tileAtlas: this.mapService?.getTileAtlas() ?? null,
}), }
},
initialViewport, initialViewport,
) )
@ -728,32 +741,41 @@ export class OnlineSession {
switch (ev.type) { switch (ev.type) {
case 'LoadAct': { case 'LoadAct': {
const defaultTownArea = ([1, 40, 75, 103, 109] as const)[ev.act] ?? 1 // 1.13c `0x03 LoadAct` (D2Client 0x6fb5db50) always names the level the player is being
const effectiveAreaId = ev.areaId > 0 ? ev.areaId : defaultTownArea // loaded into. Without it (or without a buildable LevelView) the client cannot place the
// player on the server's map, so fail fast instead of guessing a town (#539).
if (ev.areaId <= 0) {
this.handleUnexpectedDisconnect(
`0x03 LoadAct carried no areaId (act=${String(ev.act)}): cannot build the level the server placed the player in`,
)
break
}
if (this.mapService) { if (this.mapService) {
try { try {
this.mapService.onLoadActPacket( this.mapService.onLoadActPacket(
{ {
act: ev.act, act: ev.act,
mapSeed: ev.mapSeed, mapSeed: ev.mapSeed,
areaId: effectiveAreaId, areaId: ev.areaId,
}, },
{ difficulty: this.world.difficulty }, { difficulty: this.world.difficulty },
) )
} catch { } catch (err) {
// MapService may be running without packed bundle in lightweight tests const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(
`MapService failed to build area ${String(ev.areaId)} (act=${String(ev.act)}, seed=0x${(ev.mapSeed >>> 0).toString(16)}): ${msg}`,
)
break
} }
} }
const activeView = this.mapService?.getActiveLevelView() ?? undefined const activeView = this.mapService?.getActiveLevelView() ?? undefined
this.automapView.setContext( this.automapView.setContext(
ev.mapSeed, ev.mapSeed,
this.world.difficulty, this.world.difficulty,
effectiveAreaId, ev.areaId,
activeView, activeView,
) )
if (effectiveAreaId > 0) { void this.soundService.onEnterArea(ev.areaId)
void this.soundService.onEnterArea(effectiveAreaId)
}
break break
} }

View File

@ -44,7 +44,12 @@ export interface SceneFrameSnapshot {
readonly actId: 1 | 2 | 3 | 4 | 5 readonly actId: 1 | 2 | 3 | 4 | 5
readonly levelId: number readonly levelId: number
readonly viewportProfile: ViewportProfile readonly viewportProfile: ViewportProfile
/** `true` once a LevelView and a server-assigned player position exist (see `isWorldReady`). */
readonly worldReady: boolean
/** Absolute world sub-tile of the local player (D2GS coordinates). */
readonly playerSubTile: { readonly x: number; readonly y: number } readonly playerSubTile: { readonly x: number; readonly y: number }
/** Level-local sub-tile of the local player (`world - levelView.subOrigin`). */
readonly playerLocalSubTile: { readonly x: number; readonly y: number }
readonly playerWorld: { readonly x: number; readonly y: number } readonly playerWorld: { readonly x: number; readonly y: number }
readonly cameraTopLeft: { readonly x: number; readonly y: number } readonly cameraTopLeft: { readonly x: number; readonly y: number }
readonly floorQuads: readonly SceneTileQuad[] readonly floorQuads: readonly SceneTileQuad[]
@ -66,6 +71,8 @@ export interface SceneSource {
getLevelId(): number getLevelId(): number
getTick(): number getTick(): number
getPlayerSubTile(): { readonly x: number; readonly y: number } getPlayerSubTile(): { readonly x: number; readonly y: number }
/** LevelView loaded and local player position assigned by the server. */
isWorldReady(): boolean
getPlayerLocalSubTile(): { readonly x: number; readonly y: number } getPlayerLocalSubTile(): { readonly x: number; readonly y: number }
getPlayerWorldOrtho(): { readonly x: number; readonly y: number } getPlayerWorldOrtho(): { readonly x: number; readonly y: number }
getCameraTopLeft(profile?: ViewportProfile): { readonly x: number; readonly y: number } getCameraTopLeft(profile?: ViewportProfile): { readonly x: number; readonly y: number }
@ -149,23 +156,23 @@ function mapLevelDrawToQuad(item: LevelDrawItem, fallbackActId: number): SceneTi
} }
} }
/**
* Level-local sub-tile shift for the active `LevelView`.
*
* Contract: every unit / player coordinate fed into a `ClientWorldSceneSource` is an absolute
* world sub-tile coordinate as sent by D2GS (`0x15`, `0x59`, `0x95`, `0xAC`, ...). The level-local
* sub-tile is always `world - levelView.subOrigin` (`subOrigin = DRLG coord * 5`). There is no
* heuristic "already local?" guess any more (the old `player < origin * 0.5` probe silently accepted
* two coordinate contracts). Without a `LevelView` there is no level to anchor to: shift is zero and
* `isWorldReady()` reports not-ready so the renderer does not draw units at arbitrary positions.
*/
function resolveLevelSubTileShift( function resolveLevelSubTileShift(
playerSubX: number,
playerSubY: number,
levelView: LevelView | null | undefined, levelView: LevelView | null | undefined,
): { readonly shiftX: number; readonly shiftY: number } { ): { readonly shiftX: number; readonly shiftY: number } {
if (!levelView) { if (!levelView) {
return { shiftX: 0, shiftY: 0 } return { shiftX: 0, shiftY: 0 }
} }
const ox = levelView.subOriginX ?? levelView.originX ?? 0 return { shiftX: levelView.subOriginX, shiftY: levelView.subOriginY }
const oy = levelView.subOriginY ?? levelView.originY ?? 0
if (ox === 0 && oy === 0) {
return { shiftX: 0, shiftY: 0 }
}
if (playerSubX < ox * 0.5 && playerSubY < oy * 0.5) {
return { shiftX: 0, shiftY: 0 }
}
return { shiftX: ox, shiftY: oy }
} }
function normalizeUnitsToLocalSubTiles( function normalizeUnitsToLocalSubTiles(
@ -240,9 +247,21 @@ export class ClientWorldSceneSource implements SceneSource {
return { x: st.playerSubX, y: st.playerSubY } return { x: st.playerSubX, y: st.playerSubY }
} }
/**
* The world can only be drawn once D2GS has (a) loaded the act/level (`0x03 LoadAct` →
* `LevelView`) and (b) assigned the local player a world position (`0x15` / `0x95` / ...).
* D2 world sub-tile coordinates are always far from `(0, 0)` (DRLG act origins), so `(0, 0)`
* means "not yet assigned". Until then nothing world-relative may be drawn (#539: never guess a
* spawn / camera anchor).
*/
isWorldReady(): boolean {
const st = this.stateProvider()
return Boolean(st.levelView) && (st.playerSubX !== 0 || st.playerSubY !== 0)
}
getPlayerLocalSubTile(): { readonly x: number; readonly y: number } { getPlayerLocalSubTile(): { readonly x: number; readonly y: number } {
const st = this.stateProvider() const st = this.stateProvider()
const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
return { return {
x: st.playerSubX - shiftX, x: st.playerSubX - shiftX,
y: st.playerSubY - shiftY, y: st.playerSubY - shiftY,
@ -251,7 +270,7 @@ export class ClientWorldSceneSource implements SceneSource {
getPlayerWorldOrtho(): { readonly x: number; readonly y: number } { getPlayerWorldOrtho(): { readonly x: number; readonly y: number } {
const st = this.stateProvider() const st = this.stateProvider()
const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
const isoOriginX = st.levelView?.isoOriginX ?? 0 const isoOriginX = st.levelView?.isoOriginX ?? 0
const isoOriginY = st.levelView?.isoOriginY ?? 0 const isoOriginY = st.levelView?.isoOriginY ?? 0
return subTileToWorldOrtho( return subTileToWorldOrtho(
@ -275,19 +294,19 @@ export class ClientWorldSceneSource implements SceneSource {
isSubTileWalkable(subX: number, subY: number): boolean { isSubTileWalkable(subX: number, subY: number): boolean {
const st = this.stateProvider() const st = this.stateProvider()
if (st.levelView && st.levelView.rooms.length > 0) { // No LevelView → no collision data → nothing is walkable (never a permissive `return true`).
if (!st.levelView) return false
return isWalkableWorldSubTile(st.levelView, Math.floor(subX), Math.floor(subY)) return isWalkableWorldSubTile(st.levelView, Math.floor(subX), Math.floor(subY))
} }
return true
}
buildFrameSnapshot(cullPaddingPx = 240): SceneFrameSnapshot { buildFrameSnapshot(cullPaddingPx = 240): SceneFrameSnapshot {
const st = this.stateProvider() const st = this.stateProvider()
const profile = this.viewportProfile const profile = this.viewportProfile
const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
const isoOriginX = st.levelView?.isoOriginX ?? 0 const isoOriginX = st.levelView?.isoOriginX ?? 0
const isoOriginY = st.levelView?.isoOriginY ?? 0 const isoOriginY = st.levelView?.isoOriginY ?? 0
const playerSubTile = { x: st.playerSubX, y: st.playerSubY } const playerSubTile = { x: st.playerSubX, y: st.playerSubY }
const playerLocalSubTile = { x: st.playerSubX - shiftX, y: st.playerSubY - shiftY }
const playerWorld = subTileToWorldOrtho( const playerWorld = subTileToWorldOrtho(
st.playerSubX - shiftX, st.playerSubX - shiftX,
st.playerSubY - shiftY, st.playerSubY - shiftY,
@ -351,7 +370,9 @@ export class ClientWorldSceneSource implements SceneSource {
actId: this.getActId(), actId: this.getActId(),
levelId: this.getLevelId(), levelId: this.getLevelId(),
viewportProfile: profile, viewportProfile: profile,
worldReady: this.isWorldReady(),
playerSubTile, playerSubTile,
playerLocalSubTile,
playerWorld, playerWorld,
cameraTopLeft, cameraTopLeft,
floorQuads, floorQuads,
@ -365,7 +386,7 @@ export class ClientWorldSceneSource implements SceneSource {
buildLightingInputs(preset: LightingPreset): LightingInputs { buildLightingInputs(preset: LightingPreset): LightingInputs {
const st = this.stateProvider() const st = this.stateProvider()
const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
const isoOriginX = st.levelView?.isoOriginX ?? 0 const isoOriginX = st.levelView?.isoOriginX ?? 0
const isoOriginY = st.levelView?.isoOriginY ?? 0 const isoOriginY = st.levelView?.isoOriginY ?? 0
const localPlayerSub = { const localPlayerSub = {
@ -401,7 +422,7 @@ export class ClientWorldSceneSource implements SceneSource {
profile: ViewportProfile = this.viewportProfile, profile: ViewportProfile = this.viewportProfile,
): SceneUnitDrawable | null { ): SceneUnitDrawable | null {
const st = this.stateProvider() const st = this.stateProvider()
const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView) const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
const isoOriginX = st.levelView?.isoOriginX ?? 0 const isoOriginX = st.levelView?.isoOriginX ?? 0
const isoOriginY = st.levelView?.isoOriginY ?? 0 const isoOriginY = st.levelView?.isoOriginY ?? 0
const playerWorld = subTileToWorldOrtho( const playerWorld = subTileToWorldOrtho(

View File

@ -148,9 +148,19 @@ export function screenToWorld(
/** /**
* Convert 1.13c isometric sub-tile coordinates `(subX, subY)` to world orthogonal pixels. * Convert 1.13c isometric sub-tile coordinates `(subX, subY)` to world orthogonal pixels.
* Each sub-tile is 32×16 ortho pixels: *
* `worldX = originX + (subX - subY) * 16` * Diablo II 1.13c floor tiles are 160×80 px diamonds (DT1 floor blocks) holding 5×5 sub-tiles,
* `worldY = originY + (subX + subY) * 8` * so one sub-tile is a 32×16 px diamond and the tile grid places cell `(cx, cy)` at
* `((cx - cy) * 80, (cx + cy) * 40)` (`ORTHO_CELL_WIDTH` / `ORTHO_CELL_HEIGHT`, see
* `buildIsoMapScene` / `localSubTileToOrtho`). Units (server sub-tile coordinates) MUST use the
* same lattice as the tiles they stand on:
* `worldX = originX + (subX - subY) * 16` (`ORTHO_SUB_TILE_WIDTH = 80 / 5 = 16`)
* `worldY = originY + (subX + subY) * 8` (`ORTHO_SUB_TILE_HEIGHT = 40 / 5 = 8`)
*
* Issue #539: this used to multiply by `ORTHO_SUB_TILE_WIDTH / 2` and `ORTHO_SUB_TILE_HEIGHT / 2`,
* i.e. a 16×8 px lattice at half the tile scale. Every unit (and the camera that follows the
* player) was drawn at half its distance from the level iso origin, so a player standing on
* Rogue Encampment floor appeared outside the palisade among the trees.
*/ */
export function subTileToWorldOrtho( export function subTileToWorldOrtho(
subX: number, subX: number,
@ -159,13 +169,14 @@ export function subTileToWorldOrtho(
originY = 0, originY = 0,
): { x: number; y: number } { ): { x: number; y: number } {
return { return {
x: originX + (subX - subY) * (ORTHO_SUB_TILE_WIDTH / 2), x: originX + (subX - subY) * ORTHO_SUB_TILE_WIDTH,
y: originY + (subX + subY) * (ORTHO_SUB_TILE_HEIGHT / 2), y: originY + (subX + subY) * ORTHO_SUB_TILE_HEIGHT,
} }
} }
/** /**
* Convert world orthogonal pixels `(worldX, worldY)` to continuous sub-tile coordinates. * Convert world orthogonal pixels `(worldX, worldY)` to continuous sub-tile coordinates
* (exact inverse of {@link subTileToWorldOrtho}).
*/ */
export function worldOrthoToSubTile( export function worldOrthoToSubTile(
worldX: number, worldX: number,
@ -176,13 +187,14 @@ export function worldOrthoToSubTile(
const dx = (worldX - originX) / ORTHO_SUB_TILE_WIDTH const dx = (worldX - originX) / ORTHO_SUB_TILE_WIDTH
const dy = (worldY - originY) / ORTHO_SUB_TILE_HEIGHT const dy = (worldY - originY) / ORTHO_SUB_TILE_HEIGHT
return { return {
x: dy + dx, x: (dy + dx) / 2,
y: dy - dx, y: (dy - dx) / 2,
} }
} }
/** /**
* Convert sub-tile `(subX, subY)` directly to screen coordinates relative to camera sub-tile `(camSubX, camSubY)`. * Convert sub-tile `(subX, subY)` directly to screen coordinates relative to camera sub-tile `(camSubX, camSubY)`
* (same 32×16 px sub-tile lattice as {@link subTileToWorldOrtho}).
*/ */
export function subTileToScreen( export function subTileToScreen(
subX: number, subX: number,
@ -194,13 +206,14 @@ export function subTileToScreen(
const dSubX = subX - camSubX const dSubX = subX - camSubX
const dSubY = subY - camSubY const dSubY = subY - camSubY
return { return {
x: profile.width / 2 + (dSubX - dSubY) * (ORTHO_SUB_TILE_WIDTH / 2), x: profile.width / 2 + (dSubX - dSubY) * ORTHO_SUB_TILE_WIDTH,
y: profile.height / 2 + (dSubX + dSubY) * (ORTHO_SUB_TILE_HEIGHT / 2), y: profile.height / 2 + (dSubX + dSubY) * ORTHO_SUB_TILE_HEIGHT,
} }
} }
/** /**
* Convert screen click `(screenX, screenY)` to fractional sub-tile coordinates relative to camera sub-tile `(camSubX, camSubY)`. * Convert screen click `(screenX, screenY)` to fractional sub-tile coordinates relative to camera sub-tile `(camSubX, camSubY)`
* (exact inverse of {@link subTileToScreen}).
*/ */
export function screenToSubTile( export function screenToSubTile(
screenX: number, screenX: number,
@ -214,8 +227,8 @@ export function screenToSubTile(
const dx = relX / ORTHO_SUB_TILE_WIDTH const dx = relX / ORTHO_SUB_TILE_WIDTH
const dy = relY / ORTHO_SUB_TILE_HEIGHT const dy = relY / ORTHO_SUB_TILE_HEIGHT
return { return {
x: camSubX + (dy + dx), x: camSubX + (dy + dx) / 2,
y: camSubY + (dy - dx), y: camSubY + (dy - dx) / 2,
} }
} }

View File

@ -144,6 +144,19 @@ export class ClientWorld {
return this._revealedRooms return this._revealedRooms
} }
/**
* Distinct area ids of the rooms currently revealed by the server (`0x07` minus `0x08`), in
* first-seen order. Used to resolve which level contains the player's server position (#539).
*/
getRevealedAreaIds(): number[] {
const out: number[] = []
for (const key of this._revealedRooms) {
const areaId = Number(key.slice(0, key.indexOf(':')))
if (areaId > 0 && !out.includes(areaId)) out.push(areaId)
}
return out
}
get waypoints(): ReadonlySet<number> { get waypoints(): ReadonlySet<number> {
return this._waypoints return this._waypoints
} }