@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,761 @@
1
+ /**
2
+ * STARHOP — the Bunti platformer (launch-week performance drop).
3
+ *
4
+ * A ~3-screen side-scroller that flexes the motion stack: dt-based physics
5
+ * at 60fps, held-key running, coyote time + buffered variable-height jumps,
6
+ * a halfblock pixel-sprite player (createPixelCanvas), two-layer parallax
7
+ * (gradient sky + pixel hills), themed tile platforms, star pickups with
8
+ * sparkle bursts, and a live FPS / frame-time HUD. Physics lives demo-side
9
+ * in ./platformer-physics.ts — Bunti ships primitives, not game APIs.
10
+ *
11
+ * Keys: left/right or a/d run · space/up jump (hold = higher) · 1/2/3
12
+ * themes · r restart · q quit.
13
+ * Headless: --once renders one frame; --size 100x30 fakes a viewport.
14
+ */
15
+
16
+ import {
17
+ createPixelCanvas,
18
+ darkTheme,
19
+ type Gradient,
20
+ type PixelCanvas,
21
+ type RGB,
22
+ render,
23
+ type Theme,
24
+ visibleWidth,
25
+ } from '../index.js';
26
+ import { themes } from '../themes/index.js';
27
+ import {
28
+ clearHeld,
29
+ createHeldKeys,
30
+ createPlayer,
31
+ type HeldAction,
32
+ isHeldActive,
33
+ noteHeld,
34
+ type Platform,
35
+ type SimInput,
36
+ stepPlayer,
37
+ } from './platformer-physics.js';
38
+
39
+ // --- CLI flags (headless testing / recording) --------------------------------
40
+
41
+ const once = process.argv.includes('--once');
42
+ const sizeArg = process.argv[process.argv.indexOf('--size') + 1];
43
+ if (process.argv.includes('--size') && sizeArg?.includes('x')) {
44
+ const [c, r] = sizeArg.split('x').map((n) => Number.parseInt(n, 10));
45
+ if (c && r) {
46
+ Object.defineProperty(process.stdout, 'columns', {
47
+ value: c,
48
+ configurable: true,
49
+ });
50
+ Object.defineProperty(process.stdout, 'rows', {
51
+ value: r,
52
+ configurable: true,
53
+ });
54
+ }
55
+ }
56
+
57
+ const THEMES: Theme[] = [darkTheme, themes.dracula!, themes['tokyo-night']!];
58
+
59
+ // --- Level -------------------------------------------------------------------
60
+ // x in columns, `top` in rows above the ground line. WORLD_W ~ 3 screens at
61
+ // 80 columns. Two pits interrupt the ground; low bridges span them.
62
+
63
+ const WORLD_W = 216;
64
+ const SPAWN_X = 6;
65
+ const GOAL_X = 208;
66
+
67
+ const GROUND: Platform[] = [
68
+ { x: 0, w: 96, top: 0 }, // pit A: 96..99
69
+ { x: 100, w: 62, top: 0 }, // pit B: 162..165
70
+ { x: 166, w: 50, top: 0 },
71
+ ];
72
+
73
+ const LEDGES: Platform[] = [
74
+ { x: 20, w: 10, top: 3 },
75
+ { x: 37, w: 8, top: 5 },
76
+ { x: 52, w: 12, top: 3 },
77
+ { x: 71, w: 8, top: 6 },
78
+ { x: 90, w: 16, top: 3 }, // bridge over pit A
79
+ { x: 112, w: 10, top: 3 },
80
+ { x: 127, w: 8, top: 6 },
81
+ { x: 141, w: 10, top: 3 },
82
+ { x: 157, w: 14, top: 3 }, // bridge over pit B
83
+ { x: 178, w: 10, top: 3 },
84
+ { x: 192, w: 8, top: 5 },
85
+ ];
86
+
87
+ const PLATFORMS: Platform[] = [...GROUND, ...LEDGES];
88
+
89
+ interface StarSpot {
90
+ x: number;
91
+ top: number;
92
+ }
93
+
94
+ /** Collectibles, placed along both the ground run and the ledge route. */
95
+ const STAR_SPOTS: StarSpot[] = [
96
+ { x: 12, top: 1 },
97
+ { x: 25, top: 5 },
98
+ { x: 41, top: 7 },
99
+ { x: 58, top: 5 },
100
+ { x: 75, top: 8 },
101
+ { x: 86, top: 2 },
102
+ { x: 98, top: 5 }, // over pit A
103
+ { x: 117, top: 6 },
104
+ { x: 131, top: 8 },
105
+ { x: 146, top: 5 },
106
+ { x: 164, top: 5 }, // over pit B
107
+ { x: 183, top: 5 },
108
+ { x: 196, top: 7 },
109
+ { x: 203, top: 1 },
110
+ ];
111
+
112
+ // --- Game state (module-level, reset by resetGame) ---------------------------
113
+
114
+ const player = createPlayer(SPAWN_X, 0);
115
+ const held = createHeldKeys();
116
+ const collected: boolean[] = new Array(STAR_SPOTS.length).fill(false);
117
+ let score = 0;
118
+ let simNow = 0; // accumulated ctx.dt — the game clock
119
+ let startedAt = -1; // first input -> speedrun timer start
120
+ let finishedAt = -1; // reached the rocket
121
+ let squashUntil = -1; // landing squash window
122
+ let respawnedAt = -1e9; // respawn blink window
123
+ let runPhase = 0; // run-cycle animation phase
124
+ let camX = 0;
125
+ let safeX = SPAWN_X; // last grounded spot, for pit respawns
126
+ let safeY = 0;
127
+ let frameMs = 0; // smoothed frame time for the HUD
128
+
129
+ function resetGame() {
130
+ player.x = SPAWN_X;
131
+ player.y = 0;
132
+ player.vx = 0;
133
+ player.vy = 0;
134
+ player.grounded = true;
135
+ player.facing = 1;
136
+ player.lastGroundedAt = simNow;
137
+ player.jumpBufferedAt = Number.NEGATIVE_INFINITY;
138
+ player.coyoteSpent = false;
139
+ collected.fill(false);
140
+ score = 0;
141
+ startedAt = -1;
142
+ finishedAt = -1;
143
+ squashUntil = -1;
144
+ respawnedAt = -1e9;
145
+ camX = 0;
146
+ safeX = SPAWN_X;
147
+ safeY = 0;
148
+ for (const s of sparkles) s.active = false;
149
+ }
150
+
151
+ // --- Sparkle pool (star pickups + landing dust; zero per-frame allocation) ---
152
+
153
+ interface Sparkle {
154
+ active: boolean;
155
+ kind: 'star' | 'dust';
156
+ x: number; // world column
157
+ y: number; // rows above ground
158
+ bornAt: number;
159
+ }
160
+
161
+ const SPARKLE_LIFE_MS = 380;
162
+ const sparkles: Sparkle[] = Array.from({ length: 10 }, () => ({
163
+ active: false,
164
+ kind: 'star' as const,
165
+ x: 0,
166
+ y: 0,
167
+ bornAt: 0,
168
+ }));
169
+ let sparkleCursor = 0;
170
+
171
+ function spawnSparkle(kind: Sparkle['kind'], x: number, y: number) {
172
+ const s = sparkles[sparkleCursor]!;
173
+ sparkleCursor = (sparkleCursor + 1) % sparkles.length;
174
+ s.active = true;
175
+ s.kind = kind;
176
+ s.x = x;
177
+ s.y = y;
178
+ s.bornAt = simNow;
179
+ }
180
+
181
+ /** Burst offsets (cells), reused across every sparkle. */
182
+ const BURST: ReadonlyArray<readonly [number, number]> = [
183
+ [0, -1],
184
+ [1, 0],
185
+ [0, 1],
186
+ [-1, 0],
187
+ [1, -1],
188
+ [-1, -1],
189
+ ];
190
+
191
+ // --- Sprites (halfblock pixels: 1 cell = 2 vertical px) ----------------------
192
+ // Legend: '.' transparent · '1' body · '2' trim/feet · '3' eye.
193
+
194
+ const PLAYER_FRAMES = {
195
+ idle: ['.111.', '11111', '13131', '11111', '.111.', '.2.2.'],
196
+ runA: ['.111.', '11111', '13131', '11111', '.111.', '2..2.'],
197
+ runB: ['.111.', '11111', '13131', '11111', '.111.', '.2..2'],
198
+ jump: ['.111.', '11111', '13131', '11111', '.212.', '.....'],
199
+ squash: ['.......', '.11111.', '1311131', '1111111'],
200
+ } as const;
201
+ type PlayerFrame = keyof typeof PLAYER_FRAMES;
202
+
203
+ const ROCKET = ['..1..', '.111.', '.131.', '.111.', '.111.', '11111', '2...2'];
204
+
205
+ function parseSprite(rows: readonly string[]): number[][] {
206
+ return rows.map((r) => [...r].map((ch) => (ch === '.' ? 0 : Number(ch))));
207
+ }
208
+
209
+ const FRAMES: Record<PlayerFrame, number[][]> = {
210
+ idle: parseSprite(PLAYER_FRAMES.idle),
211
+ runA: parseSprite(PLAYER_FRAMES.runA),
212
+ runB: parseSprite(PLAYER_FRAMES.runB),
213
+ jump: parseSprite(PLAYER_FRAMES.jump),
214
+ squash: parseSprite(PLAYER_FRAMES.squash),
215
+ };
216
+ const ROCKET_PX = parseSprite(ROCKET);
217
+
218
+ /** Paints a parsed sprite into a canvas with a 3-slot palette. */
219
+ function drawSprite(
220
+ px: PixelCanvas,
221
+ sprite: number[][],
222
+ offX: number,
223
+ offY: number,
224
+ c1: RGB,
225
+ c2: RGB,
226
+ c3: RGB,
227
+ ) {
228
+ for (let y = 0; y < sprite.length; y++) {
229
+ const row = sprite[y]!;
230
+ for (let x = 0; x < row.length; x++) {
231
+ const v = row[x];
232
+ if (v === 1) px.set(offX + x, offY + y, c1);
233
+ else if (v === 2) px.set(offX + x, offY + y, c2);
234
+ else if (v === 3) px.set(offX + x, offY + y, c3);
235
+ }
236
+ }
237
+ }
238
+
239
+ const playerCanvas = createPixelCanvas(7, 8);
240
+ const rocketCanvas = createPixelCanvas(5, 12); // rocket + flame rows
241
+
242
+ // --- Theme palette (rebuilt only on theme swap; RGB + style objects pooled) --
243
+
244
+ function mixRGB(a: RGB, b: RGB, t: number): RGB {
245
+ return {
246
+ r: Math.round(a.r + (b.r - a.r) * t),
247
+ g: Math.round(a.g + (b.g - a.g) * t),
248
+ b: Math.round(a.b + (b.b - a.b) * t),
249
+ };
250
+ }
251
+ const BLACK: RGB = { r: 0, g: 0, b: 0 };
252
+ const WHITE: RGB = { r: 255, g: 255, b: 255 };
253
+
254
+ interface BlitStylePool {
255
+ fg?: RGB;
256
+ bg?: RGB;
257
+ bold?: boolean;
258
+ }
259
+
260
+ interface Palette {
261
+ skyColors: RGB[];
262
+ hillFar: RGB;
263
+ hillNear: RGB;
264
+ body: RGB;
265
+ trim: RGB;
266
+ eye: RGB;
267
+ rocketBody: RGB;
268
+ rocketWindow: RGB;
269
+ flameA: RGB;
270
+ flameB: RGB;
271
+ starA: RGB;
272
+ starB: RGB;
273
+ voidFill: BlitStylePool;
274
+ groundCap: BlitStylePool; // '▀' fg=cap bg=body
275
+ groundDeep: BlitStylePool;
276
+ platCap: BlitStylePool; // '▀' fg=cap bg=body
277
+ dotDim: BlitStylePool;
278
+ dotBright: BlitStylePool;
279
+ star: BlitStylePool; // fg mutated per star per frame
280
+ sparkle: BlitStylePool; // fg mutated per sparkle per frame
281
+ hudTitle: BlitStylePool;
282
+ hudStar: BlitStylePool;
283
+ hudMuted: BlitStylePool;
284
+ hint: BlitStylePool;
285
+ }
286
+
287
+ const paletteCache = new Map<string, Palette>();
288
+
289
+ function paletteFor(t: Theme): Palette {
290
+ const cached = paletteCache.get(t.name);
291
+ if (cached) return cached;
292
+
293
+ const bg = t.background.rgb;
294
+ const surface = t.surface.rgb;
295
+ const primary = t.primary.rgb;
296
+ const accent = t.accent.rgb;
297
+ const warning = t.warning.rgb;
298
+ const groundBody = mixRGB(surface, primary, 0.1);
299
+ const platBody = mixRGB(surface, accent, 0.18);
300
+
301
+ const p: Palette = {
302
+ skyColors: [mixRGB(bg, BLACK, 0.45), bg, mixRGB(bg, primary, 0.2)],
303
+ hillFar: mixRGB(bg, primary, 0.16),
304
+ hillNear: mixRGB(bg, primary, 0.34),
305
+ body: primary,
306
+ trim: mixRGB(primary, BLACK, 0.45),
307
+ eye: mixRGB(bg, BLACK, 0.35),
308
+ rocketBody: mixRGB(t.foreground.rgb, accent, 0.35),
309
+ rocketWindow: mixRGB(bg, BLACK, 0.3),
310
+ flameA: warning,
311
+ flameB: t.danger.rgb,
312
+ starA: warning,
313
+ starB: mixRGB(warning, WHITE, 0.55),
314
+ voidFill: { bg: mixRGB(bg, BLACK, 0.6) },
315
+ groundCap: { fg: mixRGB(surface, primary, 0.55), bg: groundBody },
316
+ groundDeep: { bg: mixRGB(groundBody, BLACK, 0.4) },
317
+ platCap: { fg: mixRGB(surface, accent, 0.6), bg: platBody },
318
+ dotDim: { fg: mixRGB(bg, t.muted.rgb, 0.5) },
319
+ dotBright: { fg: t.muted.rgb },
320
+ star: { fg: warning },
321
+ sparkle: { fg: warning },
322
+ hudTitle: { fg: t.foreground.rgb, bold: true },
323
+ hudStar: { fg: warning, bold: true },
324
+ hudMuted: { fg: t.muted.rgb },
325
+ hint: { fg: t.muted.rgb, bg: mixRGB(groundBody, BLACK, 0.4) },
326
+ };
327
+ paletteCache.set(t.name, p);
328
+ return p;
329
+ }
330
+
331
+ const gradientCache = new Map<string, Gradient>();
332
+
333
+ // --- Parallax hills (pixel canvas, rebuilt per frame; resized on demand) -----
334
+
335
+ let hillsCanvas: PixelCanvas | null = null;
336
+ let hillsW = -1;
337
+ let hillsPxH = -1;
338
+
339
+ function drawHills(camXNow: number, pal: Palette, w: number, pxH: number) {
340
+ if (!hillsCanvas || hillsW !== w || hillsPxH !== pxH) {
341
+ hillsCanvas = createPixelCanvas(w, pxH);
342
+ hillsW = w;
343
+ hillsPxH = pxH;
344
+ }
345
+ const px = hillsCanvas;
346
+ px.clear();
347
+ for (let x = 0; x < w; x++) {
348
+ const far = x + camXNow * 0.35;
349
+ let hFar =
350
+ pxH * (0.52 + 0.3 * Math.sin(far * 0.051 + 1.4)) +
351
+ pxH * 0.16 * Math.sin(far * 0.017 + 0.6);
352
+ hFar = Math.max(1, Math.min(pxH, Math.round(hFar)));
353
+ for (let y = pxH - hFar; y < pxH; y++) px.set(x, y, pal.hillFar);
354
+
355
+ const near = x + camXNow * 0.65;
356
+ let hNear =
357
+ pxH * (0.3 + 0.2 * Math.sin(near * 0.078 + 4.2)) +
358
+ pxH * 0.1 * Math.sin(near * 0.031 + 2.1);
359
+ hNear = Math.max(0, Math.min(pxH, Math.round(hNear)));
360
+ for (let y = pxH - hNear; y < pxH; y++) px.set(x, y, pal.hillNear);
361
+ }
362
+ return px;
363
+ }
364
+
365
+ // --- Sky dots (deterministic, twinkling; parallax 0.25x) ----------------------
366
+
367
+ function mulberry32(seed: number) {
368
+ let a = seed;
369
+ return () => {
370
+ a |= 0;
371
+ a = (a + 0x6d2b79f5) | 0;
372
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
373
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
374
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
375
+ };
376
+ }
377
+
378
+ interface SkyDot {
379
+ x: number;
380
+ yf: number;
381
+ phase: number;
382
+ big: boolean;
383
+ }
384
+ const SKY_DOTS: SkyDot[] = (() => {
385
+ const rng = mulberry32(0xb0071);
386
+ return Array.from({ length: 64 }, () => ({
387
+ x: rng() * (WORLD_W * 0.25 + 160),
388
+ yf: rng(),
389
+ phase: rng() * Math.PI * 2,
390
+ big: rng() < 0.15,
391
+ }));
392
+ })();
393
+
394
+ // --- Precomputed tile strings (no per-frame repeat allocations) ---------------
395
+
396
+ const CAP_GLYPHS = new Map<Platform, string>();
397
+ for (const p of PLATFORMS) CAP_GLYPHS.set(p, '▀'.repeat(p.w));
398
+
399
+ /** Pits derived from the gaps between ground segments. */
400
+ const PITS: { x: number; w: number }[] = [];
401
+ for (let i = 0; i < GROUND.length - 1; i++) {
402
+ const a = GROUND[i]!;
403
+ const b = GROUND[i + 1]!;
404
+ PITS.push({ x: a.x + a.w, w: b.x - (a.x + a.w) });
405
+ }
406
+
407
+ // --- Input mapping ------------------------------------------------------------
408
+
409
+ const KEY_ACTION: Record<string, HeldAction> = {
410
+ left: 'left',
411
+ a: 'left',
412
+ right: 'right',
413
+ d: 'right',
414
+ ' ': 'jump',
415
+ up: 'jump',
416
+ w: 'jump',
417
+ };
418
+
419
+ /** Reused input snapshot — stepPlayer never sees an allocation. */
420
+ const input: SimInput = {
421
+ left: false,
422
+ right: false,
423
+ jumpPressed: false,
424
+ jumpHeld: false,
425
+ };
426
+
427
+ // --- Render loop ---------------------------------------------------------------
428
+
429
+ render(
430
+ (ctx) => {
431
+ const t = ctx.theme;
432
+ const pal = paletteFor(t);
433
+ const { width: W, height: H } = ctx;
434
+ const dt = ctx.dt; // ms, clamped to 100 by the engine
435
+ simNow += dt;
436
+ frameMs = frameMs === 0 ? dt : frameMs * 0.9 + dt * 0.1;
437
+
438
+ // --- Input ---------------------------------------------------------------
439
+ if (ctx.lastKey === 'q' || ctx.lastKey === 'escape') ctx.requestStop();
440
+ if (ctx.lastKey === 'r') resetGame();
441
+ const pick = Number.parseInt(ctx.lastKey ?? '', 10);
442
+ if (pick >= 1 && pick <= THEMES.length) ctx.setTheme(THEMES[pick - 1]!);
443
+
444
+ input.jumpPressed = false;
445
+ for (const e of ctx.keys) {
446
+ const action = KEY_ACTION[e.key];
447
+ if (!action) continue;
448
+ if (e.kind === 'release') {
449
+ clearHeld(held, action);
450
+ } else {
451
+ // Any press-stream event counts as a jump intent: legacy terminals
452
+ // have no key-up, so "space active near landing" must buffer a jump
453
+ // (repeats included) or touchdown rebounds would depend on auto-
454
+ // repeat phase. Holding space simply keeps hopping — intended.
455
+ if (action === 'jump') input.jumpPressed = true;
456
+ noteHeld(held, action, simNow);
457
+ if (startedAt < 0) startedAt = simNow;
458
+ }
459
+ }
460
+ input.left = isHeldActive(held, 'left', simNow);
461
+ input.right = isHeldActive(held, 'right', simNow);
462
+ input.jumpHeld = isHeldActive(held, 'jump', simNow);
463
+
464
+ // --- Simulate ------------------------------------------------------------
465
+ const finished = finishedAt >= 0;
466
+ if (finished) {
467
+ // Let the player skid to a stop while the rocket launches.
468
+ input.left = false;
469
+ input.right = false;
470
+ input.jumpPressed = false;
471
+ }
472
+ stepPlayer(player, input, dt, simNow, PLATFORMS);
473
+ if (player.landedImpact > 12) {
474
+ squashUntil = simNow + 90;
475
+ spawnSparkle('dust', player.x, player.y);
476
+ }
477
+ if (player.grounded) {
478
+ safeX = player.x;
479
+ safeY = player.y;
480
+ }
481
+ if (player.y < -6) {
482
+ // Fell into a pit: respawn at the last grounded spot.
483
+ player.x = safeX;
484
+ player.y = safeY;
485
+ player.vx = 0;
486
+ player.vy = 0;
487
+ player.grounded = true;
488
+ respawnedAt = simNow;
489
+ }
490
+ runPhase += Math.abs(player.vx) * (dt / 1000) * 1.6;
491
+
492
+ // Star pickups: player AABB is x±halfW, rows y..y+3.
493
+ for (let i = 0; i < STAR_SPOTS.length; i++) {
494
+ if (collected[i]) continue;
495
+ const s = STAR_SPOTS[i]!;
496
+ if (
497
+ Math.abs(s.x - player.x) <= 2.2 &&
498
+ s.top >= player.y - 0.6 &&
499
+ s.top <= player.y + 3.2
500
+ ) {
501
+ collected[i] = true;
502
+ score++;
503
+ spawnSparkle('star', s.x, s.top);
504
+ }
505
+ }
506
+
507
+ // Goal: reach the rocket.
508
+ if (!finished && player.grounded && player.x >= GOAL_X - 3) {
509
+ finishedAt = simNow;
510
+ }
511
+
512
+ // --- Camera ----------------------------------------------------------------
513
+ const camTarget = Math.min(
514
+ Math.max(0, player.x - W * 0.38),
515
+ Math.max(0, WORLD_W - W),
516
+ );
517
+ camX += (camTarget - camX) * Math.min(1, (dt / 1000) * 6);
518
+ if (once) camX = camTarget;
519
+ const camXi = Math.round(camX);
520
+
521
+ // --- Draw: sky -----------------------------------------------------------
522
+ const skyKey = t.name;
523
+ let sky = gradientCache.get(skyKey);
524
+ if (!sky) {
525
+ sky = ctx.gradient({
526
+ colors: pal.skyColors,
527
+ direction: 'vertical',
528
+ steps: 64, // fine steps: no visible banding across tall viewports
529
+ });
530
+ gradientCache.set(skyKey, sky);
531
+ }
532
+ ctx.wallpaper(sky);
533
+
534
+ if (W < 60 || H < 16) {
535
+ const msg = 'starhop needs at least 60x16';
536
+ ctx.blit(Math.max(0, (W - msg.length) >> 1), H >> 1, msg, pal.hudMuted);
537
+ return;
538
+ }
539
+
540
+ const groundRow = H - 2; // ground surface: top edge of this row
541
+ const hillRows = Math.max(3, Math.min(11, Math.floor((H - 10) / 2.5)));
542
+
543
+ // Twinkling sky dots (skipped on light themes).
544
+ if (t.mode === 'dark') {
545
+ const skyH = Math.max(1, groundRow - hillRows - 2);
546
+ for (const d of SKY_DOTS) {
547
+ const sx = Math.floor(d.x - camX * 0.25);
548
+ if (sx < 0 || sx >= W) continue;
549
+ const tw = 0.5 + 0.5 * Math.sin(simNow * 0.0021 + d.phase * 4);
550
+ if (tw < 0.4) continue;
551
+ const row = 1 + Math.floor(d.yf * skyH);
552
+ ctx.blit(
553
+ sx,
554
+ row,
555
+ d.big ? '+' : '·',
556
+ tw > 0.82 ? pal.dotBright : pal.dotDim,
557
+ );
558
+ }
559
+ }
560
+
561
+ // Parallax hills sit on the ground line.
562
+ drawHills(camX, pal, W, hillRows * 2).blitTo(
563
+ ctx.state,
564
+ 0,
565
+ groundRow - hillRows,
566
+ );
567
+
568
+ // --- Draw: goal rocket (behind tiles/player) --------------------------------
569
+ {
570
+ const lift = finished
571
+ ? Math.min(H + 8, 3 * ((simNow - finishedAt) / 1000) ** 2)
572
+ : 0;
573
+ rocketCanvas.clear();
574
+ drawSprite(
575
+ rocketCanvas,
576
+ ROCKET_PX,
577
+ 0,
578
+ 0,
579
+ pal.rocketBody,
580
+ pal.trim,
581
+ pal.rocketWindow,
582
+ );
583
+ if (finished) {
584
+ // Flicker a two-phase exhaust flame under the rocket.
585
+ const phase = Math.floor(simNow / 66) % 2;
586
+ const fa = phase === 0 ? pal.flameA : pal.flameB;
587
+ const fb = phase === 0 ? pal.flameB : pal.flameA;
588
+ rocketCanvas.set(2, 7, fa);
589
+ rocketCanvas.set(1, 8, fb);
590
+ rocketCanvas.set(3, 8, fb);
591
+ rocketCanvas.set(2, 9, fa);
592
+ if (phase === 0) rocketCanvas.set(2, 10, fb);
593
+ }
594
+ const feetPx = groundRow * 2 - Math.round(lift * 2);
595
+ const topPx = feetPx - 7; // rocket bitmap is 7 px tall
596
+ const cellY = Math.floor(topPx / 2);
597
+ rocketCanvas.blitTo(ctx.state, GOAL_X - 2 - camXi, cellY);
598
+ }
599
+
600
+ // --- Draw: ground, pits, platforms ------------------------------------------
601
+ ctx.rect(0, groundRow, W, 2, pal.voidFill); // pit voids under everything
602
+ for (const seg of GROUND) {
603
+ const sx = seg.x - camXi;
604
+ const from = Math.max(sx, 0);
605
+ const to = Math.min(sx + seg.w, W);
606
+ if (to <= from) continue;
607
+ ctx.blit(
608
+ from,
609
+ groundRow,
610
+ CAP_GLYPHS.get(seg)!.substring(from - sx, to - sx),
611
+ pal.groundCap,
612
+ );
613
+ ctx.rect(from, groundRow + 1, to - from, 1, pal.groundDeep);
614
+ }
615
+ for (const p of LEDGES) {
616
+ const sx = p.x - camXi;
617
+ const from = Math.max(sx, 0);
618
+ const to = Math.min(sx + p.w, W);
619
+ if (to <= from) continue;
620
+ ctx.blit(
621
+ from,
622
+ groundRow - p.top,
623
+ CAP_GLYPHS.get(p)!.substring(from - sx, to - sx),
624
+ pal.platCap,
625
+ );
626
+ }
627
+
628
+ // --- Draw: stars --------------------------------------------------------------
629
+ const starGlyph = ctx.icon('star');
630
+ for (let i = 0; i < STAR_SPOTS.length; i++) {
631
+ if (collected[i]) continue;
632
+ const s = STAR_SPOTS[i]!;
633
+ const sx = Math.round(s.x) - camXi;
634
+ if (sx < 0 || sx >= W) continue;
635
+ const pulse = 0.5 + 0.5 * Math.sin(simNow * 0.004 + i * 1.7);
636
+ pal.star.fg = pulse > 0.66 ? pal.starB : pal.starA;
637
+ ctx.blit(sx, groundRow - s.top, starGlyph, pal.star);
638
+ }
639
+
640
+ // --- Draw: sparkles -------------------------------------------------------------
641
+ for (const s of sparkles) {
642
+ if (!s.active) continue;
643
+ const age = (simNow - s.bornAt) / SPARKLE_LIFE_MS;
644
+ if (age >= 1) {
645
+ s.active = false;
646
+ continue;
647
+ }
648
+ const cx = Math.round(s.x) - camXi;
649
+ const cy = groundRow - Math.round(s.y) - 1;
650
+ const radius = s.kind === 'star' ? 1 + age * 1.6 : 1 + age * 0.8;
651
+ const glyph = age < 0.45 ? '+' : '·';
652
+ pal.sparkle.fg = s.kind === 'star' ? pal.starB : pal.trim;
653
+ const n = s.kind === 'star' ? BURST.length : 4;
654
+ for (let i = 0; i < n; i++) {
655
+ const [ox, oy] = BURST[i]!;
656
+ ctx.blit(
657
+ cx + Math.round(ox * radius),
658
+ cy + Math.round(oy * radius),
659
+ glyph,
660
+ pal.sparkle,
661
+ );
662
+ }
663
+ }
664
+
665
+ // --- Draw: player (halfblock sprite with sub-cell vertical motion) --------------
666
+ const blink =
667
+ simNow - respawnedAt < 550 && Math.floor(simNow / 70) % 2 === 0;
668
+ if (!blink) {
669
+ const squashing = player.grounded && simNow < squashUntil;
670
+ const frame: PlayerFrame = !player.grounded
671
+ ? 'jump'
672
+ : squashing
673
+ ? 'squash'
674
+ : Math.abs(player.vx) > 2
675
+ ? Math.floor(runPhase) % 2 === 0
676
+ ? 'runA'
677
+ : 'runB'
678
+ : 'idle';
679
+ const sprite = FRAMES[frame];
680
+ const spriteH = squashing ? 4 : 6;
681
+ const feetPx = groundRow * 2 - Math.round(player.y * 2);
682
+ const topPx = feetPx - spriteH;
683
+ const cellY = Math.floor(topPx / 2);
684
+ const innerOff = topPx - cellY * 2;
685
+ const xOff = squashing ? 0 : 1;
686
+ playerCanvas.clear();
687
+ drawSprite(
688
+ playerCanvas,
689
+ sprite,
690
+ xOff,
691
+ innerOff,
692
+ pal.body,
693
+ pal.trim,
694
+ pal.eye,
695
+ );
696
+ playerCanvas.blitTo(ctx.state, Math.round(player.x - camX) - 3, cellY);
697
+ }
698
+
699
+ // --- Draw: HUD --------------------------------------------------------------------
700
+ const title = ` ${ctx.icon('rocket')} STARHOP`;
701
+ ctx.blit(1, 0, title, pal.hudTitle);
702
+ const elapsed =
703
+ startedAt < 0 ? 0 : ((finished ? finishedAt : simNow) - startedAt) / 1000;
704
+ const starText = `${starGlyph} ${score}/${STAR_SPOTS.length}`;
705
+ const perfText =
706
+ frameMs > 0
707
+ ? ` ${elapsed.toFixed(1)}s ${(1000 / frameMs).toFixed(0)}fps ${frameMs.toFixed(1)}ms `
708
+ : ` ${elapsed.toFixed(1)}s --fps `;
709
+ const right = W - 1 - visibleWidth(starText) - visibleWidth(perfText);
710
+ ctx.blit(right, 0, starText, pal.hudStar);
711
+ ctx.blit(right + visibleWidth(starText), 0, perfText, pal.hudMuted);
712
+
713
+ const hint = ' ←/→ move · space jump · 1/2/3 theme · r restart · q quit ';
714
+ if (W >= hint.length + 2) ctx.blit(1, H - 1, hint, pal.hint);
715
+
716
+ // --- Draw: clear overlay -------------------------------------------------------------
717
+ if (finished && simNow - finishedAt > 700) {
718
+ ctx.layer({ zIndex: 10, shadow: true }, (overlay) => {
719
+ const perfect = score === STAR_SPOTS.length;
720
+ overlay.box(
721
+ {
722
+ width: 34,
723
+ height: 7,
724
+ border: 'rounded',
725
+ borderColor: t.primary,
726
+ bgColor: t.surfaceRaised,
727
+ align: 'center',
728
+ valign: 'middle',
729
+ },
730
+ (b) => {
731
+ b.text(
732
+ ctx.color.bold(
733
+ t.primary(perfect ? '★ PERFECT LAUNCH ★' : 'LAUNCHED!'),
734
+ ),
735
+ );
736
+ b.text('\n');
737
+ b.text(
738
+ t.foreground(
739
+ `${starGlyph} ${score}/${STAR_SPOTS.length} · ${elapsed.toFixed(1)}s`,
740
+ ),
741
+ );
742
+ b.text('\n\n');
743
+ b.text(t.muted('r replay · q quit'));
744
+ },
745
+ );
746
+ });
747
+ }
748
+ },
749
+ {
750
+ fps: 60,
751
+ keyboard: true,
752
+ mouse: false,
753
+ hideCursor: true,
754
+ alternateBuffer: !once,
755
+ theme: darkTheme,
756
+ // Same policy as the other public demos (demo-layout.ts): assume a Nerd
757
+ // Font terminal so the star/rocket-era glyphs render.
758
+ nerdFont: true,
759
+ once,
760
+ },
761
+ );