SOURCE / PINNED RELEASE
Made of little things.
Poly Dogfight
- Release
- b8511ee374e3…
- Author-recorded commit
- 5579434b9d8b…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b
Archive hash verified: bcadd2430d8ea2bc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* Deterministic obstacle placement and collision volumes. Each obstacle kind has
* a unit-scale local template (Y up, base at origin) shared with the mesh
* builders in src/world/obstacle-meshes.ts.
*/
import { Matrix4, Quaternion, Vector3 } from 'three';
import { PLANET_RADIUS, WORLD_SEED } from './constants.ts';
import { mulberry32 } from './noise.ts';
import { RIFT, riftCoordinates, terrainHeight } from './terrain.ts';
export type ObstacleKind =
| 'arch'
| 'spire'
| 'hoodoo'
| 'island'
| 'halo'
| 'lighthouse'
| 'windmill'
| 'tree'
| 'pine'
| 'cloud';
export type Obstacle = {
kind: ObstacleKind;
matrix: Matrix4;
/** World-space centre and radius of the whole object, for spacing. */
center: Vector3;
radius: number;
scale: number;
};
type Sphere = [x: number, y: number, z: number, r: number];
function ringSpheres(count: number, radius: number, tube: number, arc = Math.PI * 2): Sphere[] {
const out: Sphere[] = [];
for (let i = 0; i <= count; i++) {
const a = (arc * i) / count;
out.push([Math.cos(a) * radius, Math.sin(a) * radius, 0, tube]);
}
return out;
}
/** Local collision spheres for each kind (unit scale). */
export const COLLIDERS: Record<ObstacleKind, Sphere[]> = {
arch: [...ringSpheres(14, 1, 0.26, Math.PI), [1, -0.4, 0, 0.33], [-1, -0.4, 0, 0.33]],
spire: [0.05, 0.2, 0.35, 0.5, 0.65, 0.8, 0.92].map((y) => [0, y, 0, 0.13 * (1 - y) + 0.03] as Sphere),
hoodoo: [
[0, 0.15, 0, 0.15],
[0, 0.45, 0, 0.13],
[0, 0.75, 0, 0.13],
[0, 1.02, 0, 0.17],
...Array.from({ length: 8 }, (_, i): Sphere => {
const a = (i / 8) * Math.PI * 2;
return [Math.cos(a) * 0.36, 1.02, Math.sin(a) * 0.36, 0.14];
}),
],
island: [
[0, -0.15, 0, 0.75],
...Array.from({ length: 6 }, (_, i): Sphere => {
const a = (i / 6) * Math.PI * 2;
return [Math.cos(a) * 0.62, -0.05, Math.sin(a) * 0.62, 0.38];
}),
[0, -0.8, 0, 0.42],
[0, 0.35, 0, 0.3],
],
halo: ringSpheres(24, 1, 0.14),
lighthouse: [0.1, 0.3, 0.5, 0.7, 0.9, 1.05].map((y) => [0, y, 0, 0.13] as Sphere),
windmill: [
[0, 0.15, 0, 0.17],
[0, 0.45, 0, 0.14],
[0, 0.75, 0, 0.13],
[0, 0.95, 0.2, 0.5],
],
tree: [
[0, 0.25, 0, 0.18],
[0, 0.6, 0, 0.3],
],
pine: [
[0, 0.3, 0, 0.22],
[0, 0.7, 0, 0.16],
],
cloud: [],
};
const UP = new Vector3(0, 1, 0);
function randomDirection(random: () => number): Vector3 {
const u = random() * 2 - 1;
const theta = random() * Math.PI * 2;
const r = Math.sqrt(1 - u * u);
return new Vector3(r * Math.cos(theta), u, r * Math.sin(theta));
}
/** Point at angular offset (dx, dz) radians around `dir` in its local tangent frame. */
function offsetDirection(dir: Vector3, dx: number, dz: number): Vector3 {
const t1 = new Vector3().crossVectors(dir, Math.abs(dir.y) < 0.9 ? UP : new Vector3(1, 0, 0)).normalize();
const t2 = new Vector3().crossVectors(dir, t1);
return dir.clone().addScaledVector(t1, dx).addScaledVector(t2, dz).normalize();
}
function slope(dir: Vector3): number {
const h0 = terrainHeight(dir.x, dir.y, dir.z);
let max = 0;
for (const [dx, dz] of [
[0.01, 0],
[-0.01, 0],
[0, 0.01],
[0, -0.01],
] as const) {
const d = offsetDirection(dir, dx, dz);
max = Math.max(max, Math.abs(terrainHeight(d.x, d.y, d.z) - h0));
}
return max / (0.01 * PLANET_RADIUS);
}
function lowestGround(dir: Vector3, footprint: number): number {
let min = terrainHeight(dir.x, dir.y, dir.z);
const ang = footprint / PLANET_RADIUS;
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
const d = offsetDirection(dir, Math.cos(a) * ang, Math.sin(a) * ang);
min = Math.min(min, terrainHeight(d.x, d.y, d.z));
}
return min;
}
function makeMatrix(dir: Vector3, altitude: number, spin: number, scale: number | Vector3, tilt = 0): Matrix4 {
const q = new Quaternion().setFromUnitVectors(UP, dir);
q.multiply(new Quaternion().setFromAxisAngle(UP, spin));
if (tilt) q.multiply(new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), tilt));
const s = typeof scale === 'number' ? new Vector3(scale, scale, scale) : scale;
return new Matrix4().compose(dir.clone().multiplyScalar(PLANET_RADIUS + altitude), q, s);
}
export type ObstacleField = {
obstacles: Obstacle[];
hits(p: Vector3, r: number): boolean;
colliderCount: number;
};
export function generateObstacles(seed = WORLD_SEED): ObstacleField {
const random = mulberry32(seed ^ 0x5eed);
const obstacles: Obstacle[] = [];
const range = (a: number, b: number) => a + (b - a) * random();
const fits = (center: Vector3, radius: number, kinds?: ObstacleKind[]) =>
obstacles.every(
(o) => (kinds && !kinds.includes(o.kind)) || o.center.distanceTo(center) > o.radius + radius,
);
const add = (kind: ObstacleKind, matrix: Matrix4, radius: number, scale: number, centerOffset = 0.5) => {
const center = new Vector3(0, centerOffset, 0).applyMatrix4(matrix);
obstacles.push({ kind, matrix, center, radius, scale });
};
const big: ObstacleKind[] = ['arch', 'spire', 'hoodoo', 'island', 'halo', 'lighthouse', 'windmill'];
// Natural bridges spanning the Great Rift canyon.
for (let i = 0; i < 5; i++) {
const along = RIFT.arcStart + 0.3 + i * ((RIFT.arcEnd - RIFT.arcStart - 0.6) / 4);
const [e1, e2] = [new Vector3(...RIFT.e1), new Vector3(...RIFT.e2)];
const dir = e1.multiplyScalar(Math.cos(along)).add(e2.multiplyScalar(Math.sin(along))).normalize();
// Shift onto the wiggling centreline.
const { across } = riftCoordinates(dir.x, dir.y, dir.z);
dir.addScaledVector(new Vector3(...RIFT.normal), -Math.sin(across)).normalize();
const tangentAlong = new Vector3(...RIFT.normal).cross(dir).normalize();
// Arch spans across the canyon: its local X must point across (along the rift normal).
const q = new Quaternion().setFromUnitVectors(UP, dir);
const localZ = new Vector3(0, 0, 1).applyQuaternion(q);
const spin = Math.atan2(
new Vector3().crossVectors(localZ, tangentAlong).dot(dir),
localZ.dot(tangentAlong),
);
const scale = 34;
const base = 46;
const matrix = makeMatrix(dir, base, spin, scale);
add('arch', matrix, scale * 1.1, scale);
}
const place = (
kind: ObstacleKind,
count: number,
opts: {
scale: [number, number];
minH: number;
maxH: number;
maxSlope?: number;
footprint: number;
radius: number;
floatAlt?: [number, number];
near?: Vector3[];
nearSpread?: number;
tilt?: number;
attempts?: number;
spacing?: ObstacleKind[];
centerOffset?: number;
},
) => {
let placed = 0;
const attempts = opts.attempts ?? count * 40;
for (let n = 0; n < attempts && placed < count; n++) {
let dir: Vector3;
if (opts.near) {
const c = opts.near[Math.floor(random() * opts.near.length)]!;
const spread = opts.nearSpread ?? 0.08;
dir = offsetDirection(c, range(-spread, spread), range(-spread, spread));
} else dir = randomDirection(random);
const h = terrainHeight(dir.x, dir.y, dir.z);
if (h < opts.minH || h > opts.maxH) continue;
if (opts.maxSlope !== undefined && slope(dir) > opts.maxSlope) continue;
const { across, along } = riftCoordinates(dir.x, dir.y, dir.z);
const inRift = Math.abs(across) < 0.07 && along > RIFT.arcStart - 0.1 && along < RIFT.arcEnd + 0.1;
if (inRift && kind !== 'cloud') continue;
const scale = range(opts.scale[0], opts.scale[1]);
const altitude = opts.floatAlt
? Math.max(0, h) + range(opts.floatAlt[0], opts.floatAlt[1])
: lowestGround(dir, opts.footprint * scale) - 1.5;
const tilt = opts.tilt ? range(-opts.tilt, opts.tilt) : 0;
const matrix = makeMatrix(dir, altitude, random() * Math.PI * 2, scale, tilt);
const center = new Vector3(0, opts.centerOffset ?? 0.5, 0).applyMatrix4(matrix);
const radius = opts.radius * scale;
if (!fits(center, radius, opts.spacing ?? big)) continue;
add(kind, matrix, radius, scale, opts.centerOffset ?? 0.5);
placed++;
}
};
place('arch', 16, { scale: [18, 26], minH: 3, maxH: 40, maxSlope: 0.5, footprint: 1, radius: 1.2 });
const spireFields = Array.from({ length: 9 }, () => {
for (;;) {
const d = randomDirection(random);
if (terrainHeight(d.x, d.y, d.z) > 2) return d;
}
});
place('spire', 60, {
scale: [45, 105],
minH: 1,
maxH: 60,
footprint: 0.1,
radius: 0.22,
near: spireFields,
nearSpread: 0.07,
tilt: 0.12,
});
place('hoodoo', 22, { scale: [32, 52], minH: 3, maxH: 45, maxSlope: 0.6, footprint: 0.2, radius: 0.6, centerOffset: 0.8 });
place('halo', 10, { scale: [20, 30], minH: -40, maxH: 50, footprint: 0, radius: 1.1, floatAlt: [42, 72], centerOffset: 0 });
place('island', 12, { scale: [16, 28], minH: -60, maxH: 30, footprint: 0, radius: 1.1, floatAlt: [80, 140], centerOffset: 0 });
place('lighthouse', 9, { scale: [26, 32], minH: 1, maxH: 5, maxSlope: 0.4, footprint: 0.15, radius: 0.3 });
place('windmill', 12, { scale: [20, 26], minH: 4, maxH: 26, maxSlope: 0.25, footprint: 0.15, radius: 0.6 });
place('cloud', 70, { scale: [14, 30], minH: -100, maxH: 200, footprint: 0, radius: 1, floatAlt: [105, 175], spacing: ['cloud'], centerOffset: 0 });
// Forests: denser where a low-frequency mask says so.
place('tree', 650, { scale: [6, 11], minH: 2, maxH: 26, maxSlope: 0.5, footprint: 0.2, radius: 0.35, attempts: 12000, spacing: [...big, 'tree', 'pine'] });
place('pine', 650, { scale: [8, 14], minH: 14, maxH: 55, maxSlope: 0.9, footprint: 0.2, radius: 0.25, attempts: 12000, spacing: [...big, 'tree', 'pine'] });
// Spatial hash of world-space collision spheres.
const cell = 40;
// Numeric cell keys (no string per lookup); cells span ±64 × 40 m, far past every obstacle.
const SPAN = 128;
const keyOf = (gx: number, gy: number, gz: number) =>
Math.abs(gx) >= SPAN / 2 || Math.abs(gy) >= SPAN / 2 || Math.abs(gz) >= SPAN / 2
? -1
: ((gx + SPAN / 2) * SPAN + gy + SPAN / 2) * SPAN + gz + SPAN / 2;
const grid = new Map<number, number[]>();
const spheres: number[] = [];
const v = new Vector3();
for (const o of obstacles) {
const s = o.matrix.getMaxScaleOnAxis();
for (const [x, y, z, r] of COLLIDERS[o.kind]) {
v.set(x, y, z).applyMatrix4(o.matrix);
const index = spheres.length / 4;
spheres.push(v.x, v.y, v.z, r * s);
const rr = r * s + 3;
for (let gx = Math.floor((v.x - rr) / cell); gx <= Math.floor((v.x + rr) / cell); gx++)
for (let gy = Math.floor((v.y - rr) / cell); gy <= Math.floor((v.y + rr) / cell); gy++)
for (let gz = Math.floor((v.z - rr) / cell); gz <= Math.floor((v.z + rr) / cell); gz++) {
const key = keyOf(gx, gy, gz);
const list = grid.get(key);
if (list) list.push(index);
else grid.set(key, [index]);
}
}
}
const data = new Float32Array(spheres);
return {
obstacles,
colliderCount: data.length / 4,
hits(p, r) {
const list = grid.get(keyOf(Math.floor(p.x / cell), Math.floor(p.y / cell), Math.floor(p.z / cell)));
if (!list) return false;
for (const i of list) {
const dx = p.x - data[i * 4]!;
const dy = p.y - data[i * 4 + 1]!;
const dz = p.z - data[i * 4 + 2]!;
const rr = r + data[i * 4 + 3]!;
if (dx * dx + dy * dy + dz * dz < rr * rr) return true;
}
return false;
},
};
}
