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:
parent
3c1ed16bf9
commit
87b074d04d
|
|
@ -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 }>> {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue