1
0
Fork 0
tsgames/src/common/navigation/bresenham.ts

402 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { clamp } from "@common/utils";
import { shadowCast } from "./fov";
interface Point {
x: number;
y: number;
}
interface BresenhamOptions {
minX?: number;
maxX?: number;
minY?: number;
maxY?: number;
/**
* Connectivity mode for the walk.
* - `4` (default) — 4-connected; diagonal moves split into two axis steps (`dx+dy+1` points total).
* - `8` — 8-connected; diagonal moves emitted as a single step (`max(dx,dy)+1` points).
* Useful for LOS / lighting where 4-connectivity would unfairly block diagonals.
*/
directions?: 4 | 8;
}
export interface BresenhamLineOptions extends BresenhamOptions { }
interface BresenhamCircleBaseOptions<T extends boolean | 'fov' | 'shadow'> extends BresenhamOptions {
/**
* - `false` (default) — outline only.
* - `true` — filled disc (span-from-outline scanline).
* - `'fov'` — ray-casting field of view.
*/
fill?: T;
}
type BresenhamCircleFillOptions = BresenhamCircleBaseOptions<boolean>;
interface BresenhamCircleFovOptions extends BresenhamCircleBaseOptions<'fov' | 'shadow'> {
/**
* A callback that is called for each point on the ray casted from the start
* point to the current point. If the callback returns `true`, the ray is
* terminated and the generator skips to the next ray.
*/
breaker?: (x: number, y: number) => boolean;
}
type BresenhamCircleOptions = BresenhamCircleFillOptions | BresenhamCircleFovOptions;
// ---------------------------------------------------------------------------
// Cohen-Sutherland outcodes
// ---------------------------------------------------------------------------
const INSIDE = 0, LEFT = 1, RIGHT = 2, BOTTOM = 4, TOP = 8;
function outcode(
x: number, y: number,
minX: number, maxX: number, minY: number, maxY: number,
): number {
let code = INSIDE;
if (x < minX) code |= LEFT;
else if (x > maxX) code |= RIGHT;
if (y < minY) code |= BOTTOM;
else if (y > maxY) code |= TOP;
return code;
}
/**
* Clip a floating-point segment to [minX, maxX] × [minY, maxY] using
* Cohen-Sutherland. Returns the clipped endpoints as floats, or null if
* the segment is entirely outside.
*
* We work in floats here so that the clipped endpoints land precisely on
* the boundary; Bresenham then re-discretises from those clipped floats.
*/
function cohenSutherland(
x0: number, y0: number,
x1: number, y1: number,
minX: number, maxX: number,
minY: number, maxY: number,
): [number, number, number, number] | null {
let code0 = outcode(x0, y0, minX, maxX, minY, maxY);
let code1 = outcode(x1, y1, minX, maxX, minY, maxY);
for (; ;) {
if (!(code0 | code1)) return [x0, y0, x1, y1]; // trivially inside
if (code0 & code1) return null; // trivially outside
// Pick an outside point, clip it to the boundary it crosses.
const codeOut = code0 || code1;
let x = 0, y = 0;
const dx = x1 - x0, dy = y1 - y0;
if (codeOut & TOP) { x = x0 + dx * (maxY - y0) / dy; y = maxY; }
else if (codeOut & BOTTOM) { x = x0 + dx * (minY - y0) / dy; y = minY; }
else if (codeOut & RIGHT) { y = y0 + dy * (maxX - x0) / dx; x = maxX; }
else { y = y0 + dy * (minX - x0) / dx; x = minX; }
if (codeOut === code0) {
x0 = x; y0 = y;
code0 = outcode(x0, y0, minX, maxX, minY, maxY);
} else {
x1 = x; y1 = y;
code1 = outcode(x1, y1, minX, maxX, minY, maxY);
}
}
}
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
/**
* Bresenham's line algorithm on a 2-D integer grid.
*
* Produces the minimal set of grid cells that a line from `(fromX, fromY)`
* to `(toX, toY)` passes through, in order from start to end.
*
* **Guarantees:**
* - `result[0]` is `(fromX, fromY)` and `result[last]` is `(toX, toY)` (when inside bounds).
* - `directions=4`: every consecutive pair is 4-connected (shares an edge).
* - `directions=8`: every consecutive pair is 8-connected (shares an edge or corner).
* - All returned points satisfy the optional axis-aligned clip bounds.
* - Works for any direction and slope, including axis-aligned and perfectly diagonal.
*
* **Clipping:**
* When bounds are supplied, the segment is first clipped to the rectangle using
* Cohen-Sutherland, then only the clipped range is walked. Both clipped endpoints
* are included in the output. Returns an empty array if the segment lies entirely
* outside the bounds.
*
* @param fromX - Integer x of the start point.
* @param fromY - Integer y of the start point.
* @param toX - Integer x of the end point.
* @param toY - Integer y of the end point.
* @param options - Optional clip rectangle and connectivity mode. Non-integer bounds
* are floored (min) / ceiled (max) so boundary cells are included
* when the segment grazes them.
* @returns Ordered array of integer grid points. Empty when the segment lies entirely
* outside the supplied bounds.
*/
export function* bresenhamLineGen(
fromX: number,
fromY: number,
toX: number,
toY: number,
options?: BresenhamLineOptions,
): Generator<Point, void, void> {
// Round inputs to integers — the algorithm is defined on integer grids.
let x0 = Math.round(fromX), y0 = Math.round(fromY);
let x1 = Math.round(toX), y1 = Math.round(toY);
// --- Clipping ---
if (options) {
const minX = Math.floor(options.minX ?? -Infinity);
const maxX = Math.ceil(options.maxX ?? Infinity);
const minY = Math.floor(options.minY ?? -Infinity);
const maxY = Math.ceil(options.maxY ?? Infinity);
const clipped = cohenSutherland(x0, y0, x1, y1, minX, maxX, minY, maxY);
if (!clipped) return;
// Re-snap clipped floats to the nearest integer cell that is inside bounds.
x0 = Math.round(clipped[0]); y0 = Math.round(clipped[1]);
x1 = Math.round(clipped[2]); y1 = Math.round(clipped[3]);
// Clamp to ensure rounding didn't push us one cell outside.
x0 = clamp(x0, minX, maxX);
y0 = clamp(y0, minY, maxY);
x1 = clamp(x1, minX, maxX);
y1 = clamp(y1, minY, maxY);
}
// --- Bresenham walk ---
const use8 = options?.directions === 8;
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1; // step direction on X
const sy = y0 < y1 ? 1 : -1; // step direction on Y
let x = x0, y = y0;
if (dx === 0 && dy === 0) {
// Single point.
yield { x, y };
return;
}
if (dx >= dy) {
// X is the driving axis.
let err = 2 * dy - dx;
for (let i = 0; i <= dx; i++) {
yield { x, y };
if (i === dx) break;
if (err >= 0) {
if (!use8) yield { x, y: y + sy }; // 4-connected: emit y step before x
y += sy;
err -= 2 * dx;
}
err += 2 * dy;
x += sx;
}
} else {
// Y is the driving axis.
let err = 2 * dx - dy;
for (let i = 0; i <= dy; i++) {
yield { x, y };
if (i === dy) break;
if (err >= 0) {
if (!use8) yield { x: x + sx, y }; // 4-connected: emit x step before y
x += sx;
err -= 2 * dy;
}
err += 2 * dx;
y += sy;
}
}
}
/**
* Array-returning wrapper for {@link bresenhamLineGen}.
* @see bresenhamLineGen for full parameter and algorithm documentation.
*/
export const bresenhamLine = (
fromX: number,
fromY: number,
toX: number,
toY: number,
options?: BresenhamLineOptions,
): Point[] => Array.from(bresenhamLineGen(fromX, fromY, toX, toY, options));
/////////
/**
* Yields integer grid points forming a circle outline or filled disc centred
* on `(x, y)` with radius `r`.
*
* ---
*
* ## Outline mode (`fill: false`, default) — midpoint circle algorithm
*
* Tracks one point `(ox, oy)` in the first octant (`0 ≤ ox ≤ oy`) and uses
* 8-fold symmetry to emit up to 8 points per step. A decision variable `d`
* (initialised to `1 r`) tracks which side of the true circle boundary the
* next midpoint falls on:
*
* - `d < 0` → midpoint is inside → keep `oy`, update `d += 2·ox + 3`
* - `d ≥ 0` → midpoint is outside → `oy--`, update `d += 2·(ox oy) + 5`
*
* `ox` increments every step; the loop runs while `ox ≤ oy`.
*
* Duplicate-point guards:
* - The `ox` reflections are skipped when `ox === 0` (axis crossings).
* - The swapped octant block is skipped when `ox === oy` (45° diagonal).
*
* ## Fill mode (`fill: true`) — span-from-outline scanline
*
* Runs the same midpoint algorithm to collect the horizontal half-width
* (`spanRight`) for every row offset `|dy|`, then fills each row from
* `cx spanRight[|dy|]` to `cx + spanRight[|dy|]` inclusive. The fill
* boundary is therefore **pixel-for-pixel identical** to the outline.
*
* ## FOV mode (`fill: 'fov'`) — ray-casting field of view
*
* For each outline point, casts an 4-connected Bresenham ray from the centre
* to that point and yields every cell along the ray. You can pass `breaker`
* to signal an obstacle — the rest of that ray is then
* skipped and the next outline point's ray begins. The obstacle cell itself
* is yielded (obstacle is visible).
*
* **Bounds:** The outline is always generated without clipping so that rays
* extend to the full circle perimeter. The individual rays are clipped to
* the supplied bounds, so only in-bounds cells are yielded.
*
* ---
*
* ## Clipping
*
* Points outside `[minX, maxX] × [minY, maxY]` are silently skipped.
* Bounds are floored/ceiled to integer cells.
*
* @param x - Centre x (rounded to nearest integer).
* @param y - Centre y (rounded to nearest integer).
* @param r - Radius in grid cells (clamped to `≥ 0`, rounded). Radius 0
* yields only the centre point.
* @param options - Optional `fill` flag and clip bounds.
*/
export function* bresenhamCircleGen(
x: number,
y: number,
r: number,
options?: BresenhamCircleOptions,
): Generator<Point, void, void> {
if (options?.fill === 'fov') {
const lineBounds: BresenhamLineOptions = {
directions: options.directions,
minX: options.minX,
maxX: options.maxX,
minY: options.minY,
maxY: options.maxY,
};
for (const { x: ox, y: oy } of bresenhamCircleGen(x, y, r, { fill: false })) {
for (const linePoint of bresenhamLineGen(x, y, ox, oy, lineBounds)) {
yield linePoint;
if (options?.breaker?.(linePoint.x, linePoint.y)) break;
}
}
return;
}
const cx = Math.round(x);
const cy = Math.round(y);
const radius = Math.max(0, Math.round(r));
const minX = options?.minX !== undefined ? Math.floor(options.minX) : -Infinity;
const maxX = options?.maxX !== undefined ? Math.ceil(options.maxX) : Infinity;
const minY = options?.minY !== undefined ? Math.floor(options.minY) : -Infinity;
const maxY = options?.maxY !== undefined ? Math.ceil(options.maxY) : Infinity;
const fill = options?.fill === true;
const inBounds = (px: number, py: number) =>
px >= minX && px <= maxX && py >= minY && py <= maxY;
if (radius === 0) {
if (inBounds(cx, cy)) yield { x: cx, y: cy };
return;
}
if (options?.fill === 'shadow') {
yield* shadowCast(x, y, r, (x, y) => {
if (!inBounds(x, y)) return true;
return options?.breaker?.(x, y) ?? false;
});
return;
}
if (fill) {
// Span-from-outline: use the midpoint algorithm to find the exact
// horizontal extent for each row, then fill between those extents.
// Guarantees the fill boundary matches the outline pixel-for-pixel.
const spanRight = new Int32Array(radius + 1); // index = |dy|, value = max ox
let ox = 0, oy = radius, d = 1 - radius;
while (ox <= oy) {
// oy is the half-width for rows ±ox; ox is the half-width for rows ±oy.
if (spanRight[oy] < ox) spanRight[oy] = ox;
if (spanRight[ox] < oy) spanRight[ox] = oy;
if (d < 0) { d += 2 * ox + 3; }
else { d += 2 * (ox - oy) + 5; oy--; }
ox++;
}
for (let dy = -radius; dy <= radius; dy++) {
const dxMax = spanRight[Math.abs(dy)];
for (let dx = -dxMax; dx <= dxMax; dx++) {
const px = cx + dx, py = cy + dy;
if (inBounds(px, py)) yield { x: px, y: py };
}
}
return;
}
// Midpoint circle algorithm (Bresenham) for outline.
// Guards prevent duplicate points at axis crossings (ox === 0) and on the
// 45° diagonal (ox === oy) where naïve 8-fold expansion collapses octants.
let ox = 0;
let oy = radius;
let d = 1 - radius;
while (ox <= oy) {
// Primary octant and its y-reflection (top / bottom).
if (inBounds(cx + ox, cy + oy)) yield { x: cx + ox, y: cy + oy };
if (ox > 0 && inBounds(cx - ox, cy + oy)) yield { x: cx - ox, y: cy + oy };
if (inBounds(cx + ox, cy - oy)) yield { x: cx + ox, y: cy - oy };
if (ox > 0 && inBounds(cx - ox, cy - oy)) yield { x: cx - ox, y: cy - oy };
// Swapped octant (left / right), skipped on the diagonal where it
// would duplicate the primary octant points.
if (ox < oy) {
if (inBounds(cx + oy, cy + ox)) yield { x: cx + oy, y: cy + ox };
if (inBounds(cx - oy, cy + ox)) yield { x: cx - oy, y: cy + ox };
if (ox > 0 && inBounds(cx + oy, cy - ox)) yield { x: cx + oy, y: cy - ox };
if (ox > 0 && inBounds(cx - oy, cy - ox)) yield { x: cx - oy, y: cy - ox };
}
if (d < 0) {
d += 2 * ox + 3;
} else {
d += 2 * (ox - oy) + 5;
oy--;
}
ox++;
}
}
/**
* Array-returning wrapper for {@link bresenhamCircleGen}.
* @see bresenhamCircleGen for full parameter and algorithm documentation.
*/
export const bresenhamCircle = (
x: number,
y: number,
r: number,
options?: BresenhamCircleFillOptions,
): Point[] => Array.from(bresenhamCircleGen(x, y, r, options));