@ludra/cli 0.5.0 → 0.8.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 (107) 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 -0
  49. package/dist/commands/check.js +57 -14
  50. package/dist/commands/dev.js +7 -2
  51. package/dist/commands/new.js +1 -1
  52. package/dist/commands/play.d.ts +1 -0
  53. package/dist/commands/play.js +12 -4
  54. package/dist/commands/review.d.ts +22 -0
  55. package/dist/commands/review.js +183 -0
  56. package/dist/commands/test.d.ts +1 -0
  57. package/dist/commands/test.js +20 -5
  58. package/dist/headless.d.ts +2 -4
  59. package/dist/headless.js +21 -3
  60. package/dist/index.js +25 -5
  61. package/dist/project.d.ts +18 -5
  62. package/dist/project.js +61 -21
  63. package/dist/server.js +22 -9
  64. package/package.json +4 -3
  65. package/templates/breakout/CLAUDE.md +43 -0
  66. package/templates/breakout/_gitignore +2 -0
  67. package/templates/breakout/ludra.json +5 -0
  68. package/templates/breakout/package.json +13 -0
  69. package/templates/breakout/scenes/main.scene.json +653 -0
  70. package/templates/breakout/scripts/backdrop.ts +101 -0
  71. package/templates/breakout/scripts/ball.ts +89 -0
  72. package/templates/breakout/scripts/brick.ts +51 -0
  73. package/templates/breakout/scripts/paddle.ts +53 -0
  74. package/templates/breakout/scripts/rules.ts +147 -0
  75. package/templates/breakout/scripts/wall.ts +21 -0
  76. package/templates/breakout/shaders/neon.frag +42 -0
  77. package/templates/breakout/tests/gameplay.test.json +31 -0
  78. package/templates/platformer/CLAUDE.md +13 -8
  79. package/templates/platformer/assets/lantern.svg +9 -0
  80. package/templates/platformer/package.json +1 -1
  81. package/templates/platformer/prefabs/coin.prefab.json +8 -0
  82. package/templates/platformer/prefabs/slime.prefab.json +9 -0
  83. package/templates/platformer/scenes/level-2.scene.json +413 -0
  84. package/templates/platformer/scenes/main.scene.json +356 -59
  85. package/templates/platformer/scripts/backdrop.ts +75 -0
  86. package/templates/platformer/scripts/hud.ts +111 -0
  87. package/templates/platformer/scripts/lantern.ts +35 -0
  88. package/templates/platformer/shaders/glow.frag +29 -0
  89. package/templates/platformer/tests/gameplay.test.json +45 -7
  90. package/templates/platformer/tests/level-2.test.json +49 -0
  91. package/templates/topdown/CLAUDE.md +39 -0
  92. package/templates/topdown/_gitignore +2 -0
  93. package/templates/topdown/assets/gem.svg +16 -0
  94. package/templates/topdown/assets/grass.svg +8 -0
  95. package/templates/topdown/assets/hedge.svg +10 -0
  96. package/templates/topdown/assets/hero.svg +23 -0
  97. package/templates/topdown/assets/portal.svg +15 -0
  98. package/templates/topdown/assets/slime.svg +21 -0
  99. package/templates/topdown/assets/thorns.svg +7 -0
  100. package/templates/topdown/ludra.json +5 -0
  101. package/templates/topdown/package.json +13 -0
  102. package/templates/topdown/scenes/main.scene.json +499 -0
  103. package/templates/topdown/scripts/hud.ts +94 -0
  104. package/templates/topdown/scripts/night.ts +67 -0
  105. package/templates/topdown/scripts/portal.ts +34 -0
  106. package/templates/topdown/shaders/dream.frag +31 -0
  107. package/templates/topdown/tests/gameplay.test.json +30 -0
@@ -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;
@@ -0,0 +1,67 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ /** A stable pseudo-random number per index, so fireflies keep their paths. */
4
+ function hash(n: number): number {
5
+ const x = Math.sin(n * 91.7 + 47.3) * 43758.5453;
6
+ return x - Math.floor(x);
7
+ }
8
+
9
+ /**
10
+ * Moonlit night over the grove: darkness everywhere except the hero's lantern,
11
+ * then colored light from glowing things (params.lights maps an id prefix to a
12
+ * color), and fireflies. Presentation only: the darkness never hides a hazard
13
+ * completely (params.darkness stays well below 1).
14
+ */
15
+ export default {
16
+ drawUI({ g, world, view, time }) {
17
+ const toScreen = (x: number, y: number) => [(x - view.cameraX) * view.zoom, (y - view.cameraY) * view.zoom] as const;
18
+ const hero = world.player();
19
+ const darkness = 0.62;
20
+
21
+ // Darkness with a soft hole where the lantern shines.
22
+ const [hx, hy] = hero ? toScreen(hero.x, hero.y) : [view.width / 2, view.height / 2];
23
+ const flicker = 1 + Math.sin(time * 11) * 0.015 + Math.sin(time * 7.3) * 0.02;
24
+ const night = g.createRadialGradient(hx, hy, 30 * flicker, hx, hy, 210 * flicker);
25
+ night.addColorStop(0, "rgba(6, 10, 30, 0)");
26
+ night.addColorStop(1, `rgba(6, 10, 30, ${darkness})`);
27
+ g.fillStyle = night;
28
+ g.fillRect(0, 0, view.width, view.height);
29
+
30
+ // Light from glowing things, added on top of the darkness.
31
+ g.globalCompositeOperation = "lighter";
32
+ const lights: Record<string, string> = { "gem-": "#2fb8ff", portal: "#b25cff", "slime-": "#8a3cff", thorns: "#ff3355" };
33
+ for (const e of world.all()) {
34
+ const prefix = Object.keys(lights).find((p) => e.id.startsWith(p));
35
+ if (!prefix) continue;
36
+ const [x, y] = toScreen(e.x, e.y);
37
+ const radius = (prefix === "portal" ? 110 : 60) * (0.85 + 0.15 * Math.sin(time * 2.4 + e.x));
38
+ const light = g.createRadialGradient(x, y, 0, x, y, radius);
39
+ light.addColorStop(0, `${lights[prefix]}66`);
40
+ light.addColorStop(1, `${lights[prefix]}00`);
41
+ g.fillStyle = light;
42
+ g.fillRect(x - radius, y - radius, radius * 2, radius * 2);
43
+ }
44
+ if (hero) {
45
+ const lantern = g.createRadialGradient(hx, hy, 0, hx, hy, 120 * flicker);
46
+ lantern.addColorStop(0, "rgba(255, 190, 110, 0.32)");
47
+ lantern.addColorStop(1, "rgba(255, 190, 110, 0)");
48
+ g.fillStyle = lantern;
49
+ g.fillRect(hx - 130, hy - 130, 260, 260);
50
+ }
51
+
52
+ // Fireflies drifting on their own little loops.
53
+ for (let i = 0; i < 36; i++) {
54
+ const [x, y] = toScreen(
55
+ hash(i) * world.width + Math.sin(time * (0.3 + hash(i + 50) * 0.5) + i) * 40,
56
+ hash(i + 100) * world.height + Math.cos(time * (0.25 + hash(i + 80) * 0.4) + i * 2) * 30,
57
+ );
58
+ const blink = Math.max(0, Math.sin(time * (1.5 + hash(i + 7) * 2) + i * 3));
59
+ g.fillStyle = `rgba(220, 255, 140, ${0.75 * blink})`;
60
+ g.shadowColor = "#d4ff6b";
61
+ g.shadowBlur = 10;
62
+ g.beginPath();
63
+ g.arc(x, y, 1.6, 0, Math.PI * 2);
64
+ g.fill();
65
+ }
66
+ },
67
+ } satisfies Script;
@@ -0,0 +1,34 @@
1
+ import type { Script } from "@ludra/core";
2
+
3
+ /**
4
+ * A vortex of light over the portal sprite. It spins slowly while the portal
5
+ * is asleep and wakes up (faster, brighter) once params.need crystals are found.
6
+ */
7
+ export default {
8
+ draw({ g, self, world, params, time }) {
9
+ const awake = world.score >= (params.need as number);
10
+ const spin = time * (awake ? 3.2 : 0.9);
11
+ g.globalCompositeOperation = "lighter";
12
+ g.lineCap = "round";
13
+ for (let i = 0; i < 5; i++) {
14
+ const radius = 8 + i * 4.5;
15
+ const start = spin * (i % 2 === 0 ? 1 : -1.3) + i;
16
+ g.strokeStyle = i % 2 === 0 ? "#d8a6ff" : "#6be4ff";
17
+ g.globalAlpha = awake ? 0.9 : 0.35;
18
+ g.lineWidth = awake ? 2.5 : 1.5;
19
+ g.shadowColor = g.strokeStyle;
20
+ g.shadowBlur = awake ? 14 : 6;
21
+ g.beginPath();
22
+ g.arc(self.x, self.y, radius, start, start + Math.PI * 1.1);
23
+ g.stroke();
24
+ }
25
+ if (awake) {
26
+ const core = g.createRadialGradient(self.x, self.y, 0, self.x, self.y, 26);
27
+ core.addColorStop(0, "rgba(255, 255, 255, 0.9)");
28
+ core.addColorStop(1, "rgba(178, 92, 255, 0)");
29
+ g.globalAlpha = 0.7 + 0.3 * Math.sin(time * 6);
30
+ g.fillStyle = core;
31
+ g.fillRect(self.x - 26, self.y - 26, 52, 52);
32
+ }
33
+ },
34
+ } satisfies Script;