diff --git a/src/common/navigation/bresenham.ts b/src/common/navigation/bresenham.ts index be3c6e3..bf0c999 100644 --- a/src/common/navigation/bresenham.ts +++ b/src/common/navigation/bresenham.ts @@ -5,7 +5,7 @@ export interface Point { y: number; } -export interface BresenhamOptions { +interface BresenhamOptions { minX?: number; maxX?: number; minY?: number; @@ -19,6 +19,18 @@ export interface BresenhamOptions { directions?: 4 | 8; } +export interface BresenhamLineOptions extends BresenhamOptions {} + +export interface BresenhamCircleOptions extends BresenhamOptions { + /** + * - `false` (default) — outline only. + * - `true` — filled disc (span-from-outline scanline). + * - `'fov'` — ray-casting field of view; the generator accepts `true` via + * `.next(true)` to signal an obstacle and skip the rest of that ray. + */ + fill?: T; +} + // --------------------------------------------------------------------------- // Cohen-Sutherland outcodes // --------------------------------------------------------------------------- @@ -111,13 +123,13 @@ function cohenSutherland( * @returns Ordered array of integer grid points. Empty when the segment lies entirely * outside the supplied bounds. */ -export function bresenham( +export function* bresenhamLineGen( fromX: number, fromY: number, toX: number, toY: number, - options?: BresenhamOptions, -): Point[] { + options?: BresenhamLineOptions, +): Generator { // 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); @@ -130,7 +142,7 @@ export function bresenham( const maxY = Math.ceil(options.maxY ?? Infinity); const clipped = cohenSutherland(x0, y0, x1, y1, minX, maxX, minY, maxY); - if (!clipped) return []; + 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]); @@ -144,7 +156,6 @@ export function bresenham( } // --- Bresenham walk --- - const points: Point[] = []; const use8 = options?.directions === 8; const dx = Math.abs(x1 - x0); @@ -156,18 +167,18 @@ export function bresenham( if (dx === 0 && dy === 0) { // Single point. - points.push({ x, y }); - return points; + yield { x, y }; + return; } if (dx >= dy) { // X is the driving axis. let err = 2 * dy - dx; for (let i = 0; i <= dx; i++) { - points.push({ x, y }); + yield { x, y }; if (i === dx) break; if (err >= 0) { - if (!use8) points.push({ x, y: y + sy }); // 4-connected: emit y step before x + if (!use8) yield { x, y: y + sy }; // 4-connected: emit y step before x y += sy; err -= 2 * dx; } @@ -178,10 +189,10 @@ export function bresenham( // Y is the driving axis. let err = 2 * dx - dy; for (let i = 0; i <= dy; i++) { - points.push({ x, y }); + yield { x, y }; if (i === dy) break; if (err >= 0) { - if (!use8) points.push({ x: x + sx, y }); // 4-connected: emit x step before y + if (!use8) yield { x: x + sx, y }; // 4-connected: emit x step before y x += sx; err -= 2 * dy; } @@ -189,6 +200,208 @@ export function bresenham( y += sy; } } - - return points; } + +/** + * 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 8-connected Bresenham ray from the centre + * to that point and yields every cell along the ray. The generator uses the + * **send protocol**: the caller can pass `true` to `.next(true)` after + * receiving a cell 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 always yielded (it is visible; only the cells behind it are blocked). + * + * ```ts + * const fov = bresenhamCircleGen(cx, cy, r, { fill: 'fov' }); + * let result = fov.next(); + * while (!result.done) { + * const blocked = isWall(result.value); + * result = fov.next(blocked); // pass true to stop this ray + * } + * ``` + * + * **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. + * + * **Coverage:** One ray is cast per outline point (`4r` rays total). At large + * radii, the angular gap between adjacent rays may leave interior cells + * uncovered. For precise FOV at large radii, prefer a shadowcasting algorithm. + * + * --- + * + * ## 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; +export function bresenhamCircleGen( + x: number, + y: number, + r: number, + options: BresenhamCircleOptions<'fov'>, +): Generator; + +export function* bresenhamCircleGen( + x: number, + y: number, + r: number, + options?: BresenhamCircleOptions, +): Generator { + 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)) { + const skipLine = yield linePoint; + if (skipLine) 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 (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?: BresenhamCircleOptions, +): Point[] => Array.from(bresenhamCircleGen(x, y, r, options)); diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 92039dd..4dc09a0 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -1,17 +1,25 @@ -import { TextDisplay } from "@common/display/text"; -import { Inventory } from "@common/rpg/components/inventory"; -import { Position } from "@common/rpg/components/position"; -import { World } from "@common/rpg/core/world"; -import { TextDisplaySystem } from "@common/rpg/systems/render/text"; +import { createCanvas } from "@common/display/canvas"; +import { bresenhamCircleGen } from "@common/navigation/bresenham"; + +const S = 20; -console.log("Hello, world 1"); export default async function main() { - const world = new World(); - const e = world.createEntity(); - e.add(new Position(1, 1)); - e.add(new Inventory()); - console.log("Hello, world!"); - const display = new TextDisplay(); - new TextDisplaySystem(display); - console.log("Hello, world 2"); + const canvas = createCanvas(S, S); + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.fillStyle = 'black'; + for (const fill of [true, false, 'fov'] as const) { + let i = 0; + + console.time(`fill=${fill}`); + // @ts-ignore + for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill })) { + i++; + ctx.fillRect(x, y, 1, 1); + } + console.timeEnd(`fill=${fill}`); + console.log(`i=${i}`) + } } diff --git a/test/common/navigation/bresenham.test.ts b/test/common/navigation/bresenham.test.ts index 06c574a..b69a776 100644 --- a/test/common/navigation/bresenham.test.ts +++ b/test/common/navigation/bresenham.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { bresenham } from "@common/navigation/bresenham"; +import { bresenhamLine } from "@common/navigation/bresenham"; // Helpers const pts = (coords: [number, number][]) => @@ -26,31 +26,31 @@ function is8Connected(points: { x: number; y: number }[]): boolean { describe("bresenham", () => { describe("single point", () => { it("returns one point when start === end", () => { - expect(bresenham(3, 3, 3, 3)).toEqual(pts([[3, 3]])); + expect(bresenhamLine(3, 3, 3, 3)).toEqual(pts([[3, 3]])); }); }); describe("axis-aligned lines", () => { it("horizontal right", () => { - expect(bresenham(0, 0, 3, 0)).toEqual(pts([[0, 0], [1, 0], [2, 0], [3, 0]])); + expect(bresenhamLine(0, 0, 3, 0)).toEqual(pts([[0, 0], [1, 0], [2, 0], [3, 0]])); }); it("horizontal left", () => { - expect(bresenham(3, 0, 0, 0)).toEqual(pts([[3, 0], [2, 0], [1, 0], [0, 0]])); + expect(bresenhamLine(3, 0, 0, 0)).toEqual(pts([[3, 0], [2, 0], [1, 0], [0, 0]])); }); it("vertical down", () => { - expect(bresenham(0, 0, 0, 3)).toEqual(pts([[0, 0], [0, 1], [0, 2], [0, 3]])); + expect(bresenhamLine(0, 0, 0, 3)).toEqual(pts([[0, 0], [0, 1], [0, 2], [0, 3]])); }); it("vertical up", () => { - expect(bresenham(0, 3, 0, 0)).toEqual(pts([[0, 3], [0, 2], [0, 1], [0, 0]])); + expect(bresenhamLine(0, 3, 0, 0)).toEqual(pts([[0, 3], [0, 2], [0, 1], [0, 0]])); }); }); describe("diagonal lines", () => { it("perfect diagonal — directions=4 splits into axis steps", () => { - const result = bresenham(0, 0, 2, 2); + const result = bresenhamLine(0, 0, 2, 2); expect(result[0]).toEqual({ x: 0, y: 0 }); expect(result[result.length - 1]).toEqual({ x: 2, y: 2 }); expect(is4Connected(result)).toBe(true); @@ -59,7 +59,7 @@ describe("bresenham", () => { }); it("perfect diagonal — directions=8 emits single diagonal steps", () => { - const result = bresenham(0, 0, 2, 2, { directions: 8 }); + const result = bresenhamLine(0, 0, 2, 2, { directions: 8 }); expect(result).toEqual(pts([[0, 0], [1, 1], [2, 2]])); }); @@ -71,7 +71,7 @@ describe("bresenham", () => { [0, 0, -3, -3], ] as any; for (const [fx, fy, tx, ty] of cases) { - const r = bresenham(fx, fy, tx, ty, { directions: 8 }); + const r = bresenhamLine(fx, fy, tx, ty, { directions: 8 }); expect(r[0]).toEqual({ x: fx, y: fy }); expect(r[r.length - 1]).toEqual({ x: tx, y: ty }); expect(is8Connected(r)).toBe(true); @@ -82,31 +82,31 @@ describe("bresenham", () => { describe("connectivity guarantees", () => { it("directions=4 (default) is always 4-connected", () => { // steep slope - expect(is4Connected(bresenham(0, 0, 3, 7))).toBe(true); + expect(is4Connected(bresenhamLine(0, 0, 3, 7))).toBe(true); // shallow slope - expect(is4Connected(bresenham(0, 0, 7, 3))).toBe(true); + expect(is4Connected(bresenhamLine(0, 0, 7, 3))).toBe(true); // negative direction - expect(is4Connected(bresenham(5, 5, -2, 1))).toBe(true); + expect(is4Connected(bresenhamLine(5, 5, -2, 1))).toBe(true); }); it("directions=8 is always 8-connected", () => { - expect(is8Connected(bresenham(0, 0, 3, 7, { directions: 8 }))).toBe(true); - expect(is8Connected(bresenham(0, 0, 7, 3, { directions: 8 }))).toBe(true); - expect(is8Connected(bresenham(5, 5, -2, 1, { directions: 8 }))).toBe(true); + expect(is8Connected(bresenhamLine(0, 0, 3, 7, { directions: 8 }))).toBe(true); + expect(is8Connected(bresenhamLine(0, 0, 7, 3, { directions: 8 }))).toBe(true); + expect(is8Connected(bresenhamLine(5, 5, -2, 1, { directions: 8 }))).toBe(true); }); it("directions=4 output length is dx+dy+1", () => { - const r = bresenham(0, 0, 4, 3); + const r = bresenhamLine(0, 0, 4, 3); expect(r.length).toBe(4 + 3 + 1); }); it("directions=8 output length is max(dx,dy)+1", () => { - const r = bresenham(0, 0, 4, 3, { directions: 8 }); + const r = bresenhamLine(0, 0, 4, 3, { directions: 8 }); expect(r.length).toBe(Math.max(4, 3) + 1); }); it("start and end are always first and last points", () => { - const r = bresenham(1, 2, 5, 8); + const r = bresenhamLine(1, 2, 5, 8); expect(r[0]).toEqual({ x: 1, y: 2 }); expect(r[r.length - 1]).toEqual({ x: 5, y: 8 }); }); @@ -114,24 +114,24 @@ describe("bresenham", () => { describe("clipping", () => { it("returns empty array when segment is entirely outside bounds", () => { - expect(bresenham(10, 10, 20, 20, { minX: 0, maxX: 5, minY: 0, maxY: 5 })).toEqual([]); + expect(bresenhamLine(10, 10, 20, 20, { minX: 0, maxX: 5, minY: 0, maxY: 5 })).toEqual([]); }); it("clips start when it lies outside bounds", () => { - const r = bresenham(-5, 0, 5, 0, { minX: 0, maxX: 10, minY: 0, maxY: 10 }); + const r = bresenhamLine(-5, 0, 5, 0, { minX: 0, maxX: 10, minY: 0, maxY: 10 }); expect(r[0].x).toBeGreaterThanOrEqual(0); expect(r[r.length - 1]).toEqual({ x: 5, y: 0 }); }); it("clips end when it lies outside bounds", () => { - const r = bresenham(0, 0, 15, 0, { minX: 0, maxX: 10, minY: 0, maxY: 10 }); + const r = bresenhamLine(0, 0, 15, 0, { minX: 0, maxX: 10, minY: 0, maxY: 10 }); expect(r[0]).toEqual({ x: 0, y: 0 }); expect(r[r.length - 1].x).toBeLessThanOrEqual(10); }); it("all returned points are within bounds", () => { const bounds = { minX: 1, maxX: 8, minY: 1, maxY: 8 }; - const r = bresenham(0, 0, 10, 10, bounds); + const r = bresenhamLine(0, 0, 10, 10, bounds); for (const p of r) { expect(p.x).toBeGreaterThanOrEqual(bounds.minX); expect(p.x).toBeLessThanOrEqual(bounds.maxX); @@ -142,24 +142,24 @@ describe("bresenham", () => { it("segment touching only a corner of bounds returns at least one point", () => { // line passes through (0,0) exactly, bounds include only (0,0) - const r = bresenham(-2, -2, 2, 2, { minX: 0, maxX: 0, minY: 0, maxY: 0 }); + const r = bresenhamLine(-2, -2, 2, 2, { minX: 0, maxX: 0, minY: 0, maxY: 0 }); expect(r.length).toBeGreaterThan(0); }); it("clipping preserves 4-connectivity within bounds", () => { - const r = bresenham(-3, -3, 10, 10, { minX: 0, maxX: 6, minY: 0, maxY: 6 }); + const r = bresenhamLine(-3, -3, 10, 10, { minX: 0, maxX: 6, minY: 0, maxY: 6 }); expect(is4Connected(r)).toBe(true); }); it("clipping preserves 8-connectivity within bounds", () => { - const r = bresenham(-3, -3, 10, 10, { minX: 0, maxX: 6, minY: 0, maxY: 6, directions: 8 }); + const r = bresenhamLine(-3, -3, 10, 10, { minX: 0, maxX: 6, minY: 0, maxY: 6, directions: 8 }); expect(is8Connected(r)).toBe(true); }); }); describe("non-integer inputs", () => { it("rounds float inputs to nearest integer", () => { - expect(bresenham(0.4, 0.4, 2.6, 0.4)).toEqual(pts([[0, 0], [1, 0], [2, 0], [3, 0]])); + expect(bresenhamLine(0.4, 0.4, 2.6, 0.4)).toEqual(pts([[0, 0], [1, 0], [2, 0], [3, 0]])); }); }); });