export type LaserPoint = { x: number; y: number }; export type LaserCollider = | { type: 'rect'; minX: number; minY: number; maxX: number; maxY: number } | { type: 'circle'; center: LaserPoint; radius: number } | { type: 'polygon'; points: LaserPoint[] }; export type LaserHit = { ratio: number; distance: number; point: LaserPoint; colliderIndex: number; }; const EPSILON = 1e-7; function cross(ax: number, ay: number, bx: number, by: number): number { return ax * by - ay * bx; } function pointOnEdge(point: LaserPoint, a: LaserPoint, b: LaserPoint): boolean { const dx = b.x - a.x, dy = b.y - a.y; const px = point.x - a.x, py = point.y - a.y; const length = Math.sqrt(dx * dx + dy * dy); if (length < EPSILON) return px * px + py * py <= EPSILON * EPSILON; if (Math.abs(cross(dx, dy, px, py)) > EPSILON * length) return false; const projection = (px * dx + py * dy) / length; return projection >= -EPSILON && projection <= length + EPSILON; } function pointInPolygon(point: LaserPoint, points: LaserPoint[]): boolean { let inside = false; for (let i = 0, j = points.length - 1; i < points.length; j = i++) { const a = points[j], b = points[i]; if (pointOnEdge(point, a, b)) return true; if ((a.y > point.y) !== (b.y > point.y) && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x) inside = !inside; } return inside; } function polygonHit(start: LaserPoint, end: LaserPoint, points: LaserPoint[]): number | null { if (points.length < 3) return null; if (pointInPolygon(start, points)) return 0; const dx = end.x - start.x, dy = end.y - start.y; const lengthSquared = dx * dx + dy * dy; if (lengthSquared < EPSILON * EPSILON) return null; let nearest = Infinity; for (let i = 0, j = points.length - 1; i < points.length; j = i++) { const a = points[j], b = points[i]; const ex = b.x - a.x, ey = b.y - a.y; const px = a.x - start.x, py = a.y - start.y; const determinant = cross(dx, dy, ex, ey); if (Math.abs(determinant) <= EPSILON) { // A beam running along a polygon edge still touches the solid. if (Math.abs(cross(px, py, dx, dy)) > EPSILON) continue; const t1 = (px * dx + py * dy) / lengthSquared; const t2 = ((b.x - start.x) * dx + (b.y - start.y) * dy) / lengthSquared; const entry = Math.max(0, Math.min(t1, t2)); if (entry <= Math.min(1, Math.max(t1, t2))) nearest = Math.min(nearest, entry); } else { const ratio = cross(px, py, ex, ey) / determinant; const edgeRatio = cross(px, py, dx, dy) / determinant; if (ratio >= -EPSILON && ratio <= 1 + EPSILON && edgeRatio >= -EPSILON && edgeRatio <= 1 + EPSILON) { nearest = Math.min(nearest, Math.max(0, Math.min(1, ratio))); } } } return nearest === Infinity ? null : nearest; } function colliderHit(start: LaserPoint, end: LaserPoint, collider: LaserCollider): number | null { const dx = end.x - start.x, dy = end.y - start.y; if (collider.type === 'circle') { const x = start.x - collider.center.x, y = start.y - collider.center.y; const c = x * x + y * y - collider.radius * collider.radius; if (c <= 0) return 0; const a = dx * dx + dy * dy; if (a < EPSILON * EPSILON) return null; const b = x * dx + y * dy; const discriminant = b * b - a * c; if (discriminant < -EPSILON) return null; const ratio = (-b - Math.sqrt(Math.max(0, discriminant))) / a; return ratio >= -EPSILON && ratio <= 1 + EPSILON ? Math.max(0, Math.min(1, ratio)) : null; } if (collider.type === 'polygon') return polygonHit(start, end, collider.points); let entry = 0, exit = 1; for (const axis of ['x', 'y']) { const origin = axis === 'x' ? start.x : start.y; const delta = axis === 'x' ? dx : dy; const min = axis === 'x' ? collider.minX : collider.minY; const max = axis === 'x' ? collider.maxX : collider.maxY; if (Math.abs(delta) < EPSILON) { if (origin < min - EPSILON || origin > max + EPSILON) return null; continue; } const t1 = (min - origin) / delta, t2 = (max - origin) / delta; entry = Math.max(entry, Math.min(t1, t2)); exit = Math.min(exit, Math.max(t1, t2)); if (entry > exit) return null; } return entry; } /** World-space beam centerline only: visual beam thickness never affects blocking. */ export function nearestLaserHit(start: LaserPoint, end: LaserPoint, colliders: LaserCollider[]): LaserHit | null { let ratio = Infinity, colliderIndex = -1; for (let i = 0; i < colliders.length; i++) { const hit = colliderHit(start, end, colliders[i]); if (hit !== null && hit < ratio) { ratio = hit; colliderIndex = i; } } if (colliderIndex < 0) return null; const dx = end.x - start.x, dy = end.y - start.y; return { ratio, distance: Math.sqrt(dx * dx + dy * dy) * ratio, point: { x: start.x + dx * ratio, y: start.y + dy * ratio }, colliderIndex, }; }