@zakmandhro/bunti 0.2.0 → 0.3.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 (127) hide show
  1. package/README.md +16 -10
  2. package/dist/cli.d.ts +2 -2
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +33 -9
  5. package/dist/cli.js.map +1 -1
  6. package/dist/colors.d.ts +2 -2
  7. package/dist/colors.js +2 -2
  8. package/dist/components/Box.d.ts +1 -1
  9. package/dist/components/Button.d.ts +6 -3
  10. package/dist/components/Button.d.ts.map +1 -1
  11. package/dist/components/Button.js +12 -3
  12. package/dist/components/Button.js.map +1 -1
  13. package/dist/components/Card.d.ts +1 -1
  14. package/dist/components/Card.js +3 -3
  15. package/dist/components/Header.d.ts +1 -1
  16. package/dist/components/Header.js +2 -2
  17. package/dist/components/Input.d.ts +2 -2
  18. package/dist/components/Input.d.ts.map +1 -1
  19. package/dist/components/Input.js +5 -1
  20. package/dist/components/Input.js.map +1 -1
  21. package/dist/components/Link.d.ts +2 -2
  22. package/dist/components/Link.d.ts.map +1 -1
  23. package/dist/components/Link.js +12 -3
  24. package/dist/components/Link.js.map +1 -1
  25. package/dist/components/Modal.d.ts +1 -1
  26. package/dist/components/Modal.d.ts.map +1 -1
  27. package/dist/components/Modal.js +11 -6
  28. package/dist/components/Modal.js.map +1 -1
  29. package/dist/components/Progress.d.ts +2 -2
  30. package/dist/components/Progress.js +2 -2
  31. package/dist/components/Spinner.d.ts +2 -2
  32. package/dist/components/index.d.ts +9 -9
  33. package/dist/components/index.js +9 -9
  34. package/dist/data/nf-glyphs.d.ts +2 -2
  35. package/dist/demo-registry.d.ts.map +1 -1
  36. package/dist/demo-registry.js +6 -1
  37. package/dist/demo-registry.js.map +1 -1
  38. package/dist/demos/2048.js +559 -0
  39. package/dist/demos/2048.ts +1 -1
  40. package/dist/demos/animation.js +177 -0
  41. package/dist/demos/animation.ts +1 -1
  42. package/dist/demos/demo-layout.js +85 -0
  43. package/dist/demos/demo-layout.ts +6 -1
  44. package/dist/demos/engine.js +244 -0
  45. package/dist/demos/interaction.js +74 -0
  46. package/dist/demos/interaction.ts +1 -1
  47. package/dist/demos/login.js +175 -0
  48. package/dist/demos/login.ts +1 -1
  49. package/dist/demos/mission-control.js +427 -0
  50. package/dist/demos/platformer-physics.js +122 -0
  51. package/dist/demos/platformer-physics.ts +284 -0
  52. package/dist/demos/platformer.js +519 -0
  53. package/dist/demos/platformer.ts +761 -0
  54. package/dist/demos/showcase.js +181 -0
  55. package/dist/demos/showcase.ts +1 -1
  56. package/dist/diagnostics.d.ts +2 -2
  57. package/dist/dsl/context.d.ts +2 -2
  58. package/dist/dsl/context.d.ts.map +1 -1
  59. package/dist/dsl/context.js +122 -27
  60. package/dist/dsl/context.js.map +1 -1
  61. package/dist/dsl/hooks.d.ts +2 -2
  62. package/dist/dsl/hooks.js +1 -1
  63. package/dist/dsl/interaction.d.ts +19 -4
  64. package/dist/dsl/interaction.d.ts.map +1 -1
  65. package/dist/dsl/interaction.js +54 -5
  66. package/dist/dsl/interaction.js.map +1 -1
  67. package/dist/dsl/layers.d.ts +2 -2
  68. package/dist/dsl/motion.d.ts +2 -2
  69. package/dist/dsl/motion.js +2 -2
  70. package/dist/dsl/render.d.ts +2 -2
  71. package/dist/dsl/render.d.ts.map +1 -1
  72. package/dist/dsl/render.js +4 -8
  73. package/dist/dsl/render.js.map +1 -1
  74. package/dist/dsl/types.d.ts +58 -12
  75. package/dist/dsl/types.d.ts.map +1 -1
  76. package/dist/dsl/types.js.map +1 -1
  77. package/dist/dsl.d.ts +4 -4
  78. package/dist/dsl.js +3 -3
  79. package/dist/easing.d.ts +1 -1
  80. package/dist/geometry.d.ts +1 -1
  81. package/dist/geometry.js +1 -1
  82. package/dist/icons-full.d.ts +2 -2
  83. package/dist/icons-full.js +3 -3
  84. package/dist/icons.d.ts +4 -4
  85. package/dist/icons.js +3 -3
  86. package/dist/index.d.ts +44 -34
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +29 -24
  89. package/dist/index.js.map +1 -1
  90. package/dist/layout.d.ts +61 -8
  91. package/dist/layout.d.ts.map +1 -1
  92. package/dist/layout.js +77 -21
  93. package/dist/layout.js.map +1 -1
  94. package/dist/pixels.d.ts +51 -0
  95. package/dist/pixels.d.ts.map +1 -0
  96. package/dist/pixels.js +82 -0
  97. package/dist/pixels.js.map +1 -0
  98. package/dist/render.d.ts +1 -1
  99. package/dist/render.d.ts.map +1 -1
  100. package/dist/render.js +44 -10
  101. package/dist/render.js.map +1 -1
  102. package/dist/state.d.ts +22 -3
  103. package/dist/state.d.ts.map +1 -1
  104. package/dist/state.js +1 -1
  105. package/dist/state.js.map +1 -1
  106. package/dist/theme.d.ts +2 -2
  107. package/dist/theme.js +1 -1
  108. package/dist/themes/catppuccin-mocha.d.ts +1 -1
  109. package/dist/themes/catppuccin-mocha.js +1 -1
  110. package/dist/themes/dracula.d.ts +1 -1
  111. package/dist/themes/dracula.js +1 -1
  112. package/dist/themes/github-light.d.ts +1 -1
  113. package/dist/themes/github-light.js +1 -1
  114. package/dist/themes/index.d.ts +7 -7
  115. package/dist/themes/index.js +6 -6
  116. package/dist/themes/nord.d.ts +1 -1
  117. package/dist/themes/nord.js +1 -1
  118. package/dist/themes/one-dark-pro.d.ts +1 -1
  119. package/dist/themes/one-dark-pro.js +1 -1
  120. package/dist/themes/tokyo-night.d.ts +1 -1
  121. package/dist/themes/tokyo-night.js +1 -1
  122. package/dist/utils.d.ts +6 -1
  123. package/dist/utils.d.ts.map +1 -1
  124. package/dist/utils.js +68 -3
  125. package/dist/utils.js.map +1 -1
  126. package/llms.txt +4 -2
  127. package/package.json +8 -4
@@ -0,0 +1,519 @@
1
+ import {
2
+ createPixelCanvas,
3
+ darkTheme,
4
+ render,
5
+ visibleWidth
6
+ } from "../index.js";
7
+ import { themes } from "../themes/index.js";
8
+ import {
9
+ clearHeld,
10
+ createHeldKeys,
11
+ createPlayer,
12
+ isHeldActive,
13
+ noteHeld,
14
+ stepPlayer
15
+ } from "./platformer-physics.js";
16
+ const once = process.argv.includes("--once");
17
+ const sizeArg = process.argv[process.argv.indexOf("--size") + 1];
18
+ if (process.argv.includes("--size") && sizeArg?.includes("x")) {
19
+ const [c, r] = sizeArg.split("x").map((n) => Number.parseInt(n, 10));
20
+ if (c && r) {
21
+ Object.defineProperty(process.stdout, "columns", {
22
+ value: c,
23
+ configurable: true
24
+ });
25
+ Object.defineProperty(process.stdout, "rows", {
26
+ value: r,
27
+ configurable: true
28
+ });
29
+ }
30
+ }
31
+ const THEMES = [darkTheme, themes.dracula, themes["tokyo-night"]];
32
+ const WORLD_W = 216;
33
+ const SPAWN_X = 6;
34
+ const GOAL_X = 208;
35
+ const GROUND = [
36
+ { x: 0, w: 96, top: 0 },
37
+ { x: 100, w: 62, top: 0 },
38
+ { x: 166, w: 50, top: 0 }
39
+ ];
40
+ const LEDGES = [
41
+ { x: 20, w: 10, top: 3 },
42
+ { x: 37, w: 8, top: 5 },
43
+ { x: 52, w: 12, top: 3 },
44
+ { x: 71, w: 8, top: 6 },
45
+ { x: 90, w: 16, top: 3 },
46
+ { x: 112, w: 10, top: 3 },
47
+ { x: 127, w: 8, top: 6 },
48
+ { x: 141, w: 10, top: 3 },
49
+ { x: 157, w: 14, top: 3 },
50
+ { x: 178, w: 10, top: 3 },
51
+ { x: 192, w: 8, top: 5 }
52
+ ];
53
+ const PLATFORMS = [...GROUND, ...LEDGES];
54
+ const STAR_SPOTS = [
55
+ { x: 12, top: 1 },
56
+ { x: 25, top: 5 },
57
+ { x: 41, top: 7 },
58
+ { x: 58, top: 5 },
59
+ { x: 75, top: 8 },
60
+ { x: 86, top: 2 },
61
+ { x: 98, top: 5 },
62
+ { x: 117, top: 6 },
63
+ { x: 131, top: 8 },
64
+ { x: 146, top: 5 },
65
+ { x: 164, top: 5 },
66
+ { x: 183, top: 5 },
67
+ { x: 196, top: 7 },
68
+ { x: 203, top: 1 }
69
+ ];
70
+ const player = createPlayer(SPAWN_X, 0);
71
+ const held = createHeldKeys();
72
+ const collected = new Array(STAR_SPOTS.length).fill(false);
73
+ let score = 0;
74
+ let simNow = 0;
75
+ let startedAt = -1;
76
+ let finishedAt = -1;
77
+ let squashUntil = -1;
78
+ let respawnedAt = -1e9;
79
+ let runPhase = 0;
80
+ let camX = 0;
81
+ let safeX = SPAWN_X;
82
+ let safeY = 0;
83
+ let frameMs = 0;
84
+ function resetGame() {
85
+ player.x = SPAWN_X;
86
+ player.y = 0;
87
+ player.vx = 0;
88
+ player.vy = 0;
89
+ player.grounded = true;
90
+ player.facing = 1;
91
+ player.lastGroundedAt = simNow;
92
+ player.jumpBufferedAt = Number.NEGATIVE_INFINITY;
93
+ player.coyoteSpent = false;
94
+ collected.fill(false);
95
+ score = 0;
96
+ startedAt = -1;
97
+ finishedAt = -1;
98
+ squashUntil = -1;
99
+ respawnedAt = -1e9;
100
+ camX = 0;
101
+ safeX = SPAWN_X;
102
+ safeY = 0;
103
+ for (const s of sparkles)
104
+ s.active = false;
105
+ }
106
+ const SPARKLE_LIFE_MS = 380;
107
+ const sparkles = Array.from({ length: 10 }, () => ({
108
+ active: false,
109
+ kind: "star",
110
+ x: 0,
111
+ y: 0,
112
+ bornAt: 0
113
+ }));
114
+ let sparkleCursor = 0;
115
+ function spawnSparkle(kind, x, y) {
116
+ const s = sparkles[sparkleCursor];
117
+ sparkleCursor = (sparkleCursor + 1) % sparkles.length;
118
+ s.active = true;
119
+ s.kind = kind;
120
+ s.x = x;
121
+ s.y = y;
122
+ s.bornAt = simNow;
123
+ }
124
+ const BURST = [
125
+ [0, -1],
126
+ [1, 0],
127
+ [0, 1],
128
+ [-1, 0],
129
+ [1, -1],
130
+ [-1, -1]
131
+ ];
132
+ const PLAYER_FRAMES = {
133
+ idle: [".111.", "11111", "13131", "11111", ".111.", ".2.2."],
134
+ runA: [".111.", "11111", "13131", "11111", ".111.", "2..2."],
135
+ runB: [".111.", "11111", "13131", "11111", ".111.", ".2..2"],
136
+ jump: [".111.", "11111", "13131", "11111", ".212.", "....."],
137
+ squash: [".......", ".11111.", "1311131", "1111111"]
138
+ };
139
+ const ROCKET = ["..1..", ".111.", ".131.", ".111.", ".111.", "11111", "2...2"];
140
+ function parseSprite(rows) {
141
+ return rows.map((r) => [...r].map((ch) => ch === "." ? 0 : Number(ch)));
142
+ }
143
+ const FRAMES = {
144
+ idle: parseSprite(PLAYER_FRAMES.idle),
145
+ runA: parseSprite(PLAYER_FRAMES.runA),
146
+ runB: parseSprite(PLAYER_FRAMES.runB),
147
+ jump: parseSprite(PLAYER_FRAMES.jump),
148
+ squash: parseSprite(PLAYER_FRAMES.squash)
149
+ };
150
+ const ROCKET_PX = parseSprite(ROCKET);
151
+ function drawSprite(px, sprite, offX, offY, c1, c2, c3) {
152
+ for (let y = 0;y < sprite.length; y++) {
153
+ const row = sprite[y];
154
+ for (let x = 0;x < row.length; x++) {
155
+ const v = row[x];
156
+ if (v === 1)
157
+ px.set(offX + x, offY + y, c1);
158
+ else if (v === 2)
159
+ px.set(offX + x, offY + y, c2);
160
+ else if (v === 3)
161
+ px.set(offX + x, offY + y, c3);
162
+ }
163
+ }
164
+ }
165
+ const playerCanvas = createPixelCanvas(7, 8);
166
+ const rocketCanvas = createPixelCanvas(5, 12);
167
+ function mixRGB(a, b, t) {
168
+ return {
169
+ r: Math.round(a.r + (b.r - a.r) * t),
170
+ g: Math.round(a.g + (b.g - a.g) * t),
171
+ b: Math.round(a.b + (b.b - a.b) * t)
172
+ };
173
+ }
174
+ const BLACK = { r: 0, g: 0, b: 0 };
175
+ const WHITE = { r: 255, g: 255, b: 255 };
176
+ const paletteCache = new Map;
177
+ function paletteFor(t) {
178
+ const cached = paletteCache.get(t.name);
179
+ if (cached)
180
+ return cached;
181
+ const bg = t.background.rgb;
182
+ const surface = t.surface.rgb;
183
+ const primary = t.primary.rgb;
184
+ const accent = t.accent.rgb;
185
+ const warning = t.warning.rgb;
186
+ const groundBody = mixRGB(surface, primary, 0.1);
187
+ const platBody = mixRGB(surface, accent, 0.18);
188
+ const p = {
189
+ skyColors: [mixRGB(bg, BLACK, 0.45), bg, mixRGB(bg, primary, 0.2)],
190
+ hillFar: mixRGB(bg, primary, 0.16),
191
+ hillNear: mixRGB(bg, primary, 0.34),
192
+ body: primary,
193
+ trim: mixRGB(primary, BLACK, 0.45),
194
+ eye: mixRGB(bg, BLACK, 0.35),
195
+ rocketBody: mixRGB(t.foreground.rgb, accent, 0.35),
196
+ rocketWindow: mixRGB(bg, BLACK, 0.3),
197
+ flameA: warning,
198
+ flameB: t.danger.rgb,
199
+ starA: warning,
200
+ starB: mixRGB(warning, WHITE, 0.55),
201
+ voidFill: { bg: mixRGB(bg, BLACK, 0.6) },
202
+ groundCap: { fg: mixRGB(surface, primary, 0.55), bg: groundBody },
203
+ groundDeep: { bg: mixRGB(groundBody, BLACK, 0.4) },
204
+ platCap: { fg: mixRGB(surface, accent, 0.6), bg: platBody },
205
+ dotDim: { fg: mixRGB(bg, t.muted.rgb, 0.5) },
206
+ dotBright: { fg: t.muted.rgb },
207
+ star: { fg: warning },
208
+ sparkle: { fg: warning },
209
+ hudTitle: { fg: t.foreground.rgb, bold: true },
210
+ hudStar: { fg: warning, bold: true },
211
+ hudMuted: { fg: t.muted.rgb },
212
+ hint: { fg: t.muted.rgb, bg: mixRGB(groundBody, BLACK, 0.4) }
213
+ };
214
+ paletteCache.set(t.name, p);
215
+ return p;
216
+ }
217
+ const gradientCache = new Map;
218
+ let hillsCanvas = null;
219
+ let hillsW = -1;
220
+ let hillsPxH = -1;
221
+ function drawHills(camXNow, pal, w, pxH) {
222
+ if (!hillsCanvas || hillsW !== w || hillsPxH !== pxH) {
223
+ hillsCanvas = createPixelCanvas(w, pxH);
224
+ hillsW = w;
225
+ hillsPxH = pxH;
226
+ }
227
+ const px = hillsCanvas;
228
+ px.clear();
229
+ for (let x = 0;x < w; x++) {
230
+ const far = x + camXNow * 0.35;
231
+ let hFar = pxH * (0.52 + 0.3 * Math.sin(far * 0.051 + 1.4)) + pxH * 0.16 * Math.sin(far * 0.017 + 0.6);
232
+ hFar = Math.max(1, Math.min(pxH, Math.round(hFar)));
233
+ for (let y = pxH - hFar;y < pxH; y++)
234
+ px.set(x, y, pal.hillFar);
235
+ const near = x + camXNow * 0.65;
236
+ let hNear = pxH * (0.3 + 0.2 * Math.sin(near * 0.078 + 4.2)) + pxH * 0.1 * Math.sin(near * 0.031 + 2.1);
237
+ hNear = Math.max(0, Math.min(pxH, Math.round(hNear)));
238
+ for (let y = pxH - hNear;y < pxH; y++)
239
+ px.set(x, y, pal.hillNear);
240
+ }
241
+ return px;
242
+ }
243
+ function mulberry32(seed) {
244
+ let a = seed;
245
+ return () => {
246
+ a |= 0;
247
+ a = a + 1831565813 | 0;
248
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
249
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
250
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
251
+ };
252
+ }
253
+ const SKY_DOTS = (() => {
254
+ const rng = mulberry32(721009);
255
+ return Array.from({ length: 64 }, () => ({
256
+ x: rng() * (WORLD_W * 0.25 + 160),
257
+ yf: rng(),
258
+ phase: rng() * Math.PI * 2,
259
+ big: rng() < 0.15
260
+ }));
261
+ })();
262
+ const CAP_GLYPHS = new Map;
263
+ for (const p of PLATFORMS)
264
+ CAP_GLYPHS.set(p, "▀".repeat(p.w));
265
+ const PITS = [];
266
+ for (let i = 0;i < GROUND.length - 1; i++) {
267
+ const a = GROUND[i];
268
+ const b = GROUND[i + 1];
269
+ PITS.push({ x: a.x + a.w, w: b.x - (a.x + a.w) });
270
+ }
271
+ const KEY_ACTION = {
272
+ left: "left",
273
+ a: "left",
274
+ right: "right",
275
+ d: "right",
276
+ " ": "jump",
277
+ up: "jump",
278
+ w: "jump"
279
+ };
280
+ const input = {
281
+ left: false,
282
+ right: false,
283
+ jumpPressed: false,
284
+ jumpHeld: false
285
+ };
286
+ render((ctx) => {
287
+ const t = ctx.theme;
288
+ const pal = paletteFor(t);
289
+ const { width: W, height: H } = ctx;
290
+ const dt = ctx.dt;
291
+ simNow += dt;
292
+ frameMs = frameMs === 0 ? dt : frameMs * 0.9 + dt * 0.1;
293
+ if (ctx.lastKey === "q" || ctx.lastKey === "escape")
294
+ ctx.requestStop();
295
+ if (ctx.lastKey === "r")
296
+ resetGame();
297
+ const pick = Number.parseInt(ctx.lastKey ?? "", 10);
298
+ if (pick >= 1 && pick <= THEMES.length)
299
+ ctx.setTheme(THEMES[pick - 1]);
300
+ input.jumpPressed = false;
301
+ for (const e of ctx.keys) {
302
+ const action = KEY_ACTION[e.key];
303
+ if (!action)
304
+ continue;
305
+ if (e.kind === "release") {
306
+ clearHeld(held, action);
307
+ } else {
308
+ if (action === "jump")
309
+ input.jumpPressed = true;
310
+ noteHeld(held, action, simNow);
311
+ if (startedAt < 0)
312
+ startedAt = simNow;
313
+ }
314
+ }
315
+ input.left = isHeldActive(held, "left", simNow);
316
+ input.right = isHeldActive(held, "right", simNow);
317
+ input.jumpHeld = isHeldActive(held, "jump", simNow);
318
+ const finished = finishedAt >= 0;
319
+ if (finished) {
320
+ input.left = false;
321
+ input.right = false;
322
+ input.jumpPressed = false;
323
+ }
324
+ stepPlayer(player, input, dt, simNow, PLATFORMS);
325
+ if (player.landedImpact > 12) {
326
+ squashUntil = simNow + 90;
327
+ spawnSparkle("dust", player.x, player.y);
328
+ }
329
+ if (player.grounded) {
330
+ safeX = player.x;
331
+ safeY = player.y;
332
+ }
333
+ if (player.y < -6) {
334
+ player.x = safeX;
335
+ player.y = safeY;
336
+ player.vx = 0;
337
+ player.vy = 0;
338
+ player.grounded = true;
339
+ respawnedAt = simNow;
340
+ }
341
+ runPhase += Math.abs(player.vx) * (dt / 1000) * 1.6;
342
+ for (let i = 0;i < STAR_SPOTS.length; i++) {
343
+ if (collected[i])
344
+ continue;
345
+ const s = STAR_SPOTS[i];
346
+ if (Math.abs(s.x - player.x) <= 2.2 && s.top >= player.y - 0.6 && s.top <= player.y + 3.2) {
347
+ collected[i] = true;
348
+ score++;
349
+ spawnSparkle("star", s.x, s.top);
350
+ }
351
+ }
352
+ if (!finished && player.grounded && player.x >= GOAL_X - 3) {
353
+ finishedAt = simNow;
354
+ }
355
+ const camTarget = Math.min(Math.max(0, player.x - W * 0.38), Math.max(0, WORLD_W - W));
356
+ camX += (camTarget - camX) * Math.min(1, dt / 1000 * 6);
357
+ if (once)
358
+ camX = camTarget;
359
+ const camXi = Math.round(camX);
360
+ const skyKey = t.name;
361
+ let sky = gradientCache.get(skyKey);
362
+ if (!sky) {
363
+ sky = ctx.gradient({
364
+ colors: pal.skyColors,
365
+ direction: "vertical",
366
+ steps: 64
367
+ });
368
+ gradientCache.set(skyKey, sky);
369
+ }
370
+ ctx.wallpaper(sky);
371
+ if (W < 60 || H < 16) {
372
+ const msg = "starhop needs at least 60x16";
373
+ ctx.blit(Math.max(0, W - msg.length >> 1), H >> 1, msg, pal.hudMuted);
374
+ return;
375
+ }
376
+ const groundRow = H - 2;
377
+ const hillRows = Math.max(3, Math.min(11, Math.floor((H - 10) / 2.5)));
378
+ if (t.mode === "dark") {
379
+ const skyH = Math.max(1, groundRow - hillRows - 2);
380
+ for (const d of SKY_DOTS) {
381
+ const sx = Math.floor(d.x - camX * 0.25);
382
+ if (sx < 0 || sx >= W)
383
+ continue;
384
+ const tw = 0.5 + 0.5 * Math.sin(simNow * 0.0021 + d.phase * 4);
385
+ if (tw < 0.4)
386
+ continue;
387
+ const row = 1 + Math.floor(d.yf * skyH);
388
+ ctx.blit(sx, row, d.big ? "+" : "·", tw > 0.82 ? pal.dotBright : pal.dotDim);
389
+ }
390
+ }
391
+ drawHills(camX, pal, W, hillRows * 2).blitTo(ctx.state, 0, groundRow - hillRows);
392
+ {
393
+ const lift = finished ? Math.min(H + 8, 3 * ((simNow - finishedAt) / 1000) ** 2) : 0;
394
+ rocketCanvas.clear();
395
+ drawSprite(rocketCanvas, ROCKET_PX, 0, 0, pal.rocketBody, pal.trim, pal.rocketWindow);
396
+ if (finished) {
397
+ const phase = Math.floor(simNow / 66) % 2;
398
+ const fa = phase === 0 ? pal.flameA : pal.flameB;
399
+ const fb = phase === 0 ? pal.flameB : pal.flameA;
400
+ rocketCanvas.set(2, 7, fa);
401
+ rocketCanvas.set(1, 8, fb);
402
+ rocketCanvas.set(3, 8, fb);
403
+ rocketCanvas.set(2, 9, fa);
404
+ if (phase === 0)
405
+ rocketCanvas.set(2, 10, fb);
406
+ }
407
+ const feetPx = groundRow * 2 - Math.round(lift * 2);
408
+ const topPx = feetPx - 7;
409
+ const cellY = Math.floor(topPx / 2);
410
+ rocketCanvas.blitTo(ctx.state, GOAL_X - 2 - camXi, cellY);
411
+ }
412
+ ctx.rect(0, groundRow, W, 2, pal.voidFill);
413
+ for (const seg of GROUND) {
414
+ const sx = seg.x - camXi;
415
+ const from = Math.max(sx, 0);
416
+ const to = Math.min(sx + seg.w, W);
417
+ if (to <= from)
418
+ continue;
419
+ ctx.blit(from, groundRow, CAP_GLYPHS.get(seg).substring(from - sx, to - sx), pal.groundCap);
420
+ ctx.rect(from, groundRow + 1, to - from, 1, pal.groundDeep);
421
+ }
422
+ for (const p of LEDGES) {
423
+ const sx = p.x - camXi;
424
+ const from = Math.max(sx, 0);
425
+ const to = Math.min(sx + p.w, W);
426
+ if (to <= from)
427
+ continue;
428
+ ctx.blit(from, groundRow - p.top, CAP_GLYPHS.get(p).substring(from - sx, to - sx), pal.platCap);
429
+ }
430
+ const starGlyph = ctx.icon("star");
431
+ for (let i = 0;i < STAR_SPOTS.length; i++) {
432
+ if (collected[i])
433
+ continue;
434
+ const s = STAR_SPOTS[i];
435
+ const sx = Math.round(s.x) - camXi;
436
+ if (sx < 0 || sx >= W)
437
+ continue;
438
+ const pulse = 0.5 + 0.5 * Math.sin(simNow * 0.004 + i * 1.7);
439
+ pal.star.fg = pulse > 0.66 ? pal.starB : pal.starA;
440
+ ctx.blit(sx, groundRow - s.top, starGlyph, pal.star);
441
+ }
442
+ for (const s of sparkles) {
443
+ if (!s.active)
444
+ continue;
445
+ const age = (simNow - s.bornAt) / SPARKLE_LIFE_MS;
446
+ if (age >= 1) {
447
+ s.active = false;
448
+ continue;
449
+ }
450
+ const cx = Math.round(s.x) - camXi;
451
+ const cy = groundRow - Math.round(s.y) - 1;
452
+ const radius = s.kind === "star" ? 1 + age * 1.6 : 1 + age * 0.8;
453
+ const glyph = age < 0.45 ? "+" : "·";
454
+ pal.sparkle.fg = s.kind === "star" ? pal.starB : pal.trim;
455
+ const n = s.kind === "star" ? BURST.length : 4;
456
+ for (let i = 0;i < n; i++) {
457
+ const [ox, oy] = BURST[i];
458
+ ctx.blit(cx + Math.round(ox * radius), cy + Math.round(oy * radius), glyph, pal.sparkle);
459
+ }
460
+ }
461
+ const blink = simNow - respawnedAt < 550 && Math.floor(simNow / 70) % 2 === 0;
462
+ if (!blink) {
463
+ const squashing = player.grounded && simNow < squashUntil;
464
+ const frame = !player.grounded ? "jump" : squashing ? "squash" : Math.abs(player.vx) > 2 ? Math.floor(runPhase) % 2 === 0 ? "runA" : "runB" : "idle";
465
+ const sprite = FRAMES[frame];
466
+ const spriteH = squashing ? 4 : 6;
467
+ const feetPx = groundRow * 2 - Math.round(player.y * 2);
468
+ const topPx = feetPx - spriteH;
469
+ const cellY = Math.floor(topPx / 2);
470
+ const innerOff = topPx - cellY * 2;
471
+ const xOff = squashing ? 0 : 1;
472
+ playerCanvas.clear();
473
+ drawSprite(playerCanvas, sprite, xOff, innerOff, pal.body, pal.trim, pal.eye);
474
+ playerCanvas.blitTo(ctx.state, Math.round(player.x - camX) - 3, cellY);
475
+ }
476
+ const title = ` ${ctx.icon("rocket")} STARHOP`;
477
+ ctx.blit(1, 0, title, pal.hudTitle);
478
+ const elapsed = startedAt < 0 ? 0 : ((finished ? finishedAt : simNow) - startedAt) / 1000;
479
+ const starText = `${starGlyph} ${score}/${STAR_SPOTS.length}`;
480
+ const perfText = frameMs > 0 ? ` ${elapsed.toFixed(1)}s ${(1000 / frameMs).toFixed(0)}fps ${frameMs.toFixed(1)}ms ` : ` ${elapsed.toFixed(1)}s --fps `;
481
+ const right = W - 1 - visibleWidth(starText) - visibleWidth(perfText);
482
+ ctx.blit(right, 0, starText, pal.hudStar);
483
+ ctx.blit(right + visibleWidth(starText), 0, perfText, pal.hudMuted);
484
+ const hint = " ←/→ move · space jump · 1/2/3 theme · r restart · q quit ";
485
+ if (W >= hint.length + 2)
486
+ ctx.blit(1, H - 1, hint, pal.hint);
487
+ if (finished && simNow - finishedAt > 700) {
488
+ ctx.layer({ zIndex: 10, shadow: true }, (overlay) => {
489
+ const perfect = score === STAR_SPOTS.length;
490
+ overlay.box({
491
+ width: 34,
492
+ height: 7,
493
+ border: "rounded",
494
+ borderColor: t.primary,
495
+ bgColor: t.surfaceRaised,
496
+ align: "center",
497
+ valign: "middle"
498
+ }, (b) => {
499
+ b.text(ctx.color.bold(t.primary(perfect ? "★ PERFECT LAUNCH ★" : "LAUNCHED!")));
500
+ b.text(`
501
+ `);
502
+ b.text(t.foreground(`${starGlyph} ${score}/${STAR_SPOTS.length} · ${elapsed.toFixed(1)}s`));
503
+ b.text(`
504
+
505
+ `);
506
+ b.text(t.muted("r replay · q quit"));
507
+ });
508
+ });
509
+ }
510
+ }, {
511
+ fps: 60,
512
+ keyboard: true,
513
+ mouse: false,
514
+ hideCursor: true,
515
+ alternateBuffer: !once,
516
+ theme: darkTheme,
517
+ nerdFont: true,
518
+ once
519
+ });