fuzzyworld

nuts / goo / files / src / renderer.ts

Branch goo · default branch · all branches

renderer.ts

Blame: the commit that last changed each line · file · history

909551ba 2026-04-13 Pavel Safronov1import type { NutBody, GooConstraint } from './physics'
74d506c6 2026-04-12 Pavel Safronov2import Matter from 'matter-js'
f1262fd9 2026-04-12 Pavel Safronov3import { CONFIG } from './config'
b008eb4c 2026-04-12 Pavel Safronov4import QRCode from 'qrcode'
98ba5504 2026-04-13 Pavel Safronov5import { ROCK_TEMPLATES } from './rockTemplates'
44eb5832 2026-04-12 Pavel Safronov6
f1262fd9 2026-04-12 Pavel Safronov7// ── 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
44eb5832 2026-04-12 Pavel Safronov15]
16
a2b3b1f4 2026-04-12 Pavel Safronov17const 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
44eb5832 2026-04-12 Pavel Safronov25// ── Cache pre-rendered sprites ─────────────────────────────────────────────
26const spriteCache = new Map<string, HTMLCanvasElement>()
27
f1262fd9 2026-04-12 Pavel Safronov28function roundToNearest4(n: number): number {
29 return Math.round(n / 4) * 4
44eb5832 2026-04-12 Pavel Safronov30}
31
f1262fd9 2026-04-12 Pavel Safronov32// ── Gem palette ────────────────────────────────────────────────────────────
33interface GemDef {
34 sides: number
35 rotation: number
36 hi: string; mid: string; shadow: string; rim: string
37 facetHi: string; facetLo: string
38}
39
40const GEM_TYPES: GemDef[] = [
41 // 0 — Diamond: icy white-blue, 4-sided ◇
42 {
43 sides: 4, rotation: Math.PI / 4,
44 hi: '#F4FBFF', mid: '#B8D8F0', shadow: '#5880A8', rim: '#C8E4F4',
45 facetHi: 'rgba(255,255,255,0.55)', facetLo: 'rgba(70,120,170,0.35)'
46 },
47 // 1 — Ruby: deep red, hexagon
48 {
49 sides: 6, rotation: 0,
50 hi: '#FF7090', mid: '#C01030', shadow: '#580010', rim: '#FF2040',
51 facetHi: 'rgba(255,130,150,0.55)', facetLo: 'rgba(55,0,10,0.45)'
52 },
53 // 2 — Sapphire: deep blue, hexagon rotated
54 {
55 sides: 6, rotation: Math.PI / 6,
56 hi: '#80A8FF', mid: '#1838C8', shadow: '#060848', rim: '#3058F0',
57 facetHi: 'rgba(130,170,255,0.55)', facetLo: 'rgba(4,8,55,0.45)'
58 },
59 // 3 — Emerald: deep green, octagon
60 {
61 sides: 8, rotation: Math.PI / 8,
62 hi: '#70E090', mid: '#108040', shadow: '#022810', rim: '#28B060',
63 facetHi: 'rgba(100,230,140,0.55)', facetLo: 'rgba(2,24,8,0.45)'
64 },
65 // 4 — Amethyst: purple, hexagon
66 {
67 sides: 6, rotation: Math.PI / 12,
68 hi: '#D080FF', mid: '#7020C0', shadow: '#280040', rim: '#A030E0',
69 facetHi: 'rgba(205,105,255,0.55)', facetLo: 'rgba(28,0,50,0.45)'
70 },
71]
72
73function gemPolygon(cx: number, cy: number, r: number, sides: number, rot: number): Array<[number, number]> {
74 const pts: Array<[number, number]> = []
75 for (let i = 0; i < sides; i++) {
76 const a = rot + (i / sides) * Math.PI * 2
77 pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r])
78 }
79 return pts
80}
81
82function tracePoly(ctx: CanvasRenderingContext2D, pts: Array<[number, number]>): void {
83 ctx.beginPath()
84 ctx.moveTo(pts[0][0], pts[0][1])
85 for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
86 ctx.closePath()
87}
88
89function buildGemSprite(radius: number, gemVariant: number): HTMLCanvasElement {
90 const g = GEM_TYPES[gemVariant % GEM_TYPES.length]
91 const pad = Math.ceil(radius * 0.35)
92 const size = Math.ceil(radius * 2 + pad * 2)
44eb5832 2026-04-12 Pavel Safronov93 const oc = document.createElement('canvas')
94 oc.width = size
95 oc.height = size
96 const ctx = oc.getContext('2d')!
97 const cx = size / 2
98 const cy = size / 2
99
f1262fd9 2026-04-12 Pavel Safronov100 // Shadow
101 ctx.save()
102 ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
103 ctx.globalAlpha = 0.5
104 ctx.fillStyle = '#000'
105 tracePoly(ctx, gemPolygon(cx + pad * 0.15, cy + pad * 0.35, radius * 0.9, g.sides, g.rotation))
106 ctx.fill()
107 ctx.restore()
108
109 const pts = gemPolygon(cx, cy, radius, g.sides, g.rotation)
110
111 // Base radial gradient fill
112 const grad = ctx.createRadialGradient(cx - radius * 0.3, cy - radius * 0.35, radius * 0.05, cx, cy, radius)
113 grad.addColorStop(0, g.hi)
114 grad.addColorStop(0.55, g.mid)
115 grad.addColorStop(1, g.shadow)
116 tracePoly(ctx, pts)
117 ctx.fillStyle = grad
118 ctx.fill()
119
120 // Alternating facet triangles
121 ctx.save()
122 tracePoly(ctx, pts)
123 ctx.clip()
124 for (let i = 0; i < pts.length; i++) {
125 const [ax, ay] = pts[i]
126 const [bx, by] = pts[(i + 1) % pts.length]
127 ctx.beginPath()
128 ctx.moveTo(cx, cy)
129 ctx.lineTo(ax, ay)
130 ctx.lineTo(bx, by)
131 ctx.closePath()
132 ctx.fillStyle = i % 2 === 0 ? g.facetHi : g.facetLo
133 ctx.fill()
44eb5832 2026-04-12 Pavel Safronov134 }
f1262fd9 2026-04-12 Pavel Safronov135 ctx.restore()
44eb5832 2026-04-12 Pavel Safronov136
f1262fd9 2026-04-12 Pavel Safronov137 // Bright rim
138 tracePoly(ctx, pts)
139 ctx.strokeStyle = g.rim
140 ctx.lineWidth = Math.max(1.5, radius * 0.04)
141 ctx.stroke()
44eb5832 2026-04-12 Pavel Safronov142
f1262fd9 2026-04-12 Pavel Safronov143 // Faint facet-edge lines from centre to vertices
144 ctx.strokeStyle = 'rgba(255,255,255,0.22)'
145 ctx.lineWidth = 1
146 for (const [px, py] of pts) {
147 ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(px, py); ctx.stroke()
148 }
44eb5832 2026-04-12 Pavel Safronov149
f1262fd9 2026-04-12 Pavel Safronov150 // Specular highlight — top-left soft glow
151 ctx.save()
152 tracePoly(ctx, pts)
153 ctx.clip()
154 const hiGrad = ctx.createRadialGradient(cx - radius * 0.25, cy - radius * 0.3, 0, cx, cy, radius * 0.7)
155 hiGrad.addColorStop(0, 'rgba(255,255,255,0.55)')
156 hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.14)')
157 hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
158 ctx.fillStyle = hiGrad
159 ctx.fillRect(0, 0, size, size)
160 ctx.restore()
44eb5832 2026-04-12 Pavel Safronov161
f1262fd9 2026-04-12 Pavel Safronov162 return oc
163}
44eb5832 2026-04-12 Pavel Safronov164
f1262fd9 2026-04-12 Pavel Safronov165function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement {
166 const c = PALETTE[colorIndex % PALETTE.length]
167 const rx = radius
168 const ry = Math.round(radius / CONFIG.nutAspectRatio)
169 const pad = Math.ceil(rx * 0.35)
170 const sizeW = Math.ceil(rx * 2 + pad * 2)
171 const sizeH = Math.ceil(ry * 2 + pad * 2)
172 const oc = document.createElement('canvas')
173 oc.width = sizeW
174 oc.height = sizeH
175 const ctx = oc.getContext('2d')!
176 const cx = sizeW / 2
177 const cy = sizeH / 2
44eb5832 2026-04-12 Pavel Safronov178
f1262fd9 2026-04-12 Pavel Safronov179 // Contained drop shadow – blurred offset ellipse drawn before the nut
180 ctx.save()
181 ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
182 ctx.globalAlpha = 0.55
183 ctx.fillStyle = '#000'
44eb5832 2026-04-12 Pavel Safronov184 ctx.beginPath()
f1262fd9 2026-04-12 Pavel Safronov185 ctx.ellipse(cx + pad * 0.15, cy + pad * 0.35, rx * 0.88, ry * 0.88, 0, 0, Math.PI * 2)
186 ctx.fill()
187 ctx.restore()
44eb5832 2026-04-12 Pavel Safronov188
f1262fd9 2026-04-12 Pavel Safronov189 // Ellipse fill – off-centre radial gradient for 3-D sheen
190 const grad = ctx.createRadialGradient(
191 cx - rx * 0.28, cy - ry * 0.32, rx * 0.04,
192 cx, cy, rx
193 )
194 grad.addColorStop(0, c.hi)
195 grad.addColorStop(0.5, c.mid)
196 grad.addColorStop(1, c.shadow)
44eb5832 2026-04-12 Pavel Safronov197 ctx.beginPath()
f1262fd9 2026-04-12 Pavel Safronov198 ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
199 ctx.fillStyle = grad
200 ctx.fill()
201
202 // Rim stroke
203 ctx.lineWidth = Math.max(1.5, rx * 0.035)
204 ctx.strokeStyle = c.rim
44eb5832 2026-04-12 Pavel Safronov205 ctx.stroke()
206
f1262fd9 2026-04-12 Pavel Safronov207 // Specular highlight – small bright arc top-left
208 ctx.beginPath()
209 ctx.ellipse(cx - rx * 0.22, cy - ry * 0.28, rx * 0.28, ry * 0.18, -0.5, 0, Math.PI * 2)
210 ctx.fillStyle = 'rgba(255,255,255,0.12)'
211 ctx.fill()
44eb5832 2026-04-12 Pavel Safronov212
213 return oc
214}
215
a2b3b1f4 2026-04-12 Pavel Safronov216function buildMetalSprite(radius: number, colorIndex: number): HTMLCanvasElement {
217 const c = METAL_PALETTE[colorIndex % METAL_PALETTE.length]
fd4adeec 2026-04-12 Pavel Safronov218 const pad = Math.ceil(radius * 0.35)
219 const size = Math.ceil(radius * 2 + pad * 2)
a2b3b1f4 2026-04-12 Pavel Safronov220 const oc = document.createElement('canvas')
fd4adeec 2026-04-12 Pavel Safronov221 oc.width = size
222 oc.height = size
a2b3b1f4 2026-04-12 Pavel Safronov223 const ctx = oc.getContext('2d')!
fd4adeec 2026-04-12 Pavel Safronov224 const cx = size / 2
225 const cy = size / 2
226 const holeR = radius * 0.38 // bolt hole radius
a2b3b1f4 2026-04-12 Pavel Safronov227
fd4adeec 2026-04-12 Pavel Safronov228 // Helper: hexagon path (pointy-top, 6 sides)
229 function hexPath(r: number, ox = 0, oy = 0) {
230 ctx.beginPath()
231 for (let i = 0; i < 6; i++) {
232 const a = (i / 6) * Math.PI * 2
233 i === 0
234 ? ctx.moveTo(ox + Math.cos(a) * r, oy + Math.sin(a) * r)
235 : ctx.lineTo(ox + Math.cos(a) * r, oy + Math.sin(a) * r)
236 }
237 ctx.closePath()
238 }
239
240 // Drop shadow – hex minus hole via evenodd winding
a2b3b1f4 2026-04-12 Pavel Safronov241 ctx.save()
242 ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
243 ctx.globalAlpha = 0.6
244 ctx.fillStyle = '#000'
245 ctx.beginPath()
fd4adeec 2026-04-12 Pavel Safronov246 const sox = cx + pad * 0.08, soy = cy + pad * 0.28
247 for (let i = 0; i < 6; i++) {
248 const a = (i / 6) * Math.PI * 2
249 i === 0 ? ctx.moveTo(sox + Math.cos(a) * radius * 0.88, soy + Math.sin(a) * radius * 0.88)
250 : ctx.lineTo(sox + Math.cos(a) * radius * 0.88, soy + Math.sin(a) * radius * 0.88)
251 }
252 ctx.closePath()
253 ctx.arc(sox, soy, holeR * 0.85, 0, Math.PI * 2, true)
254 ctx.fill('evenodd')
a2b3b1f4 2026-04-12 Pavel Safronov255 ctx.restore()
256
fd4adeec 2026-04-12 Pavel Safronov257 // Base metallic radial gradient
a2b3b1f4 2026-04-12 Pavel Safronov258 const grad = ctx.createRadialGradient(
fd4adeec 2026-04-12 Pavel Safronov259 cx - radius * 0.3, cy - radius * 0.35, radius * 0.02,
260 cx, cy, radius
a2b3b1f4 2026-04-12 Pavel Safronov261 )
262 grad.addColorStop(0, c.hi)
263 grad.addColorStop(0.25, c.mid)
264 grad.addColorStop(0.6, c.shadow)
265 grad.addColorStop(0.85, c.mid)
266 grad.addColorStop(1, c.rim)
fd4adeec 2026-04-12 Pavel Safronov267 // Hex + hole (evenodd = hole is empty)
a2b3b1f4 2026-04-12 Pavel Safronov268 ctx.beginPath()
fd4adeec 2026-04-12 Pavel Safronov269 for (let i = 0; i < 6; i++) {
270 const a = (i / 6) * Math.PI * 2
271 i === 0 ? ctx.moveTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)
272 : ctx.lineTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)
273 }
274 ctx.closePath()
275 ctx.arc(cx, cy, holeR, 0, Math.PI * 2, true)
a2b3b1f4 2026-04-12 Pavel Safronov276 ctx.fillStyle = grad
fd4adeec 2026-04-12 Pavel Safronov277 ctx.fill('evenodd')
a2b3b1f4 2026-04-12 Pavel Safronov278
fd4adeec 2026-04-12 Pavel Safronov279 // Outer rim stroke
280 hexPath(radius, cx, cy)
281 ctx.lineWidth = Math.max(1.5, radius * 0.045)
a2b3b1f4 2026-04-12 Pavel Safronov282 ctx.strokeStyle = c.rim
283 ctx.stroke()
284
fd4adeec 2026-04-12 Pavel Safronov285 // Broad specular band (clipped to hex, not over hole)
286 ctx.save()
287 ctx.beginPath()
288 for (let i = 0; i < 6; i++) {
289 const a = (i / 6) * Math.PI * 2
290 i === 0 ? ctx.moveTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)
291 : ctx.lineTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius)
292 }
293 ctx.closePath()
294 ctx.arc(cx, cy, holeR, 0, Math.PI * 2, true)
295 ctx.clip('evenodd')
296 const specGrad = ctx.createLinearGradient(cx - radius * 0.5, cy - radius * 0.6, cx + radius * 0.1, cy + radius * 0.1)
a2b3b1f4 2026-04-12 Pavel Safronov297 specGrad.addColorStop(0, 'rgba(255,255,255,0.0)')
298 specGrad.addColorStop(0.3, 'rgba(255,255,255,0.45)')
299 specGrad.addColorStop(0.6, 'rgba(255,255,255,0.22)')
300 specGrad.addColorStop(1, 'rgba(255,255,255,0.0)')
301 ctx.fillStyle = specGrad
fd4adeec 2026-04-12 Pavel Safronov302 ctx.fillRect(0, 0, size, size)
303 ctx.restore()
304
305 // Hole inner bevel — dark outer ring + lighter inner ring
306 ctx.beginPath()
307 ctx.arc(cx, cy, holeR, 0, Math.PI * 2)
308 ctx.lineWidth = Math.max(1.5, radius * 0.06)
309 ctx.strokeStyle = c.rim
310 ctx.stroke()
311 ctx.beginPath()
312 ctx.arc(cx, cy, holeR - ctx.lineWidth * 0.6, 0, Math.PI * 2)
313 ctx.lineWidth = Math.max(1, radius * 0.03)
314 ctx.strokeStyle = 'rgba(255,255,255,0.25)'
315 ctx.stroke()
a2b3b1f4 2026-04-12 Pavel Safronov316
fd4adeec 2026-04-12 Pavel Safronov317 // Sharp bright specular dot (on upper-left face)
a2b3b1f4 2026-04-12 Pavel Safronov318 ctx.beginPath()
fd4adeec 2026-04-12 Pavel Safronov319 ctx.ellipse(cx - radius * 0.28, cy - radius * 0.3, radius * 0.16, radius * 0.10, -0.4, 0, Math.PI * 2)
a2b3b1f4 2026-04-12 Pavel Safronov320 ctx.fillStyle = 'rgba(255,255,255,0.72)'
321 ctx.fill()
322
323 return oc
324}
325
74d506c6 2026-04-12 Pavel Safronov326// ── Rock (drawn directly from world-space body vertices) ───────────────────
327
328const ROCK_PALETTE = [
329 { fill: '#7A5A38', hi: '#A07A52', dark: '#4A3218', stroke: '#2E1C08' },
330 { fill: '#6A5028', hi: '#8E7045', dark: '#3C2A0E', stroke: '#221608' },
331 { fill: '#887060', hi: '#A89080', dark: '#604840', stroke: '#3A2820' },
332 { fill: '#705540', hi: '#987860', dark: '#483020', stroke: '#2C1810' },
333]
334
98ba5504 2026-04-13 Pavel Safronov335function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: number, colorIndex: number, hitCount: number, templateIdx: number): void {
74d506c6 2026-04-12 Pavel Safronov336 const c = ROCK_PALETTE[colorIndex % ROCK_PALETTE.length]
337 const verts = body.vertices
338 const cx = body.position.x
339 const cy = body.position.y
340 const blurR = Math.ceil(radius * 0.15)
341
342 function rockPath(ox = 0, oy = 0) {
343 ctx.beginPath()
344 ctx.moveTo(verts[0].x + ox, verts[0].y + oy)
345 for (let i = 1; i < verts.length; i++) ctx.lineTo(verts[i].x + ox, verts[i].y + oy)
346 ctx.closePath()
347 }
348
349 // Drop shadow
350 ctx.save()
351 ctx.filter = `blur(${blurR}px)`
352 ctx.globalAlpha = 0.35
353 ctx.fillStyle = '#000'
354 rockPath(radius * 0.07, radius * 0.1)
355 ctx.fill()
356 ctx.restore()
357
358 // Base metallic-earthy radial gradient
359 const grad = ctx.createRadialGradient(
360 cx - radius * 0.2, cy - radius * 0.25, radius * 0.05,
361 cx, cy, radius * 0.95
362 )
363 grad.addColorStop(0, c.hi)
364 grad.addColorStop(0.5, c.fill)
365 grad.addColorStop(1, c.dark)
366 rockPath()
367 ctx.fillStyle = grad
368 ctx.fill()
369
370 // Rim stroke
371 ctx.lineWidth = Math.max(1.5, radius * 0.05)
372 ctx.strokeStyle = c.stroke
373 ctx.stroke()
374
375 // Specular highlight clipped to rock shape
376 ctx.save()
377 rockPath()
378 ctx.clip()
379 ctx.beginPath()
380 ctx.ellipse(cx - radius * 0.2, cy - radius * 0.22, radius * 0.3, radius * 0.18, -0.5, 0, Math.PI * 2)
381 ctx.fillStyle = 'rgba(255,255,255,0.14)'
382 ctx.fill()
383 ctx.restore()
384
98ba5504 2026-04-13 Pavel Safronov385 // Cracks — template-based, vertex-to-vertex through shared fracture point
74d506c6 2026-04-12 Pavel Safronov386 const HITS_TO_SPLIT = 10
387 const progress = hitCount / HITS_TO_SPLIT
388 if (progress >= 0.1) {
98ba5504 2026-04-13 Pavel Safronov389 const template = ROCK_TEMPLATES[templateIdx % ROCK_TEMPLATES.length]
390 const numCracks = Math.min(Math.floor(progress * template.cracks.length) + 1, template.cracks.length)
391
392 const cos = Math.cos(body.angle), sin = Math.sin(body.angle)
393 const tw = (lx: number, ly: number) => ({
394 x: cx + (lx * radius) * cos - (ly * radius) * sin,
395 y: cy + (lx * radius) * sin + (ly * radius) * cos,
396 })
397 const fw = tw(template.fracture.x, template.fracture.y)
398
74d506c6 2026-04-12 Pavel Safronov399 ctx.save()
400 rockPath()
401 ctx.clip()
402 ctx.lineCap = 'round'
98ba5504 2026-04-13 Pavel Safronov403
74d506c6 2026-04-12 Pavel Safronov404 for (let i = 0; i < numCracks; i++) {
98ba5504 2026-04-13 Pavel Safronov405 const [v0l, v1l] = template.cracks[i]
406 const v0 = tw(v0l.x, v0l.y)
407 const v1 = tw(v1l.x, v1l.y)
408
409 const lw = Math.max(0.8, radius * 0.035 * (0.4 + progress))
410 ctx.lineWidth = lw
411 ctx.strokeStyle = `rgba(0,0,0,${0.5 + progress * 0.35})`
74d506c6 2026-04-12 Pavel Safronov412 ctx.beginPath()
98ba5504 2026-04-13 Pavel Safronov413 ctx.moveTo(v0.x, v0.y)
414 ctx.lineTo(fw.x, fw.y)
415 ctx.lineTo(v1.x, v1.y)
74d506c6 2026-04-12 Pavel Safronov416 ctx.stroke()
98ba5504 2026-04-13 Pavel Safronov417 ctx.lineWidth = Math.max(0.4, lw * 0.35)
418 ctx.strokeStyle = `rgba(255,255,255,${0.1 + progress * 0.1})`
74d506c6 2026-04-12 Pavel Safronov419 ctx.stroke()
420 }
421 ctx.restore()
422 }
423}
424
a2b3b1f4 2026-04-12 Pavel Safronov425function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'metalnut', gemVariant: number): HTMLCanvasElement {
f1262fd9 2026-04-12 Pavel Safronov426 const r = roundToNearest4(radius)
427 const key = kind === 'gem'
428 ? `gem_${r}_${gemVariant % GEM_TYPES.length}`
a2b3b1f4 2026-04-12 Pavel Safronov429 : kind === 'metalnut'
430 ? `metalnut_${r}_${colorIndex % METAL_PALETTE.length}`
431 : `nut_${r}_${colorIndex % PALETTE.length}`
44eb5832 2026-04-12 Pavel Safronov432 let sprite = spriteCache.get(key)
433 if (!sprite) {
a2b3b1f4 2026-04-12 Pavel Safronov434 sprite = kind === 'gem' ? buildGemSprite(r, gemVariant)
435 : kind === 'metalnut' ? buildMetalSprite(r, colorIndex)
436 : buildSprite(r, colorIndex)
44eb5832 2026-04-12 Pavel Safronov437 spriteCache.set(key, sprite)
438 }
439 return sprite
440}
441
cfe80f49 2026-04-13 Pavel Safronov442// ── Stress ball palette ───────────────────────────────────────────────────
443const STRESSBALL_COLORS = [
444 { body: '#d94040', shadow: '#8a1515', hi: '#f08080' }, // red
445 { body: '#d97030', shadow: '#8a3800', hi: '#f0a878' }, // orange
446 { body: '#c8be18', shadow: '#807600', hi: '#e8e070' }, // yellow
447 { body: '#3aaa3a', shadow: '#1a6a1a', hi: '#78d878' }, // green
448 { body: '#3878cc', shadow: '#183a88', hi: '#80aae8' }, // blue
449 { body: '#8850c0', shadow: '#441878', hi: '#c094e8' }, // purple
450]
451
452function drawStressBall(
453 ctx: CanvasRenderingContext2D,
454 x: number, y: number,
455 radius: number,
456 colorIndex: number,
457 squish: number,
458): void {
459 const c = STRESSBALL_COLORS[colorIndex % STRESSBALL_COLORS.length]
460 const r = radius
461
462 // Shadow
463 ctx.save()
464 ctx.filter = `blur(${Math.ceil(r * 0.12)}px)`
465 ctx.globalAlpha = 0.28
466 ctx.fillStyle = '#000'
467 ctx.beginPath()
468 ctx.arc(x + r * 0.1, y + r * 0.12, r * 0.85, 0, Math.PI * 2)
469 ctx.fill()
470 ctx.restore()
471
472 // Body (slightly flattened at bottom — rubber resting shape)
473 const grad = ctx.createRadialGradient(x - r * 0.28, y - r * 0.28, r * 0.05, x, y, r)
474 grad.addColorStop(0, c.hi)
475 grad.addColorStop(0.5, c.body)
476 grad.addColorStop(1, c.shadow)
477 ctx.beginPath()
478 ctx.arc(x, y, r, 0, Math.PI * 2)
479 ctx.fillStyle = grad
480 ctx.fill()
481
482 // Subtle matte rim (no stroke, just darkened outer band)
483 const rimGrad = ctx.createRadialGradient(x, y, r * 0.7, x, y, r)
484 rimGrad.addColorStop(0, 'rgba(0,0,0,0)')
485 rimGrad.addColorStop(1, 'rgba(0,0,0,0.28)')
486 ctx.beginPath()
487 ctx.arc(x, y, r, 0, Math.PI * 2)
488 ctx.fillStyle = rimGrad
489 ctx.fill()
490
491 // Dimples — small indents scattered over surface
492 const dimpleCount = Math.max(3, Math.floor(r / 12))
493 const angleStep = (Math.PI * 2) / dimpleCount
494 ctx.save()
495 ctx.globalAlpha = 0.22
496 ctx.fillStyle = c.shadow
497 for (let i = 0; i < dimpleCount; i++) {
498 const a = i * angleStep
499 const dr = r * 0.55
500 const dx = x + Math.cos(a) * dr
501 const dy = y + Math.sin(a) * dr
502 const dimR = Math.max(2, r * 0.065)
503 ctx.beginPath()
504 ctx.arc(dx, dy, dimR, 0, Math.PI * 2)
505 ctx.fill()
506 }
507 ctx.restore()
508
509 // Soft specular highlight
510 ctx.save()
511 ctx.beginPath()
512 ctx.arc(x, y, r, 0, Math.PI * 2)
513 ctx.clip()
514 const hiGrad = ctx.createRadialGradient(x - r * 0.28, y - r * 0.32, 0, x - r * 0.1, y - r * 0.1, r * 0.7)
515 hiGrad.addColorStop(0, 'rgba(255,255,255,0.45)')
516 hiGrad.addColorStop(0.5, 'rgba(255,255,255,0.08)')
517 hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
518 ctx.fillStyle = hiGrad
519 ctx.fillRect(x - r, y - r, r * 2, r * 2)
520 ctx.restore()
521
522 // Squish stress lines — faint concentric rings when compressed
523 if (squish > 0.05) {
524 ctx.save()
525 ctx.beginPath()
526 ctx.arc(x, y, r, 0, Math.PI * 2)
527 ctx.clip()
528 ctx.globalAlpha = squish * 0.35
529 ctx.strokeStyle = c.shadow
530 ctx.lineWidth = Math.max(1, r * 0.04)
531 ctx.beginPath()
532 ctx.arc(x, y, r * 0.6, 0, Math.PI * 2)
533 ctx.stroke()
534 ctx.globalAlpha = squish * 0.20
535 ctx.beginPath()
536 ctx.arc(x, y, r * 0.35, 0, Math.PI * 2)
537 ctx.stroke()
538 ctx.restore()
539 }
540}
541
f1262fd9 2026-04-12 Pavel Safronov542// ── Balloon palette ─────────────────────────────────────────────────────
543interface BalloonColor { body: string; hi: string; shadow: string; rim: string; string: string }
544const BALLOON_COLORS: BalloonColor[] = [
545 { body: '#FF4466', hi: '#FF99AA', shadow: '#AA0022', rim: '#FF2244', string: '#CC1133' }, // red
546 { body: '#FF8800', hi: '#FFCC66', shadow: '#AA4400', rim: '#FF6600', string: '#CC5500' }, // orange
547 { body: '#FFE000', hi: '#FFF599', shadow: '#AA8800', rim: '#FFCC00', string: '#BB9900' }, // yellow
548 { body: '#33CC55', hi: '#88EE99', shadow: '#116622', rim: '#22AA44', string: '#228833' }, // green
549 { body: '#4488FF', hi: '#99BBFF', shadow: '#1133CC', rim: '#2255EE', string: '#1144CC' }, // blue
550 { body: '#EE44CC', hi: '#FF99EE', shadow: '#991188', rim: '#DD22BB', string: '#BB0099' }, // pink
551 { body: '#22DDEE', hi: '#88EEFF', shadow: '#117788', rim: '#11CCDD', string: '#0099AA' }, // cyan
552]
553
554function drawBalloon(
555 ctx: CanvasRenderingContext2D,
556 x: number, y: number,
557 radius: number,
558 colorIndex: number,
559): void {
560 const c = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length]
561 const r = radius
562
563 // Shadow
564 ctx.save()
565 ctx.filter = `blur(${Math.ceil(r * 0.14)}px)`
566 ctx.globalAlpha = 0.32
567 ctx.fillStyle = '#000'
568 ctx.beginPath()
569 ctx.arc(x + r * 0.12, y + r * 0.15, r * 0.88, 0, Math.PI * 2)
570 ctx.fill()
571 ctx.restore()
572
573 // Body
574 const grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.04, x, y, r)
575 grad.addColorStop(0, c.hi)
576 grad.addColorStop(0.55, c.body)
577 grad.addColorStop(1, c.shadow)
578 ctx.beginPath()
579 ctx.arc(x, y, r, 0, Math.PI * 2)
580 ctx.fillStyle = grad
581 ctx.fill()
582 ctx.strokeStyle = c.rim
583 ctx.lineWidth = Math.max(1.5, r * 0.03)
584 ctx.stroke()
585
586 // Specular highlight
587 ctx.save()
588 ctx.beginPath()
589 ctx.arc(x, y, r, 0, Math.PI * 2)
590 ctx.clip()
591 const hiGrad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.38, 0, x - r * 0.1, y - r * 0.1, r * 0.65)
592 hiGrad.addColorStop(0, 'rgba(255,255,255,0.78)')
593 hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.18)')
594 hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
595 ctx.fillStyle = hiGrad
596 ctx.fillRect(x - r, y - r, r * 2, r * 2)
597 ctx.restore()
598
599 // Knot
600 const knotH = Math.max(4, r * 0.13)
601 ctx.beginPath()
602 ctx.moveTo(x, y + r + knotH * 1.6)
603 ctx.lineTo(x - knotH * 0.55, y + r)
604 ctx.lineTo(x + knotH * 0.55, y + r)
605 ctx.closePath()
606 ctx.fillStyle = c.rim
607 ctx.fill()
608
609 // String
610 const stringLen = r * 0.9
611 ctx.beginPath()
612 ctx.moveTo(x, y + r + knotH * 1.6)
613 ctx.quadraticCurveTo(
614 x + r * 0.18, y + r + stringLen * 0.5,
615 x + r * 0.06, y + r + stringLen + knotH * 1.6,
616 )
617 ctx.strokeStyle = c.string
618 ctx.lineWidth = Math.max(1, r * 0.025)
b008eb4c 2026-04-12 Pavel Safronov619 ctx.save()
620 ctx.globalAlpha *= 0.75
f1262fd9 2026-04-12 Pavel Safronov621 ctx.stroke()
b008eb4c 2026-04-12 Pavel Safronov622 ctx.restore()
f1262fd9 2026-04-12 Pavel Safronov623}
624
44eb5832 2026-04-12 Pavel Safronov625// ── Renderer ───────────────────────────────────────────────────────────────
626
627export interface Renderer {
909551ba 2026-04-13 Pavel Safronov628 render: (nuts: NutBody[], gravityAngle: number, showArrow?: boolean, gooConstraints?: GooConstraint[]) => void
b008eb4c 2026-04-12 Pavel Safronov629 setShareURL: (url: string) => void
44eb5832 2026-04-12 Pavel Safronov630 resize: (width: number, height: number) => void
631}
632
633export function createRenderer(canvas: HTMLCanvasElement): Renderer {
634 const ctx = canvas.getContext('2d')!
b008eb4c 2026-04-12 Pavel Safronov635 let qrBitmap: HTMLCanvasElement | null = null
636 let qrPending = false
637 let lastURL = ''
638
639 async function regenerateQR(url: string): Promise<void> {
640 const size = Math.min(canvas.width, canvas.height)
641 const qrCanvas = document.createElement('canvas')
642 await QRCode.toCanvas(qrCanvas, url, {
643 width: size,
644 margin: 2,
645 color: { dark: '#ffffff', light: '#00000000' },
646 })
647 qrBitmap = qrCanvas
648 qrPending = false
649 }
650
651 function setShareURL(url: string): void {
652 if (url === lastURL) return
653 lastURL = url
654 if (!url) {
655 qrBitmap = null
656 qrPending = false
657 return
658 }
659 if (!qrPending) {
660 qrPending = true
661 regenerateQR(url)
662 }
663 }
44eb5832 2026-04-12 Pavel Safronov664
909551ba 2026-04-13 Pavel Safronov665 function render(nuts: NutBody[], gravityAngle: number, showArrow = false, gooConstraints: GooConstraint[] = []): void {
44eb5832 2026-04-12 Pavel Safronov666 const w = canvas.width
667 const h = canvas.height
668
669 // Background
670 ctx.fillStyle = '#111111'
671 ctx.fillRect(0, 0, w, h)
672
909551ba 2026-04-13 Pavel Safronov673 // ── Goo bonds (drawn behind bodies) ────────────────────────────────
674 for (const gc of gooConstraints) {
675 const ax = gc.bodyA.position.x, ay = gc.bodyA.position.y
676 const bx = gc.bodyB.position.x, by = gc.bodyB.position.y
677 const alpha = Math.max(0, gc.strength)
678 const lineW = Math.min(gc.nutA.radius, gc.nutB.radius) * 0.38
679 // Goo droop blob — a fat translucent stroke between centers
680 ctx.save()
681 ctx.globalAlpha = alpha * 0.88
682 ctx.strokeStyle = '#7deb28'
683 ctx.lineWidth = lineW
684 ctx.lineCap = 'round'
685 ctx.beginPath()
686 ctx.moveTo(ax, ay)
687 ctx.lineTo(bx, by)
688 ctx.stroke()
689 // Brighter inner line
690 ctx.globalAlpha = alpha * 0.55
691 ctx.strokeStyle = '#c8ff80'
692 ctx.lineWidth = lineW * 0.4
693 ctx.beginPath()
694 ctx.moveTo(ax, ay)
695 ctx.lineTo(bx, by)
696 ctx.stroke()
697 ctx.restore()
698 }
699
f1262fd9 2026-04-12 Pavel Safronov700 // Nuts, gems & balloons
909551ba 2026-04-13 Pavel Safronov701 for (const { body, radius, colorIndex, kind, gemVariant, hitCount, templateIdx, stickyAmount } of nuts) {
f1262fd9 2026-04-12 Pavel Safronov702 const { position, angle } = body
703
704 if (kind === 'balloon') {
705 ctx.save()
706 ctx.translate(position.x, position.y)
707 ctx.rotate(gravityAngle)
cfe80f49 2026-04-13 Pavel Safronov708 // Squish deformation: compress along velocity, expand perpendicular
709 const squish = Math.min(hitCount, 0.85)
710 if (squish > 0.02) {
711 const vx = body.velocity.x, vy = body.velocity.y
712 const spd = Math.hypot(vx, vy)
713 if (spd > 0.5) {
714 const velAngle = Math.atan2(vy, vx) - gravityAngle
715 ctx.rotate(velAngle)
716 ctx.scale(1 - squish * 0.38, 1 + squish * 0.30)
717 ctx.rotate(-velAngle)
718 }
719 }
f1262fd9 2026-04-12 Pavel Safronov720 drawBalloon(ctx, 0, 0, radius, colorIndex)
721 ctx.restore()
cfe80f49 2026-04-13 Pavel Safronov722 } else if (kind === 'stressball') {
723 ctx.save()
724 ctx.translate(position.x, position.y)
725 const squish = Math.min(hitCount, 1.0)
726 if (squish > 0.02) {
727 const vx = body.velocity.x, vy = body.velocity.y
728 const spd = Math.hypot(vx, vy)
729 if (spd > 0.5) {
730 const velAngle = Math.atan2(vy, vx)
731 ctx.rotate(velAngle)
732 ctx.scale(1 - squish * 0.32, 1 + squish * 0.26)
733 ctx.rotate(-velAngle)
734 }
735 }
736 drawStressBall(ctx, 0, 0, radius, colorIndex, squish)
737 ctx.restore()
74d506c6 2026-04-12 Pavel Safronov738 } else if (kind === 'rock') {
98ba5504 2026-04-13 Pavel Safronov739 drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx)
f1262fd9 2026-04-12 Pavel Safronov740 } else {
a2b3b1f4 2026-04-12 Pavel Safronov741 const sprite = getSprite(radius, colorIndex, kind as 'nut' | 'gem' | 'metalnut', gemVariant)
f1262fd9 2026-04-12 Pavel Safronov742 ctx.save()
743 ctx.translate(position.x, position.y)
744 ctx.rotate(angle)
745 ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height)
746 ctx.restore()
747 }
909551ba 2026-04-13 Pavel Safronov748
749 // ── Goo sticky overlay ────────────────────────────────────────
750 if (stickyAmount > 0.01) {
751 const cx = body.position.x, cy = body.position.y
752 ctx.save()
753 const gooGrad = ctx.createRadialGradient(cx, cy, radius * 0.5, cx, cy, radius * 1.15)
754 gooGrad.addColorStop(0, 'rgba(125,235,40,0)')
755 gooGrad.addColorStop(0.65, `rgba(125,235,40,${(stickyAmount * 0.30).toFixed(2)})`)
756 gooGrad.addColorStop(1, `rgba(125,235,40,${(stickyAmount * 0.72).toFixed(2)})`)
757 ctx.fillStyle = gooGrad
758 ctx.beginPath()
759 ctx.arc(cx, cy, radius * 1.15, 0, Math.PI * 2)
760 ctx.fill()
761 ctx.restore()
762 }
f1262fd9 2026-04-12 Pavel Safronov763 }
44eb5832 2026-04-12 Pavel Safronov764
937f9abc 2026-04-12 Pavel Safronov765 // Gravity direction arrow (shown in auto-gravity modes)
766 if (showArrow) {
767 const arrowAlpha = 0.55
768 const margin = 28
769 const len = 22
770 const headLen = 9
771 const headAngle = 0.42
772 // Arrow is placed bottom-centre, pointing "down" (current gravity direction)
773 const ax = w / 2
774 const ay = h - margin
775 const dx = Math.sin(gravityAngle) // gravityAngle is atan2(gy,gx) - π/2
776 const dy = -Math.cos(gravityAngle) // so sin/cos maps to screen x/y gravity
777
778 ctx.save()
779 ctx.globalAlpha = arrowAlpha
780 ctx.strokeStyle = '#ffffff'
781 ctx.fillStyle = '#ffffff'
782 ctx.lineWidth = 2.5
783 ctx.lineCap = 'round'
784 ctx.lineJoin = 'round'
785
786 const tx = ax + dx * len
787 const ty = ay + dy * len
788 const bx = ax - dx * (len * 0.3)
789 const by = ay - dy * (len * 0.3)
790
791 // Shaft
792 ctx.beginPath()
793 ctx.moveTo(bx, by)
794 ctx.lineTo(tx, ty)
795 ctx.stroke()
796
797 // Arrowhead
798 const angle = Math.atan2(dy, dx)
799 ctx.beginPath()
800 ctx.moveTo(tx, ty)
801 ctx.lineTo(tx - headLen * Math.cos(angle - headAngle), ty - headLen * Math.sin(angle - headAngle))
802 ctx.lineTo(tx - headLen * Math.cos(angle + headAngle), ty - headLen * Math.sin(angle + headAngle))
803 ctx.closePath()
804 ctx.fill()
805
806 ctx.restore()
807 }
44eb5832 2026-04-12 Pavel Safronov808 }
809
810 function resize(width: number, height: number): void {
811 canvas.width = width
812 canvas.height = height
813 }
814
98ba5504 2026-04-13 Pavel Safronov815 return { render, setShareURL, resize }
44eb5832 2026-04-12 Pavel Safronov816}