@ludra/cli 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/blocks/chasing-enemy/assets/slime.svg +21 -0
  2. package/blocks/chasing-enemy/block.json +64 -0
  3. package/blocks/chasing-enemy/demo.json +32 -0
  4. package/blocks/chasing-enemy/scripts/chaser.ts +19 -0
  5. package/blocks/coin-row/assets/coin.svg +22 -0
  6. package/blocks/coin-row/block.json +157 -0
  7. package/blocks/coin-row/demo.json +15 -0
  8. package/blocks/falling-platform/assets/falling-platform.svg +13 -0
  9. package/blocks/falling-platform/block.json +55 -0
  10. package/blocks/falling-platform/demo.json +24 -0
  11. package/blocks/falling-platform/scripts/falling-platform.ts +56 -0
  12. package/blocks/jump-pad/assets/jump-pad.svg +6 -0
  13. package/blocks/jump-pad/block.json +56 -0
  14. package/blocks/jump-pad/demo.json +17 -0
  15. package/blocks/jump-pad/scripts/jump-pad.ts +10 -0
  16. package/blocks/key-and-door/assets/door.svg +12 -0
  17. package/blocks/key-and-door/assets/key.svg +9 -0
  18. package/blocks/key-and-door/block.json +90 -0
  19. package/blocks/key-and-door/demo.json +31 -0
  20. package/blocks/key-and-door/scripts/key.ts +12 -0
  21. package/blocks/key-and-door/scripts/locked-door.ts +15 -0
  22. package/blocks/moving-platform/assets/lift.svg +9 -0
  23. package/blocks/moving-platform/block.json +38 -0
  24. package/blocks/moving-platform/demo.json +27 -0
  25. package/blocks/patrol-enemy/assets/slime.svg +21 -0
  26. package/blocks/patrol-enemy/block.json +60 -0
  27. package/blocks/patrol-enemy/demo.json +22 -0
  28. package/blocks/spikes/assets/spikes.svg +8 -0
  29. package/blocks/spikes/block.json +49 -0
  30. package/blocks/spikes/demo.json +16 -0
  31. package/blocks/teleporter/assets/teleporter.svg +14 -0
  32. package/blocks/teleporter/block.json +89 -0
  33. package/blocks/teleporter/demo.json +17 -0
  34. package/blocks/teleporter/scripts/teleporter.ts +17 -0
  35. package/blocks/time-limit/block.json +36 -0
  36. package/blocks/time-limit/demo.json +25 -0
  37. package/blocks/time-limit/scripts/time-limit.ts +16 -0
  38. package/blocks/turret/assets/bullet.svg +5 -0
  39. package/blocks/turret/assets/turret.svg +10 -0
  40. package/blocks/turret/block.json +59 -0
  41. package/blocks/turret/demo.json +35 -0
  42. package/blocks/turret/scripts/bullet.ts +11 -0
  43. package/blocks/turret/scripts/turret.ts +36 -0
  44. package/dist/blocks.d.ts +65 -0
  45. package/dist/blocks.js +95 -0
  46. package/dist/commands/blocks.d.ts +12 -0
  47. package/dist/commands/blocks.js +49 -0
  48. package/dist/commands/check.d.ts +1 -1
  49. package/dist/commands/check.js +8 -3
  50. package/dist/commands/components.js +5 -1
  51. package/dist/commands/dev.js +3 -2
  52. package/dist/commands/new.js +1 -1
  53. package/dist/commands/play.js +25 -9
  54. package/dist/commands/test.d.ts +1 -1
  55. package/dist/commands/test.js +4 -4
  56. package/dist/headless.js +6 -1
  57. package/dist/index.js +23 -3
  58. package/dist/project.d.ts +17 -1
  59. package/dist/project.js +91 -8
  60. package/dist/server.d.ts +1 -0
  61. package/dist/server.js +27 -2
  62. package/package.json +4 -3
  63. package/templates/breakout/CLAUDE.md +42 -0
  64. package/templates/breakout/_gitignore +2 -0
  65. package/templates/breakout/ludra.json +5 -0
  66. package/templates/breakout/package.json +13 -0
  67. package/templates/breakout/scenes/main.scene.json +648 -0
  68. package/templates/breakout/scripts/backdrop.ts +101 -0
  69. package/templates/breakout/scripts/ball.ts +90 -0
  70. package/templates/breakout/scripts/brick.ts +51 -0
  71. package/templates/breakout/scripts/paddle.ts +53 -0
  72. package/templates/breakout/scripts/rules.ts +128 -0
  73. package/templates/breakout/scripts/wall.ts +21 -0
  74. package/templates/breakout/shaders/neon.frag +42 -0
  75. package/templates/breakout/tests/gameplay.test.json +31 -0
  76. package/templates/platformer/CLAUDE.md +4 -1
  77. package/templates/platformer/package.json +1 -1
  78. package/templates/topdown/CLAUDE.md +38 -0
  79. package/templates/topdown/_gitignore +2 -0
  80. package/templates/topdown/assets/gem.svg +16 -0
  81. package/templates/topdown/assets/grass.svg +8 -0
  82. package/templates/topdown/assets/hedge.svg +10 -0
  83. package/templates/topdown/assets/hero.svg +23 -0
  84. package/templates/topdown/assets/portal.svg +15 -0
  85. package/templates/topdown/assets/slime.svg +21 -0
  86. package/templates/topdown/assets/thorns.svg +7 -0
  87. package/templates/topdown/ludra.json +5 -0
  88. package/templates/topdown/package.json +13 -0
  89. package/templates/topdown/scenes/main.scene.json +499 -0
  90. package/templates/topdown/scripts/hud.ts +94 -0
  91. package/templates/topdown/scripts/night.ts +67 -0
  92. package/templates/topdown/scripts/portal.ts +34 -0
  93. package/templates/topdown/shaders/dream.frag +31 -0
  94. package/templates/topdown/tests/gameplay.test.json +30 -0
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="80" height="40" viewBox="0 0 80 40">
2
+ <!-- Spritesheet: 2 frames of 40x40, a swirling exit portal set in stone. -->
3
+ <g>
4
+ <circle cx="20" cy="20" r="18" fill="#56658f"/>
5
+ <circle cx="20" cy="20" r="14" fill="#7b2cbf"/>
6
+ <path d="M20 8 a12 12 0 0 1 0 24 a6 6 0 0 1 0 -12 a3 3 0 0 0 0 -6" fill="#c77dff"/>
7
+ <circle cx="20" cy="20" r="3" fill="#f3d9ff"/>
8
+ </g>
9
+ <g transform="translate(40 0)">
10
+ <circle cx="20" cy="20" r="18" fill="#56658f"/>
11
+ <circle cx="20" cy="20" r="14" fill="#7b2cbf"/>
12
+ <path d="M32 20 a12 12 0 0 1 -24 0 a6 6 0 0 1 12 0 a3 3 0 0 0 6 0" fill="#c77dff"/>
13
+ <circle cx="20" cy="20" r="4" fill="#f3d9ff"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="26" viewBox="0 0 64 26">
2
+ <!-- Spritesheet: 2 frames of 32x26, a bouncing slime facing right. -->
3
+ <g>
4
+ <path d="M2 25 C 2 10, 8 3, 16 3 C 24 3, 30 10, 30 25 Z" fill="#c77dff"/>
5
+ <path d="M5 25 C 5 18, 9 14, 13 14 C 9 17, 8 21, 8 25 Z" fill="#e0b0ff" opacity="0.7"/>
6
+ <path d="M14 11 l 6 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
7
+ <path d="M27 11 l -4 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
8
+ <circle cx="18" cy="15" r="2" fill="#2a1440"/>
9
+ <circle cx="24" cy="15" r="2" fill="#2a1440"/>
10
+ <path d="M17 20 q 4 2 8 0" stroke="#2a1440" stroke-width="1.5" fill="none" stroke-linecap="round"/>
11
+ </g>
12
+ <g transform="translate(32 0)">
13
+ <path d="M0 25 C 0 14, 7 8, 16 8 C 25 8, 32 14, 32 25 Z" fill="#c77dff"/>
14
+ <path d="M3 25 C 3 20, 7 17, 11 17 C 7 19, 6 22, 6 25 Z" fill="#e0b0ff" opacity="0.7"/>
15
+ <path d="M14 15 l 6 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
16
+ <path d="M28 15 l -4 2" stroke="#2a1440" stroke-width="2" stroke-linecap="round"/>
17
+ <circle cx="18" cy="18.5" r="2" fill="#2a1440"/>
18
+ <circle cx="24.5" cy="18.5" r="2" fill="#2a1440"/>
19
+ <path d="M17 22.5 q 4 1.5 8 0" stroke="#2a1440" stroke-width="1.5" fill="none" stroke-linecap="round"/>
20
+ </g>
21
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
2
+ <!-- Tile: a patch of thorns, seen from above. Deadly. -->
3
+ <rect width="20" height="20" fill="#4a2a35"/>
4
+ <path d="M5 5 l-3 -3 M5 5 l3 -3 M5 5 l0 4" stroke="#ff5d73" stroke-width="1.6" stroke-linecap="round"/>
5
+ <path d="M15 12 l-3 -3 M15 12 l3 -3 M15 12 l0 4" stroke="#ff5d73" stroke-width="1.6" stroke-linecap="round"/>
6
+ <path d="M6 16 l-2 -2 M6 16 l2 -2" stroke="#ff8fa0" stroke-width="1.4" stroke-linecap="round"/>
7
+ </svg>
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "entry": "scenes/main.scene.json",
4
+ "window": { "width": 960, "height": 540 }
5
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "ludra dev",
7
+ "check": "ludra check",
8
+ "test": "ludra test"
9
+ },
10
+ "devDependencies": {
11
+ "@ludra/cli": "^0.7.0"
12
+ }
13
+ }
@@ -0,0 +1,499 @@
1
+ {
2
+ "name": "Crystal Grove",
3
+ "world": {
4
+ "width": 960,
5
+ "height": 540,
6
+ "gravity": 0,
7
+ "background": "#1f3a29"
8
+ },
9
+ "fonts": {
10
+ "Cinzel": "google"
11
+ },
12
+ "postfx": {
13
+ "shader": "shaders/dream.frag",
14
+ "uniforms": {
15
+ "bloom": 2.2,
16
+ "warmth": 0.8,
17
+ "vignette": 1.1
18
+ }
19
+ },
20
+ "effects": {
21
+ "collect": {
22
+ "sound": "coin",
23
+ "particles": {
24
+ "count": 14,
25
+ "color": "#7fe0ff",
26
+ "speed": 120,
27
+ "life": 0.5,
28
+ "size": 3,
29
+ "gravity": 0
30
+ }
31
+ },
32
+ "die": {
33
+ "sound": "explosion",
34
+ "shake": 8,
35
+ "particles": {
36
+ "count": 26,
37
+ "color": "#ff5d73",
38
+ "speed": 200,
39
+ "life": 0.6,
40
+ "size": 4,
41
+ "gravity": 0
42
+ }
43
+ },
44
+ "win": {
45
+ "sound": "powerup",
46
+ "shake": 3,
47
+ "particles": {
48
+ "count": 60,
49
+ "color": "#c77dff",
50
+ "speed": 240,
51
+ "life": 1.2,
52
+ "size": 4,
53
+ "gravity": 0
54
+ }
55
+ }
56
+ },
57
+ "entities": [
58
+ {
59
+ "id": "floor",
60
+ "components": {
61
+ "transform": {
62
+ "x": 480,
63
+ "y": 270
64
+ },
65
+ "sprite": {
66
+ "color": "#2f5a3a",
67
+ "image": "assets/grass.svg",
68
+ "tile": true,
69
+ "width": 960,
70
+ "height": 540,
71
+ "layer": -1
72
+ }
73
+ }
74
+ },
75
+ {
76
+ "id": "hero",
77
+ "components": {
78
+ "transform": {
79
+ "x": 80,
80
+ "y": 270
81
+ },
82
+ "body": {
83
+ "type": "dynamic",
84
+ "width": 22,
85
+ "height": 22
86
+ },
87
+ "sprite": {
88
+ "color": "#ff9f68",
89
+ "image": "assets/hero.svg",
90
+ "frameWidth": 24,
91
+ "width": 24,
92
+ "height": 24,
93
+ "idle": "0",
94
+ "run": "0-1",
95
+ "fps": 8,
96
+ "layer": 3
97
+ },
98
+ "topdown": {
99
+ "speed": 180
100
+ }
101
+ }
102
+ },
103
+ {
104
+ "id": "wall-top",
105
+ "components": {
106
+ "transform": {
107
+ "x": 480,
108
+ "y": 10
109
+ },
110
+ "body": {
111
+ "type": "static",
112
+ "width": 960,
113
+ "height": 20
114
+ },
115
+ "sprite": {
116
+ "color": "#2d6a3e",
117
+ "image": "assets/hedge.svg",
118
+ "tile": true
119
+ }
120
+ }
121
+ },
122
+ {
123
+ "id": "wall-bottom",
124
+ "components": {
125
+ "transform": {
126
+ "x": 480,
127
+ "y": 530
128
+ },
129
+ "body": {
130
+ "type": "static",
131
+ "width": 960,
132
+ "height": 20
133
+ },
134
+ "sprite": {
135
+ "color": "#2d6a3e",
136
+ "image": "assets/hedge.svg",
137
+ "tile": true
138
+ }
139
+ }
140
+ },
141
+ {
142
+ "id": "wall-left",
143
+ "components": {
144
+ "transform": {
145
+ "x": 10,
146
+ "y": 270
147
+ },
148
+ "body": {
149
+ "type": "static",
150
+ "width": 20,
151
+ "height": 540
152
+ },
153
+ "sprite": {
154
+ "color": "#2d6a3e",
155
+ "image": "assets/hedge.svg",
156
+ "tile": true
157
+ }
158
+ }
159
+ },
160
+ {
161
+ "id": "wall-right",
162
+ "components": {
163
+ "transform": {
164
+ "x": 950,
165
+ "y": 270
166
+ },
167
+ "body": {
168
+ "type": "static",
169
+ "width": 20,
170
+ "height": 540
171
+ },
172
+ "sprite": {
173
+ "color": "#2d6a3e",
174
+ "image": "assets/hedge.svg",
175
+ "tile": true
176
+ }
177
+ }
178
+ },
179
+ {
180
+ "id": "hedge-west",
181
+ "components": {
182
+ "transform": {
183
+ "x": 300,
184
+ "y": 190
185
+ },
186
+ "body": {
187
+ "type": "static",
188
+ "width": 40,
189
+ "height": 340
190
+ },
191
+ "sprite": {
192
+ "color": "#2d6a3e",
193
+ "image": "assets/hedge.svg",
194
+ "tile": true
195
+ }
196
+ }
197
+ },
198
+ {
199
+ "id": "hedge-east",
200
+ "components": {
201
+ "transform": {
202
+ "x": 640,
203
+ "y": 350
204
+ },
205
+ "body": {
206
+ "type": "static",
207
+ "width": 40,
208
+ "height": 340
209
+ },
210
+ "sprite": {
211
+ "color": "#2d6a3e",
212
+ "image": "assets/hedge.svg",
213
+ "tile": true
214
+ }
215
+ }
216
+ },
217
+ {
218
+ "id": "gem-1",
219
+ "components": {
220
+ "transform": {
221
+ "x": 150,
222
+ "y": 90
223
+ },
224
+ "body": {
225
+ "type": "static",
226
+ "width": 20,
227
+ "height": 20,
228
+ "sensor": true
229
+ },
230
+ "sprite": {
231
+ "color": "#4cc9f0",
232
+ "image": "assets/gem.svg",
233
+ "frameWidth": 20,
234
+ "idle": "0-1",
235
+ "fps": 3,
236
+ "layer": 1
237
+ },
238
+ "collectible": {
239
+ "score": 1
240
+ }
241
+ }
242
+ },
243
+ {
244
+ "id": "gem-2",
245
+ "components": {
246
+ "transform": {
247
+ "x": 180,
248
+ "y": 460
249
+ },
250
+ "body": {
251
+ "type": "static",
252
+ "width": 20,
253
+ "height": 20,
254
+ "sensor": true
255
+ },
256
+ "sprite": {
257
+ "color": "#4cc9f0",
258
+ "image": "assets/gem.svg",
259
+ "frameWidth": 20,
260
+ "idle": "0-1",
261
+ "fps": 3,
262
+ "layer": 1
263
+ },
264
+ "collectible": {
265
+ "score": 1
266
+ }
267
+ }
268
+ },
269
+ {
270
+ "id": "gem-3",
271
+ "components": {
272
+ "transform": {
273
+ "x": 470,
274
+ "y": 100
275
+ },
276
+ "body": {
277
+ "type": "static",
278
+ "width": 20,
279
+ "height": 20,
280
+ "sensor": true
281
+ },
282
+ "sprite": {
283
+ "color": "#4cc9f0",
284
+ "image": "assets/gem.svg",
285
+ "frameWidth": 20,
286
+ "idle": "0-1",
287
+ "fps": 3,
288
+ "layer": 1
289
+ },
290
+ "collectible": {
291
+ "score": 1
292
+ }
293
+ }
294
+ },
295
+ {
296
+ "id": "gem-4",
297
+ "components": {
298
+ "transform": {
299
+ "x": 470,
300
+ "y": 450
301
+ },
302
+ "body": {
303
+ "type": "static",
304
+ "width": 20,
305
+ "height": 20,
306
+ "sensor": true
307
+ },
308
+ "sprite": {
309
+ "color": "#4cc9f0",
310
+ "image": "assets/gem.svg",
311
+ "frameWidth": 20,
312
+ "idle": "0-1",
313
+ "fps": 3,
314
+ "layer": 1
315
+ },
316
+ "collectible": {
317
+ "score": 1
318
+ }
319
+ }
320
+ },
321
+ {
322
+ "id": "gem-5",
323
+ "components": {
324
+ "transform": {
325
+ "x": 820,
326
+ "y": 110
327
+ },
328
+ "body": {
329
+ "type": "static",
330
+ "width": 20,
331
+ "height": 20,
332
+ "sensor": true
333
+ },
334
+ "sprite": {
335
+ "color": "#4cc9f0",
336
+ "image": "assets/gem.svg",
337
+ "frameWidth": 20,
338
+ "idle": "0-1",
339
+ "fps": 3,
340
+ "layer": 1
341
+ },
342
+ "collectible": {
343
+ "score": 1
344
+ }
345
+ }
346
+ },
347
+ {
348
+ "id": "slime-middle",
349
+ "components": {
350
+ "transform": {
351
+ "x": 345,
352
+ "y": 280
353
+ },
354
+ "body": {
355
+ "type": "kinematic",
356
+ "width": 30,
357
+ "height": 24,
358
+ "sensor": true
359
+ },
360
+ "sprite": {
361
+ "color": "#c77dff",
362
+ "image": "assets/slime.svg",
363
+ "frameWidth": 32,
364
+ "idle": "0",
365
+ "run": "0-1",
366
+ "fps": 4,
367
+ "layer": 2
368
+ },
369
+ "patrol": {
370
+ "axis": "x",
371
+ "distance": 250,
372
+ "speed": 80
373
+ },
374
+ "hazard": {}
375
+ }
376
+ },
377
+ {
378
+ "id": "slime-east",
379
+ "components": {
380
+ "transform": {
381
+ "x": 690,
382
+ "y": 300
383
+ },
384
+ "body": {
385
+ "type": "kinematic",
386
+ "width": 30,
387
+ "height": 24,
388
+ "sensor": true
389
+ },
390
+ "sprite": {
391
+ "color": "#c77dff",
392
+ "image": "assets/slime.svg",
393
+ "frameWidth": 32,
394
+ "idle": "0",
395
+ "run": "0-1",
396
+ "fps": 4,
397
+ "layer": 2
398
+ },
399
+ "patrol": {
400
+ "axis": "x",
401
+ "distance": 230,
402
+ "speed": 90
403
+ },
404
+ "hazard": {}
405
+ }
406
+ },
407
+ {
408
+ "id": "thorns",
409
+ "components": {
410
+ "transform": {
411
+ "x": 770,
412
+ "y": 470
413
+ },
414
+ "body": {
415
+ "type": "static",
416
+ "width": 40,
417
+ "height": 80,
418
+ "sensor": true
419
+ },
420
+ "sprite": {
421
+ "color": "#ff5d73",
422
+ "image": "assets/thorns.svg",
423
+ "tile": true,
424
+ "layer": 0
425
+ },
426
+ "hazard": {}
427
+ }
428
+ },
429
+ {
430
+ "id": "portal",
431
+ "components": {
432
+ "transform": {
433
+ "x": 880,
434
+ "y": 470
435
+ },
436
+ "body": {
437
+ "type": "static",
438
+ "width": 40,
439
+ "height": 40,
440
+ "sensor": true
441
+ },
442
+ "sprite": {
443
+ "color": "#7b2cbf",
444
+ "image": "assets/portal.svg",
445
+ "frameWidth": 40,
446
+ "idle": "0-1",
447
+ "fps": 4,
448
+ "flip": false,
449
+ "layer": 1
450
+ },
451
+ "emitter": {
452
+ "rate": 8,
453
+ "color": "#e0b0ff",
454
+ "speed": 40,
455
+ "spread": 360,
456
+ "life": 0.9,
457
+ "size": 3,
458
+ "gravity": 0
459
+ },
460
+ "goal": {
461
+ "requireScore": 5
462
+ },
463
+ "script": {
464
+ "src": "scripts/portal.ts",
465
+ "params": {
466
+ "need": 5
467
+ }
468
+ }
469
+ }
470
+ },
471
+ {
472
+ "id": "night",
473
+ "components": {
474
+ "transform": {
475
+ "x": 0,
476
+ "y": 0
477
+ },
478
+ "script": {
479
+ "src": "scripts/night.ts"
480
+ }
481
+ }
482
+ },
483
+ {
484
+ "id": "hud",
485
+ "components": {
486
+ "transform": {
487
+ "x": 0,
488
+ "y": 0
489
+ },
490
+ "script": {
491
+ "src": "scripts/hud.ts",
492
+ "params": {
493
+ "crystals": 5
494
+ }
495
+ }
496
+ }
497
+ }
498
+ ]
499
+ }
@@ -0,0 +1,94 @@
1
+ import type { DrawContext, Script } from "@ludra/core";
2
+
3
+ const TITLE = "Cinzel, Georgia, serif";
4
+
5
+ /** Centered text with a soft colored glow behind it. */
6
+ function glowText(g: DrawContext["g"], text: string, x: number, y: number, size: number, color: string, weight = 700): void {
7
+ g.font = `${weight} ${size}px ${TITLE}`;
8
+ g.textAlign = "center";
9
+ g.shadowColor = color;
10
+ g.shadowBlur = size * 0.5;
11
+ g.fillStyle = "#fff7e6";
12
+ g.fillText(text, x, y);
13
+ g.shadowBlur = 0;
14
+ }
15
+
16
+ /**
17
+ * The grove's interface: a title card, crystal slots that fill as they are
18
+ * found (params.crystals), a message when the portal wakes, a flash on death
19
+ * and the victory screen.
20
+ */
21
+ export default {
22
+ drawUI({ g, world, view, time, recent, image, params }) {
23
+ const total = params.crystals as number;
24
+
25
+ // Crystal slots, top left, on a dark leaf-shaped plate.
26
+ g.fillStyle = "rgba(8, 14, 30, 0.55)";
27
+ g.beginPath();
28
+ g.roundRect(24, 18, 40 + total * 30, 44, 22);
29
+ g.fill();
30
+ const gem = image("assets/gem.svg");
31
+ for (let i = 0; i < total; i++) {
32
+ const x = 44 + i * 30;
33
+ const found = i < world.score;
34
+ const justFound = found && recent("collect", 0.5).length > 0 && i === world.score - 1;
35
+ const pop = justFound ? 1 + 0.5 * (1 - (time - recent("collect", 0.5).at(-1)!.time) / 0.5) : 1;
36
+ g.globalAlpha = found ? 1 : 0.25;
37
+ g.shadowColor = "#2fb8ff";
38
+ g.shadowBlur = found ? 14 : 0;
39
+ if (gem) g.drawImage(gem, 0, 0, 20, 20, x - 10 * pop, 40 - 10 * pop, 20 * pop, 20 * pop);
40
+ }
41
+ g.globalAlpha = 1;
42
+ g.shadowBlur = 0;
43
+
44
+ // Title card that fades out as the hero sets off.
45
+ if (time < 3 && !world.won) {
46
+ g.globalAlpha = Math.min(1, (3 - time) / 1.2);
47
+ glowText(g, "CRYSTAL GROVE", view.width / 2, view.height / 2 - 20, 54, "#7fd6ff");
48
+ g.font = `400 16px ${TITLE}`;
49
+ g.fillStyle = "rgba(255, 247, 230, 0.85)";
50
+ g.fillText(`Gather the ${total} crystals and wake the portal`, view.width / 2, view.height / 2 + 18);
51
+ g.globalAlpha = 1;
52
+ }
53
+
54
+ // The last crystal wakes the portal.
55
+ const last = recent("collect", 2.5).find(() => world.score === total);
56
+ if (last && !world.won) {
57
+ g.globalAlpha = Math.min(1, (2.5 - (time - last.time)) / 0.8);
58
+ glowText(g, "The portal awakens", view.width / 2, 90, 26, "#c77dff");
59
+ g.globalAlpha = 1;
60
+ }
61
+
62
+ // Thorns and slimes hurt: a red pulse from the edges.
63
+ const hurt = recent("die", 0.6).at(-1);
64
+ if (hurt) {
65
+ const edge = g.createRadialGradient(view.width / 2, view.height / 2, view.height * 0.25, view.width / 2, view.height / 2, view.width * 0.7);
66
+ edge.addColorStop(0, "rgba(160, 0, 30, 0)");
67
+ edge.addColorStop(1, `rgba(160, 0, 30, ${0.65 * (1 - (time - hurt.time) / 0.6)})`);
68
+ g.fillStyle = edge;
69
+ g.fillRect(0, 0, view.width, view.height);
70
+ }
71
+
72
+ g.font = `400 11px ${TITLE}`;
73
+ g.textAlign = "right";
74
+ g.fillStyle = "rgba(255, 247, 230, 0.4)";
75
+ g.fillText("M sound · R restart", view.width - 24, view.height - 16);
76
+
77
+ if (world.won) {
78
+ const win = recent("win", 1e9).at(-1);
79
+ const since = win ? time - win.time : 0;
80
+ g.fillStyle = `rgba(4, 6, 20, ${Math.min(0.65, since)})`;
81
+ g.fillRect(0, 0, view.width, view.height);
82
+ g.globalAlpha = Math.min(1, since * 1.5);
83
+ glowText(g, "THE GROVE IS AT PEACE", view.width / 2, view.height / 2 - 8, 44, "#b25cff");
84
+ g.font = `400 18px ${TITLE}`;
85
+ g.fillStyle = "rgba(255, 247, 230, 0.9)";
86
+ const falls = world.deaths === 0 ? "without a scratch" : `after ${world.deaths} fall${world.deaths === 1 ? "" : "s"}`;
87
+ g.fillText(`${total} crystals in ${(win?.time ?? time).toFixed(1)}s, ${falls}`, view.width / 2, view.height / 2 + 32);
88
+ g.font = `400 13px ${TITLE}`;
89
+ g.fillStyle = "rgba(255, 247, 230, 0.55)";
90
+ g.fillText("Press R to wander again", view.width / 2, view.height / 2 + 66);
91
+ g.globalAlpha = 1;
92
+ }
93
+ },
94
+ } satisfies Script;