963 lines
36 KiB
TypeScript
963 lines
36 KiB
TypeScript
/**
|
|
* Level links: the baked description of every way out of a level.
|
|
*
|
|
* The world graph in `world-graph.ts` says *that* the Cold Plains connects to
|
|
* the Stony Field. It cannot say *where*, because "where" depends on the roll
|
|
* of the generator that built this particular copy of the Cold Plains. This
|
|
* module is the other half: the shapes that carry the coordinates, and the
|
|
* geometry helpers that recover them when the generator did not hand them over.
|
|
*
|
|
* Three kinds of link, because the player crosses them in three different ways:
|
|
*
|
|
* - {@link SceneEntrance} — a gap in the border that the player simply walks
|
|
* through. No click, no click target, and in the original game no loading
|
|
* screen either; the engine streams the neighbouring level in behind a very
|
|
* short fade.
|
|
* - {@link SceneWarp} — a stair, cave mouth or door. Clickable, described by a
|
|
* row of `LvlWarp.txt`, and always a full transition.
|
|
* - {@link SceneWaypoint} — the blue portal ring. Not a link to one place but
|
|
* to every activated waypoint at once, so it carries a network id rather than
|
|
* a destination.
|
|
*
|
|
* ## Coordinates
|
|
*
|
|
* Everything here is in **sub-tiles**, the five-to-a-cell grid the collision
|
|
* map and the player's position both use, with `(0, 0)` at the level's origin.
|
|
* The generators work in cells and `LvlWarp.txt` works in pixels; both are
|
|
* converted on the way in, so that nothing downstream has to remember which
|
|
* unit a given number is in.
|
|
*
|
|
* ## Browser safety
|
|
*
|
|
* Imported by the runtime, so the same rules as `maze.ts` and `wilderness.ts`
|
|
* apply: no `node:` builtins, no `Buffer`, no `Math.random`, no `process.env`.
|
|
*/
|
|
|
|
import type { Side } from './world-graph.ts'
|
|
import { SUB_TILES_PER_TILE } from './map.ts'
|
|
|
|
/**
|
|
* How close the player must be to a border seam for it to fire, in sub-tiles.
|
|
*
|
|
* Generous: a seam is a gap several tiles wide and the player should cross it
|
|
* by walking at it, not by finding one exact sub-tile. Three sub-tiles is a
|
|
* little over half a cell either side of the recorded midpoint.
|
|
*
|
|
* Lives here rather than in the scene because `verify-world-walk` has to agree
|
|
* with the scene about what counts as standing on a link; two copies of the
|
|
* number would let the guardrail pass while the game does something else.
|
|
*/
|
|
export const SEAM_TRIGGER_SUBTILES = 3
|
|
|
|
/**
|
|
* How close the player must be to a warp for the use key to take it.
|
|
*
|
|
* Tighter than a seam, because warps sit in open ground and two of them can be
|
|
* in the same room.
|
|
*/
|
|
export const WARP_TRIGGER_SUBTILES = 5
|
|
|
|
|
|
/**
|
|
* A collision map, reduced to what the geometry helpers need.
|
|
*
|
|
* Structurally satisfied by both `IsoMapScene` and `CollisionGrid`, so callers
|
|
* can pass either without adapting.
|
|
*/
|
|
export interface LinkGrid {
|
|
/** Cells across and down. */
|
|
readonly cellsX: number
|
|
readonly cellsY: number
|
|
/** Sub-tiles across; `cellsX * 5`. */
|
|
readonly gridWidth: number
|
|
/** Sub-tiles down; `cellsY * 5`. */
|
|
readonly gridHeight: number
|
|
/** One byte per sub-tile, non-zero meaning impassable. */
|
|
readonly blocked: Uint8Array
|
|
}
|
|
|
|
/** A gap in the border the player walks through. */
|
|
export interface SceneEntrance {
|
|
/** The level on the other side. */
|
|
readonly toLevelId: number
|
|
/** Which edge of this level the gap is in. */
|
|
readonly side: Side
|
|
/** The generator's name for it, e.g. `Cold Plains Exit`. */
|
|
readonly label: string
|
|
/** The gap itself, in sub-tiles: cross this and you have left. */
|
|
readonly x: number
|
|
readonly y: number
|
|
/**
|
|
* Where to put the player when they arrive *here* through this gap.
|
|
*
|
|
* One step inside the level, never on the gap itself: landing on the trigger
|
|
* would bounce the player straight back out.
|
|
*/
|
|
readonly arriveX: number
|
|
readonly arriveY: number
|
|
}
|
|
|
|
/** A stair, cave mouth or door. */
|
|
export interface SceneWarp {
|
|
/** The level on the other side, or -1 when the bake could not resolve one. */
|
|
readonly toLevelId: number
|
|
/** The `LvlWarp.txt` row describing it, or -1 when there is no row. */
|
|
readonly warpId: number
|
|
/**
|
|
* Which way it goes. `down`/`up` are the two ends of a dungeon staircase;
|
|
* `in`/`out` are a preset's mouth, where there is no depth to speak of.
|
|
*/
|
|
readonly direction: 'up' | 'down' | 'in' | 'out'
|
|
/** The generator's name for it. */
|
|
readonly label: string
|
|
/** The anchor sub-tile: what the click target is measured from. */
|
|
readonly x: number
|
|
readonly y: number
|
|
/** Where the player lands when arriving here, in sub-tiles. */
|
|
readonly arriveX: number
|
|
readonly arriveY: number
|
|
/**
|
|
* The click target, in pixels relative to the anchor sub-tile's bottom
|
|
* corner, straight from `LvlWarp.txt`. A zero-sized box means the warp has no
|
|
* clickable tile and is triggered by walking into it.
|
|
*/
|
|
readonly selectX: number
|
|
readonly selectY: number
|
|
readonly selectDX: number
|
|
readonly selectDY: number
|
|
/** The short auto-walk away from the warp on arrival, in sub-tiles. */
|
|
readonly exitWalkX: number
|
|
readonly exitWalkY: number
|
|
/**
|
|
* How the bake found this warp.
|
|
*
|
|
* - `tile` — the map's own special tile said so. This is the real position.
|
|
* - `room` — the maze generator stamped a staircase room and the warp was
|
|
* matched to it by direction. Right room, approximate spot within it.
|
|
* - `fallback` — neither existed, and the warp was put on open ground so the
|
|
* level is not a trap. The position is invented and says nothing about
|
|
* where the original game put the stairs.
|
|
*
|
|
* The last case only happens for level types whose DRLG staircase pass is not
|
|
* transcribed yet, and `verify-packs` counts them so the number cannot creep
|
|
* up unnoticed.
|
|
*/
|
|
readonly source: 'tile' | 'room' | 'fallback'
|
|
}
|
|
|
|
/** A waypoint pedestal. */
|
|
export interface SceneWaypoint {
|
|
/** `Levels.txt` `Waypoint`, 0..38. The network is keyed on this. */
|
|
readonly waypointId: number
|
|
/** The pedestal, in sub-tiles. */
|
|
readonly x: number
|
|
readonly y: number
|
|
/** Where the player lands when arriving by waypoint. */
|
|
readonly arriveX: number
|
|
readonly arriveY: number
|
|
/**
|
|
* How the position was decided.
|
|
*
|
|
* - `object` — read off a waypoint object baked into the level's artwork.
|
|
* This is the real position.
|
|
* - `placed` — chosen by {@link findWaypointSpot} because the generator does
|
|
* not yet run `DRLGOUTDOORS_SpawnAct12Waypoint`. An approximation.
|
|
*/
|
|
readonly source: 'object' | 'placed'
|
|
}
|
|
|
|
/** Everything the runtime needs to leave a level. */
|
|
export interface SceneLinks {
|
|
readonly entrances: readonly SceneEntrance[]
|
|
readonly warps: readonly SceneWarp[]
|
|
readonly waypoints: readonly SceneWaypoint[]
|
|
/**
|
|
* Graph edges this level could not place an opening for.
|
|
*
|
|
* Recorded rather than silently dropped: an edge with nowhere to stand is a
|
|
* hole in the world, and the verifier reports on this list.
|
|
*/
|
|
readonly unplacedEdges: readonly { readonly toLevelId: number; readonly reason: string }[]
|
|
/** Diagnostic notes and warnings from link resolution (e.g. fallback warps placed). */
|
|
readonly notes?: readonly string[]
|
|
}
|
|
|
|
/**
|
|
* Convert a cell coordinate to the sub-tile at its centre.
|
|
*
|
|
* @param cell - the cell coordinate.
|
|
* @returns the centre sub-tile.
|
|
*/
|
|
export function cellToSubTile(cell: number): number {
|
|
return cell * SUB_TILES_PER_TILE + 2
|
|
}
|
|
|
|
/**
|
|
* Whether a sub-tile can be stood on.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @param x - sub-tile x.
|
|
* @param y - sub-tile y.
|
|
* @returns true when in bounds and not blocked.
|
|
*/
|
|
export function isWalkable(grid: LinkGrid, x: number, y: number): boolean {
|
|
if (x < 0 || y < 0 || x >= grid.gridWidth || y >= grid.gridHeight) return false
|
|
return grid.blocked[y * grid.gridWidth + x] === 0
|
|
}
|
|
|
|
/**
|
|
* A subset of a level's walkable sub-tiles, one byte each.
|
|
*
|
|
* Used to mean "the part of the map the player can actually get to", so that a
|
|
* puddle of open ground outside the town wall is not mistaken for a gate.
|
|
*/
|
|
export type WalkableRegion = Uint8Array
|
|
|
|
/**
|
|
* The biggest connected patch of open ground in a level.
|
|
*
|
|
* Maps are not one connected space. A town's artwork leaves walkable sub-tiles
|
|
* in the moat outside its wall; a cave's rock has pockets the layout never
|
|
* joins up. Anything placed in one of those is a link the player can see on the
|
|
* minimap and never touch, which is exactly the failure this pass exists to
|
|
* prevent. Taking the largest component is a blunt rule, but on every map
|
|
* measured the main play area dwarfs the leftovers by an order of magnitude.
|
|
*
|
|
* Four-connected, matching the engine's axis-aligned feet box: a diagonal
|
|
* squeeze between two blocked corners is not somewhere a player can walk.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @returns one byte per sub-tile, 1 inside the main region.
|
|
*/
|
|
export function largestWalkableRegion(grid: LinkGrid): WalkableRegion {
|
|
const width = grid.gridWidth
|
|
const height = grid.gridHeight
|
|
const component = new Int32Array(width * height).fill(-1)
|
|
const sizes: number[] = []
|
|
// An explicit stack rather than recursion: the biggest maps are 425x425,
|
|
// which is deep enough to overflow the call stack.
|
|
const stack: number[] = []
|
|
|
|
for (let seed = 0; seed < component.length; seed += 1) {
|
|
if (component[seed] !== -1 || grid.blocked[seed] !== 0) continue
|
|
const label = sizes.length
|
|
let size = 0
|
|
component[seed] = label
|
|
stack.push(seed)
|
|
while (stack.length > 0) {
|
|
const here = stack.pop()!
|
|
size += 1
|
|
const x = here % width
|
|
const y = (here - x) / width
|
|
if (x > 0) { const n = here - 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
|
|
if (x < width - 1) { const n = here + 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
|
|
if (y > 0) { const n = here - width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
|
|
if (y < height - 1) { const n = here + width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
|
|
}
|
|
sizes.push(size)
|
|
}
|
|
|
|
let best = -1
|
|
let bestSize = 0
|
|
sizes.forEach((size, label) => { if (size > bestSize) { bestSize = size; best = label } })
|
|
|
|
const region = new Uint8Array(component.length)
|
|
if (best < 0) return region
|
|
for (let at = 0; at < component.length; at += 1) if (component[at] === best) region[at] = 1
|
|
return region
|
|
}
|
|
|
|
/**
|
|
* Whether a sub-tile is inside a region, or walkable when there is no region.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @param region - the region to test against, or undefined for "anywhere open".
|
|
* @param x - sub-tile x.
|
|
* @param y - sub-tile y.
|
|
* @returns true when the sub-tile qualifies.
|
|
*/
|
|
function inRegion(grid: LinkGrid, region: WalkableRegion | undefined, x: number, y: number): boolean {
|
|
if (!isWalkable(grid, x, y)) return false
|
|
return region === undefined || region[y * grid.gridWidth + x] === 1
|
|
}
|
|
|
|
/**
|
|
* The nearest walkable sub-tile to a point.
|
|
*
|
|
* Searches outwards in square rings, so the first hit is the closest by
|
|
* Chebyshev distance and ties break in a fixed order rather than by chance.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @param x - sub-tile x to search around.
|
|
* @param y - sub-tile y to search around.
|
|
* @param maxRadius - how far out to give up, in sub-tiles.
|
|
* @param region - when given, only sub-tiles inside it count.
|
|
* @returns the sub-tile, or null if everything within `maxRadius` is blocked.
|
|
*/
|
|
export function nearestWalkable(
|
|
grid: LinkGrid,
|
|
x: number,
|
|
y: number,
|
|
maxRadius = 24,
|
|
region?: WalkableRegion,
|
|
): { x: number; y: number } | null {
|
|
if (inRegion(grid, region, x, y)) return { x, y }
|
|
for (let radius = 1; radius <= maxRadius; radius += 1) {
|
|
for (let dy = -radius; dy <= radius; dy += 1) {
|
|
for (let dx = -radius; dx <= radius; dx += 1) {
|
|
// Only the ring, not its interior: the interior was covered by the
|
|
// smaller radii already.
|
|
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
|
if (inRegion(grid, region, x + dx, y + dy)) return { x: x + dx, y: y + dy }
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Whether a link here could be set off by a player standing in a region.
|
|
*
|
|
* The runtime fires a link when the player's sub-tile is within the link's
|
|
* trigger radius of it. That test says nothing about whether the player can
|
|
* *get* to such a sub-tile: a staircase surrounded by open ground that is
|
|
* walled off from the rest of the map passes it and is still unreachable. So
|
|
* the question worth asking at bake time is the trigger test restricted to the
|
|
* region the player actually inhabits.
|
|
*
|
|
* With no region this degenerates to "is there open ground in the box", which
|
|
* is the weaker check the bake used before regions existed.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @param region - the ground the player can reach, or undefined for anywhere.
|
|
* @param x - the link's sub-tile x.
|
|
* @param y - the link's sub-tile y.
|
|
* @param radius - the link's trigger radius, in sub-tiles.
|
|
* @returns true when some sub-tile of the region lies inside the trigger box.
|
|
*/
|
|
export function triggerableFrom(
|
|
grid: LinkGrid,
|
|
region: WalkableRegion | undefined,
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
): boolean {
|
|
for (let dy = -radius; dy <= radius; dy += 1) {
|
|
for (let dx = -radius; dx <= radius; dx += 1) {
|
|
if (inRegion(grid, region, x + dx, y + dy)) return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* The inward direction for a side, in sub-tiles.
|
|
*
|
|
* @param side - the edge.
|
|
* @returns a unit step pointing into the level.
|
|
*/
|
|
function inwardStep(side: Side): { x: number; y: number } {
|
|
switch (side) {
|
|
case 'north':
|
|
return { x: 0, y: 1 }
|
|
case 'south':
|
|
return { x: 0, y: -1 }
|
|
case 'west':
|
|
return { x: 1, y: 0 }
|
|
case 'east':
|
|
return { x: -1, y: 0 }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Choose an arrival sub-tile inward from a border seam so arriving cannot
|
|
* immediately re-trigger the seam.
|
|
*
|
|
* Searches outward in square rings around a point `SEAM_TRIGGER_SUBTILES + 3`
|
|
* steps inside the level from `(x, y)`, accepting only sub-tiles in `region`
|
|
* that lie strictly outside the trigger box around `(x, y)`.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @param x - the seam trigger's sub-tile x.
|
|
* @param y - the seam trigger's sub-tile y.
|
|
* @param side - which edge of the level the seam sits on.
|
|
* @param region - when given, only sub-tiles inside it qualify.
|
|
* @returns an arrival sub-tile safe from immediate re-triggering.
|
|
*/
|
|
export function seamArrivalSpot(
|
|
grid: LinkGrid,
|
|
x: number,
|
|
y: number,
|
|
side: Side,
|
|
region?: WalkableRegion,
|
|
): { x: number; y: number } {
|
|
const step = inwardStep(side)
|
|
const targetX = x + step.x * (SEAM_TRIGGER_SUBTILES + 3)
|
|
const targetY = y + step.y * (SEAM_TRIGGER_SUBTILES + 3)
|
|
const maxRadius = Math.max(grid.gridWidth, grid.gridHeight, 64)
|
|
for (let radius = 0; radius <= maxRadius; radius += 1) {
|
|
for (let dy = -radius; dy <= radius; dy += 1) {
|
|
for (let dx = -radius; dx <= radius; dx += 1) {
|
|
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
|
const px = targetX + dx
|
|
const py = targetY + dy
|
|
if (!inRegion(grid, region, px, py)) continue
|
|
if (Math.abs(px - x) <= SEAM_TRIGGER_SUBTILES && Math.abs(py - y) <= SEAM_TRIGGER_SUBTILES) continue
|
|
return { x: px, y: py }
|
|
}
|
|
}
|
|
}
|
|
// Never place the player directly back on or inside the trigger box around (x, y),
|
|
// which would trigger an immediate bounce back in an infinite ping-pong loop.
|
|
const clampedX = Math.max(0, Math.min(grid.gridWidth - 1, targetX))
|
|
const clampedY = Math.max(0, Math.min(grid.gridHeight - 1, targetY))
|
|
if (Math.abs(clampedX - x) <= SEAM_TRIGGER_SUBTILES && Math.abs(clampedY - y) <= SEAM_TRIGGER_SUBTILES) {
|
|
return { x: targetX, y: targetY }
|
|
}
|
|
return { x: clampedX, y: clampedY }
|
|
}
|
|
|
|
/** Where an edge of a level opens, and how convincing the opening is. */
|
|
export interface BorderOpening {
|
|
/** The gap itself, in sub-tiles. */
|
|
readonly x: number
|
|
readonly y: number
|
|
/** One step inside, where an arriving player is put. */
|
|
readonly arriveX: number
|
|
readonly arriveY: number
|
|
/**
|
|
* How far in from the border the gap was found, in sub-tiles.
|
|
*
|
|
* Zero means the walkable area runs off the edge of the map, which is what a
|
|
* real gate looks like. Anything larger means the scan gave up on the border
|
|
* and settled for the walkable area's closest approach to it.
|
|
*/
|
|
readonly inset: number
|
|
/** How wide the gap is along the edge, in sub-tiles. */
|
|
readonly width: number
|
|
}
|
|
|
|
/**
|
|
* Find the gap in one edge of a level's border.
|
|
*
|
|
* For generated levels the generator already knows where it cut the border, and
|
|
* this is not needed. Preset levels are the reason it exists: the Rogue
|
|
* Encampment's gate is painted into fixed artwork, and the only way to find it
|
|
* without hand-measuring every town is to look at what the artwork left
|
|
* walkable.
|
|
*
|
|
* The scan works inwards from the edge because a border is a band, not a line:
|
|
* the outermost sub-tiles of a town are solid cliff, and the gate first becomes
|
|
* walkable a little way in. The first band row with any walkable run wins, and
|
|
* within it the longest run, whose middle is the gap.
|
|
*
|
|
* How far in is worth looking is not a small fixed number. Measured on the
|
|
* baked collision maps, Kurast 4's west margin is 55 sub-tiles of solid jungle
|
|
* and its east margin 48, and the Act 3 docks are walled 45 sub-tiles deep on
|
|
* the east; an earlier flat limit of 40 declared all three edges solid. The
|
|
* default is therefore half the level's depth: past the midpoint a gap is no
|
|
* longer on the side we were asked about, so that is the natural place to stop
|
|
* rather than an arbitrary one.
|
|
*
|
|
* Passing `region` is strongly recommended. Without it the Rogue Encampment's
|
|
* four variants all report a gate on whichever side they were asked about,
|
|
* because every one of them has walkable sand in the moat *outside* the camp
|
|
* wall — a gate the player can see and never reach.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @param side - which edge to search.
|
|
* @param region - when given, only gaps inside it count as gaps.
|
|
* @param bandDepth - how many sub-tiles inwards to look before giving up.
|
|
* Defaults to half the level's depth on the axis being searched.
|
|
* @returns the opening, or null if the edge is solid all the way in.
|
|
*/
|
|
export function findBorderOpening(
|
|
grid: LinkGrid,
|
|
side: Side,
|
|
region?: WalkableRegion,
|
|
bandDepth?: number,
|
|
): BorderOpening | null {
|
|
const horizontal = side === 'north' || side === 'south'
|
|
const span = horizontal ? grid.gridWidth : grid.gridHeight
|
|
const depth = horizontal ? grid.gridHeight : grid.gridWidth
|
|
const limit = Math.min(bandDepth ?? Math.floor(depth / 2), depth)
|
|
|
|
for (let inset = 0; inset < limit; inset += 1) {
|
|
const fixed = side === 'north' || side === 'west' ? inset : depth - 1 - inset
|
|
|
|
let bestStart = -1
|
|
let bestLength = 0
|
|
let runStart = -1
|
|
for (let along = 0; along <= span; along += 1) {
|
|
const open = along < span
|
|
&& (horizontal ? inRegion(grid, region, along, fixed) : inRegion(grid, region, fixed, along))
|
|
if (open) {
|
|
if (runStart < 0) runStart = along
|
|
continue
|
|
}
|
|
if (runStart >= 0) {
|
|
const length = along - runStart
|
|
if (length > bestLength) {
|
|
bestLength = length
|
|
bestStart = runStart
|
|
}
|
|
runStart = -1
|
|
}
|
|
}
|
|
if (bestLength === 0) continue
|
|
|
|
const middle = bestStart + Math.floor(bestLength / 2)
|
|
const x = horizontal ? middle : fixed
|
|
const y = horizontal ? fixed : middle
|
|
const arrive = seamArrivalSpot(grid, x, y, side, region)
|
|
return { x, y, arriveX: arrive.x, arriveY: arrive.y, inset, width: bestLength }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Choose a topologically suitable spot to place an outdoor or indoor waypoint.
|
|
*
|
|
* Evaluates candidate clearance (at least 3x3 sub-tiles of clear walkable space)
|
|
* and topological suitability, avoiding map edges and narrow dead-ends.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @param region - when given, only sub-tiles inside it count.
|
|
* @param options - optional clearance and edge margin thresholds.
|
|
* @returns the chosen sub-tile coordinate, or null when the level has no open ground at all.
|
|
*/
|
|
export function findWaypointSpot(
|
|
grid: LinkGrid,
|
|
region?: WalkableRegion,
|
|
options?: {
|
|
minClearance?: number
|
|
edgeMargin?: number
|
|
},
|
|
): { x: number; y: number } | null {
|
|
const W = grid.gridWidth
|
|
const H = grid.gridHeight
|
|
if (W <= 0 || H <= 0) return null
|
|
|
|
const centreX = Math.floor(W / 2)
|
|
const centreY = Math.floor(H / 2)
|
|
|
|
// Distance transform: calculate clearance (Chebyshev distance to blocked / non-region tile)
|
|
// dist = 1 means 1x1 walkable, dist = 2 means 3x3 walkable, dist = 3 means 5x5 walkable, etc.
|
|
const dist = new Int32Array(W * H)
|
|
for (let y = 0; y < H; y += 1) {
|
|
for (let x = 0; x < W; x += 1) {
|
|
dist[y * W + x] = inRegion(grid, region, x, y) ? 999999 : 0
|
|
}
|
|
}
|
|
|
|
// Forward pass
|
|
for (let y = 0; y < H; y += 1) {
|
|
for (let x = 0; x < W; x += 1) {
|
|
const idx = y * W + x
|
|
if (dist[idx] === 0) continue
|
|
let minVal = dist[idx]
|
|
if (y > 0 && x > 0) minVal = Math.min(minVal, dist[(y - 1) * W + (x - 1)] + 1)
|
|
else minVal = 1
|
|
if (y > 0) minVal = Math.min(minVal, dist[(y - 1) * W + x] + 1)
|
|
else minVal = 1
|
|
if (y > 0 && x < W - 1) minVal = Math.min(minVal, dist[(y - 1) * W + (x + 1)] + 1)
|
|
else minVal = 1
|
|
if (x > 0) minVal = Math.min(minVal, dist[y * W + (x - 1)] + 1)
|
|
else minVal = 1
|
|
|
|
dist[idx] = minVal
|
|
}
|
|
}
|
|
|
|
// Backward pass
|
|
for (let y = H - 1; y >= 0; y -= 1) {
|
|
for (let x = W - 1; x >= 0; x -= 1) {
|
|
const idx = y * W + x
|
|
if (dist[idx] === 0) continue
|
|
let minVal = dist[idx]
|
|
if (x < W - 1) minVal = Math.min(minVal, dist[y * W + (x + 1)] + 1)
|
|
else minVal = 1
|
|
if (y < H - 1 && x > 0) minVal = Math.min(minVal, dist[(y + 1) * W + (x - 1)] + 1)
|
|
else minVal = 1
|
|
if (y < H - 1) minVal = Math.min(minVal, dist[(y + 1) * W + x] + 1)
|
|
else minVal = 1
|
|
if (y < H - 1 && x < W - 1) minVal = Math.min(minVal, dist[(y + 1) * W + (x + 1)] + 1)
|
|
else minVal = 1
|
|
|
|
dist[idx] = minVal
|
|
}
|
|
}
|
|
|
|
// Required clearance: default 2 (meaning >= 3x3 clear sub-tiles)
|
|
const reqClearance = options?.minClearance !== undefined
|
|
? Math.max(1, options.minClearance >= 3 && options.minClearance % 2 === 1 ? Math.floor(options.minClearance / 2) + 1 : options.minClearance)
|
|
: 2
|
|
|
|
// Default edge margin: at least 3, up to 15 or 1/8 of level
|
|
const defaultMargin = Math.min(15, Math.max(3, Math.floor(Math.min(W, H) / 8)))
|
|
const edgeMargin = options?.edgeMargin ?? defaultMargin
|
|
|
|
// Candidate search: try with edgeMargin first, then relax if no candidates found
|
|
const marginsToTry = [edgeMargin, Math.min(3, edgeMargin), 1]
|
|
const clearancesToTry = [Math.max(reqClearance, 3), reqClearance, 1]
|
|
|
|
for (const minC of clearancesToTry) {
|
|
for (const m of marginsToTry) {
|
|
let bestSpot: { x: number; y: number } | null = null
|
|
let bestScore = -Infinity
|
|
|
|
for (let y = m; y < H - m; y += 1) {
|
|
for (let x = m; x < W - m; x += 1) {
|
|
const c = dist[y * W + x]!
|
|
if (c < minC) continue
|
|
|
|
// Topological suitability score: prioritize higher clearance, penalize distance to map center
|
|
const distToCenter = Math.hypot(x - centreX, y - centreY)
|
|
const score = c * 1000 - distToCenter
|
|
|
|
if (score > bestScore) {
|
|
bestScore = score
|
|
bestSpot = { x, y }
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestSpot !== null) {
|
|
return bestSpot
|
|
}
|
|
}
|
|
}
|
|
|
|
return nearestWalkable(grid, centreX, centreY, Math.max(W, H), region)
|
|
}
|
|
|
|
/** Cell width in screen pixels (the isometric diamond's width). */
|
|
const ORTHO_CELL_WIDTH = 80
|
|
/** Cell height in screen pixels (half the diamond's width: a 2:1 projection). */
|
|
const ORTHO_CELL_HEIGHT = 40
|
|
|
|
export interface IsoSpawnOptions {
|
|
/** Optional warps to prioritize for spawn placement (e.g. entrance warps). */
|
|
readonly warps?: readonly (SceneWarp | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number; readonly direction?: string })[] | undefined
|
|
/** Optional entrances to prioritize. */
|
|
readonly entrances?: readonly (SceneEntrance | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number })[] | undefined
|
|
/** Optional list of floor placements if not present on scene. */
|
|
readonly floors?: readonly { readonly cellX: number; readonly cellY: number }[] | undefined
|
|
}
|
|
|
|
export interface IsoSpawnTarget {
|
|
readonly cellsX: number
|
|
readonly cellsY: number
|
|
readonly blocked: Uint8Array
|
|
readonly gridWidth?: number
|
|
readonly gridHeight?: number
|
|
readonly originX?: number
|
|
readonly originY?: number
|
|
readonly collisionMasks?: Uint16Array | undefined
|
|
readonly floors?: readonly { readonly cellX: number; readonly cellY: number }[] | undefined
|
|
readonly warps?: readonly (SceneWarp | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number; readonly direction?: string })[] | undefined
|
|
readonly entrances?: readonly (SceneEntrance | { readonly x: number; readonly y: number; readonly arriveX?: number; readonly arriveY?: number })[] | undefined
|
|
}
|
|
|
|
function subTileAtCoords(
|
|
originX: number,
|
|
originY: number,
|
|
cellsX: number,
|
|
cellsY: number,
|
|
x: number,
|
|
y: number,
|
|
): { subX: number; subY: number; inBounds: boolean } {
|
|
const px = x - originX
|
|
const py = y - originY
|
|
const cx = (px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2
|
|
const cy = (py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2
|
|
const cellX = Math.floor(cx)
|
|
const cellY = Math.floor(cy)
|
|
const inBounds = cellX >= 0 && cellY >= 0 && cellX < cellsX && cellY < cellsY
|
|
const localSubX = Math.min(Math.max(0, Math.floor((cx - cellX) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
|
|
const localSubY = Math.min(Math.max(0, Math.floor((cy - cellY) * SUB_TILES_PER_TILE)), SUB_TILES_PER_TILE - 1)
|
|
const subX = cellX * SUB_TILES_PER_TILE + localSubX
|
|
const subY = cellY * SUB_TILES_PER_TILE + localSubY
|
|
return { subX, subY, inBounds }
|
|
}
|
|
|
|
function cellCentreCoords(originX: number, originY: number, cellX: number, cellY: number): { x: number; y: number } {
|
|
return {
|
|
x: (cellX - cellY) * ORTHO_CELL_WIDTH + originX,
|
|
y: (cellX + cellY) * ORTHO_CELL_HEIGHT + originY + ORTHO_CELL_HEIGHT / 2,
|
|
}
|
|
}
|
|
|
|
function subTileCentreCoords(
|
|
originX: number,
|
|
originY: number,
|
|
subX: number,
|
|
subY: number,
|
|
): { x: number; y: number } {
|
|
const cx = (subX + 0.5) / SUB_TILES_PER_TILE
|
|
const cy = (subY + 0.5) / SUB_TILES_PER_TILE
|
|
return {
|
|
x: (cx - cy) * ORTHO_CELL_WIDTH + originX,
|
|
y: (cx + cy) * ORTHO_CELL_HEIGHT + originY,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the largest connected walkable region that contains at least one floor tile.
|
|
*
|
|
* @param grid - the collision map.
|
|
* @param hasFloor - whether a cell coordinate contains a valid floor.
|
|
* @returns one byte per sub-tile, 1 inside the largest floor-containing region.
|
|
*/
|
|
export function largestWalkableRegionWithFloors(
|
|
grid: LinkGrid,
|
|
hasFloor?: (cellX: number, cellY: number) => boolean,
|
|
): WalkableRegion {
|
|
const width = grid.gridWidth
|
|
const height = grid.gridHeight
|
|
const component = new Int32Array(width * height).fill(-1)
|
|
const sizes: number[] = []
|
|
const containsFloor: boolean[] = []
|
|
const stack: number[] = []
|
|
|
|
for (let seed = 0; seed < component.length; seed += 1) {
|
|
if (component[seed] !== -1 || grid.blocked[seed] !== 0) continue
|
|
const label = sizes.length
|
|
let size = 0
|
|
let floorFound = false
|
|
component[seed] = label
|
|
stack.push(seed)
|
|
while (stack.length > 0) {
|
|
const here = stack.pop()!
|
|
size += 1
|
|
const x = here % width
|
|
const y = Math.floor(here / width)
|
|
const cx = Math.floor(x / SUB_TILES_PER_TILE)
|
|
const cy = Math.floor(y / SUB_TILES_PER_TILE)
|
|
if (hasFloor === undefined || hasFloor(cx, cy)) {
|
|
floorFound = true
|
|
}
|
|
if (x > 0) { const n = here - 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
|
|
if (x < width - 1) { const n = here + 1; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
|
|
if (y > 0) { const n = here - width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
|
|
if (y < height - 1) { const n = here + width; if (component[n] === -1 && grid.blocked[n] === 0) { component[n] = label; stack.push(n) } }
|
|
}
|
|
sizes.push(size)
|
|
containsFloor.push(floorFound)
|
|
}
|
|
|
|
let best = -1
|
|
let bestSize = 0
|
|
for (let label = 0; label < sizes.length; label += 1) {
|
|
if (containsFloor[label] && sizes[label]! > bestSize) {
|
|
bestSize = sizes[label]!
|
|
best = label
|
|
}
|
|
}
|
|
if (best < 0) {
|
|
for (let label = 0; label < sizes.length; label += 1) {
|
|
if (sizes[label]! > bestSize) {
|
|
bestSize = sizes[label]!
|
|
best = label
|
|
}
|
|
}
|
|
}
|
|
|
|
const region = new Uint8Array(component.length)
|
|
if (best < 0) return region
|
|
for (let at = 0; at < component.length; at += 1) {
|
|
if (component[at] === best) region[at] = 1
|
|
}
|
|
return region
|
|
}
|
|
|
|
/**
|
|
* Find a valid, walkable spawn point on a floor tile.
|
|
*
|
|
* Ensures:
|
|
* 1. The spawn is located on a valid floor tile and blocked === 0.
|
|
* 2. If available, prioritizes entrance warps / entrances.
|
|
* 3. Falls back to the largest walkable region that contains floors,
|
|
* avoiding pure void cells even when canvas center is void.
|
|
*
|
|
* @param scene - the scene or collision grid.
|
|
* @param options - optional warps, entrances, or floor placements.
|
|
* @returns a scene-space point in pixels, or null when nothing is walkable.
|
|
*/
|
|
export function findIsoSpawn(
|
|
scene: IsoSpawnTarget,
|
|
options?: IsoSpawnOptions,
|
|
): { x: number; y: number } | null {
|
|
const cellsX = scene.cellsX
|
|
const cellsY = scene.cellsY
|
|
const gridWidth = scene.gridWidth ?? cellsX * SUB_TILES_PER_TILE
|
|
const gridHeight = scene.gridHeight
|
|
?? (scene.blocked.length ? Math.floor(scene.blocked.length / gridWidth) : cellsY * SUB_TILES_PER_TILE)
|
|
const originX = scene.originX ?? 0
|
|
const originY = scene.originY ?? 0
|
|
|
|
const floors = options?.floors ?? scene.floors
|
|
let hasFloorCell: (cx: number, cy: number) => boolean
|
|
if (floors !== undefined && floors.length > 0) {
|
|
const floorSet = new Set<string>()
|
|
for (const f of floors) {
|
|
floorSet.add(`${f.cellX},${f.cellY}`)
|
|
}
|
|
hasFloorCell = (cx, cy) => floorSet.has(`${cx},${cy}`)
|
|
} else {
|
|
hasFloorCell = (cx, cy) => {
|
|
if (cx < 0 || cy < 0 || cx >= cellsX || cy >= cellsY) return false
|
|
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
|
|
for (let sx = 0; sx < SUB_TILES_PER_TILE; sx += 1) {
|
|
const gx = cx * SUB_TILES_PER_TILE + sx
|
|
const gy = cy * SUB_TILES_PER_TILE + sy
|
|
const idx = gy * gridWidth + gx
|
|
if (scene.blocked[idx] === 0) {
|
|
if (scene.collisionMasks && (scene.collisionMasks[idx]! & 0x0020) !== 0) {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
const linkGrid: LinkGrid = {
|
|
cellsX,
|
|
cellsY,
|
|
gridWidth,
|
|
gridHeight,
|
|
blocked: scene.blocked,
|
|
}
|
|
|
|
const isSubTileWalkable = (sx: number, sy: number): boolean => {
|
|
if (sx < 0 || sy < 0 || sx >= gridWidth || sy >= gridHeight) return false
|
|
return scene.blocked[sy * gridWidth + sx] === 0
|
|
}
|
|
|
|
// 1. Prioritize entrance warps or entrances if available.
|
|
const warps = options?.warps ?? scene.warps
|
|
if (warps !== undefined && warps.length > 0) {
|
|
const sortedWarps = [...warps].sort((a, b) => {
|
|
const dirScore = (w: any) =>
|
|
w.direction === 'in' ? 4 : w.direction === 'up' ? 3 : w.direction === 'down' ? 2 : 1
|
|
return dirScore(b) - dirScore(a)
|
|
})
|
|
for (const warp of sortedWarps) {
|
|
const ax = ('arriveX' in warp && typeof warp.arriveX === 'number') ? warp.arriveX : warp.x
|
|
const ay = ('arriveY' in warp && typeof warp.arriveY === 'number') ? warp.arriveY : warp.y
|
|
const cx = Math.floor(ax / SUB_TILES_PER_TILE)
|
|
const cy = Math.floor(ay / SUB_TILES_PER_TILE)
|
|
if (hasFloorCell(cx, cy)) {
|
|
const pt = subTileCentreCoords(originX, originY, ax, ay)
|
|
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
|
|
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY)) {
|
|
return pt
|
|
}
|
|
const near = nearestWalkable(linkGrid, ax, ay, 5)
|
|
if (near !== null) {
|
|
const ncx = Math.floor(near.x / SUB_TILES_PER_TILE)
|
|
const ncy = Math.floor(near.y / SUB_TILES_PER_TILE)
|
|
if (hasFloorCell(ncx, ncy)) {
|
|
const npt = subTileCentreCoords(originX, originY, near.x, near.y)
|
|
const nsub = subTileAtCoords(originX, originY, cellsX, cellsY, npt.x, npt.y)
|
|
if (nsub.inBounds && isSubTileWalkable(nsub.subX, nsub.subY)) {
|
|
return npt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const entrances = options?.entrances ?? scene.entrances
|
|
if (entrances !== undefined && entrances.length > 0) {
|
|
for (const ent of entrances) {
|
|
const ax = ('arriveX' in ent && typeof ent.arriveX === 'number') ? ent.arriveX : ent.x
|
|
const ay = ('arriveY' in ent && typeof ent.arriveY === 'number') ? ent.arriveY : ent.y
|
|
const cx = Math.floor(ax / SUB_TILES_PER_TILE)
|
|
const cy = Math.floor(ay / SUB_TILES_PER_TILE)
|
|
if (hasFloorCell(cx, cy)) {
|
|
const pt = subTileCentreCoords(originX, originY, ax, ay)
|
|
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
|
|
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY)) {
|
|
return pt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Locate within the largest walkable region containing floors.
|
|
const region = largestWalkableRegionWithFloors(linkGrid, hasFloorCell)
|
|
let regionCount = 0
|
|
let sumX = 0
|
|
let sumY = 0
|
|
for (let sy = 0; sy < gridHeight; sy += 1) {
|
|
for (let sx = 0; sx < gridWidth; sx += 1) {
|
|
if (region[sy * gridWidth + sx] === 1) {
|
|
regionCount += 1
|
|
sumX += sx
|
|
sumY += sy
|
|
}
|
|
}
|
|
}
|
|
|
|
const canvasMidX = Math.floor(cellsX / 2)
|
|
const canvasMidY = Math.floor(cellsY / 2)
|
|
const canvasMidPt = cellCentreCoords(originX, originY, canvasMidX, canvasMidY)
|
|
const canvasMidSub = subTileAtCoords(originX, originY, cellsX, cellsY, canvasMidPt.x, canvasMidPt.y)
|
|
|
|
let centreX = canvasMidX
|
|
let centreY = canvasMidY
|
|
|
|
const canvasMidValid = hasFloorCell(canvasMidX, canvasMidY)
|
|
&& canvasMidSub.inBounds
|
|
&& isSubTileWalkable(canvasMidSub.subX, canvasMidSub.subY)
|
|
&& (regionCount === 0 || region[canvasMidSub.subY * gridWidth + canvasMidSub.subX] === 1)
|
|
|
|
if (!canvasMidValid && regionCount > 0) {
|
|
centreX = Math.round(sumX / regionCount / SUB_TILES_PER_TILE)
|
|
centreY = Math.round(sumY / regionCount / SUB_TILES_PER_TILE)
|
|
}
|
|
|
|
const maxRadius = Math.max(cellsX, cellsY)
|
|
for (let radius = 0; radius < maxRadius; radius += 1) {
|
|
for (let dy = -radius; dy <= radius; dy += 1) {
|
|
for (let dx = -radius; dx <= radius; dx += 1) {
|
|
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
|
const cellX = centreX + dx
|
|
const cellY = centreY + dy
|
|
if (cellX < 0 || cellY < 0 || cellX >= cellsX || cellY >= cellsY) continue
|
|
if (!hasFloorCell(cellX, cellY)) continue
|
|
|
|
// Check centre of the cell
|
|
const pt = cellCentreCoords(originX, originY, cellX, cellY)
|
|
const sub = subTileAtCoords(originX, originY, cellsX, cellsY, pt.x, pt.y)
|
|
if (sub.inBounds && isSubTileWalkable(sub.subX, sub.subY) && (regionCount === 0 || region[sub.subY * gridWidth + sub.subX] === 1)) {
|
|
return pt
|
|
}
|
|
|
|
// If centre sub-tile was blocked, check other sub-tiles in this cell
|
|
for (let lsy = 0; lsy < SUB_TILES_PER_TILE; lsy += 1) {
|
|
for (let lsx = 0; lsx < SUB_TILES_PER_TILE; lsx += 1) {
|
|
const sx = cellX * SUB_TILES_PER_TILE + lsx
|
|
const sy = cellY * SUB_TILES_PER_TILE + lsy
|
|
if (isSubTileWalkable(sx, sy) && (regionCount === 0 || region[sy * gridWidth + sx] === 1)) {
|
|
const spt = subTileCentreCoords(originX, originY, sx, sy)
|
|
const ssub = subTileAtCoords(originX, originY, cellsX, cellsY, spt.x, spt.y)
|
|
if (ssub.inBounds && isSubTileWalkable(ssub.subX, ssub.subY)) {
|
|
return spt
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|