diff --git a/src/common/navigation/bresenham.ts b/src/common/navigation/bresenham.ts index 402d855..a53a586 100644 --- a/src/common/navigation/bresenham.ts +++ b/src/common/navigation/bresenham.ts @@ -1,4 +1,5 @@ import { clamp } from "@common/utils"; +import { shadowCast } from "./fov"; interface Point { x: number; @@ -19,9 +20,9 @@ interface BresenhamOptions { directions?: 4 | 8; } -export interface BresenhamLineOptions extends BresenhamOptions {} +export interface BresenhamLineOptions extends BresenhamOptions { } -interface BresenhamCircleBaseOptions extends BresenhamOptions { +interface BresenhamCircleBaseOptions extends BresenhamOptions { /** * - `false` (default) — outline only. * - `true` — filled disc (span-from-outline scanline). @@ -31,7 +32,7 @@ interface BresenhamCircleBaseOptions extends Bresenha } type BresenhamCircleFillOptions = BresenhamCircleBaseOptions; -interface BresenhamCircleFovOptions extends BresenhamCircleBaseOptions<'fov'> { +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 @@ -323,6 +324,14 @@ export function* bresenhamCircleGen( 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. diff --git a/src/common/navigation/fov.ts b/src/common/navigation/fov.ts new file mode 100644 index 0000000..e8b47b2 --- /dev/null +++ b/src/common/navigation/fov.ts @@ -0,0 +1,102 @@ +export function* shadowCast( + x: number, + y: number, + r: number, + breaker: (x: number, y: number) => boolean +): Generator<{ x: number; y: number }> { + if (r < 0) return; + + const radiusSq = r * r; + const seen = new Set(); + const keyOf = (px: number, py: number) => `${px},${py}`; + + // Yield the source tile itself if it is not blocked. + if (!breaker(x, y)) { + seen.add(keyOf(x, y)); + yield { x, y }; + } + + type Frame = { + octant: number; + row: number; + start: number; + end: number; + }; + + // 8 octants around the origin. + const OCTANTS: ReadonlyArray = [ + [1, 0, 0, 1], + [0, 1, 1, 0], + [0, 1, -1, 0], + [1, 0, 0, -1], + [-1, 0, 0, -1], + [0, -1, -1, 0], + [0, -1, 1, 0], + [-1, 0, 0, 1], + ]; + + const stack: Frame[] = []; + for (let octant = 7; octant >= 0; octant--) { + stack.push({ octant, row: 1, start: 1.0, end: 0.0 }); + } + + while (stack.length > 0) { + const frame = stack.pop()!; + if (frame.start < frame.end) continue; + + const [xx, xy, yx, yy] = OCTANTS[frame.octant]; + + let start = frame.start; + let blocked = false; + let newStart = start; + + for (let row = frame.row; row <= r; row++) { + let dx = -row - 1; + const dy = -row; + + while (dx <= 0) { + dx++; + + const mapX = x + dx * xx + dy * xy; + const mapY = y + dx * yx + dy * yy; + + const leftSlope = (dx - 0.5) / (dy + 0.5); + const rightSlope = (dx + 0.5) / (dy - 0.5); + + if (start < rightSlope) continue; + if (frame.end > leftSlope) break; + + const opaque = breaker(mapX, mapY); + + if (dx * dx + dy * dy <= radiusSq && !opaque) { + const key = keyOf(mapX, mapY); + if (!seen.has(key)) { + seen.add(key); + yield { x: mapX, y: mapY }; + } + } + + if (blocked) { + if (opaque) { + newStart = rightSlope; + continue; + } + + blocked = false; + start = newStart; + } else if (opaque && row < r) { + blocked = true; + stack.push({ + octant: frame.octant, + row: row + 1, + start, + end: leftSlope, + }); + newStart = rightSlope; + } + } + + if (blocked) break; + } + } +} diff --git a/src/games/dropballs/index.ts b/src/games/dropballs/index.ts index 2a907a3..79594d1 100644 --- a/src/games/dropballs/index.ts +++ b/src/games/dropballs/index.ts @@ -1,6 +1,6 @@ import { createCanvas } from "@common/display/canvas"; import { gameLoop } from "@common/game"; -import { bresenhamCircleGen } from "@common/navigation/bresenham"; +import { circleGen } from "@common/navigation/bresenham"; import Physics from "@common/physics"; import { mapNumber } from "@common/utils"; @@ -162,7 +162,7 @@ const frame = (dt: number, state: State) => { function fillCircle(ctx: CanvasRenderingContext2D, xc: number, yc: number, r: number) { // because default circle fill is antialiased - for (const { x, y } of bresenhamCircleGen(xc, yc, r, { fill: true })) { + for (const { x, y } of circleGen(xc, yc, r, { fill: true })) { ctx.fillRect(x, y, 1, 1); } } diff --git a/src/games/playground/index.tsx b/src/games/playground/index.tsx index 4dc09a0..6eb35ae 100644 --- a/src/games/playground/index.tsx +++ b/src/games/playground/index.tsx @@ -3,6 +3,8 @@ import { bresenhamCircleGen } from "@common/navigation/bresenham"; const S = 20; +const breaker = (x: number, y: number) => Math.hypot(x - S * 0.3, y - S * 0.3) <= S / 16; + export default async function main() { const canvas = createCanvas(S, S); @@ -10,12 +12,11 @@ export default async function main() { if (!ctx) return; ctx.fillStyle = 'black'; - for (const fill of [true, false, 'fov'] as const) { + for (const fill of ['fov', 'shadow'] 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 })) { + for (const { x, y } of bresenhamCircleGen(S / 2, S / 2, S * 0.4, { fill, breaker })) { i++; ctx.fillRect(x, y, 1, 1); }