126 lines
5.2 KiB
TypeScript
126 lines
5.2 KiB
TypeScript
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,
|
|
};
|
|
}
|