@zakmandhro/bunti 0.1.5 → 0.2.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 (192) hide show
  1. package/AGENTS.md +65 -0
  2. package/README.md +81 -11
  3. package/dist/cli.d.ts +44 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +147 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/colors.d.ts +77 -7
  8. package/dist/colors.d.ts.map +1 -1
  9. package/dist/colors.js +238 -39
  10. package/dist/colors.js.map +1 -1
  11. package/dist/components/Box.d.ts +6 -0
  12. package/dist/components/Box.d.ts.map +1 -1
  13. package/dist/components/Box.js +5 -0
  14. package/dist/components/Box.js.map +1 -1
  15. package/dist/components/Button.d.ts +12 -2
  16. package/dist/components/Button.d.ts.map +1 -1
  17. package/dist/components/Button.js +35 -24
  18. package/dist/components/Button.js.map +1 -1
  19. package/dist/components/Card.d.ts +24 -2
  20. package/dist/components/Card.d.ts.map +1 -1
  21. package/dist/components/Card.js +56 -14
  22. package/dist/components/Card.js.map +1 -1
  23. package/dist/components/Header.d.ts +11 -1
  24. package/dist/components/Header.d.ts.map +1 -1
  25. package/dist/components/Header.js +12 -8
  26. package/dist/components/Header.js.map +1 -1
  27. package/dist/components/Input.d.ts +10 -1
  28. package/dist/components/Input.d.ts.map +1 -1
  29. package/dist/components/Input.js +172 -41
  30. package/dist/components/Input.js.map +1 -1
  31. package/dist/components/Link.d.ts +21 -0
  32. package/dist/components/Link.d.ts.map +1 -0
  33. package/dist/components/Link.js +29 -0
  34. package/dist/components/Link.js.map +1 -0
  35. package/dist/components/Modal.d.ts +25 -0
  36. package/dist/components/Modal.d.ts.map +1 -1
  37. package/dist/components/Modal.js +83 -24
  38. package/dist/components/Modal.js.map +1 -1
  39. package/dist/components/Progress.d.ts +27 -0
  40. package/dist/components/Progress.d.ts.map +1 -0
  41. package/dist/components/Progress.js +39 -0
  42. package/dist/components/Progress.js.map +1 -0
  43. package/dist/components/Spinner.d.ts +19 -0
  44. package/dist/components/Spinner.d.ts.map +1 -0
  45. package/dist/components/Spinner.js +27 -0
  46. package/dist/components/Spinner.js.map +1 -0
  47. package/dist/components/index.d.ts +3 -0
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/index.js +3 -0
  50. package/dist/components/index.js.map +1 -1
  51. package/dist/data/nf-glyphs.d.ts +11 -0
  52. package/dist/data/nf-glyphs.d.ts.map +1 -0
  53. package/dist/data/nf-glyphs.js +535 -0
  54. package/dist/data/nf-glyphs.js.map +1 -0
  55. package/dist/data/nf-names.d.ts +8 -0
  56. package/dist/data/nf-names.d.ts.map +1 -0
  57. package/dist/data/nf-names.js +2 -0
  58. package/dist/data/nf-names.js.map +1 -0
  59. package/dist/demo-registry.d.ts +27 -0
  60. package/dist/demo-registry.d.ts.map +1 -0
  61. package/dist/demo-registry.js +57 -0
  62. package/dist/demo-registry.js.map +1 -0
  63. package/dist/demos/2048.ts +743 -0
  64. package/dist/demos/animation.ts +246 -0
  65. package/dist/demos/demo-layout.ts +124 -0
  66. package/dist/demos/engine.ts +374 -0
  67. package/dist/demos/interaction.ts +91 -0
  68. package/dist/demos/login.ts +203 -0
  69. package/dist/demos/mission-control.ts +558 -0
  70. package/dist/demos/showcase.ts +224 -0
  71. package/dist/detect.d.ts +92 -3
  72. package/dist/detect.d.ts.map +1 -1
  73. package/dist/detect.js +218 -40
  74. package/dist/detect.js.map +1 -1
  75. package/dist/diagnostics.d.ts +73 -0
  76. package/dist/diagnostics.d.ts.map +1 -0
  77. package/dist/diagnostics.js +225 -0
  78. package/dist/diagnostics.js.map +1 -0
  79. package/dist/dsl/context.d.ts +11 -0
  80. package/dist/dsl/context.d.ts.map +1 -0
  81. package/dist/dsl/context.js +563 -0
  82. package/dist/dsl/context.js.map +1 -0
  83. package/dist/dsl/hooks.d.ts +12 -0
  84. package/dist/dsl/hooks.d.ts.map +1 -0
  85. package/dist/dsl/hooks.js +148 -0
  86. package/dist/dsl/hooks.js.map +1 -0
  87. package/dist/dsl/interaction.d.ts +16 -0
  88. package/dist/dsl/interaction.d.ts.map +1 -0
  89. package/dist/dsl/interaction.js +101 -0
  90. package/dist/dsl/interaction.js.map +1 -0
  91. package/dist/dsl/layers.d.ts +14 -0
  92. package/dist/dsl/layers.d.ts.map +1 -0
  93. package/dist/dsl/layers.js +87 -0
  94. package/dist/dsl/layers.js.map +1 -0
  95. package/dist/dsl/motion.d.ts +10 -0
  96. package/dist/dsl/motion.d.ts.map +1 -0
  97. package/dist/dsl/motion.js +150 -0
  98. package/dist/dsl/motion.js.map +1 -0
  99. package/dist/dsl/render.d.ts +22 -0
  100. package/dist/dsl/render.d.ts.map +1 -0
  101. package/dist/dsl/render.js +69 -0
  102. package/dist/dsl/render.js.map +1 -0
  103. package/dist/dsl/types.d.ts +510 -0
  104. package/dist/dsl/types.d.ts.map +1 -0
  105. package/dist/dsl/types.js +33 -0
  106. package/dist/dsl/types.js.map +1 -0
  107. package/dist/dsl.d.ts +13 -154
  108. package/dist/dsl.d.ts.map +1 -1
  109. package/dist/dsl.js +12 -658
  110. package/dist/dsl.js.map +1 -1
  111. package/dist/easing.d.ts +41 -1
  112. package/dist/easing.d.ts.map +1 -1
  113. package/dist/easing.js +105 -2
  114. package/dist/easing.js.map +1 -1
  115. package/dist/geometry.d.ts +39 -0
  116. package/dist/geometry.d.ts.map +1 -1
  117. package/dist/geometry.js +20 -0
  118. package/dist/geometry.js.map +1 -1
  119. package/dist/icons-full.d.ts +5 -0
  120. package/dist/icons-full.d.ts.map +1 -0
  121. package/dist/icons-full.js +31 -0
  122. package/dist/icons-full.js.map +1 -0
  123. package/dist/icons.d.ts +426 -5
  124. package/dist/icons.d.ts.map +1 -1
  125. package/dist/icons.js +144 -15
  126. package/dist/icons.js.map +1 -1
  127. package/dist/index.d.ts +93 -22
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +17 -7
  130. package/dist/index.js.map +1 -1
  131. package/dist/input.d.ts +177 -0
  132. package/dist/input.d.ts.map +1 -0
  133. package/dist/input.js +533 -0
  134. package/dist/input.js.map +1 -0
  135. package/dist/layout.d.ts +111 -6
  136. package/dist/layout.d.ts.map +1 -1
  137. package/dist/layout.js +163 -23
  138. package/dist/layout.js.map +1 -1
  139. package/dist/render.d.ts +33 -1
  140. package/dist/render.d.ts.map +1 -1
  141. package/dist/render.js +431 -116
  142. package/dist/render.js.map +1 -1
  143. package/dist/state.d.ts +137 -3
  144. package/dist/state.d.ts.map +1 -1
  145. package/dist/state.js +25 -0
  146. package/dist/state.js.map +1 -1
  147. package/dist/theme.d.ts +129 -0
  148. package/dist/theme.d.ts.map +1 -0
  149. package/dist/theme.js +167 -0
  150. package/dist/theme.js.map +1 -0
  151. package/dist/themes/catppuccin-mocha.d.ts +19 -0
  152. package/dist/themes/catppuccin-mocha.d.ts.map +1 -0
  153. package/dist/themes/catppuccin-mocha.js +36 -0
  154. package/dist/themes/catppuccin-mocha.js.map +1 -0
  155. package/dist/themes/dracula.d.ts +21 -0
  156. package/dist/themes/dracula.d.ts.map +1 -0
  157. package/dist/themes/dracula.js +37 -0
  158. package/dist/themes/dracula.js.map +1 -0
  159. package/dist/themes/github-light.d.ts +21 -0
  160. package/dist/themes/github-light.d.ts.map +1 -0
  161. package/dist/themes/github-light.js +36 -0
  162. package/dist/themes/github-light.js.map +1 -0
  163. package/dist/themes/index.d.ts +19 -0
  164. package/dist/themes/index.d.ts.map +1 -0
  165. package/dist/themes/index.js +17 -0
  166. package/dist/themes/index.js.map +1 -0
  167. package/dist/themes/nord.d.ts +23 -0
  168. package/dist/themes/nord.d.ts.map +1 -0
  169. package/dist/themes/nord.js +38 -0
  170. package/dist/themes/nord.js.map +1 -0
  171. package/dist/themes/one-dark-pro.d.ts +21 -0
  172. package/dist/themes/one-dark-pro.d.ts.map +1 -0
  173. package/dist/themes/one-dark-pro.js +36 -0
  174. package/dist/themes/one-dark-pro.js.map +1 -0
  175. package/dist/themes/tokyo-night.d.ts +20 -0
  176. package/dist/themes/tokyo-night.d.ts.map +1 -0
  177. package/dist/themes/tokyo-night.js +37 -0
  178. package/dist/themes/tokyo-night.js.map +1 -0
  179. package/dist/utils.d.ts +1 -0
  180. package/dist/utils.d.ts.map +1 -1
  181. package/dist/utils.js +1 -0
  182. package/dist/utils.js.map +1 -1
  183. package/dist/vendor/colors.d.ts +45 -0
  184. package/dist/vendor/colors.d.ts.map +1 -0
  185. package/dist/vendor/colors.js +72 -0
  186. package/dist/vendor/colors.js.map +1 -0
  187. package/llms.txt +79 -0
  188. package/package.json +23 -7
  189. package/dist/data/glyphs.d.ts +0 -5
  190. package/dist/data/glyphs.d.ts.map +0 -1
  191. package/dist/data/glyphs.js +0 -29
  192. package/dist/data/glyphs.js.map +0 -1
@@ -0,0 +1,743 @@
1
+ import { Box, Card, Modal } from '../components/index.js';
2
+ import {
3
+ clamp01,
4
+ easeInOutCubic,
5
+ easeOutCubic,
6
+ visibleWidth,
7
+ } from '../index.js';
8
+ import type { RGB } from '../state.js';
9
+ import { demo } from './demo-layout';
10
+
11
+ // --- Game Types ---
12
+ type Grid = number[][];
13
+
14
+ // --- Core 2048 Logic Helpers ---
15
+
16
+ function initGame(): Grid {
17
+ const grid = [
18
+ [0, 0, 0, 0],
19
+ [0, 0, 0, 0],
20
+ [0, 0, 0, 0],
21
+ [0, 0, 0, 0],
22
+ ];
23
+ spawnTile(grid);
24
+ spawnTile(grid);
25
+ return grid;
26
+ }
27
+
28
+ function spawnTile(grid: Grid) {
29
+ const empties: { r: number; c: number }[] = [];
30
+ for (let r = 0; r < 4; r++) {
31
+ for (let c = 0; c < 4; c++) {
32
+ if (grid[r]![c] === 0) {
33
+ empties.push({ r, c });
34
+ }
35
+ }
36
+ }
37
+ if (empties.length > 0) {
38
+ const { r, c } = empties[Math.floor(Math.random() * empties.length)]!;
39
+ grid[r]![c] = Math.random() < 0.9 ? 2 : 4;
40
+ }
41
+ }
42
+
43
+ function transpose(grid: Grid): Grid {
44
+ const result = [
45
+ [0, 0, 0, 0],
46
+ [0, 0, 0, 0],
47
+ [0, 0, 0, 0],
48
+ [0, 0, 0, 0],
49
+ ];
50
+ for (let r = 0; r < 4; r++) {
51
+ for (let c = 0; c < 4; c++) {
52
+ result[c]![r] = grid[r]![c]!;
53
+ }
54
+ }
55
+ return result;
56
+ }
57
+
58
+ interface TileMove {
59
+ fromRow: number;
60
+ fromCol: number;
61
+ toRow: number;
62
+ toCol: number;
63
+ value: number;
64
+ merged: boolean;
65
+ }
66
+
67
+ interface PeakMergeCelebration {
68
+ row: number;
69
+ col: number;
70
+ value: number;
71
+ startedAt: number;
72
+ }
73
+
74
+ function moveRowLeft(row: number[]): {
75
+ newRow: number[];
76
+ moves: { from: number; to: number; merged: boolean; val: number }[];
77
+ score: number;
78
+ } {
79
+ const newRow = [0, 0, 0, 0];
80
+ const moves: { from: number; to: number; merged: boolean; val: number }[] =
81
+ [];
82
+ const mergedInThisTurn = [false, false, false, false];
83
+ let writeCol = 0;
84
+ let score = 0;
85
+
86
+ for (let col = 0; col < 4; col++) {
87
+ const val = row[col]!;
88
+ if (val === 0) continue;
89
+
90
+ if (
91
+ writeCol > 0 &&
92
+ newRow[writeCol - 1] === val &&
93
+ !mergedInThisTurn[writeCol - 1]
94
+ ) {
95
+ newRow[writeCol - 1] = val * 2;
96
+ score += val * 2;
97
+ mergedInThisTurn[writeCol - 1] = true;
98
+
99
+ // Update the previous moves that wrote to writeCol - 1 to reflect the merge
100
+ for (const m of moves) {
101
+ if (m.to === writeCol - 1) {
102
+ m.merged = true;
103
+ }
104
+ }
105
+
106
+ moves.push({ from: col, to: writeCol - 1, merged: true, val });
107
+ } else {
108
+ newRow[writeCol] = val;
109
+ moves.push({ from: col, to: writeCol, merged: false, val });
110
+ writeCol++;
111
+ }
112
+ }
113
+
114
+ return { newRow, moves, score };
115
+ }
116
+
117
+ function slideLeft(grid: Grid): {
118
+ next: Grid;
119
+ scoreGained: number;
120
+ moves: TileMove[];
121
+ } {
122
+ const next: Grid = [];
123
+ let scoreGained = 0;
124
+ const moves: TileMove[] = [];
125
+
126
+ for (let r = 0; r < 4; r++) {
127
+ const { newRow, moves: rowMoves, score } = moveRowLeft(grid[r]!);
128
+ next.push(newRow);
129
+ scoreGained += score;
130
+
131
+ for (const rm of rowMoves) {
132
+ moves.push({
133
+ fromRow: r,
134
+ fromCol: rm.from,
135
+ toRow: r,
136
+ toCol: rm.to,
137
+ value: rm.val,
138
+ merged: rm.merged,
139
+ });
140
+ }
141
+ }
142
+
143
+ return { next, scoreGained, moves };
144
+ }
145
+
146
+ function slideRight(grid: Grid): {
147
+ next: Grid;
148
+ scoreGained: number;
149
+ moves: TileMove[];
150
+ } {
151
+ const next: Grid = [];
152
+ let scoreGained = 0;
153
+ const moves: TileMove[] = [];
154
+
155
+ for (let r = 0; r < 4; r++) {
156
+ const reversed = [...grid[r]!].reverse();
157
+ const { newRow, moves: rowMoves, score } = moveRowLeft(reversed);
158
+ next.push([...newRow].reverse());
159
+ scoreGained += score;
160
+
161
+ for (const rm of rowMoves) {
162
+ const fromCol = 3 - rm.from;
163
+ const toCol = 3 - rm.to;
164
+ moves.push({
165
+ fromRow: r,
166
+ fromCol,
167
+ toRow: r,
168
+ toCol,
169
+ value: rm.val,
170
+ merged: rm.merged,
171
+ });
172
+ }
173
+ }
174
+
175
+ return { next, scoreGained, moves };
176
+ }
177
+
178
+ function slideUp(grid: Grid): {
179
+ next: Grid;
180
+ scoreGained: number;
181
+ moves: TileMove[];
182
+ } {
183
+ const transposed = transpose(grid);
184
+ const { next: slid, scoreGained, moves: slidMoves } = slideLeft(transposed);
185
+ const moves = slidMoves.map((m) => ({
186
+ fromRow: m.fromCol,
187
+ fromCol: m.fromRow,
188
+ toRow: m.toCol,
189
+ toCol: m.toRow,
190
+ value: m.value,
191
+ merged: m.merged,
192
+ }));
193
+ return { next: transpose(slid), scoreGained, moves };
194
+ }
195
+
196
+ function slideDown(grid: Grid): {
197
+ next: Grid;
198
+ scoreGained: number;
199
+ moves: TileMove[];
200
+ } {
201
+ const transposed = transpose(grid);
202
+ const { next: slid, scoreGained, moves: slidMoves } = slideRight(transposed);
203
+ const moves = slidMoves.map((m) => ({
204
+ fromRow: m.fromCol,
205
+ fromCol: m.fromRow,
206
+ toRow: m.toCol,
207
+ toCol: m.toRow,
208
+ value: m.value,
209
+ merged: m.merged,
210
+ }));
211
+ return { next: transpose(slid), scoreGained, moves };
212
+ }
213
+
214
+ function gridChanged(g1: Grid, g2: Grid): boolean {
215
+ for (let r = 0; r < 4; r++) {
216
+ for (let c = 0; c < 4; c++) {
217
+ if (g1[r]![c] !== g2[r]![c]) return true;
218
+ }
219
+ }
220
+ return false;
221
+ }
222
+
223
+ function getMaxTile(grid: Grid): number {
224
+ let max = 0;
225
+ for (let r = 0; r < 4; r++) {
226
+ for (let c = 0; c < 4; c++) {
227
+ max = Math.max(max, grid[r]![c]!);
228
+ }
229
+ }
230
+ return max;
231
+ }
232
+
233
+ function findPeakMerge(
234
+ moves: TileMove[],
235
+ next: Grid,
236
+ currentMaxTile: number,
237
+ ): Omit<PeakMergeCelebration, 'startedAt'> | null {
238
+ let peak: Omit<PeakMergeCelebration, 'startedAt'> | null = null;
239
+ const seenTargets = new Set<string>();
240
+
241
+ for (const move of moves) {
242
+ if (!move.merged) continue;
243
+
244
+ const key = `${move.toRow}:${move.toCol}`;
245
+ if (seenTargets.has(key)) continue;
246
+ seenTargets.add(key);
247
+
248
+ const value = next[move.toRow]![move.toCol]!;
249
+ if (value <= currentMaxTile) continue;
250
+ if (peak && value <= peak.value) continue;
251
+
252
+ peak = {
253
+ row: move.toRow,
254
+ col: move.toCol,
255
+ value,
256
+ };
257
+ }
258
+
259
+ return peak;
260
+ }
261
+
262
+ function isGameOver(grid: Grid): boolean {
263
+ // Any empty cells left?
264
+ for (let r = 0; r < 4; r++) {
265
+ for (let c = 0; c < 4; c++) {
266
+ if (grid[r]![c] === 0) return false;
267
+ }
268
+ }
269
+ // Any adjacent equal values horizontally?
270
+ for (let r = 0; r < 4; r++) {
271
+ for (let c = 0; c < 3; c++) {
272
+ if (grid[r]![c] === grid[r]![c + 1]) return false;
273
+ }
274
+ }
275
+ // Any adjacent equal values vertically?
276
+ for (let r = 0; r < 3; r++) {
277
+ for (let c = 0; c < 4; c++) {
278
+ if (grid[r]![c] === grid[r + 1]![c]) return false;
279
+ }
280
+ }
281
+ return true;
282
+ }
283
+
284
+ // --- Text Centering Helper ---
285
+ function centerText(text: string, width: number): string {
286
+ const padTotal = width - text.length;
287
+ if (padTotal <= 0) return text.substring(0, width);
288
+ const padLeft = Math.floor(padTotal / 2);
289
+ const padRight = padTotal - padLeft;
290
+ return ' '.repeat(padLeft) + text + ' '.repeat(padRight);
291
+ }
292
+
293
+ function mixRGB(from: RGB, to: RGB, amount: number): RGB {
294
+ const t = clamp01(amount);
295
+ return {
296
+ r: Math.round(from.r + (to.r - from.r) * t),
297
+ g: Math.round(from.g + (to.g - from.g) * t),
298
+ b: Math.round(from.b + (to.b - from.b) * t),
299
+ };
300
+ }
301
+
302
+ // --- Tile Theme Colors (Original 2048 palette) ---
303
+ interface TileColors {
304
+ bg: RGB;
305
+ fg: RGB;
306
+ }
307
+
308
+ function getTileColors(val: number): TileColors {
309
+ switch (val) {
310
+ case 2:
311
+ return { bg: { r: 238, g: 228, b: 218 }, fg: { r: 119, g: 110, b: 101 } };
312
+ case 4:
313
+ return { bg: { r: 237, g: 224, b: 200 }, fg: { r: 119, g: 110, b: 101 } };
314
+ case 8:
315
+ return { bg: { r: 242, g: 177, b: 121 }, fg: { r: 249, g: 246, b: 242 } };
316
+ case 16:
317
+ return { bg: { r: 245, g: 149, b: 99 }, fg: { r: 249, g: 246, b: 242 } };
318
+ case 32:
319
+ return { bg: { r: 246, g: 124, b: 95 }, fg: { r: 249, g: 246, b: 242 } };
320
+ case 64:
321
+ return { bg: { r: 246, g: 94, b: 59 }, fg: { r: 249, g: 246, b: 242 } };
322
+ case 128:
323
+ return { bg: { r: 237, g: 207, b: 114 }, fg: { r: 249, g: 246, b: 242 } };
324
+ case 256:
325
+ return { bg: { r: 237, g: 204, b: 97 }, fg: { r: 249, g: 246, b: 242 } };
326
+ case 512:
327
+ return { bg: { r: 156, g: 206, b: 56 }, fg: { r: 249, g: 246, b: 242 } };
328
+ case 1024:
329
+ return { bg: { r: 70, g: 180, b: 233 }, fg: { r: 249, g: 246, b: 242 } };
330
+ case 2048:
331
+ return { bg: { r: 164, g: 75, b: 227 }, fg: { r: 249, g: 246, b: 242 } };
332
+ default:
333
+ if (val > 2048) {
334
+ return {
335
+ bg: { r: 224, g: 36, b: 171 },
336
+ fg: { r: 255, g: 255, b: 255 },
337
+ };
338
+ }
339
+ // Empty grid cell background (dark slate)
340
+ return { bg: { r: 44, g: 47, b: 56 }, fg: { r: 80, g: 85, b: 95 } };
341
+ }
342
+ }
343
+
344
+ const SLIDE_ANIMATION_DURATION = 220;
345
+ const PEAK_MERGE_DURATION = 380;
346
+ const GRID_SIZE = 4;
347
+ const TILE_W = 8;
348
+ const TILE_H = 3;
349
+ const TILE_GAP_X = 2;
350
+ const TILE_GAP_Y = 1;
351
+ const TILE_STEP_X = TILE_W + TILE_GAP_X;
352
+ const TILE_STEP_Y = TILE_H + TILE_GAP_Y;
353
+ const BOARD_W = GRID_SIZE * TILE_W + (GRID_SIZE - 1) * TILE_GAP_X + 2;
354
+ const BOARD_H = GRID_SIZE * TILE_H + (GRID_SIZE - 1) * TILE_GAP_Y + 2;
355
+
356
+ function drawPeakMergeCelebration(
357
+ ctx: Parameters<Parameters<typeof Box>[2]>[0],
358
+ celebration: PeakMergeCelebration | null,
359
+ ) {
360
+ if (!celebration) return;
361
+
362
+ const age = Date.now() - celebration.startedAt;
363
+ if (age < 0 || age > PEAK_MERGE_DURATION) return;
364
+
365
+ const progress = clamp01(age / PEAK_MERGE_DURATION);
366
+ const glow = 1 - easeOutCubic(progress);
367
+ const flash = Math.sin(progress * Math.PI) * (1 - progress * 0.35);
368
+ const tileX = ctx.offsetX + 1 + celebration.col * TILE_STEP_X;
369
+ const tileY = ctx.offsetY + 1 + celebration.row * TILE_STEP_Y;
370
+ const { bg: tileBg, fg: tileFg } = getTileColors(celebration.value);
371
+ const glowColor = mixRGB(
372
+ { r: 62, g: 52, b: 30 },
373
+ { r: 255, g: 226, b: 86 },
374
+ glow,
375
+ );
376
+ const numberColor = mixRGB(tileFg, { r: 255, g: 255, b: 255 }, flash);
377
+ const tileColor = mixRGB(tileBg, { r: 255, g: 226, b: 86 }, flash * 0.22);
378
+
379
+ ctx.layer(8, (fx) => {
380
+ fx.rect(tileX - 1, tileY - 1, TILE_W + 2, TILE_H + 2, { bg: glowColor });
381
+
382
+ fx.rect(tileX, tileY, TILE_W, TILE_H, { bg: tileColor });
383
+ fx.blit(
384
+ tileX,
385
+ tileY + 1,
386
+ fx.color.bold(centerText(celebration.value.toString(), TILE_W)),
387
+ {
388
+ fg: numberColor,
389
+ bg: tileColor,
390
+ bold: true,
391
+ },
392
+ );
393
+ });
394
+ }
395
+
396
+ // --- Main 2048 Demo ---
397
+
398
+ demo(
399
+ 'Bunti Retro Arcade: 2048',
400
+ (ctx, bounds) => {
401
+ const {
402
+ color,
403
+ gradient,
404
+ lastKey,
405
+ usePersistentState,
406
+ useState,
407
+ wallpaper,
408
+ } = ctx;
409
+
410
+ // --- State Initialization ---
411
+ const [grid, setGrid] = useState<Grid | null>('2048_grid', null);
412
+ const [score, setScore] = useState<number>('2048_score', 0);
413
+ const [highScore, setHighScore] = usePersistentState<number>(
414
+ '2048_highscore',
415
+ 0,
416
+ );
417
+ const [gameOver, setGameOver] = useState<boolean>('2048_gameover', false);
418
+ const [win, setWin] = useState<boolean>('2048_win', false);
419
+ const [keepPlaying, setKeepPlaying] = useState<boolean>(
420
+ '2048_keepplaying',
421
+ false,
422
+ );
423
+ const [activeMoves, setActiveMoves] = useState<TileMove[]>(
424
+ '2048_activemoves',
425
+ [],
426
+ );
427
+ const [slideStart, setSlideStart] = useState<number>('2048_slidestart', 0);
428
+ const [maxTile, setMaxTile] = useState<number>('2048_maxtile', 0);
429
+ const [peakMerge, setPeakMerge] = useState<PeakMergeCelebration | null>(
430
+ '2048_peakmerge',
431
+ null,
432
+ );
433
+
434
+ const resetGame = () => {
435
+ const g = initGame();
436
+ setGrid(g);
437
+ setScore(0);
438
+ setGameOver(false);
439
+ setWin(false);
440
+ setKeepPlaying(false);
441
+ setActiveMoves([]);
442
+ setSlideStart(0);
443
+ setMaxTile(getMaxTile(g));
444
+ setPeakMerge(null);
445
+ };
446
+
447
+ if (grid === null) {
448
+ resetGame();
449
+ return;
450
+ }
451
+
452
+ // --- Process Input Events ---
453
+ if (lastKey) {
454
+ let moved = false;
455
+ let next: Grid = [];
456
+ let scoreGained = 0;
457
+ let computedMoves: TileMove[] = [];
458
+
459
+ if (win && !keepPlaying) {
460
+ if (lastKey === 'enter' || lastKey === 'c') {
461
+ setKeepPlaying(true);
462
+ setWin(false);
463
+ }
464
+ } else if (!gameOver) {
465
+ if (lastKey === 'up' || lastKey === 'w') {
466
+ const res = slideUp(grid);
467
+ next = res.next;
468
+ scoreGained = res.scoreGained;
469
+ computedMoves = res.moves;
470
+ moved = gridChanged(grid, next);
471
+ } else if (lastKey === 'down' || lastKey === 's') {
472
+ const res = slideDown(grid);
473
+ next = res.next;
474
+ scoreGained = res.scoreGained;
475
+ computedMoves = res.moves;
476
+ moved = gridChanged(grid, next);
477
+ } else if (lastKey === 'left' || lastKey === 'a') {
478
+ const res = slideLeft(grid);
479
+ next = res.next;
480
+ scoreGained = res.scoreGained;
481
+ computedMoves = res.moves;
482
+ moved = gridChanged(grid, next);
483
+ } else if (lastKey === 'right' || lastKey === 'd') {
484
+ const res = slideRight(grid);
485
+ next = res.next;
486
+ scoreGained = res.scoreGained;
487
+ computedMoves = res.moves;
488
+ moved = gridChanged(grid, next);
489
+ }
490
+
491
+ if (moved) {
492
+ const now = Date.now();
493
+ const peak = findPeakMerge(computedMoves, next, maxTile);
494
+
495
+ setActiveMoves(computedMoves);
496
+ setSlideStart(now);
497
+
498
+ spawnTile(next);
499
+ setGrid(next);
500
+ setMaxTile(Math.max(maxTile, getMaxTile(next)));
501
+ if (peak) {
502
+ setPeakMerge({
503
+ ...peak,
504
+ startedAt: now + SLIDE_ANIMATION_DURATION,
505
+ });
506
+ }
507
+ const newScore = score + scoreGained;
508
+ setScore(newScore);
509
+
510
+ if (newScore > highScore) {
511
+ setHighScore(newScore);
512
+ }
513
+
514
+ // Check if user hit 2048
515
+ if (!win && !keepPlaying) {
516
+ for (let r = 0; r < 4; r++) {
517
+ for (let c = 0; c < 4; c++) {
518
+ if (next[r]![c] === 2048) {
519
+ setWin(true);
520
+ }
521
+ }
522
+ }
523
+ }
524
+
525
+ // Check for Game Over
526
+ if (isGameOver(next)) {
527
+ setGameOver(true);
528
+ }
529
+ }
530
+ }
531
+
532
+ if (lastKey === 'r') {
533
+ resetGame();
534
+ }
535
+ }
536
+
537
+ // --- Draw UI ---
538
+ wallpaper(
539
+ gradient({
540
+ colors: [
541
+ { r: 7, g: 10, b: 18 },
542
+ { r: 15, g: 28, b: 46 },
543
+ { r: 33, g: 15, b: 49 },
544
+ { r: 8, g: 11, b: 20 },
545
+ ],
546
+ direction: 'vertical',
547
+ steps: 24,
548
+ }),
549
+ );
550
+
551
+ // Calculate layout sizing and center the game board
552
+ const W = bounds.w;
553
+ const H = bounds.h;
554
+ const boardW = BOARD_W;
555
+ const boardH = BOARD_H;
556
+ const boardX = bounds.x + Math.max(0, Math.floor((W - boardW) / 2));
557
+
558
+ // Calculate vertical layout based on available bounds.h to ensure y >= 0 and avoid any overlapping.
559
+ // Scoreboard needs 3 rows, and board needs 19 rows.
560
+ const totalNeededH = 3 + boardH;
561
+ const extraH = Math.max(0, H - totalNeededH);
562
+ const topPadding = Math.floor(extraH / 2);
563
+
564
+ const scoreboardY = bounds.y + topPadding;
565
+ const boardY = scoreboardY + 3; // Shift board down to make room for scoreboard (3 lines)
566
+
567
+ // Header Panel: Scoreboard & Title
568
+ Card(
569
+ ctx,
570
+ {
571
+ x: boardX,
572
+ y: scoreboardY,
573
+ width: boardW,
574
+ height: 3,
575
+ border: 'none',
576
+ padding: [0, 0],
577
+ },
578
+ (sub) => {
579
+ const titleStr = color.yellow(color.bold(` 󰓓 2048 `));
580
+ const scoreStr = color.white(
581
+ `SCORE: ${color.green(color.bold(score.toString()))} | BEST: ${color.yellow(color.bold(highScore.toString()))}`,
582
+ );
583
+ const titleW = visibleWidth(titleStr);
584
+ const scoreW = visibleWidth(scoreStr);
585
+ const spaceCount = Math.max(0, boardW - titleW - scoreW);
586
+ sub.text(`${titleStr}${' '.repeat(spaceCount)}${scoreStr}`);
587
+ },
588
+ );
589
+
590
+ // Main 4x4 Game Grid Container
591
+ Box(
592
+ ctx,
593
+ {
594
+ x: boardX,
595
+ y: boardY,
596
+ width: boardW,
597
+ height: boardH,
598
+ border: 'none',
599
+ padding: [0, 0],
600
+ },
601
+ (sub) => {
602
+ const elapsed = Date.now() - slideStart;
603
+ const isAnimating =
604
+ elapsed < SLIDE_ANIMATION_DURATION && activeMoves.length > 0;
605
+
606
+ // 1. Draw empty grid cells in background first
607
+ for (let r = 0; r < GRID_SIZE; r++) {
608
+ for (let c = 0; c < GRID_SIZE; c++) {
609
+ const relX = 1 + c * TILE_STEP_X;
610
+ const relY = 1 + r * TILE_STEP_Y;
611
+ const absX = sub.offsetX + relX;
612
+ const absY = sub.offsetY + relY;
613
+ const { bg: bgCol } = getTileColors(0);
614
+ sub.rect(absX, absY, TILE_W, TILE_H, { bg: bgCol });
615
+ }
616
+ }
617
+
618
+ if (isAnimating) {
619
+ const progress = clamp01(elapsed / SLIDE_ANIMATION_DURATION);
620
+ const eased = easeInOutCubic(progress);
621
+
622
+ for (const m of activeMoves) {
623
+ const startX = 1 + m.fromCol * TILE_STEP_X;
624
+ const startY = 1 + m.fromRow * TILE_STEP_Y;
625
+ const endX = 1 + m.toCol * TILE_STEP_X;
626
+ const endY = 1 + m.toRow * TILE_STEP_Y;
627
+
628
+ const currX = startX + eased * (endX - startX);
629
+ const currY = startY + eased * (endY - startY);
630
+
631
+ const absX = Math.round(sub.offsetX + currX);
632
+ const absY = Math.round(sub.offsetY + currY);
633
+
634
+ const { bg: bgCol, fg: fgCol } = getTileColors(m.value);
635
+ sub.rect(absX, absY, TILE_W, TILE_H, { bg: bgCol });
636
+ const valStr = m.value.toString();
637
+ const centered = color.bold(centerText(valStr, TILE_W));
638
+ sub.blit(absX, absY + 1, centered, {
639
+ fg: fgCol,
640
+ bg: bgCol,
641
+ bold: true,
642
+ });
643
+ }
644
+ } else {
645
+ // Render static tiles
646
+ for (let r = 0; r < GRID_SIZE; r++) {
647
+ for (let c = 0; c < GRID_SIZE; c++) {
648
+ const val = grid[r]![c]!;
649
+ if (val === 0) continue; // Already drawn empty background
650
+
651
+ const relX = 1 + c * TILE_STEP_X;
652
+ const relY = 1 + r * TILE_STEP_Y;
653
+ const absX = sub.offsetX + relX;
654
+ const absY = sub.offsetY + relY;
655
+
656
+ const { bg: bgCol, fg: fgCol } = getTileColors(val);
657
+
658
+ sub.rect(absX, absY, TILE_W, TILE_H, { bg: bgCol });
659
+ const valStr = val.toString();
660
+ const centered = color.bold(centerText(valStr, TILE_W));
661
+ sub.blit(absX, absY + 1, centered, {
662
+ fg: fgCol,
663
+ bg: bgCol,
664
+ bold: true,
665
+ });
666
+ }
667
+ }
668
+ }
669
+
670
+ drawPeakMergeCelebration(sub, peakMerge);
671
+
672
+ // Overlays (Modals on top of Grid)
673
+ if (gameOver) {
674
+ sub.layer(10, (overlay) => {
675
+ Modal(
676
+ overlay,
677
+ {
678
+ width: 27,
679
+ height: 7,
680
+ border: 'none',
681
+ bgColor: { r: 35, g: 0, b: 0 },
682
+ align: 'center',
683
+ },
684
+ (modalSub) => {
685
+ modalSub.text('\n');
686
+ modalSub.text(color.red(color.bold('GAME OVER\n')));
687
+ modalSub.text(color.dim(`Final Score: ${score}\n\n`));
688
+ modalSub.text(color.yellow('[R] To Restart'));
689
+ },
690
+ );
691
+ });
692
+ } else if (win && !keepPlaying) {
693
+ sub.layer(10, (overlay) => {
694
+ Modal(
695
+ overlay,
696
+ {
697
+ width: 27,
698
+ height: 7,
699
+ border: 'none',
700
+ bgColor: { r: 35, g: 35, b: 0 },
701
+ align: 'center',
702
+ },
703
+ (modalSub) => {
704
+ modalSub.text('\n');
705
+ modalSub.text(
706
+ color.fg('gold', color.bold('YOU REACHED 2048!\n')),
707
+ );
708
+ modalSub.text(color.dim('Magnificent job!\n\n'));
709
+ modalSub.text(color.green('[Enter] Keep Playing'));
710
+ },
711
+ );
712
+ });
713
+ }
714
+ },
715
+ );
716
+
717
+ // Bottom Control Banner
718
+ Box(
719
+ ctx,
720
+ {
721
+ x: boardX,
722
+ y: boardY + boardH,
723
+ width: boardW,
724
+ height: 3,
725
+ border: 'none',
726
+ padding: [0, 0],
727
+ align: 'center',
728
+ valign: 'middle',
729
+ },
730
+ (sub) => {
731
+ sub.text(
732
+ color.dim(
733
+ `󰜲 ${color.bold('W,A,S,D')} or ${color.bold('ARROWS')} to slide\n${color.bold('R')} restart | ${color.bold('Q')} quit`,
734
+ ),
735
+ );
736
+ },
737
+ );
738
+ },
739
+ {
740
+ fps: 60,
741
+ mouse: false, // Pure keyboard game
742
+ },
743
+ );