nuts / shampoo / files / src / rockTemplates.ts
Branch shampoo · default branch · all branches
rockTemplates.ts
98ba5504 2026-04-13 Pavel Safronov | 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 | })() |