484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
import {
|
|
COLLIDE_BLANK,
|
|
COLLIDE_WALL,
|
|
type CollisionGrid,
|
|
} from '../../common/world/d2map.ts'
|
|
import type { LightingInputs, LightingPreset } from '../scene/lighting-presets.ts'
|
|
import {
|
|
getCollisionWorldSubTile,
|
|
isWalkableWorldSubTile,
|
|
type LevelDrawItem,
|
|
type LevelView,
|
|
} from '../map/level-view.ts'
|
|
import type { PackedTilePlacement, TileAtlas } from '../map/tile-atlas.ts'
|
|
import {
|
|
buildSceneDrawables,
|
|
type BuildSceneDrawablesResult,
|
|
type RenderableUnitInput,
|
|
type SceneUnitDrawable,
|
|
} from './unit-drawables.ts'
|
|
|
|
export type UnitState = RenderableUnitInput
|
|
import {
|
|
getViewportProfile,
|
|
screenToWorld,
|
|
subTileToWorldOrtho,
|
|
VIEWPORT_800x600,
|
|
type ViewportProfile,
|
|
type ViewportResolution,
|
|
} from './viewport-profile.ts'
|
|
|
|
export interface SceneTileQuad {
|
|
readonly cellX: number
|
|
readonly cellY: number
|
|
readonly worldX: number
|
|
readonly worldY: number
|
|
readonly sortY: number
|
|
readonly act: number
|
|
readonly library: number
|
|
readonly tile: number
|
|
readonly dt1Path: string
|
|
readonly placement: PackedTilePlacement | null
|
|
readonly animatedPlacements?: readonly (PackedTilePlacement | null)[] | null | undefined
|
|
}
|
|
|
|
export interface SceneFrameSnapshot {
|
|
readonly tick: number
|
|
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[]
|
|
readonly shadowQuads: readonly SceneTileQuad[]
|
|
readonly lowerWallQuads: readonly SceneTileQuad[]
|
|
readonly upperWallQuads: readonly SceneTileQuad[]
|
|
readonly roofQuads: readonly SceneTileQuad[]
|
|
readonly entities: BuildSceneDrawablesResult
|
|
}
|
|
|
|
/**
|
|
* Engine-agnostic interface feeding `WorldRenderer`, `lighting-presets`, and `mouse-controller`
|
|
* from either online `ClientWorld` + `LevelView` or single-player state.
|
|
*/
|
|
export interface SceneSource {
|
|
getViewportProfile(): ViewportProfile
|
|
setViewportProfile(profile: ViewportProfile | ViewportResolution): ViewportProfile
|
|
getActId(): 1 | 2 | 3 | 4 | 5
|
|
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 }
|
|
getLevelView(): LevelView | null
|
|
isSubTileWalkable(subX: number, subY: number): boolean
|
|
buildFrameSnapshot(cullPaddingPx?: number): SceneFrameSnapshot
|
|
buildLightingInputs(preset: LightingPreset): LightingInputs
|
|
pickUnitAtScreen(
|
|
screenX: number,
|
|
screenY: number,
|
|
hitRadiusPx?: number,
|
|
profile?: ViewportProfile,
|
|
): SceneUnitDrawable | null
|
|
}
|
|
|
|
export interface ClientSceneWorldAdapter {
|
|
readonly tick?: number | undefined
|
|
readonly actId?: 1 | 2 | 3 | 4 | 5 | undefined
|
|
readonly levelId?: number | undefined
|
|
readonly playerSubX: number
|
|
readonly playerSubY: number
|
|
readonly playerLightRadiusBonus?: number | undefined
|
|
readonly units: Iterable<RenderableUnitInput>
|
|
readonly levelView?: LevelView | null | undefined
|
|
readonly tileAtlas?: TileAtlas | null | undefined
|
|
}
|
|
|
|
/**
|
|
* Build a `CollisionGrid` view over a `LevelView`'s sub-tile collision map for 1.13c light raycasting.
|
|
*/
|
|
export function buildCollisionGridFromLevelView(
|
|
levelView: LevelView | null | undefined,
|
|
centerSubX: number,
|
|
centerSubY: number,
|
|
windowSubTiles = 100,
|
|
): CollisionGrid {
|
|
if (levelView && levelView.rooms.length > 0) {
|
|
return {
|
|
originX: levelView.isoOriginX,
|
|
originY: levelView.isoOriginY,
|
|
cellsX: levelView.cellsX,
|
|
cellsY: levelView.cellsY,
|
|
blocked: levelView.blocked,
|
|
gridWidth: levelView.gridWidth,
|
|
collisionMasks: levelView.collisionMasks,
|
|
}
|
|
}
|
|
|
|
const cellsX = Math.max(8, Math.floor(windowSubTiles / 5))
|
|
const cellsY = Math.max(8, Math.floor(windowSubTiles / 5))
|
|
const gridWidth = cellsX * 5
|
|
const gridHeight = cellsY * 5
|
|
const blocked = new Uint8Array(gridWidth * gridHeight)
|
|
const collisionMasks = new Uint16Array(gridWidth * gridHeight)
|
|
const origin = subTileToWorldOrtho(centerSubX - gridWidth / 2, centerSubY - gridHeight / 2, 0, 0)
|
|
|
|
return {
|
|
originX: origin.x,
|
|
originY: origin.y,
|
|
cellsX,
|
|
cellsY,
|
|
blocked,
|
|
gridWidth,
|
|
collisionMasks,
|
|
}
|
|
}
|
|
|
|
function mapLevelDrawToQuad(item: LevelDrawItem, fallbackActId: number): SceneTileQuad {
|
|
const sortY = (item.cellX + item.cellY) * 40
|
|
return {
|
|
cellX: item.cellX,
|
|
cellY: item.cellY,
|
|
worldX: item.x,
|
|
worldY: item.y,
|
|
sortY,
|
|
act: item.placement?.act ?? fallbackActId,
|
|
library: item.library,
|
|
tile: item.tile,
|
|
dt1Path: item.dt1Path,
|
|
placement: item.placement,
|
|
animatedPlacements: item.animatedPlacements,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
levelView: LevelView | null | undefined,
|
|
): { readonly shiftX: number; readonly shiftY: number } {
|
|
if (!levelView) {
|
|
return { shiftX: 0, shiftY: 0 }
|
|
}
|
|
return { shiftX: levelView.subOriginX, shiftY: levelView.subOriginY }
|
|
}
|
|
|
|
function normalizeUnitsToLocalSubTiles(
|
|
units: Iterable<RenderableUnitInput>,
|
|
shiftX: number,
|
|
shiftY: number,
|
|
): RenderableUnitInput[] {
|
|
if (shiftX === 0 && shiftY === 0) {
|
|
return Array.from(units)
|
|
}
|
|
const result: RenderableUnitInput[] = []
|
|
for (const u of units) {
|
|
result.push({
|
|
...u,
|
|
x: u.x - shiftX,
|
|
y: u.y - shiftY,
|
|
...(u.targetX !== undefined ? { targetX: u.targetX - shiftX } : {}),
|
|
...(u.targetY !== undefined ? { targetY: u.targetY - shiftY } : {}),
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Concrete `SceneSource` implementation backed by `ClientWorld` unit state, `LevelView`, and `TileAtlas`.
|
|
*/
|
|
export class ClientWorldSceneSource implements SceneSource {
|
|
private viewportProfile: ViewportProfile
|
|
private stateProvider: () => ClientSceneWorldAdapter
|
|
|
|
constructor(
|
|
stateProvider: () => ClientSceneWorldAdapter,
|
|
initialViewport: ViewportProfile | ViewportResolution = VIEWPORT_800x600,
|
|
) {
|
|
this.stateProvider = stateProvider
|
|
this.viewportProfile = getViewportProfile(initialViewport)
|
|
}
|
|
|
|
getViewportProfile(): ViewportProfile {
|
|
return this.viewportProfile
|
|
}
|
|
|
|
setViewportProfile(profile: ViewportProfile | ViewportResolution): ViewportProfile {
|
|
this.viewportProfile = getViewportProfile(profile)
|
|
return this.viewportProfile
|
|
}
|
|
|
|
getActId(): 1 | 2 | 3 | 4 | 5 {
|
|
const st = this.stateProvider()
|
|
if (st.actId !== undefined) return st.actId
|
|
if (st.levelView) {
|
|
return Math.max(1, Math.min(5, (st.levelView.act ?? 0) + 1)) as 1 | 2 | 3 | 4 | 5
|
|
}
|
|
return 1
|
|
}
|
|
|
|
getLevelId(): number {
|
|
const st = this.stateProvider()
|
|
return st.levelId ?? st.levelView?.areaId ?? 1
|
|
}
|
|
|
|
getTick(): number {
|
|
return this.stateProvider().tick ?? 0
|
|
}
|
|
|
|
getLevelView(): LevelView | null {
|
|
return this.stateProvider().levelView ?? null
|
|
}
|
|
|
|
getPlayerSubTile(): { readonly x: number; readonly y: number } {
|
|
const st = this.stateProvider()
|
|
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.levelView)
|
|
return {
|
|
x: st.playerSubX - shiftX,
|
|
y: st.playerSubY - shiftY,
|
|
}
|
|
}
|
|
|
|
getPlayerWorldOrtho(): { readonly x: number; readonly y: number } {
|
|
const st = this.stateProvider()
|
|
const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
|
|
const isoOriginX = st.levelView?.isoOriginX ?? 0
|
|
const isoOriginY = st.levelView?.isoOriginY ?? 0
|
|
return subTileToWorldOrtho(
|
|
st.playerSubX - shiftX,
|
|
st.playerSubY - shiftY,
|
|
isoOriginX,
|
|
isoOriginY,
|
|
)
|
|
}
|
|
|
|
getCameraTopLeft(profile: ViewportProfile = this.viewportProfile): {
|
|
readonly x: number
|
|
readonly y: number
|
|
} {
|
|
const playerWorld = this.getPlayerWorldOrtho()
|
|
return {
|
|
x: playerWorld.x - profile.width / 2,
|
|
y: playerWorld.y - profile.height / 2,
|
|
}
|
|
}
|
|
|
|
isSubTileWalkable(subX: number, subY: number): boolean {
|
|
const st = this.stateProvider()
|
|
// 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))
|
|
}
|
|
|
|
buildFrameSnapshot(cullPaddingPx = 240): SceneFrameSnapshot {
|
|
const st = this.stateProvider()
|
|
const profile = this.viewportProfile
|
|
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,
|
|
isoOriginX,
|
|
isoOriginY,
|
|
)
|
|
const cameraTopLeft = {
|
|
x: playerWorld.x - profile.width / 2,
|
|
y: playerWorld.y - profile.height / 2,
|
|
}
|
|
|
|
const floorQuads: SceneTileQuad[] = []
|
|
const shadowQuads: SceneTileQuad[] = []
|
|
const lowerWallQuads: SceneTileQuad[] = []
|
|
const upperWallQuads: SceneTileQuad[] = []
|
|
const roofQuads: SceneTileQuad[] = []
|
|
|
|
const actId = this.getActId()
|
|
if (st.levelView) {
|
|
const minX = cameraTopLeft.x - cullPaddingPx
|
|
const maxX = cameraTopLeft.x + profile.width + cullPaddingPx
|
|
const minY = cameraTopLeft.y - cullPaddingPx
|
|
const maxY = cameraTopLeft.y + profile.height + cullPaddingPx
|
|
|
|
for (const f of st.levelView.floors) {
|
|
if (f.x >= minX && f.x <= maxX && f.y >= minY && f.y <= maxY) {
|
|
floorQuads.push(mapLevelDrawToQuad(f, actId))
|
|
}
|
|
}
|
|
for (const s of st.levelView.shadows) {
|
|
if (s.x >= minX && s.x <= maxX && s.y >= minY && s.y <= maxY) {
|
|
shadowQuads.push(mapLevelDrawToQuad(s, actId))
|
|
}
|
|
}
|
|
for (const w of st.levelView.walls) {
|
|
if (w.x >= minX && w.x <= maxX && w.y >= minY && w.y <= maxY) {
|
|
const quad = mapLevelDrawToQuad(w, actId)
|
|
if (w.roofHeight !== undefined && w.roofHeight > 0) {
|
|
lowerWallQuads.push(quad)
|
|
} else {
|
|
upperWallQuads.push(quad)
|
|
}
|
|
}
|
|
}
|
|
for (const r of st.levelView.roofs) {
|
|
if (r.x >= minX && r.x <= maxX && r.y >= minY && r.y <= maxY) {
|
|
roofQuads.push(mapLevelDrawToQuad(r, actId))
|
|
}
|
|
}
|
|
}
|
|
|
|
const localUnits = normalizeUnitsToLocalSubTiles(st.units, shiftX, shiftY)
|
|
const entities = buildSceneDrawables(localUnits, {
|
|
originX: isoOriginX,
|
|
originY: isoOriginY,
|
|
tick: st.tick ?? 0,
|
|
})
|
|
|
|
return {
|
|
tick: st.tick ?? 0,
|
|
actId: this.getActId(),
|
|
levelId: this.getLevelId(),
|
|
viewportProfile: profile,
|
|
worldReady: this.isWorldReady(),
|
|
playerSubTile,
|
|
playerLocalSubTile,
|
|
playerWorld,
|
|
cameraTopLeft,
|
|
floorQuads,
|
|
shadowQuads,
|
|
lowerWallQuads,
|
|
upperWallQuads,
|
|
roofQuads,
|
|
entities,
|
|
}
|
|
}
|
|
|
|
buildLightingInputs(preset: LightingPreset): LightingInputs {
|
|
const st = this.stateProvider()
|
|
const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
|
|
const isoOriginX = st.levelView?.isoOriginX ?? 0
|
|
const isoOriginY = st.levelView?.isoOriginY ?? 0
|
|
const localPlayerSub = {
|
|
x: st.playerSubX - shiftX,
|
|
y: st.playerSubY - shiftY,
|
|
}
|
|
const localUnits = normalizeUnitsToLocalSubTiles(st.units, shiftX, shiftY)
|
|
const entities = buildSceneDrawables(localUnits, {
|
|
originX: isoOriginX,
|
|
originY: isoOriginY,
|
|
tick: st.tick ?? 0,
|
|
})
|
|
const collisionGrid = buildCollisionGridFromLevelView(
|
|
st.levelView,
|
|
localPlayerSub.x,
|
|
localPlayerSub.y,
|
|
)
|
|
return {
|
|
preset,
|
|
levelId: this.getLevelId(),
|
|
collisionGrid,
|
|
playerSubTile: localPlayerSub,
|
|
pointLights: entities.pointLights,
|
|
tick: st.tick ?? 0,
|
|
playerLightRadiusBonus: st.playerLightRadiusBonus ?? 0,
|
|
}
|
|
}
|
|
|
|
pickUnitAtScreen(
|
|
screenX: number,
|
|
screenY: number,
|
|
hitRadiusPx = 36,
|
|
profile: ViewportProfile = this.viewportProfile,
|
|
): SceneUnitDrawable | null {
|
|
const st = this.stateProvider()
|
|
const { shiftX, shiftY } = resolveLevelSubTileShift(st.levelView)
|
|
const isoOriginX = st.levelView?.isoOriginX ?? 0
|
|
const isoOriginY = st.levelView?.isoOriginY ?? 0
|
|
const playerWorld = subTileToWorldOrtho(
|
|
st.playerSubX - shiftX,
|
|
st.playerSubY - shiftY,
|
|
isoOriginX,
|
|
isoOriginY,
|
|
)
|
|
const clickWorld = screenToWorld(
|
|
screenX,
|
|
screenY,
|
|
playerWorld.x,
|
|
playerWorld.y,
|
|
profile,
|
|
)
|
|
const localUnits = normalizeUnitsToLocalSubTiles(st.units, shiftX, shiftY)
|
|
const entities = buildSceneDrawables(localUnits, {
|
|
originX: isoOriginX,
|
|
originY: isoOriginY,
|
|
tick: st.tick ?? 0,
|
|
})
|
|
|
|
let best: SceneUnitDrawable | null = null
|
|
let bestDistSq = hitRadiusPx * hitRadiusPx
|
|
const localPlayerX = st.playerSubX - shiftX
|
|
const localPlayerY = st.playerSubY - shiftY
|
|
|
|
for (const u of entities.unitDrawables) {
|
|
if (u.isCorpse && u.unitKind === 'monster') continue
|
|
if (
|
|
u.unitKind === 'player' &&
|
|
entities.unitDrawables.length > 1 &&
|
|
Math.abs(u.subX - localPlayerX) < 1e-3 &&
|
|
Math.abs(u.subY - localPlayerY) < 1e-3
|
|
) {
|
|
continue
|
|
}
|
|
// Sprite visual center is roughly 24px above foot anchor; warp/tile/item also test foot anchor directly
|
|
const dx = clickWorld.x - u.worldX
|
|
const dyBody = clickWorld.y - (u.worldY - 24)
|
|
const distSqBody = dx * dx + dyBody * dyBody
|
|
const isGroundAnchored =
|
|
u.unitKind === 'warp' || u.unitKind === 'tile' || u.unitKind === 'item'
|
|
const dyFoot = clickWorld.y - u.worldY
|
|
const distSqFoot = dx * dx + dyFoot * dyFoot
|
|
const distSq = isGroundAnchored ? Math.min(distSqBody, distSqFoot) : distSqBody
|
|
if (distSq <= hitRadiusPx * hitRadiusPx && (best === null || distSq < bestDistSq)) {
|
|
bestDistSq = distSq
|
|
best = u
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
}
|