diablo2-web/src/client/view/scene-source.ts

444 lines
14 KiB
TypeScript

import {
COLLIDE_BLANK,
COLLIDE_WALL,
type CollisionGrid,
} from '../../game/d2map.ts'
import type { LightingInputs, LightingPreset } from '../../scene/frontend/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'
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
}
export interface SceneFrameSnapshot {
readonly tick: number
readonly actId: 1 | 2 | 3 | 4 | 5
readonly levelId: number
readonly viewportProfile: ViewportProfile
readonly playerSubTile: { 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 }
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,
}
}
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 }
}
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 }
}
getPlayerLocalSubTile(): { readonly x: number; readonly y: number } {
const st = this.stateProvider()
const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, 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.playerSubX, st.playerSubY, 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()
if (st.levelView && st.levelView.rooms.length > 0) {
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 isoOriginX = st.levelView?.isoOriginX ?? 0
const isoOriginY = st.levelView?.isoOriginY ?? 0
const playerSubTile = { x: st.playerSubX, y: st.playerSubY }
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,
playerSubTile,
playerWorld,
cameraTopLeft,
floorQuads,
shadowQuads,
lowerWallQuads,
upperWallQuads,
roofQuads,
entities,
}
}
buildLightingInputs(preset: LightingPreset): LightingInputs {
const st = this.stateProvider()
const { shiftX, shiftY } = resolveLevelSubTileShift(st.playerSubX, st.playerSubY, 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.playerSubX, st.playerSubY, 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
for (const u of entities.unitDrawables) {
if (u.isCorpse && u.unitKind === 'monster') continue
// Sprite visual center is roughly 24px above foot anchor
const dx = clickWorld.x - u.worldX
const dy = clickWorld.y - (u.worldY - 24)
const distSq = dx * dx + dy * dy
if (distSq <= bestDistSq) {
bestDistSq = distSq
best = u
}
}
return best
}
}