Files
healer-man/src/scene/productionDungeonCollision.ts
T
2026-09-12 15:14:56 -04:00

128 lines
4.9 KiB
TypeScript

import {
Float32BufferAttribute,
Group,
Mesh,
type Object3D,
} from "three";
import type { DungeonTraversalRamp } from "../game/dungeonTypes";
import { smoothDungeonStairGeometry } from "../game/dungeonTraversalRamps";
export interface PreparedCollisionTrimesh {
readonly id: string;
readonly vertices: ArrayLike<number>;
readonly indices: ArrayLike<number>;
}
// A surface must be within 30 degrees of vertical to occlude combat sight.
// This deliberately rejects floors, ramps, slopes, and ceilings.
const MAX_SIGHT_BLOCKER_NORMAL_Y = 0.5;
export function prepareCollision(root: Object3D, ramps: readonly DungeonTraversalRamp[] = []): Group {
root.updateWorldMatrix(true, true);
const inverseRootMatrix = root.matrixWorld.clone().invert();
const prepared = new Group();
prepared.name = `${root.name || "collision"}-prepared`;
root.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const geometry = mesh.geometry.clone();
const position = geometry.getAttribute("position");
if (position.normalized || !(position.array instanceof Float32Array)) {
const dequantized = new Float32Array(position.count * position.itemSize);
for (let vertex = 0; vertex < position.count; vertex += 1) {
const offset = vertex * position.itemSize;
dequantized[offset] = position.getX(vertex);
if (position.itemSize > 1) dequantized[offset + 1] = position.getY(vertex);
if (position.itemSize > 2) dequantized[offset + 2] = position.getZ(vertex);
if (position.itemSize > 3) dequantized[offset + 3] = position.getW(vertex);
}
geometry.setAttribute(
"position",
new Float32BufferAttribute(dequantized, position.itemSize),
);
}
geometry.applyMatrix4(inverseRootMatrix.clone().multiply(mesh.matrixWorld));
if (ramps.length) smoothDungeonStairGeometry(geometry, ramps);
const preparedMesh = new Mesh(geometry, mesh.material);
preparedMesh.name = mesh.name;
preparedMesh.visible = false;
prepared.add(preparedMesh);
});
return prepared;
}
export function collisionTrimeshes(root: Object3D): readonly PreparedCollisionTrimesh[] {
const result: PreparedCollisionTrimesh[] = [];
root.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const position = mesh.geometry.getAttribute("position");
const index = mesh.geometry.index;
result.push({
id: mesh.uuid,
vertices: position.array,
indices: index?.array
?? Uint32Array.from({ length: position.count }, (_, vertex) => vertex),
});
});
return result;
}
export function collisionLineOfSightTrimeshes(root: Object3D): readonly PreparedCollisionTrimesh[] {
const result: PreparedCollisionTrimesh[] = [];
root.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const position = mesh.geometry.getAttribute("position");
const sourceIndices = mesh.geometry.index?.array
?? Uint32Array.from({ length: position.count }, (_, vertex) => vertex);
const blockerSourceIndices: number[] = [];
for (let offset = 0; offset + 2 < sourceIndices.length; offset += 3) {
const first = Number(sourceIndices[offset]);
const second = Number(sourceIndices[offset + 1]);
const third = Number(sourceIndices[offset + 2]);
const firstX = position.getX(first);
const firstY = position.getY(first);
const firstZ = position.getZ(first);
const abX = position.getX(second) - firstX;
const abY = position.getY(second) - firstY;
const abZ = position.getZ(second) - firstZ;
const acX = position.getX(third) - firstX;
const acY = position.getY(third) - firstY;
const acZ = position.getZ(third) - firstZ;
const normalX = abY * acZ - abZ * acY;
const normalY = abZ * acX - abX * acZ;
const normalZ = abX * acY - abY * acX;
const normalLength = Math.hypot(normalX, normalY, normalZ);
if (normalLength <= Number.EPSILON) continue;
if (Math.abs(normalY) / normalLength > MAX_SIGHT_BLOCKER_NORMAL_Y) continue;
blockerSourceIndices.push(first, second, third);
}
if (!blockerSourceIndices.length) return;
const remappedVertices: number[] = [];
const remappedIndices = new Uint32Array(blockerSourceIndices.length);
const remap = new Map<number, number>();
blockerSourceIndices.forEach((sourceIndex, indexOffset) => {
let remappedIndex = remap.get(sourceIndex);
if (remappedIndex === undefined) {
remappedIndex = remap.size;
remap.set(sourceIndex, remappedIndex);
remappedVertices.push(
position.getX(sourceIndex),
position.getY(sourceIndex),
position.getZ(sourceIndex),
);
}
remappedIndices[indexOffset] = remappedIndex;
});
result.push({
id: `${mesh.uuid}-line-of-sight`,
vertices: Float32Array.from(remappedVertices),
indices: remappedIndices,
});
});
return result;
}