444 lines
14 KiB
TypeScript
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
|
|
}
|
|
}
|