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 PackedTileCatalogIndex,
|
||||
} from '../../common/pack-contract/index.ts'
|
||||
import { COLLIDE_MASK_INVALID } from '../../common/world/d2map.ts'
|
||||
import {
|
||||
getCollisionLocalSubTile,
|
||||
getCollisionWorldSubTile,
|
||||
|
|
@ -443,6 +444,43 @@ export class MapService {
|
|||
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(
|
||||
req: MapWorkerRequest,
|
||||
): 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 {
|
||||
applyLightingInputs,
|
||||
type LightingInputs,
|
||||
type LightingPassTargets,
|
||||
} 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`).
|
||||
*
|
||||
* 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 {
|
||||
const rawSnapshot = ctx.source.buildFrameSnapshot()
|
||||
const snapshot = ctx.source.buildFrameSnapshot()
|
||||
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 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) {
|
||||
ctx.tileGpuCache?.ensureActPalette(snapshot.actId)
|
||||
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])
|
||||
|
||||
if (ctx.lightingTargets) {
|
||||
const lightingInputs = ctx.source.buildLightingInputs(ctx.lightingPreset)
|
||||
const normalizedLightingInputs = normalizeSceneLightingInputs(
|
||||
lightingInputs,
|
||||
subShiftX,
|
||||
subShiftY,
|
||||
)
|
||||
applyLightingInputs(normalizedLightingInputs, ctx.lightingTargets)
|
||||
applyLightingInputs(ctx.source.buildLightingInputs(ctx.lightingPreset), ctx.lightingTargets)
|
||||
}
|
||||
|
||||
const drawTileQuad = (
|
||||
|
|
@ -654,13 +625,9 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
|||
originY: levelView?.isoOriginY ?? 0,
|
||||
preRevealTown: isTownLevel(snapshot.levelId),
|
||||
}
|
||||
const localSub = ctx.source.getPlayerLocalSubTile()
|
||||
const normSubX =
|
||||
localSub.x >= subShiftX && subShiftX > 0 ? localSub.x - subShiftX : localSub.x
|
||||
const normSubY =
|
||||
localSub.y >= subShiftY && subShiftY > 0 ? localSub.y - subShiftY : localSub.y
|
||||
const playerCellX = Math.floor(normSubX / 5)
|
||||
const playerCellY = Math.floor(normSubY / 5)
|
||||
const localSub = snapshot.playerLocalSubTile
|
||||
const playerCellX = Math.floor(localSub.x / 5)
|
||||
const playerCellY = Math.floor(localSub.y / 5)
|
||||
ctx.minimap.reveal(
|
||||
effectiveMinimapLevel,
|
||||
playerCellX,
|
||||
|
|
@ -707,13 +674,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
|||
|
||||
renderer.flush()
|
||||
} else if (ctx.lightingTargets) {
|
||||
const lightingInputs = ctx.source.buildLightingInputs(ctx.lightingPreset)
|
||||
const normalizedLightingInputs = normalizeSceneLightingInputs(
|
||||
lightingInputs,
|
||||
subShiftX,
|
||||
subShiftY,
|
||||
)
|
||||
applyLightingInputs(normalizedLightingInputs, ctx.lightingTargets)
|
||||
applyLightingInputs(ctx.source.buildLightingInputs(ctx.lightingPreset), ctx.lightingTargets)
|
||||
}
|
||||
|
||||
if (ctx.hudManager) {
|
||||
|
|
|
|||
|
|
@ -269,19 +269,32 @@ export class OnlineSession {
|
|||
})
|
||||
|
||||
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,
|
||||
actId: getActForArea(this.world.areaId || 1),
|
||||
levelId: this.world.areaId || 1,
|
||||
playerSubX: this.predictor.getSnapshot().x,
|
||||
playerSubY: this.predictor.getSnapshot().y,
|
||||
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
|
||||
playerSubX: predSnap.x,
|
||||
playerSubY: predSnap.y,
|
||||
units: this.buildRenderableUnits(),
|
||||
levelView:
|
||||
this.mapService?.getActiveLevelView() ??
|
||||
this.automapView.getLevelView(this.world.areaId || 1) ??
|
||||
null,
|
||||
levelView,
|
||||
tileAtlas: this.mapService?.getTileAtlas() ?? null,
|
||||
}),
|
||||
}
|
||||
},
|
||||
initialViewport,
|
||||
)
|
||||
|
||||
|
|
@ -728,32 +741,41 @@ export class OnlineSession {
|
|||
|
||||
switch (ev.type) {
|
||||
case 'LoadAct': {
|
||||
const defaultTownArea = ([1, 40, 75, 103, 109] as const)[ev.act] ?? 1
|
||||
const effectiveAreaId = ev.areaId > 0 ? ev.areaId : defaultTownArea
|
||||
// 1.13c `0x03 LoadAct` (D2Client 0x6fb5db50) always names the level the player is being
|
||||
// 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) {
|
||||
try {
|
||||
this.mapService.onLoadActPacket(
|
||||
{
|
||||
act: ev.act,
|
||||
mapSeed: ev.mapSeed,
|
||||
areaId: effectiveAreaId,
|
||||
areaId: ev.areaId,
|
||||
},
|
||||
{ difficulty: this.world.difficulty },
|
||||
)
|
||||
} catch {
|
||||
// MapService may be running without packed bundle in lightweight tests
|
||||
} catch (err) {
|
||||
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
|
||||
this.automapView.setContext(
|
||||
ev.mapSeed,
|
||||
this.world.difficulty,
|
||||
effectiveAreaId,
|
||||
ev.areaId,
|
||||
activeView,
|
||||
)
|
||||
if (effectiveAreaId > 0) {
|
||||
void this.soundService.onEnterArea(effectiveAreaId)
|
||||
}
|
||||
void this.soundService.onEnterArea(ev.areaId)
|
||||
break
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ export interface SceneFrameSnapshot {
|
|||
readonly actId: 1 | 2 | 3 | 4 | 5
|
||||
readonly levelId: number
|
||||
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 }
|
||||
/** 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 cameraTopLeft: { readonly x: number; readonly y: number }
|
||||
readonly floorQuads: readonly SceneTileQuad[]
|
||||
|
|
@ -66,6 +71,8 @@ export interface SceneSource {
|
|||
getLevelId(): number
|
||||
getTick(): 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 }
|
||||
getPlayerWorldOrtho(): { 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(
|
||||
playerSubX: number,
|
||||
playerSubY: number,
|
||||
levelView: LevelView | null | undefined,
|
||||
): { readonly shiftX: number; readonly shiftY: number } {
|
||||
if (!levelView) {
|
||||
return { shiftX: 0, shiftY: 0 }
|
||||
}
|
||||
const ox = levelView.subOriginX ?? levelView.originX ?? 0
|
||||
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 }
|
||||
return { shiftX: levelView.subOriginX, shiftY: levelView.subOriginY }
|
||||
}
|
||||
|
||||
function normalizeUnitsToLocalSubTiles(
|
||||
|
|
@ -240,9 +247,21 @@ export class ClientWorldSceneSource implements SceneSource {
|
|||
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 } {
|
||||
const st = this.stateProvider()
|
||||
const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, st.levelView)
|
||||
const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
|
||||
return {
|
||||
x: st.playerSubX - shiftX,
|
||||
y: st.playerSubY - shiftY,
|
||||
|
|
@ -251,7 +270,7 @@ export class ClientWorldSceneSource implements SceneSource {
|
|||
|
||||
getPlayerWorldOrtho(): { readonly x: number; readonly y: number } {
|
||||
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 isoOriginY = st.levelView?.isoOriginY ?? 0
|
||||
return subTileToWorldOrtho(
|
||||
|
|
@ -275,19 +294,19 @@ export class ClientWorldSceneSource implements SceneSource {
|
|||
|
||||
isSubTileWalkable(subX: number, subY: number): boolean {
|
||||
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 true
|
||||
}
|
||||
|
||||
buildFrameSnapshot(cullPaddingPx = 240): SceneFrameSnapshot {
|
||||
const st = this.stateProvider()
|
||||
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 isoOriginY = st.levelView?.isoOriginY ?? 0
|
||||
const playerSubTile = { x: st.playerSubX, y: st.playerSubY }
|
||||
const playerLocalSubTile = { x: st.playerSubX - shiftX, y: st.playerSubY - shiftY }
|
||||
const playerWorld = subTileToWorldOrtho(
|
||||
st.playerSubX - shiftX,
|
||||
st.playerSubY - shiftY,
|
||||
|
|
@ -351,7 +370,9 @@ export class ClientWorldSceneSource implements SceneSource {
|
|||
actId: this.getActId(),
|
||||
levelId: this.getLevelId(),
|
||||
viewportProfile: profile,
|
||||
worldReady: this.isWorldReady(),
|
||||
playerSubTile,
|
||||
playerLocalSubTile,
|
||||
playerWorld,
|
||||
cameraTopLeft,
|
||||
floorQuads,
|
||||
|
|
@ -365,7 +386,7 @@ export class ClientWorldSceneSource implements SceneSource {
|
|||
|
||||
buildLightingInputs(preset: LightingPreset): LightingInputs {
|
||||
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 isoOriginY = st.levelView?.isoOriginY ?? 0
|
||||
const localPlayerSub = {
|
||||
|
|
@ -401,7 +422,7 @@ export class ClientWorldSceneSource implements SceneSource {
|
|||
profile: ViewportProfile = this.viewportProfile,
|
||||
): SceneUnitDrawable | null {
|
||||
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 isoOriginY = st.levelView?.isoOriginY ?? 0
|
||||
const playerWorld = subTileToWorldOrtho(
|
||||
|
|
|
|||
|
|
@ -148,9 +148,19 @@ export function screenToWorld(
|
|||
|
||||
/**
|
||||
* 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`
|
||||
* `worldY = originY + (subX + subY) * 8`
|
||||
*
|
||||
* Diablo II 1.13c floor tiles are 160×80 px diamonds (DT1 floor blocks) holding 5×5 sub-tiles,
|
||||
* 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(
|
||||
subX: number,
|
||||
|
|
@ -159,13 +169,14 @@ export function subTileToWorldOrtho(
|
|||
originY = 0,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: originX + (subX - subY) * (ORTHO_SUB_TILE_WIDTH / 2),
|
||||
y: originY + (subX + subY) * (ORTHO_SUB_TILE_HEIGHT / 2),
|
||||
x: originX + (subX - subY) * ORTHO_SUB_TILE_WIDTH,
|
||||
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(
|
||||
worldX: number,
|
||||
|
|
@ -176,13 +187,14 @@ export function worldOrthoToSubTile(
|
|||
const dx = (worldX - originX) / ORTHO_SUB_TILE_WIDTH
|
||||
const dy = (worldY - originY) / ORTHO_SUB_TILE_HEIGHT
|
||||
return {
|
||||
x: dy + dx,
|
||||
y: dy - dx,
|
||||
x: (dy + dx) / 2,
|
||||
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(
|
||||
subX: number,
|
||||
|
|
@ -194,13 +206,14 @@ export function subTileToScreen(
|
|||
const dSubX = subX - camSubX
|
||||
const dSubY = subY - camSubY
|
||||
return {
|
||||
x: profile.width / 2 + (dSubX - dSubY) * (ORTHO_SUB_TILE_WIDTH / 2),
|
||||
y: profile.height / 2 + (dSubX + dSubY) * (ORTHO_SUB_TILE_HEIGHT / 2),
|
||||
x: profile.width / 2 + (dSubX - dSubY) * ORTHO_SUB_TILE_WIDTH,
|
||||
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(
|
||||
screenX: number,
|
||||
|
|
@ -214,8 +227,8 @@ export function screenToSubTile(
|
|||
const dx = relX / ORTHO_SUB_TILE_WIDTH
|
||||
const dy = relY / ORTHO_SUB_TILE_HEIGHT
|
||||
return {
|
||||
x: camSubX + (dy + dx),
|
||||
y: camSubY + (dy - dx),
|
||||
x: camSubX + (dy + dx) / 2,
|
||||
y: camSubY + (dy - dx) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,19 @@ export class ClientWorld {
|
|||
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> {
|
||||
return this._waypoints
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue