fuzzyworld

nuts / files / src / renderer.ts

renderer.ts

49.7 KB · history · blame

1import type { NutBody } from './physics'
2import type Matter from 'matter-js'
3import { CONFIG } from './config'
4import QRCode from 'qrcode'
5import { ROCK_TEMPLATES } from './rockTemplates'
6
7// ── Nut palette – warm tones ───────────────────────────────────────────────
8interface NutColor { hi: string; mid: string; shadow: string; rim: string }
9const PALETTE: NutColor[] = [
10 { hi: '#E8C86A', mid: '#C8993A', shadow: '#7A5810', rim: '#5A3C08' }, // golden
11 { hi: '#D4956A', mid: '#AA6030', shadow: '#6A3410', rim: '#4A2008' }, // copper-tan
12 { hi: '#C8B478', mid: '#A08840', shadow: '#6A5418', rim: '#4A3808' }, // hazel
13 { hi: '#B87850', mid: '#8C4E20', shadow: '#5A2C0A', rim: '#3A1C04' }, // dark walnut
14 { hi: '#DDB860', mid: '#B88E30', shadow: '#785808', rim: '#503A04' }, // light chestnut
15]
16
17const METAL_PALETTE: NutColor[] = [
18 { hi: '#FFFFFF', mid: '#B8C0CC', shadow: '#505868', rim: '#303848' }, // chrome
19 { hi: '#E0E8F0', mid: '#8898AC', shadow: '#404E5E', rim: '#283040' }, // steel
20 { hi: '#D0D8E0', mid: '#707880', shadow: '#3A4048', rim: '#22282E' }, // gunmetal
21 { hi: '#E8E4D8', mid: '#A0A090', shadow: '#585850', rim: '#383830' }, // pewter
22 { hi: '#F4F0E8', mid: '#C8C0A8', shadow: '#706858', rim: '#504840' }, // silver-warm
23]
24
25// ── Cache pre-rendered sprites ─────────────────────────────────────────────
26const spriteCache = new Map<string, HTMLCanvasElement>()
27
28function roundToNearest4(n: number): number {
29 return Math.round(n / 4) * 4
30}
31
32// ── Gem palette ────────────────────────────────────────────────────────────
33type GemPts = Array<[number, number]>
34
35interface GemDef {
36 hi: string; mid: string; shadow: string; rim: string
37 facetHi: string; facetLo: string
38 outline: (cx: number, cy: number, r: number) => GemPts
39 /** Custom facet drawing; omit to use default alternating triangles from centre. */
40 drawFacets?: (ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, pts: GemPts, g: GemDef) => void
41}
42
43function polygonOutline(sides: number, rot: number): (cx: number, cy: number, r: number) => GemPts {
44 return (cx, cy, r) => {
45 const pts: GemPts = []
46 for (let i = 0; i < sides; i++) {
47 const a = rot + (i / sides) * Math.PI * 2
48 pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r])
49 }
50 return pts
51 }
52}
53
54const GEM_TYPES: GemDef[] = [
55 // 0 — Diamond: icy white-blue
56 // Crown = trapezoid (narrow table at top, wide girdle below)
57 // Pavilion = equilateral triangle hanging from girdle
58 // Girdle at cy − 0.3r → triangle height = 1.5r·(√3/2) = 1.3r → tip at cy + r ✓
59 {
60 hi: '#F4FBFF', mid: '#B8D8F0', shadow: '#5880A8', rim: '#C8E4F4',
61 facetHi: 'rgba(255,255,255,0.55)', facetLo: 'rgba(70,120,170,0.35)',
62 outline: (cx, cy, r) => [
63 [cx - r * 0.30, cy - r], // TL (table left)
64 [cx + r * 0.30, cy - r], // TR (table right)
65 [cx + r * 0.75, cy - r * 0.3], // GR (girdle right)
66 [cx, cy + r], // Tip (culet)
67 [cx - r * 0.75, cy - r * 0.3], // GL (girdle left)
68 ],
69 drawFacets: (ctx, cx, cy, r, pts, g) => {
70 const [tl, tr, gr, tip, gl] = pts
71 const mid: GemPts[0] = [cx, cy - r * 0.3] // girdle midpoint
72 const fill = (a: GemPts[0], b: GemPts[0], c: GemPts[0], style: string) => {
73 ctx.beginPath()
74 ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.lineTo(c[0], c[1])
75 ctx.closePath(); ctx.fillStyle = style; ctx.fill()
76 }
77 // Crown facets
78 fill(tl, tr, mid, g.facetHi)
79 fill(tl, gl, mid, g.facetLo)
80 fill(tr, gr, mid, g.facetLo)
81 // Pavilion facets
82 fill(gl, mid, tip, g.facetHi)
83 fill(gr, mid, tip, g.facetLo)
84 // Thin girdle line
85 ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 0.8
86 ctx.beginPath(); ctx.moveTo(gl[0], gl[1]); ctx.lineTo(gr[0], gr[1]); ctx.stroke()
87 },
88 },
89
90 // 1 — Ruby: deep red, hexagon outside + hexagon inside, edge-to-edge spokes
91 {
92 hi: '#FF7090', mid: '#C01030', shadow: '#580010', rim: '#FF2040',
93 facetHi: 'rgba(255,130,150,0.55)', facetLo: 'rgba(55,0,10,0.45)',
94 outline: polygonOutline(6, 0),
95 drawFacets: (ctx, cx, cy, r, pts, g) => {
96 const inner: GemPts = pts.map(([x, y]) => [cx + (x - cx) * 0.50, cy + (y - cy) * 0.50])
97 // Six trapezoid bands between outer and inner hex
98 for (let i = 0; i < 6; i++) {
99 const [ox1, oy1] = pts[i], [ox2, oy2] = pts[(i + 1) % 6]
100 const [ix1, iy1] = inner[i], [ix2, iy2] = inner[(i + 1) % 6]
101 ctx.beginPath()
102 ctx.moveTo(ox1, oy1); ctx.lineTo(ox2, oy2); ctx.lineTo(ix2, iy2); ctx.lineTo(ix1, iy1)
103 ctx.closePath()
104 ctx.fillStyle = i % 2 === 0 ? g.facetHi : g.facetLo; ctx.fill()
105 }
106 // Inner hexagon rim
107 tracePoly(ctx, inner); ctx.strokeStyle = g.rim; ctx.lineWidth = 1.0; ctx.stroke()
108 // Spokes: outer vertex → inner vertex
109 ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 0.8
110 for (let i = 0; i < 6; i++) {
111 ctx.beginPath(); ctx.moveTo(pts[i][0], pts[i][1]); ctx.lineTo(inner[i][0], inner[i][1]); ctx.stroke()
112 }
113 },
114 },
115
116 // 2 — Sapphire: deep blue, hexagon rotated (default facets)
117 {
118 hi: '#80A8FF', mid: '#1838C8', shadow: '#060848', rim: '#3058F0',
119 facetHi: 'rgba(130,170,255,0.55)', facetLo: 'rgba(4,8,55,0.45)',
120 outline: polygonOutline(6, Math.PI / 6),
121 },
122
123 // 3 — Emerald: deep green, emerald cut — octagonal outline, rectangle table inside
124 {
125 hi: '#70E090', mid: '#108040', shadow: '#022810', rim: '#28B060',
126 facetHi: 'rgba(100,230,140,0.55)', facetLo: 'rgba(2,24,8,0.45)',
127 outline: (cx, cy, r) => {
128 const hw = r * 0.62, hh = r * 0.88, cut = r * 0.18
129 return [
130 [cx - hw + cut, cy - hh], // top edge: left
131 [cx + hw - cut, cy - hh], // top edge: right
132 [cx + hw, cy - hh + cut], // right edge: top
133 [cx + hw, cy + hh - cut], // right edge: bottom
134 [cx + hw - cut, cy + hh], // bottom edge: right
135 [cx - hw + cut, cy + hh], // bottom edge: left
136 [cx - hw, cy + hh - cut], // left edge: bottom
137 [cx - hw, cy - hh + cut], // left edge: top
138 ]
139 },
140 drawFacets: (ctx, cx, cy, r, pts, g) => {
141 // Inner rectangle (the "table")
142 const hw = r * 0.35, hh = r * 0.52
143 const tbl: GemPts = [
144 [cx - hw, cy - hh], [cx + hw, cy - hh],
145 [cx + hw, cy + hh], [cx - hw, cy + hh],
146 ]
147 // Four side trapezoids
148 const sides: Array<[GemPts[0], GemPts[0], GemPts[0], GemPts[0], string]> = [
149 [pts[0], pts[1], tbl[1], tbl[0], g.facetHi], // top
150 [pts[2], pts[3], tbl[2], tbl[1], g.facetLo], // right
151 [pts[4], pts[5], tbl[3], tbl[2], g.facetHi], // bottom
152 [pts[6], pts[7], tbl[0], tbl[3], g.facetLo], // left
153 ]
154 for (const [a, b, c, d, col] of sides) {
155 ctx.beginPath()
156 ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.lineTo(c[0], c[1]); ctx.lineTo(d[0], d[1])
157 ctx.closePath(); ctx.fillStyle = col; ctx.fill()
158 }
159 // Four corner triangles (the cut corners)
160 const corners: Array<[GemPts[0], GemPts[0], GemPts[0]]> = [
161 [pts[1], pts[2], tbl[1]],
162 [pts[3], pts[4], tbl[2]],
163 [pts[5], pts[6], tbl[3]],
164 [pts[7], pts[0], tbl[0]],
165 ]
166 for (const [a, b, ic] of corners) {
167 ctx.beginPath()
168 ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.lineTo(ic[0], ic[1])
169 ctx.closePath(); ctx.fillStyle = g.facetLo; ctx.fill()
170 }
171 // Inner rectangle outline
172 tracePoly(ctx, tbl); ctx.strokeStyle = g.rim; ctx.lineWidth = 1.0; ctx.stroke()
173 // Lines from outer cut-corner points to inner rect corners
174 ctx.strokeStyle = 'rgba(255,255,255,0.30)'; ctx.lineWidth = 0.8
175 const outerCornerPts = [pts[1], pts[3], pts[5], pts[7]]
176 for (let i = 0; i < 4; i++) {
177 ctx.beginPath()
178 ctx.moveTo(outerCornerPts[i][0], outerCornerPts[i][1])
179 ctx.lineTo(tbl[i][0], tbl[i][1]); ctx.stroke()
180 }
181 },
182 },
183
184 // 4 — Amethyst: purple, hexagon (default facets)
185 {
186 hi: '#D080FF', mid: '#7020C0', shadow: '#280040', rim: '#A030E0',
187 facetHi: 'rgba(205,105,255,0.55)', facetLo: 'rgba(28,0,50,0.45)',
188 outline: polygonOutline(6, Math.PI / 12),
189 },
190]
191
192function tracePoly(ctx: CanvasRenderingContext2D, pts: Array<[number, number]>): void {
193 ctx.beginPath()
194 ctx.moveTo(pts[0][0], pts[0][1])
195 for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
196 ctx.closePath()
197}
198
199function buildGemSprite(radius: number, gemVariant: number): HTMLCanvasElement {
200 const g = GEM_TYPES[gemVariant % GEM_TYPES.length]
201 const pad = Math.ceil(radius * 0.35)
202 const size = Math.ceil(radius * 2 + pad * 2)
203 const oc = document.createElement('canvas')
204 oc.width = size
205 oc.height = size
206 const ctx = oc.getContext('2d')!
207 const cx = size / 2
208 const cy = size / 2
209
210 // Shadow
211 ctx.save()
212 ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
213 ctx.globalAlpha = 0.5
214 ctx.fillStyle = '#000'
215 tracePoly(ctx, g.outline(cx + pad * 0.15, cy + pad * 0.35, radius * 0.9))
216 ctx.fill()
217 ctx.restore()
218
219 const pts = g.outline(cx, cy, radius)
220
221 // Base radial gradient fill
222 const grad = ctx.createRadialGradient(cx - radius * 0.3, cy - radius * 0.35, radius * 0.05, cx, cy, radius)
223 grad.addColorStop(0, g.hi)
224 grad.addColorStop(0.55, g.mid)
225 grad.addColorStop(1, g.shadow)
226 tracePoly(ctx, pts)
227 ctx.fillStyle = grad
228 ctx.fill()
229
230 // Facets (clipped to outline)
231 ctx.save()
232 tracePoly(ctx, pts)
233 ctx.clip()
234 if (g.drawFacets) {
235 g.drawFacets(ctx, cx, cy, radius, pts, g)
236 } else {
237 // Default: alternating triangles from centre to each edge
238 for (let i = 0; i < pts.length; i++) {
239 const [ax, ay] = pts[i]
240 const [bx, by] = pts[(i + 1) % pts.length]
241 ctx.beginPath()
242 ctx.moveTo(cx, cy)
243 ctx.lineTo(ax, ay)
244 ctx.lineTo(bx, by)
245 ctx.closePath()
246 ctx.fillStyle = i % 2 === 0 ? g.facetHi : g.facetLo
247 ctx.fill()
248 }
249 }
250 ctx.restore()
251
252 // Bright rim
253 tracePoly(ctx, pts)
254 ctx.strokeStyle = g.rim
255 ctx.lineWidth = Math.max(1.5, radius * 0.04)
256 ctx.stroke()
257
258 // Centre-to-vertex lines (only for default facet gems — custom ones handle their own)
259 if (!g.drawFacets) {
260 ctx.strokeStyle = 'rgba(255,255,255,0.22)'
261 ctx.lineWidth = 1
262 for (const [px, py] of pts) {
263 ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(px, py); ctx.stroke()
264 }
265 }
266
267 // Specular highlight — top-left soft glow
268 ctx.save()
269 tracePoly(ctx, pts)
270 ctx.clip()
271 const hiGrad = ctx.createRadialGradient(cx - radius * 0.25, cy - radius * 0.3, 0, cx, cy, radius * 0.7)
272 hiGrad.addColorStop(0, 'rgba(255,255,255,0.55)')
273 hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.14)')
274 hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
275 ctx.fillStyle = hiGrad
276 ctx.fillRect(0, 0, size, size)
277 ctx.restore()
278
279 return oc
280}
281
282function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement {
283 const c = PALETTE[colorIndex % PALETTE.length]
284 const rx = radius
285 const ry = Math.round(radius / CONFIG.nutAspectRatio)
286 const pad = Math.ceil(rx * 0.35)
287 const sizeW = Math.ceil(rx * 2 + pad * 2)
288 const sizeH = Math.ceil(ry * 2 + pad * 2)
289 const oc = document.createElement('canvas')
290 oc.width = sizeW
291 oc.height = sizeH
292 const ctx = oc.getContext('2d')!
293 const cx = sizeW / 2
294 const cy = sizeH / 2
295
296 // Contained drop shadow – blurred offset ellipse drawn before the nut
297 ctx.save()
298 ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
299 ctx.globalAlpha = 0.55
300 ctx.fillStyle = '#000'
301 ctx.beginPath()
302 ctx.ellipse(cx + pad * 0.15, cy + pad * 0.35, rx * 0.88, ry * 0.88, 0, 0, Math.PI * 2)
303 ctx.fill()
304 ctx.restore()
305
306 // Ellipse fill – off-centre radial gradient for 3-D sheen
307 const grad = ctx.createRadialGradient(
308 cx - rx * 0.28, cy - ry * 0.32, rx * 0.04,
309 cx, cy, rx
310 )
311 grad.addColorStop(0, c.hi)
312 grad.addColorStop(0.5, c.mid)
313 grad.addColorStop(1, c.shadow)
314 ctx.beginPath()
315 ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
316 ctx.fillStyle = grad
317 ctx.fill()
318
319 // Rim stroke
320 ctx.lineWidth = Math.max(1.5, rx * 0.035)
321 ctx.strokeStyle = c.rim
322 ctx.stroke()
323
324 // Specular highlight – small bright arc top-left
325 ctx.beginPath()
326 ctx.ellipse(cx - rx * 0.22, cy - ry * 0.28, rx * 0.28, ry * 0.18, -0.5, 0, Math.PI * 2)
327 ctx.fillStyle = 'rgba(255,255,255,0.12)'
328 ctx.fill()
329
330 return oc
331}
332
333function buildMetalSprite(radius: number, colorIndex: number): HTMLCanvasElement {
334 const c = METAL_PALETTE[colorIndex % METAL_PALETTE.length]
335 const pad = Math.ceil(radius * 0.35)
336 const size = Math.ceil(radius * 2 + pad * 2)
337 const oc = document.createElement('canvas')
338 oc.width = size
339 oc.height = size
340 const ctx = oc.getContext('2d')!
341 const cx = size / 2
342 const cy = size / 2
343 const holeR = radius * 0.38 // bolt hole radius
344
345 // Helper: hexagon path (pointy-top, 6 sides)
346 function hexPath(r: number, ox = 0, oy = 0) {
347 ctx.beginPath()
348 for (let i = 0; i < 6; i++) {
349 const a = (i / 6) * Math.PI * 2
350 i === 0
351 ? ctx.moveTo(ox + Math.cos(a) * r, oy + Math.sin(a) * r)
352 : ctx.lineTo(ox + Math.cos(a) * r, oy + Math.sin(a) * r)
353 }
354 ctx.closePath()
355 }
356
357 // Drop shadow – hex minus hole via evenodd winding
358 ctx.save()
359 ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
360 ctx.globalAlpha = 0.6
361 ctx.fillStyle = '#000'
362 ctx.beginPath()
363 const sox = cx + pad * 0.08, soy = cy + pad * 0.28
364 for (let i = 0; i < 6; i++) {
365 const a = (i / 6) * Math.PI * 2
366 i === 0 ? ctx.moveTo(sox + Math.cos(a) * radius * 0.88, soy + Math.sin(a) * radius * 0.88)
367 : ctx.lineTo(sox + Math.cos(a) * radius * 0.88, soy + Math.sin(a) * radius * 0.88)
368 }
369 ctx.closePath()
370 ctx.arc(sox, soy, holeR * 0.85, 0, Math.PI * 2, true)
371 ctx.fill('evenodd')
372 ctx.restore()
373
374 // Base metallic radial gradient
375 const grad = ctx.createRadialGradient(
376 cx - radius * 0.3, cy - radius * 0.35, radius * 0.02,
377 cx, cy, radius
378 )
379 grad.addColorStop(0, c.hi)
380 grad.addColorStop(0.25, c.mid)
381 grad.addColorStop(0.6, c.shadow)
382 grad.addColorStop(0.85, c.mid)
383 grad.addColorStop(1, c.rim)
384 // Hex + hole (evenodd = hole is empty)
385 ctx.beginPath()
386 for (let i = 0; i < 6; i++) {
387 const a = (i / 6) * Math.PI * 2
388 i === 0 ? ctx.moveTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)
389 : ctx.lineTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)
390 }
391 ctx.closePath()
392 ctx.arc(cx, cy, holeR, 0, Math.PI * 2, true)
393 ctx.fillStyle = grad
394 ctx.fill('evenodd')
395
396 // Outer rim stroke
397 hexPath(radius, cx, cy)
398 ctx.lineWidth = Math.max(1.5, radius * 0.045)
399 ctx.strokeStyle = c.rim
400 ctx.stroke()
401
402 // Broad specular band (clipped to hex, not over hole)
403 ctx.save()
404 ctx.beginPath()
405 for (let i = 0; i < 6; i++) {
406 const a = (i / 6) * Math.PI * 2
407 i === 0 ? ctx.moveTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)
408 : ctx.lineTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)
409 }
410 ctx.closePath()
411 ctx.arc(cx, cy, holeR, 0, Math.PI * 2, true)
412 ctx.clip('evenodd')
413 const specGrad = ctx.createLinearGradient(cx - radius * 0.5, cy - radius * 0.6, cx + radius * 0.1, cy + radius * 0.1)
414 specGrad.addColorStop(0, 'rgba(255,255,255,0.0)')
415 specGrad.addColorStop(0.3, 'rgba(255,255,255,0.45)')
416 specGrad.addColorStop(0.6, 'rgba(255,255,255,0.22)')
417 specGrad.addColorStop(1, 'rgba(255,255,255,0.0)')
418 ctx.fillStyle = specGrad
419 ctx.fillRect(0, 0, size, size)
420 ctx.restore()
421
422 // Hole inner bevel — dark outer ring + lighter inner ring
423 ctx.beginPath()
424 ctx.arc(cx, cy, holeR, 0, Math.PI * 2)
425 ctx.lineWidth = Math.max(1.5, radius * 0.06)
426 ctx.strokeStyle = c.rim
427 ctx.stroke()
428 ctx.beginPath()
429 ctx.arc(cx, cy, holeR - ctx.lineWidth * 0.6, 0, Math.PI * 2)
430 ctx.lineWidth = Math.max(1, radius * 0.03)
431 ctx.strokeStyle = 'rgba(255,255,255,0.25)'
432 ctx.stroke()
433
434 // Sharp bright specular dot (on upper-left face)
435 ctx.beginPath()
436 ctx.ellipse(cx - radius * 0.28, cy - radius * 0.3, radius * 0.16, radius * 0.10, -0.4, 0, Math.PI * 2)
437 ctx.fillStyle = 'rgba(255,255,255,0.72)'
438 ctx.fill()
439
440 return oc
441}
442
443// ── Rock (drawn directly from world-space body vertices) ───────────────────
444
445const ROCK_PALETTE = [
446 { fill: '#7A5A38', hi: '#A07A52', dark: '#4A3218', stroke: '#2E1C08' },
447 { fill: '#6A5028', hi: '#8E7045', dark: '#3C2A0E', stroke: '#221608' },
448 { fill: '#887060', hi: '#A89080', dark: '#604840', stroke: '#3A2820' },
449 { fill: '#705540', hi: '#987860', dark: '#483020', stroke: '#2C1810' },
450]
451
452function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: number, colorIndex: number, hitCount: number, templateIdx: number): void {
453 const c = ROCK_PALETTE[colorIndex % ROCK_PALETTE.length]
454 const verts = body.vertices
455 const cx = body.position.x
456 const cy = body.position.y
457 const blurR = Math.ceil(radius * 0.15)
458
459 function rockPath(ox = 0, oy = 0) {
460 ctx.beginPath()
461 ctx.moveTo(verts[0].x + ox, verts[0].y + oy)
462 for (let i = 1; i < verts.length; i++) ctx.lineTo(verts[i].x + ox, verts[i].y + oy)
463 ctx.closePath()
464 }
465
466 // Drop shadow
467 ctx.save()
468 ctx.filter = `blur(${blurR}px)`
469 ctx.globalAlpha = 0.35
470 ctx.fillStyle = '#000'
471 rockPath(radius * 0.07, radius * 0.1)
472 ctx.fill()
473 ctx.restore()
474
475 // Base metallic-earthy radial gradient
476 const grad = ctx.createRadialGradient(
477 cx - radius * 0.2, cy - radius * 0.25, radius * 0.05,
478 cx, cy, radius * 0.95
479 )
480 grad.addColorStop(0, c.hi)
481 grad.addColorStop(0.5, c.fill)
482 grad.addColorStop(1, c.dark)
483 rockPath()
484 ctx.fillStyle = grad
485 ctx.fill()
486
487 // Rim stroke
488 ctx.lineWidth = Math.max(1.5, radius * 0.05)
489 ctx.strokeStyle = c.stroke
490 ctx.stroke()
491
492 // Specular highlight clipped to rock shape
493 ctx.save()
494 rockPath()
495 ctx.clip()
496 ctx.beginPath()
497 ctx.ellipse(cx - radius * 0.2, cy - radius * 0.22, radius * 0.3, radius * 0.18, -0.5, 0, Math.PI * 2)
498 ctx.fillStyle = 'rgba(255,255,255,0.14)'
499 ctx.fill()
500 ctx.restore()
501
502 // Cracks — template-based, vertex-to-vertex through shared fracture point
503 const HITS_TO_SPLIT = 10
504 const progress = hitCount / HITS_TO_SPLIT
505 if (progress >= 0.1) {
506 const template = ROCK_TEMPLATES[templateIdx % ROCK_TEMPLATES.length]
507 const numCracks = Math.min(Math.floor(progress * template.cracks.length) + 1, template.cracks.length)
508
509 const cos = Math.cos(body.angle), sin = Math.sin(body.angle)
510 const tw = (lx: number, ly: number) => ({
511 x: cx + (lx * radius) * cos - (ly * radius) * sin,
512 y: cy + (lx * radius) * sin + (ly * radius) * cos,
513 })
514 const fw = tw(template.fracture.x, template.fracture.y)
515
516 ctx.save()
517 rockPath()
518 ctx.clip()
519 ctx.lineCap = 'round'
520
521 for (let i = 0; i < numCracks; i++) {
522 const [v0l, v1l] = template.cracks[i]
523 const v0 = tw(v0l.x, v0l.y)
524 const v1 = tw(v1l.x, v1l.y)
525
526 const lw = Math.max(0.8, radius * 0.035 * (0.4 + progress))
527 ctx.lineWidth = lw
528 ctx.strokeStyle = `rgba(0,0,0,${0.5 + progress * 0.35})`
529 ctx.beginPath()
530 ctx.moveTo(v0.x, v0.y)
531 ctx.lineTo(fw.x, fw.y)
532 ctx.lineTo(v1.x, v1.y)
533 ctx.stroke()
534 ctx.lineWidth = Math.max(0.4, lw * 0.35)
535 ctx.strokeStyle = `rgba(255,255,255,${0.1 + progress * 0.1})`
536 ctx.stroke()
537 }
538 ctx.restore()
539 }
540}
541
542function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'metalnut', gemVariant: number): HTMLCanvasElement {
543 const r = roundToNearest4(radius)
544 const key = kind === 'gem'
545 ? `gem_${r}_${gemVariant % GEM_TYPES.length}`
546 : kind === 'metalnut'
547 ? `metalnut_${r}_${colorIndex % METAL_PALETTE.length}`
548 : `nut_${r}_${colorIndex % PALETTE.length}`
549 let sprite = spriteCache.get(key)
550 if (!sprite) {
551 sprite = kind === 'gem' ? buildGemSprite(r, gemVariant)
552 : kind === 'metalnut' ? buildMetalSprite(r, colorIndex)
553 : buildSprite(r, colorIndex)
554 spriteCache.set(key, sprite)
555 }
556 return sprite
557}
558
559function getBalloonBodySprite(radius: number, colorIndex: number): HTMLCanvasElement {
560 const r = roundToNearest4(radius)
561 const key = `balloon_${r}_${colorIndex % BALLOON_COLORS.length}`
562 let sprite = spriteCache.get(key)
563 if (!sprite) {
564 sprite = buildBalloonBodySprite(r, colorIndex)
565 spriteCache.set(key, sprite)
566 }
567 return sprite
568}
569
570function getStressBallSprite(radius: number, colorIndex: number): HTMLCanvasElement {
571 const r = roundToNearest4(radius)
572 const key = `stressball_${r}_${colorIndex % STRESSBALL_COLORS.length}`
573 let sprite = spriteCache.get(key)
574 if (!sprite) {
575 sprite = buildStressBallBodySprite(r, colorIndex)
576 spriteCache.set(key, sprite)
577 }
578 return sprite
579}
580
581// ── Coin palette & content ────────────────────────────────────────────────
582interface CoinMaterial { hi: string; mid: string; shadow: string; rim: string; edge: string; text: string }
583const COIN_PALETTE: CoinMaterial[] = [
584 { hi: '#FFF4A8', mid: '#D49820', shadow: '#7A5000', rim: '#B87C10', edge: '#604000', text: '#4A2E00' }, // gold
585 { hi: '#FFFFFF', mid: '#C0C8D8', shadow: '#606878', rim: '#8898A8', edge: '#405060', text: '#283848' }, // silver
586 { hi: '#FFB080', mid: '#C85428', shadow: '#6A2010', rim: '#9C3C18', edge: '#4A1408', text: '#3A0E04' }, // copper
587 { hi: '#E0B868', mid: '#8C6220', shadow: '#4A3008', rim: '#705018', edge: '#3A2008', text: '#281400' }, // bronze
588 { hi: '#F0F0E8', mid: '#A8A898', shadow: '#585850', rim: '#787870', edge: '#404038', text: '#303028' }, // nickel
589 { hi: '#EEF4FF', mid: '#9AAEC4', shadow: '#486070', rim: '#6888A4', edge: '#385060', text: '#1E3A4E' }, // platinum
590]
591
592// Value shown on heads side — one per material
593const COIN_VALUES = ['$20', '$1', '1¢', '5¢', '10¢', '$50']
594
595// Animals shown on tails side
596const COIN_ANIMALS = ['🦁', '🐘', '🦅', '🦋', '🐬', '🦊', '🐉', '🦄', '🐢', '🦎']
597
598function _drawCoinBase(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, m: CoinMaterial, shadow: boolean): void {
599 if (shadow) {
600 ctx.save()
601 ctx.filter = `blur(${Math.ceil(r * 0.11)}px)`
602 ctx.globalAlpha = 0.42
603 ctx.fillStyle = '#000'
604 ctx.beginPath()
605 ctx.arc(cx + r * 0.09, cy + r * 0.11, r * 0.90, 0, Math.PI * 2)
606 ctx.fill()
607 ctx.restore()
608 }
609
610 // Main body
611 const grad = ctx.createRadialGradient(cx - r * 0.30, cy - r * 0.32, r * 0.03, cx, cy, r)
612 grad.addColorStop(0, m.hi)
613 grad.addColorStop(0.45, m.mid)
614 grad.addColorStop(0.88, m.shadow)
615 grad.addColorStop(1, m.edge)
616 ctx.beginPath()
617 ctx.arc(cx, cy, r, 0, Math.PI * 2)
618 ctx.fillStyle = grad
619 ctx.fill()
620
621 // Outer rim
622 ctx.lineWidth = Math.max(1.5, r * 0.05)
623 ctx.strokeStyle = m.rim
624 ctx.stroke()
625
626 // Milled edge notches
627 const notches = Math.max(16, Math.floor(r * 1.1))
628 ctx.save()
629 ctx.beginPath()
630 ctx.arc(cx, cy, r, 0, Math.PI * 2)
631 ctx.clip()
632 ctx.strokeStyle = 'rgba(0,0,0,0.18)'
633 ctx.lineWidth = Math.max(0.6, r * 0.018)
634 for (let i = 0; i < notches; i++) {
635 const a = (i / notches) * Math.PI * 2
636 const ir = r * 0.92, or = r
637 ctx.beginPath()
638 ctx.moveTo(cx + Math.cos(a) * ir, cy + Math.sin(a) * ir)
639 ctx.lineTo(cx + Math.cos(a) * or, cy + Math.sin(a) * or)
640 ctx.stroke()
641 }
642 ctx.restore()
643
644 // Inner border groove
645 ctx.beginPath()
646 ctx.arc(cx, cy, r * 0.87, 0, Math.PI * 2)
647 ctx.lineWidth = Math.max(0.7, r * 0.022)
648 ctx.strokeStyle = 'rgba(0,0,0,0.22)'
649 ctx.stroke()
650
651 // Specular highlight
652 ctx.save()
653 ctx.beginPath()
654 ctx.arc(cx, cy, r, 0, Math.PI * 2)
655 ctx.clip()
656 const hiGrad = ctx.createRadialGradient(cx - r * 0.30, cy - r * 0.34, 0, cx - r * 0.10, cy - r * 0.12, r * 0.70)
657 hiGrad.addColorStop(0, 'rgba(255,255,255,0.70)')
658 hiGrad.addColorStop(0.35, 'rgba(255,255,255,0.18)')
659 hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
660 ctx.fillStyle = hiGrad
661 ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
662 ctx.restore()
663}
664
665function buildCoinHeadsSprite(radius: number, materialIdx: number): HTMLCanvasElement {
666 const m = COIN_PALETTE[materialIdx % COIN_PALETTE.length]
667 const value = COIN_VALUES[materialIdx % COIN_VALUES.length]
668 const pad = Math.ceil(radius * 0.28)
669 const size = Math.ceil(radius * 2 + pad * 2)
670 const oc = document.createElement('canvas')
671 oc.width = size; oc.height = size
672 const ctx = oc.getContext('2d')!
673 const cx = size / 2, cy = size / 2
674 const r = radius
675
676 _drawCoinBase(ctx, cx, cy, r, m, true)
677
678 // Value text — centred, bold
679 ctx.save()
680 ctx.beginPath()
681 ctx.arc(cx, cy, r * 0.84, 0, Math.PI * 2)
682 ctx.clip()
683 const fontSize = Math.max(8, Math.round(r * 0.52))
684 ctx.font = `bold ${fontSize}px sans-serif`
685 ctx.textAlign = 'center'
686 ctx.textBaseline = 'middle'
687 // Embossed shadow
688 ctx.fillStyle = 'rgba(0,0,0,0.30)'
689 ctx.fillText(value, cx + 1, cy + 1)
690 ctx.fillStyle = m.text
691 ctx.fillText(value, cx, cy)
692 // "LIBERTY" arc above (small)
693 if (r >= 20) {
694 const arcFontSize = Math.max(5, Math.round(r * 0.14))
695 ctx.font = `${arcFontSize}px sans-serif`
696 ctx.fillStyle = m.text
697 ctx.globalAlpha = 0.75
698 const label = 'LIBERTY'
699 const arcR = r * 0.70
700 const totalArc = Math.PI * 0.6
701 const startAngle = -Math.PI / 2 - totalArc / 2
702 for (let i = 0; i < label.length; i++) {
703 const a = startAngle + (i / (label.length - 1)) * totalArc
704 ctx.save()
705 ctx.translate(cx + Math.cos(a) * arcR, cy + Math.sin(a) * arcR)
706 ctx.rotate(a + Math.PI / 2)
707 ctx.fillText(label[i], 0, 0)
708 ctx.restore()
709 }
710 }
711 ctx.restore()
712
713 return oc
714}
715
716
717function getCoinHeadsSprite(radius: number, materialIdx: number): HTMLCanvasElement {
718 const r = roundToNearest4(radius)
719 const key = `coin_${r}_${materialIdx % COIN_PALETTE.length}`
720 let sprite = spriteCache.get(key)
721 if (!sprite) {
722 sprite = buildCoinHeadsSprite(r, materialIdx)
723 spriteCache.set(key, sprite)
724 }
725 return sprite
726}
727
728// ── Stress ball palette ───────────────────────────────────────────────────
729const STRESSBALL_COLORS = [
730 { body: '#d94040', shadow: '#8a1515', hi: '#f08080' }, // red
731 { body: '#d97030', shadow: '#8a3800', hi: '#f0a878' }, // orange
732 { body: '#c8be18', shadow: '#807600', hi: '#e8e070' }, // yellow
733 { body: '#3aaa3a', shadow: '#1a6a1a', hi: '#78d878' }, // green
734 { body: '#3878cc', shadow: '#183a88', hi: '#80aae8' }, // blue
735 { body: '#8850c0', shadow: '#441878', hi: '#c094e8' }, // purple
736]
737
738function buildStressBallBodySprite(radius: number, colorIndex: number): HTMLCanvasElement {
739 const r = radius
740 const pad = Math.ceil(r * 0.26)
741 const size = Math.ceil(r * 2 + pad * 2)
742 const oc = document.createElement('canvas')
743 oc.width = size
744 oc.height = size
745 drawStressBall(oc.getContext('2d')!, size / 2, size / 2, r, colorIndex, 0)
746 return oc
747}
748
749function drawStressBall(
750 ctx: CanvasRenderingContext2D,
751 x: number, y: number,
752 radius: number,
753 colorIndex: number,
754 squish: number,
755): void {
756 const c = STRESSBALL_COLORS[colorIndex % STRESSBALL_COLORS.length]
757 const r = radius
758
759 // Shadow
760 ctx.save()
761 ctx.filter = `blur(${Math.ceil(r * 0.12)}px)`
762 ctx.globalAlpha = 0.28
763 ctx.fillStyle = '#000'
764 ctx.beginPath()
765 ctx.arc(x + r * 0.1, y + r * 0.12, r * 0.85, 0, Math.PI * 2)
766 ctx.fill()
767 ctx.restore()
768
769 // Body (slightly flattened at bottom — rubber resting shape)
770 const grad = ctx.createRadialGradient(x - r * 0.28, y - r * 0.28, r * 0.05, x, y, r)
771 grad.addColorStop(0, c.hi)
772 grad.addColorStop(0.5, c.body)
773 grad.addColorStop(1, c.shadow)
774 ctx.beginPath()
775 ctx.arc(x, y, r, 0, Math.PI * 2)
776 ctx.fillStyle = grad
777 ctx.fill()
778
779 // Subtle matte rim (no stroke, just darkened outer band)
780 const rimGrad = ctx.createRadialGradient(x, y, r * 0.7, x, y, r)
781 rimGrad.addColorStop(0, 'rgba(0,0,0,0)')
782 rimGrad.addColorStop(1, 'rgba(0,0,0,0.28)')
783 ctx.beginPath()
784 ctx.arc(x, y, r, 0, Math.PI * 2)
785 ctx.fillStyle = rimGrad
786 ctx.fill()
787
788 // Dimples — small indents scattered over surface
789 const dimpleCount = Math.max(3, Math.floor(r / 12))
790 const angleStep = (Math.PI * 2) / dimpleCount
791 ctx.save()
792 ctx.globalAlpha = 0.22
793 ctx.fillStyle = c.shadow
794 for (let i = 0; i < dimpleCount; i++) {
795 const a = i * angleStep
796 const dr = r * 0.55
797 const dx = x + Math.cos(a) * dr
798 const dy = y + Math.sin(a) * dr
799 const dimR = Math.max(2, r * 0.065)
800 ctx.beginPath()
801 ctx.arc(dx, dy, dimR, 0, Math.PI * 2)
802 ctx.fill()
803 }
804 ctx.restore()
805
806 // Soft specular highlight
807 ctx.save()
808 ctx.beginPath()
809 ctx.arc(x, y, r, 0, Math.PI * 2)
810 ctx.clip()
811 const hiGrad = ctx.createRadialGradient(x - r * 0.28, y - r * 0.32, 0, x - r * 0.1, y - r * 0.1, r * 0.7)
812 hiGrad.addColorStop(0, 'rgba(255,255,255,0.45)')
813 hiGrad.addColorStop(0.5, 'rgba(255,255,255,0.08)')
814 hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
815 ctx.fillStyle = hiGrad
816 ctx.fillRect(x - r, y - r, r * 2, r * 2)
817 ctx.restore()
818
819 // Squish stress lines — faint concentric rings when compressed
820 if (squish > 0.05) {
821 ctx.save()
822 ctx.beginPath()
823 ctx.arc(x, y, r, 0, Math.PI * 2)
824 ctx.clip()
825 ctx.globalAlpha = squish * 0.35
826 ctx.strokeStyle = c.shadow
827 ctx.lineWidth = Math.max(1, r * 0.04)
828 ctx.beginPath()
829 ctx.arc(x, y, r * 0.6, 0, Math.PI * 2)
830 ctx.stroke()
831 ctx.globalAlpha = squish * 0.20
832 ctx.beginPath()
833 ctx.arc(x, y, r * 0.35, 0, Math.PI * 2)
834 ctx.stroke()
835 ctx.restore()
836 }
837}
838
839// ── Balloon palette ─────────────────────────────────────────────────────
840interface BalloonColor { body: string; hi: string; shadow: string; rim: string; string: string }
841const BALLOON_COLORS: BalloonColor[] = [
842 { body: '#FF4466', hi: '#FF99AA', shadow: '#AA0022', rim: '#FF2244', string: '#CC1133' }, // red
843 { body: '#FF8800', hi: '#FFCC66', shadow: '#AA4400', rim: '#FF6600', string: '#CC5500' }, // orange
844 { body: '#FFE000', hi: '#FFF599', shadow: '#AA8800', rim: '#FFCC00', string: '#BB9900' }, // yellow
845 { body: '#33CC55', hi: '#88EE99', shadow: '#116622', rim: '#22AA44', string: '#228833' }, // green
846 { body: '#4488FF', hi: '#99BBFF', shadow: '#1133CC', rim: '#2255EE', string: '#1144CC' }, // blue
847 { body: '#EE44CC', hi: '#FF99EE', shadow: '#991188', rim: '#DD22BB', string: '#BB0099' }, // pink
848 { body: '#22DDEE', hi: '#88EEFF', shadow: '#117788', rim: '#11CCDD', string: '#0099AA' }, // cyan
849]
850
851function buildBalloonBodySprite(radius: number, colorIndex: number): HTMLCanvasElement {
852 const c = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length]
853 const r = radius
854 const pad = Math.ceil(r * 0.28)
855 const size = Math.ceil(r * 2 + pad * 2)
856 const oc = document.createElement('canvas')
857 oc.width = size
858 oc.height = size
859 const bctx = oc.getContext('2d')!
860 const cx = size / 2, cy = size / 2
861 // Shadow (blur done once, cached)
862 bctx.save()
863 bctx.filter = `blur(${Math.ceil(r * 0.14)}px)`
864 bctx.globalAlpha = 0.32
865 bctx.fillStyle = '#000'
866 bctx.beginPath()
867 bctx.arc(cx + r * 0.12, cy + r * 0.15, r * 0.88, 0, Math.PI * 2)
868 bctx.fill()
869 bctx.restore()
870 // Body
871 const grad = bctx.createRadialGradient(cx - r * 0.3, cy - r * 0.35, r * 0.04, cx, cy, r)
872 grad.addColorStop(0, c.hi)
873 grad.addColorStop(0.55, c.body)
874 grad.addColorStop(1, c.shadow)
875 bctx.beginPath()
876 bctx.arc(cx, cy, r, 0, Math.PI * 2)
877 bctx.fillStyle = grad
878 bctx.fill()
879 bctx.strokeStyle = c.rim
880 bctx.lineWidth = Math.max(1.5, r * 0.03)
881 bctx.stroke()
882 // Specular highlight
883 bctx.save()
884 bctx.beginPath()
885 bctx.arc(cx, cy, r, 0, Math.PI * 2)
886 bctx.clip()
887 const hiGrad = bctx.createRadialGradient(cx - r * 0.3, cy - r * 0.38, 0, cx - r * 0.1, cy - r * 0.1, r * 0.65)
888 hiGrad.addColorStop(0, 'rgba(255,255,255,0.78)')
889 hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.18)')
890 hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
891 bctx.fillStyle = hiGrad
892 bctx.fillRect(0, 0, size, size)
893 bctx.restore()
894 return oc
895}
896
897function drawBalloon(
898 ctx: CanvasRenderingContext2D,
899 x: number, y: number,
900 radius: number,
901 colorIndex: number,
902): void {
903 const c = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length]
904 const r = radius
905
906 // Shadow
907 ctx.save()
908 ctx.filter = `blur(${Math.ceil(r * 0.14)}px)`
909 ctx.globalAlpha = 0.32
910 ctx.fillStyle = '#000'
911 ctx.beginPath()
912 ctx.arc(x + r * 0.12, y + r * 0.15, r * 0.88, 0, Math.PI * 2)
913 ctx.fill()
914 ctx.restore()
915
916 // Body
917 const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.04, x, y, r)
918 grad.addColorStop(0, c.hi)
919 grad.addColorStop(0.55, c.body)
920 grad.addColorStop(1, c.shadow)
921 ctx.beginPath()
922 ctx.arc(x, y, r, 0, Math.PI * 2)
923 ctx.fillStyle = grad
924 ctx.fill()
925 ctx.strokeStyle = c.rim
926 ctx.lineWidth = Math.max(1.5, r * 0.03)
927 ctx.stroke()
928
929 // Specular highlight
930 ctx.save()
931 ctx.beginPath()
932 ctx.arc(x, y, r, 0, Math.PI * 2)
933 ctx.clip()
934 const hiGrad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.38, 0, x - r * 0.1, y - r * 0.1, r * 0.65)
935 hiGrad.addColorStop(0, 'rgba(255,255,255,0.78)')
936 hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.18)')
937 hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
938 ctx.fillStyle = hiGrad
939 ctx.fillRect(x - r, y - r, r * 2, r * 2)
940 ctx.restore()
941
942 // Knot
943 const knotH = Math.max(4, r * 0.13)
944 ctx.beginPath()
945 ctx.moveTo(x, y + r + knotH * 1.6)
946 ctx.lineTo(x - knotH * 0.55, y + r)
947 ctx.lineTo(x + knotH * 0.55, y + r)
948 ctx.closePath()
949 ctx.fillStyle = c.rim
950 ctx.fill()
951
952 // String
953 const stringLen = r * 0.9
954 ctx.beginPath()
955 ctx.moveTo(x, y + r + knotH * 1.6)
956 ctx.quadraticCurveTo(
957 x + r * 0.18, y + r + stringLen * 0.5,
958 x + r * 0.06, y + r + stringLen + knotH * 1.6,
959 )
960 ctx.strokeStyle = c.string
961 ctx.lineWidth = Math.max(1, r * 0.025)
962 ctx.save()
963 ctx.globalAlpha *= 0.75
964 ctx.stroke()
965 ctx.restore()
966}
967
968// ── Renderer ───────────────────────────────────────────────────────────────
969
970export interface Renderer {
971 render: (nuts: NutBody[], gravityAngle: number, showArrow?: boolean) => void
972 setShareURL: (url: string) => void
973 resize: (width: number, height: number) => void
974}
975
976export function createRenderer(canvas: HTMLCanvasElement): Renderer {
977 const ctx = canvas.getContext('2d')!
978 let qrBitmap: HTMLCanvasElement | null = null
979 let qrPending = false
980 let lastURL = ''
981
982 async function regenerateQR(url: string): Promise<void> {
983 const size = Math.min(canvas.width, canvas.height)
984 const qrCanvas = document.createElement('canvas')
985 await QRCode.toCanvas(qrCanvas, url, {
986 width: size,
987 margin: 2,
988 color: { dark: '#ffffff', light: '#00000000' },
989 })
990 qrBitmap = qrCanvas
991 qrPending = false
992 }
993
994 function setShareURL(url: string): void {
995 if (url === lastURL) return
996 lastURL = url
997 if (!url) {
998 qrBitmap = null
999 qrPending = false
1000 return
1001 }
1002 if (!qrPending) {
1003 qrPending = true
1004 regenerateQR(url)
1005 }
1006 }
1007
1008 function render(nuts: NutBody[], gravityAngle: number, showArrow = false): void {
1009 const w = canvas.width
1010 const h = canvas.height
1011
1012 // Background
1013 ctx.fillStyle = '#111111'
1014 ctx.fillRect(0, 0, w, h)
1015
1016 // Nuts, gems & balloons
1017 for (const { body, radius, colorIndex, kind, gemVariant, state: hitCount, templateIdx } of nuts) {
1018 const { position, angle } = body
1019
1020 if (kind === 'balloon') {
1021 ctx.save()
1022 ctx.translate(position.x, position.y)
1023 ctx.rotate(gravityAngle)
1024 // Squish deformation: compress along velocity, expand perpendicular
1025 const squish = Math.min(hitCount, 0.85)
1026 if (squish > 0.02) {
1027 const vx = body.velocity.x, vy = body.velocity.y
1028 const spd = Math.hypot(vx, vy)
1029 if (spd > 0.5) {
1030 const velAngle = Math.atan2(vy, vx) - gravityAngle
1031 ctx.rotate(velAngle)
1032 ctx.scale(1 - squish * 0.38, 1 + squish * 0.30)
1033 ctx.rotate(-velAngle)
1034 }
1035 }
1036 // Cached body sphere (blur shadow pre-rendered once)
1037 const balloonSprite = getBalloonBodySprite(radius, colorIndex)
1038 ctx.drawImage(balloonSprite, -balloonSprite.width / 2, -balloonSprite.height / 2)
1039 // Inline knot + string (fast, no filter)
1040 const bknotH = Math.max(4, radius * 0.13)
1041 const bstringLen = radius * 0.9
1042 const bc = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length]
1043 ctx.beginPath()
1044 ctx.moveTo(0, radius + bknotH * 1.6)
1045 ctx.lineTo(-bknotH * 0.55, radius)
1046 ctx.lineTo(bknotH * 0.55, radius)
1047 ctx.closePath()
1048 ctx.fillStyle = bc.rim
1049 ctx.fill()
1050 ctx.beginPath()
1051 ctx.moveTo(0, radius + bknotH * 1.6)
1052 ctx.quadraticCurveTo(radius * 0.18, radius + bstringLen * 0.5, radius * 0.06, radius + bstringLen + bknotH * 1.6)
1053 ctx.strokeStyle = bc.string
1054 ctx.lineWidth = Math.max(1, radius * 0.025)
1055 ctx.save()
1056 ctx.globalAlpha *= 0.75
1057 ctx.stroke()
1058 ctx.restore()
1059 ctx.restore()
1060 } else if (kind === 'stressball') {
1061 ctx.save()
1062 ctx.translate(position.x, position.y)
1063 const squish = Math.min(hitCount, 1.0)
1064 if (squish > 0.02) {
1065 const vx = body.velocity.x, vy = body.velocity.y
1066 const spd = Math.hypot(vx, vy)
1067 if (spd > 0.5) {
1068 const velAngle = Math.atan2(vy, vx)
1069 ctx.rotate(velAngle)
1070 ctx.scale(1 - squish * 0.32, 1 + squish * 0.26)
1071 ctx.rotate(-velAngle)
1072 }
1073 }
1074 // Cached body sprite (blur shadow pre-rendered once)
1075 const sbSprite = getStressBallSprite(radius, colorIndex)
1076 ctx.drawImage(sbSprite, -sbSprite.width / 2, -sbSprite.height / 2)
1077 // Inline squish stress rings (fast, no filter)
1078 if (squish > 0.05) {
1079 const sc = STRESSBALL_COLORS[colorIndex % STRESSBALL_COLORS.length]
1080 ctx.save()
1081 ctx.beginPath()
1082 ctx.arc(0, 0, radius, 0, Math.PI * 2)
1083 ctx.clip()
1084 ctx.globalAlpha = squish * 0.35
1085 ctx.strokeStyle = sc.shadow
1086 ctx.lineWidth = Math.max(1, radius * 0.04)
1087 ctx.beginPath()
1088 ctx.arc(0, 0, radius * 0.6, 0, Math.PI * 2)
1089 ctx.stroke()
1090 ctx.globalAlpha = squish * 0.20
1091 ctx.beginPath()
1092 ctx.arc(0, 0, radius * 0.35, 0, Math.PI * 2)
1093 ctx.stroke()
1094 ctx.restore()
1095 }
1096 ctx.restore()
1097 } else if (kind === 'rock') {
1098 drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx)
1099 } else if (kind === 'coin') {
1100 const sprite = getCoinHeadsSprite(radius, gemVariant)
1101 ctx.save()
1102 ctx.translate(position.x, position.y)
1103 ctx.rotate(angle)
1104 ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2)
1105 ctx.restore()
1106 } else {
1107 const sprite = getSprite(radius, colorIndex, kind as 'nut' | 'gem' | 'metalnut', gemVariant)
1108 ctx.save()
1109 ctx.translate(position.x, position.y)
1110 ctx.rotate(angle)
1111 ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height)
1112 ctx.restore()
1113 }
1114 }
1115
1116 // Gravity direction arrow (shown in auto-gravity modes)
1117 if (showArrow) {
1118 const arrowAlpha = 0.55
1119 const margin = 28
1120 const len = 22
1121 const headLen = 9
1122 const headAngle = 0.42
1123 // Arrow is placed bottom-centre, pointing "down" (current gravity direction)
1124 const ax = w / 2
1125 const ay = h - margin
1126 const dx = Math.sin(gravityAngle) // gravityAngle is atan2(gy,gx) - π/2
1127 const dy = -Math.cos(gravityAngle) // so sin/cos maps to screen x/y gravity
1128
1129 ctx.save()
1130 ctx.globalAlpha = arrowAlpha
1131 ctx.strokeStyle = '#ffffff'
1132 ctx.fillStyle = '#ffffff'
1133 ctx.lineWidth = 2.5
1134 ctx.lineCap = 'round'
1135 ctx.lineJoin = 'round'
1136
1137 const tx = ax + dx * len
1138 const ty = ay + dy * len
1139 const bx = ax - dx * (len * 0.3)
1140 const by = ay - dy * (len * 0.3)
1141
1142 // Shaft
1143 ctx.beginPath()
1144 ctx.moveTo(bx, by)
1145 ctx.lineTo(tx, ty)
1146 ctx.stroke()
1147
1148 // Arrowhead
1149 const angle = Math.atan2(dy, dx)
1150 ctx.beginPath()
1151 ctx.moveTo(tx, ty)
1152 ctx.lineTo(tx - headLen * Math.cos(angle - headAngle), ty - headLen * Math.sin(angle - headAngle))
1153 ctx.lineTo(tx - headLen * Math.cos(angle + headAngle), ty - headLen * Math.sin(angle + headAngle))
1154 ctx.closePath()
1155 ctx.fill()
1156
1157 ctx.restore()
1158 }
1159 }
1160
1161 function resize(width: number, height: number): void {
1162 canvas.width = width
1163 canvas.height = height
1164 }
1165
1166 return { render, setShareURL, resize }
1167}
1168
1169// ── Gallery renderer ───────────────────────────────────────────────────────
1170// Draws a static grid of every item/variant onto a <canvas> with labels.
1171// Called by gallery.ts when ?gallery is in the URL.
1172
1173import {
1174 GEM_VARIANT_COUNT, BALLOON_COLOR_COUNT, STRESSBALL_COLOR_COUNT,
1175 COIN_MATERIAL_COUNT,
1176} from './physics'
1177
1178const GALLERY_RADIUS = 52
1179const GALLERY_CELL = GALLERY_RADIUS * 2 + 32 // padding around each cell
1180const GALLERY_LABEL_H = 18
1181const GALLERY_ROW_H = GALLERY_CELL + GALLERY_LABEL_H + 8
1182const GALLERY_BG = '#1a1a1a'
1183const GALLERY_FG = '#e0e0e0'
1184
1185interface GalleryRow {
1186 kind: string
1187 label: string
1188 count: number
1189 /** Draw variant i at centre (cx,cy) on ctx. */
1190 draw: (ctx: CanvasRenderingContext2D, cx: number, cy: number, variant: number) => void
1191}
1192
1193function galleryRows(): GalleryRow[] {
1194 const r = GALLERY_RADIUS
1195 return [
1196 {
1197 kind: 'nut', label: 'Nut', count: PALETTE.length,
1198 draw: (ctx, cx, cy, i) => {
1199 const sp = getSprite(r, i, 'nut', 0)
1200 ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
1201 },
1202 },
1203 {
1204 kind: 'gem', label: 'Gem', count: GEM_VARIANT_COUNT,
1205 draw: (ctx, cx, cy, i) => {
1206 const sp = getSprite(r, 0, 'gem', i)
1207 ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
1208 },
1209 },
1210 {
1211 kind: 'metalnut', label: 'Metal Nut', count: METAL_PALETTE.length,
1212 draw: (ctx, cx, cy, i) => {
1213 const sp = getSprite(r, i, 'metalnut', 0)
1214 ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
1215 },
1216 },
1217 {
1218 kind: 'balloon', label: 'Balloon', count: BALLOON_COLOR_COUNT,
1219 draw: (ctx, cx, cy, i) => {
1220 const sp = getBalloonBodySprite(r, i)
1221 ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
1222 // knot + string
1223 const bc = BALLOON_COLORS[i % BALLOON_COLORS.length]
1224 const bknotH = Math.max(4, r * 0.13)
1225 const bstringLen = r * 0.9
1226 ctx.save()
1227 ctx.translate(cx, cy)
1228 ctx.beginPath()
1229 ctx.moveTo(0, r + bknotH * 1.6)
1230 ctx.lineTo(-bknotH * 0.55, r)
1231 ctx.lineTo(bknotH * 0.55, r)
1232 ctx.closePath()
1233 ctx.fillStyle = bc.rim; ctx.fill()
1234 ctx.beginPath()
1235 ctx.moveTo(0, r + bknotH * 1.6)
1236 ctx.quadraticCurveTo(r * 0.18, r + bstringLen * 0.5, r * 0.06, r + bstringLen + bknotH * 1.6)
1237 ctx.strokeStyle = bc.string; ctx.lineWidth = Math.max(1, r * 0.025)
1238 ctx.globalAlpha *= 0.75; ctx.stroke()
1239 ctx.restore()
1240 },
1241 },
1242 {
1243 kind: 'stressball', label: 'Stress Ball', count: STRESSBALL_COLOR_COUNT,
1244 draw: (ctx, cx, cy, i) => {
1245 const sp = getStressBallSprite(r, i)
1246 ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
1247 },
1248 },
1249 {
1250 kind: 'rock', label: 'Rock', count: ROCK_TEMPLATES.length,
1251 draw: (ctx, cx, cy, i) => {
1252 const tmpl = ROCK_TEMPLATES[i % ROCK_TEMPLATES.length]
1253 // Build a fake Matter.Body from normVerts scaled to radius
1254 const verts = tmpl.normVerts.map(v => ({ x: cx + v.x * r, y: cy + v.y * r }))
1255 const fakeBody = {
1256 position: { x: cx, y: cy },
1257 angle: 0,
1258 vertices: verts,
1259 speed: 0,
1260 } as unknown as Matter.Body
1261 drawRock(ctx, fakeBody, r, i % 6, 0, i % ROCK_TEMPLATES.length)
1262 },
1263 },
1264 {
1265 kind: 'coin', label: 'Coin', count: COIN_MATERIAL_COUNT,
1266 draw: (ctx, cx, cy, i) => {
1267 const sp = getCoinHeadsSprite(r, i)
1268 ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2)
1269 },
1270 },
1271 ]
1272}
1273
1274export function renderGallery(canvas: HTMLCanvasElement): void {
1275 const rows = galleryRows()
1276 const maxCount = Math.max(...rows.map(r => r.count))
1277 const cols = maxCount
1278
1279 const SECTION_LABEL_H = 28
1280 const PAD_LEFT = 110 // space for row labels on the left
1281
1282 const totalW = PAD_LEFT + cols * GALLERY_CELL + 16
1283 const totalH = rows.length * (GALLERY_ROW_H + SECTION_LABEL_H) + 32
1284
1285 canvas.width = totalW
1286 canvas.height = totalH
1287 const ctx = canvas.getContext('2d')!
1288
1289 ctx.fillStyle = GALLERY_BG
1290 ctx.fillRect(0, 0, totalW, totalH)
1291
1292 let y = 16
1293
1294 for (const row of rows) {
1295 // Section divider
1296 ctx.fillStyle = '#ffffff18'
1297 ctx.fillRect(0, y - 4, totalW, 1)
1298
1299 // Row label (left column)
1300 ctx.fillStyle = GALLERY_FG
1301 ctx.font = 'bold 14px system-ui, sans-serif'
1302 ctx.textAlign = 'left'
1303 ctx.textBaseline = 'middle'
1304 ctx.fillText(row.label, 8, y + GALLERY_ROW_H / 2)
1305
1306 // Variants
1307 for (let i = 0; i < row.count; i++) {
1308 const cx = PAD_LEFT + i * GALLERY_CELL + GALLERY_CELL / 2
1309 const cy = y + GALLERY_ROW_H / 2 - GALLERY_LABEL_H / 2
1310
1311 // Cell background
1312 ctx.fillStyle = '#ffffff08'
1313 ctx.beginPath()
1314 ctx.roundRect(PAD_LEFT + i * GALLERY_CELL + 4, y + 4, GALLERY_CELL - 8, GALLERY_ROW_H - 8, 6)
1315 ctx.fill()
1316
1317 row.draw(ctx, cx, cy, i)
1318
1319 // Variant index label below item
1320 ctx.fillStyle = '#888'
1321 ctx.font = '11px system-ui, sans-serif'
1322 ctx.textAlign = 'center'
1323 ctx.textBaseline = 'alphabetic'
1324 ctx.fillText(String(i), cx, y + GALLERY_ROW_H - 6)
1325 }
1326
1327 y += GALLERY_ROW_H + SECTION_LABEL_H
1328 }
1329}