nuts / files / src / rockTemplates.ts
rockTemplates.ts
| 1 | /** |
| 2 | * Precomputed rock fracture templates. |
| 3 | * |
| 4 | * Each template defines: |
| 5 | * - The polygon shape (normVerts, bounding radius = 1, centroid at origin) |
| 6 | * - A shared fracture point |
| 7 | * - Crack endpoint pairs (for the renderer) |
| 8 | * - Pre-split fragment polygons (for physics) |
| 9 | * |
| 10 | * Templates are generated once at module load with thorough boundary checks so |
| 11 | * that every fragment is guaranteed to be physics-valid before any rock is even |
| 12 | * spawned. |
| 13 | */ |
| 14 | |
| 15 | import Matter from 'matter-js' |
| 16 | const { Bodies, Vector } = Matter |
| 17 | |
| 18 | // ── Public types ──────────────────────────────────────────────────────────── |
| 19 | |
| 20 | export interface Vec2 { x: number; y: number } |
| 21 | |
| 22 | export interface RockFragment { |
| 23 | /** Fragment centroid in template-normalised local space (bounding radius = 1) */ |
| 24 | centroid: Vec2 |
| 25 | /** Fragment polygon vertices RELATIVE to this fragment's centroid, same scale */ |
| 26 | localVerts: Vec2[] |
| 27 | /** Bounding radius of this fragment in normalised space */ |
| 28 | boundR: number |
| 29 | } |
| 30 | |
| 31 | export interface RockTemplate { |
| 32 | /** Polygon vertices, centroid at origin, bounding radius = 1 */ |
| 33 | normVerts: Vec2[] |
| 34 | /** Fracture point in template-local space */ |
| 35 | fracture: Vec2 |
| 36 | /** Crack endpoint pairs in template-local space (same scale as normVerts) */ |
| 37 | cracks: [Vec2, Vec2][] |
| 38 | /** Pre-validated fragment polygons */ |
| 39 | fragments: RockFragment[] |
| 40 | } |
| 41 | |
| 42 | // ── Geometry helpers ──────────────────────────────────────────────────────── |
| 43 | |
| 44 | function polygonArea(verts: Vec2[]): number { |
| 45 | let a = 0 |
| 46 | const n = verts.length |
| 47 | for (let i = 0, j = n - 1; i < n; j = i++) { |
| 48 | a += verts[j].x * verts[i].y - verts[i].x * verts[j].y |
| 49 | } |
| 50 | return Math.abs(a) / 2 |
| 51 | } |
| 52 | |
| 53 | /** Proper area-weighted centroid (shoelace). */ |
| 54 | function polygonCentroid(verts: Vec2[]): Vec2 { |
| 55 | let ax = 0, ay = 0, area = 0 |
| 56 | const n = verts.length |
| 57 | for (let i = 0, j = n - 1; i < n; j = i++) { |
| 58 | const c = verts[j].x * verts[i].y - verts[i].x * verts[j].y |
| 59 | ax += (verts[j].x + verts[i].x) * c |
| 60 | ay += (verts[j].y + verts[i].y) * c |
| 61 | area += c |
| 62 | } |
| 63 | area /= 2 |
| 64 | if (Math.abs(area) < 1e-12) return { x: 0, y: 0 } |
| 65 | return { x: ax / (6 * area), y: ay / (6 * area) } |
| 66 | } |
| 67 | |
| 68 | function pointInPolygon(pt: Vec2, poly: Vec2[]): boolean { |
| 69 | let inside = false |
| 70 | const n = poly.length |
| 71 | for (let i = 0, j = n - 1; i < n; j = i++) { |
| 72 | const xi = poly[i].x, yi = poly[i].y |
| 73 | const xj = poly[j].x, yj = poly[j].y |
| 74 | if ((yi > pt.y) !== (yj > pt.y) && |
| 75 | pt.x < (xj - xi) * (pt.y - yi) / (yj - yi) + xi) { |
| 76 | inside = !inside |
| 77 | } |
| 78 | } |
| 79 | return inside |
| 80 | } |
| 81 | |
| 82 | function makeRng(seed: number) { |
| 83 | let s = (seed * 2654435761 + 1) >>> 0 |
| 84 | return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 0x100000000 } |
| 85 | } |
| 86 | |
| 87 | // ── Template builder ──────────────────────────────────────────────────────── |
| 88 | |
| 89 | const MIN_FRAG_AREA = 0.04 // normalised-space minimum fragment area |
| 90 | const MIN_FRAG_BOUND_R = 0.08 // minimum fragment bounding radius |
| 91 | |
| 92 | function buildTemplate(seed: number): RockTemplate | null { |
| 93 | const rng = makeRng(seed) |
| 94 | |
| 95 | // 1. Generate raw polygon (same distribution as spawnRock) |
| 96 | const sides = 5 + Math.floor(rng() * 4) // 5–8 sides |
| 97 | const rawVerts: Vec2[] = [] |
| 98 | for (let i = 0; i < sides; i++) { |
| 99 | const baseAngle = (i / sides) * Math.PI * 2 |
| 100 | const jitter = (rng() - 0.5) * (Math.PI / sides) * 0.85 |
| 101 | const r = 0.6 + rng() * 0.65 |
| 102 | rawVerts.push({ x: Math.cos(baseAngle + jitter) * r, y: Math.sin(baseAngle + jitter) * r }) |
| 103 | } |
| 104 | |
| 105 | // 2. Canonicalise via Matter.js — get actual body vertex order & centroid correction |
| 106 | const mVerts = rawVerts.map(v => Vector.create(v.x, v.y)) |
| 107 | const testBody = Bodies.fromVertices(0, 0, [mVerts], { |
| 108 | density: 0.0025, restitution: 0.25, friction: 0.65, frictionAir: 0.001, label: 'rock', |
| 109 | }) |
| 110 | if (!testBody || testBody.vertices.length < 4) return null |
| 111 | |
| 112 | // 3. Extract centroid-relative local verts from the canonicalised body |
| 113 | const n = testBody.vertices.length |
| 114 | const bodyLocalVerts: Vec2[] = testBody.vertices.map(v => ({ |
| 115 | x: v.x - testBody.position.x, |
| 116 | y: v.y - testBody.position.y, |
| 117 | })) |
| 118 | |
| 119 | // 4. Normalise to bounding radius = 1 |
| 120 | const maxR = Math.max(...bodyLocalVerts.map(v => Math.sqrt(v.x * v.x + v.y * v.y))) |
| 121 | if (maxR < 0.1) return null |
| 122 | const normVerts: Vec2[] = bodyLocalVerts.map(v => ({ x: v.x / maxR, y: v.y / maxR })) |
| 123 | |
| 124 | // 5. Area check on the original polygon |
| 125 | if (polygonArea(normVerts) < 0.6) return null |
| 126 | |
| 127 | // 6. Fracture point — must land inside the polygon |
| 128 | const fx = (rng() - 0.5) * 0.3 |
| 129 | const fy = (rng() - 0.5) * 0.3 |
| 130 | const fracture: Vec2 = { x: fx, y: fy } |
| 131 | if (!pointInPolygon(fracture, normVerts)) return null |
| 132 | |
| 133 | // 7. Generate crack pairs |
| 134 | const usedPairs = new Set<string>() |
| 135 | const cracks: [Vec2, Vec2][] = [] |
| 136 | const cutIndices: number[] = [] |
| 137 | |
| 138 | for (let attempt = 0; attempt < 30 && cracks.length < 3; attempt++) { |
| 139 | const startIdx = Math.floor(rng() * n) |
| 140 | const half = Math.floor(n / 2) |
| 141 | const endIdx = (startIdx + half + (rng() < 0.5 ? 0 : 1)) % n |
| 142 | if (startIdx === endIdx) continue |
| 143 | const key = `${Math.min(startIdx, endIdx)}-${Math.max(startIdx, endIdx)}` |
| 144 | if (usedPairs.has(key)) continue |
| 145 | usedPairs.add(key) |
| 146 | cracks.push([{ ...normVerts[startIdx] }, { ...normVerts[endIdx] }]) |
| 147 | if (!cutIndices.includes(startIdx)) cutIndices.push(startIdx) |
| 148 | if (!cutIndices.includes(endIdx)) cutIndices.push(endIdx) |
| 149 | } |
| 150 | |
| 151 | if (cracks.length < 2 || cutIndices.length < 2) return null |
| 152 | cutIndices.sort((a, b) => a - b) |
| 153 | |
| 154 | // 8. Build and validate fragment polygons |
| 155 | const fragments: RockFragment[] = [] |
| 156 | for (let i = 0; i < cutIndices.length; i++) { |
| 157 | const fromIdx = cutIndices[i] |
| 158 | const toIdx = cutIndices[(i + 1) % cutIndices.length] |
| 159 | |
| 160 | // Arc of vertices from fromIdx → toIdx (inclusive) plus the shared fracture point |
| 161 | const fv: Vec2[] = [{ ...fracture }] |
| 162 | let vi = fromIdx |
| 163 | while (true) { |
| 164 | fv.push({ ...normVerts[vi] }) |
| 165 | if (vi === toIdx) break |
| 166 | vi = (vi + 1) % n |
| 167 | } |
| 168 | |
| 169 | if (fv.length < 3) return null |
| 170 | |
| 171 | // Area check |
| 172 | if (polygonArea(fv) < MIN_FRAG_AREA) return null |
| 173 | |
| 174 | // Centroid and centroid-relative verts |
| 175 | const c = polygonCentroid(fv) |
| 176 | const localVerts = fv.map(v => ({ x: v.x - c.x, y: v.y - c.y })) |
| 177 | const boundR = Math.max(...localVerts.map(v => Math.sqrt(v.x * v.x + v.y * v.y))) |
| 178 | if (boundR < MIN_FRAG_BOUND_R) return null |
| 179 | |
| 180 | // Test that Matter.js can actually create this fragment body |
| 181 | const testFragVerts = localVerts.map(v => Vector.create(v.x, v.y)) |
| 182 | const testFrag = Bodies.fromVertices(0, 0, [testFragVerts], { |
| 183 | density: 0.0025, restitution: 0.25, friction: 0.65, frictionAir: 0.001, label: 'rock', |
| 184 | }) |
| 185 | if (!testFrag || testFrag.vertices.length < 3) return null |
| 186 | |
| 187 | fragments.push({ centroid: c, localVerts, boundR }) |
| 188 | } |
| 189 | |
| 190 | if (fragments.length < 2) return null |
| 191 | |
| 192 | return { normVerts, fracture, cracks, fragments } |
| 193 | } |
| 194 | |
| 195 | // ── Build the pool ───────────────────────────────────────────────────────── |
| 196 | |
| 197 | export const ROCK_TEMPLATES: readonly RockTemplate[] = (() => { |
| 198 | const templates: RockTemplate[] = [] |
| 199 | let seed = 0 |
| 200 | while (templates.length < 10 && seed < 5000) { |
| 201 | const t = buildTemplate(seed++) |
| 202 | if (t) templates.push(t) |
| 203 | } |
| 204 | return templates |
| 205 | })() |