fuzzyworld

nuts / files / src / rockTemplates.ts

rockTemplates.ts

7.8 KB · history · blame

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
15import Matter from 'matter-js'
16const { Bodies, Vector } = Matter
17
18// ── Public types ────────────────────────────────────────────────────────────
19
20export interface Vec2 { x: number; y: number }
21
22export 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
31export 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
44function 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). */
54function 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
68function 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
82function 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
89const MIN_FRAG_AREA = 0.04 // normalised-space minimum fragment area
90const MIN_FRAG_BOUND_R = 0.08 // minimum fragment bounding radius
91
92function 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
197export 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})()