novac 2.3.0 → 2.4.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.
@@ -0,0 +1,839 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * kitascii — ASCII / ANSI art toolkit
5
+ *
6
+ * Four tools in one kit:
7
+ *
8
+ * kitdef.TextArt(opts) Big ASCII-font text renderer (figlet-style)
9
+ * kitdef.Animate(opts) Preset ASCII animations (fire, matrix, starfield, plasma, rain)
10
+ * kitdef.BoxArt(opts) Interactive box / border / banner composer
11
+ * kitdef.ImageToAscii(px) Convert a 2-D pixel array to ASCII art (for use with sharp etc.)
12
+ *
13
+ * All four share the kitarcade API:
14
+ * .start() .input(key) .tick(dt) .frame() .state .score
15
+ *
16
+ * Quick-start (animation):
17
+ * const { kitdef } = require('./kitascii');
18
+ * const { RunGame } = require('./run_game');
19
+ * RunGame(kitdef.Animate({ preset: 'matrix' }));
20
+ */
21
+
22
+ // ─── ANSI / CANVAS ───────────────────────────────────────────────────────────
23
+
24
+ const FC = {
25
+ blk:30,red:31,grn:32,yel:33,blu:34,mag:35,cyn:36,wht:37,
26
+ BLK:90,RED:91,GRN:92,YEL:93,BLU:94,MAG:95,CYN:96,WHT:97,
27
+ };
28
+ const BC = {
29
+ blk:40,red:41,grn:42,yel:43,blu:44,mag:45,cyn:46,wht:47,
30
+ BLK:100,RED:101,GRN:102,YEL:103,BLU:104,MAG:105,CYN:106,WHT:107,
31
+ };
32
+ const RST = '\x1b[0m';
33
+ const BLD = '\x1b[1m';
34
+ const DIM = '\x1b[2m';
35
+
36
+ function makeCanvas(W, H) {
37
+ const ch = Array.from({length:H}, () => new Array(W).fill(' '));
38
+ const fg = Array.from({length:H}, () => new Array(W).fill(FC.wht));
39
+ const bg = Array.from({length:H}, () => new Array(W).fill(BC.blk));
40
+ const cv = {
41
+ W, H,
42
+ clear(c=' ', f=FC.wht, b=BC.blk) {
43
+ for (let y=0;y<H;y++) for (let x=0;x<W;x++) { ch[y][x]=c;fg[y][x]=f;bg[y][x]=b; }
44
+ },
45
+ set(x, y, c, f=FC.wht, b=BC.blk) {
46
+ if (x<0||x>=W||y<0||y>=H) return;
47
+ ch[y][x]=(String(c)[0])||' '; fg[y][x]=f; bg[y][x]=b;
48
+ },
49
+ put(x, y, s, f=FC.wht, b=BC.blk) {
50
+ for (let i=0;i<s.length;i++) cv.set(x+i, y, s[i], f, b);
51
+ },
52
+ fill(x, y, w, h, c, f=FC.wht, b=BC.blk) {
53
+ for (let dy=0;dy<h;dy++) for (let dx=0;dx<w;dx++) cv.set(x+dx,y+dy,c,f,b);
54
+ },
55
+ get(x, y) {
56
+ if (x<0||x>=W||y<0||y>=H) return [' ', FC.wht, BC.blk];
57
+ return [ch[y][x], fg[y][x], bg[y][x]];
58
+ },
59
+ render() {
60
+ const lines = [];
61
+ for (let y=0;y<H;y++) {
62
+ let row='', cf=-1, cb=-1;
63
+ for (let x=0;x<W;x++) {
64
+ const f=fg[y][x], b=bg[y][x];
65
+ if (f!==cf||b!==cb) { row+=`\x1b[${f};${b}m`; cf=f; cb=b; }
66
+ row+=ch[y][x];
67
+ }
68
+ lines.push(row+RST);
69
+ }
70
+ return lines.join('\n');
71
+ },
72
+ };
73
+ return cv;
74
+ }
75
+
76
+ function makeEmitter() {
77
+ const L = new Map();
78
+ return {
79
+ on(ev,fn) { if(!L.has(ev)) L.set(ev,[]); L.get(ev).push(fn); },
80
+ off(ev,fn) { if(L.has(ev)) L.set(ev,L.get(ev).filter(f=>f!==fn)); },
81
+ emit(ev,...a) { (L.get(ev)??[]).forEach(fn=>fn(...a)); },
82
+ };
83
+ }
84
+
85
+ const rng = (n) => Math.random() * n | 0;
86
+ const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
87
+
88
+ // ─── FONT DATA ───────────────────────────────────────────────────────────────
89
+ // 5-row tall block font. Each character is an array of 5 strings (rows).
90
+ // Width varies per character.
91
+
92
+ const FONT = {
93
+ ' ': [' ',' ',' ',' ',' '],
94
+ 'A': [' ██ ','█ █','████','█ █','█ █'],
95
+ 'B': ['███ ','█ █','███ ','█ █','███ '],
96
+ 'C': [' ███','█ ','█ ','█ ',' ███'],
97
+ 'D': ['██ ','█ █ ','█ █','█ █','███ '],
98
+ 'E': ['████','█ ','███ ','█ ','████'],
99
+ 'F': ['████','█ ','███ ','█ ','█ '],
100
+ 'G': [' ███','█ ','█ ██','█ █',' ███'],
101
+ 'H': ['█ █','█ █','████','█ █','█ █'],
102
+ 'I': ['███',' █ ',' █ ',' █ ','███'],
103
+ 'J': [' █',' █',' █','█ █',' █ '],
104
+ 'K': ['█ █','█ █ ','██ ','█ █ ','█ █'],
105
+ 'L': ['█ ','█ ','█ ','█ ','████'],
106
+ 'M': ['█ █','██ ██','█ █ █','█ █','█ █'],
107
+ 'N': ['█ █','██ █','█ █ █','█ ██','█ █'],
108
+ 'O': [' ██ ','█ █','█ █','█ █',' ██ '],
109
+ 'P': ['███ ','█ █','███ ','█ ','█ '],
110
+ 'Q': [' ██ ','█ █','█ █','█ ██',' ███'],
111
+ 'R': ['███ ','█ █','███ ','█ █ ','█ █'],
112
+ 'S': [' ███','█ ',' ██ ',' █','███ '],
113
+ 'T': ['████',' █ ',' █ ',' █ ',' █ '],
114
+ 'U': ['█ █','█ █','█ █','█ █',' ██ '],
115
+ 'V': ['█ █','█ █','█ █',' █ █ ',' █ '],
116
+ 'W': ['█ █','█ █','█ █ █','██ ██','█ █'],
117
+ 'X': ['█ █',' ██ ',' ██',' ██ ','█ █'], // fixed
118
+ 'Y': ['█ █','█ █',' ██ ',' █ ',' █ '],
119
+ 'Z': ['████',' █ ',' █ ','█ ','████'],
120
+ '0': [' ██ ','█ ██','██ █','█ █',' ██ '],
121
+ '1': [' █ ','██ ',' █ ',' █ ','███'],
122
+ '2': [' ██ ',' █',' ██ ','█ ','████'],
123
+ '3': ['███ ',' █',' ██ ',' █','███ '],
124
+ '4': ['█ █','█ █','████',' █',' █'],
125
+ '5': ['████','█ ','███ ',' █','███ '],
126
+ '6': [' ██ ','█ ','███ ','█ █',' ██ '],
127
+ '7': ['████',' █',' █ ',' █ ',' █ '],
128
+ '8': [' ██ ','█ █',' ██ ','█ █',' ██ '],
129
+ '9': [' ██ ','█ █',' ███',' █',' ██ '],
130
+ '!': [' █ ',' █ ',' █ ',' ',' █ '],
131
+ '?': [' ██ ',' █',' ██ ',' ',' █ '],
132
+ '.': [' ',' ',' ',' ',' █'],
133
+ ',': [' ',' ',' ',' █','█ '],
134
+ ':': [' █',' ',' █',' ',' '],
135
+ '-': [' ',' ','███',' ',' '],
136
+ '+': [' ',' █ ','███',' █ ',' '],
137
+ '/': [' █',' █ ',' █ ','█ ',' '],
138
+ '(': [' █','█ ','█ ','█ ',' █'],
139
+ ')': ['█ ',' █',' █',' █','█ '],
140
+ '#': [' █ █ ',' ████',' █ █ ',' ████',' █ █ '],
141
+ '@': [' ███ ','█ █','█ ██ ','█ ',' ███ '],
142
+ '*': [' ','█ █ █',' ███ ','█ █ █',' '],
143
+ };
144
+
145
+ // ─── TEXT ART ─────────────────────────────────────────────────────────────────
146
+
147
+ function makeTextArt(opts = {}) {
148
+ const em = makeEmitter();
149
+ const W = 80, H = 24;
150
+
151
+ let text, colorIdx, styleIdx, animTick, state, score;
152
+ let inputBuf, scrollX;
153
+
154
+ const COLORS = [
155
+ [FC.YEL, FC.yel],
156
+ [FC.CYN, FC.cyn],
157
+ [FC.GRN, FC.grn],
158
+ [FC.MAG, FC.mag],
159
+ [FC.RED, FC.red],
160
+ [FC.WHT, FC.BLK],
161
+ [FC.BLU, FC.blu],
162
+ ];
163
+
164
+ const STYLES = ['solid','shadow','outline','rainbow','wave','neon'];
165
+
166
+ const PRESETS = [
167
+ 'HELLO', 'NOVA', 'KITASCII', 'ARCADE', 'PURCE',
168
+ '12345', 'CODE IS ART', 'HACK THE PLANET',
169
+ ];
170
+
171
+ function renderText(str, style, colors, tick) {
172
+ const rows = [[], [], [], [], []];
173
+ const chars = [...str.toUpperCase()];
174
+ for (let ci = 0; ci < chars.length; ci++) {
175
+ const ch = chars[ci];
176
+ const glyph = FONT[ch] ?? FONT[' '];
177
+ for (let row = 0; row < 5; row++) {
178
+ rows[row].push({ glyphRow: glyph[row], charIdx: ci });
179
+ }
180
+ }
181
+ return rows;
182
+ }
183
+
184
+ function start() {
185
+ text = opts.text ?? PRESETS[rng(PRESETS.length)];
186
+ colorIdx = 0;
187
+ styleIdx = 0;
188
+ animTick = 0;
189
+ scrollX = 0;
190
+ state = 'playing';
191
+ score = 0;
192
+ inputBuf = text;
193
+ em.emit('start');
194
+ }
195
+
196
+ function input(key) {
197
+ if (key === 'escape') { return; }
198
+ if (key === 'up') { colorIdx = (colorIdx + 1) % COLORS.length; return; }
199
+ if (key === 'down') { colorIdx = (colorIdx - 1 + COLORS.length) % COLORS.length; return; }
200
+ if (key === 'left') { styleIdx = (styleIdx - 1 + STYLES.length) % STYLES.length; return; }
201
+ if (key === 'right') { styleIdx = (styleIdx + 1) % STYLES.length; return; }
202
+ if (key === 'tab') { text = PRESETS[(PRESETS.indexOf(text)+1)%PRESETS.length]; inputBuf=text; return; }
203
+ if (key === 'backspace') { inputBuf = inputBuf.slice(0,-1); text=inputBuf; return; }
204
+ if (key === 'enter') { text = inputBuf || 'HELLO'; return; }
205
+ if (key.length === 1 && inputBuf.length < 16) { inputBuf += key.toUpperCase(); text=inputBuf; }
206
+ }
207
+
208
+ function tick(dt=16) {
209
+ animTick += dt;
210
+ }
211
+
212
+ function frame() {
213
+ const cv = makeCanvas(W, H);
214
+ cv.clear(' ', FC.wht, BC.blk);
215
+
216
+ const style = STYLES[styleIdx];
217
+ const colors = COLORS[colorIdx];
218
+ const t = animTick / 100;
219
+
220
+ // compute rendered glyph rows
221
+ const str = (text || ' ').toUpperCase();
222
+ const chars = [...str];
223
+ const glyphs = chars.map(c => FONT[c] ?? FONT[' ']);
224
+
225
+ // measure total width
226
+ let totalW = 0;
227
+ for (const g of glyphs) totalW += (g[0]?.length ?? 3) + 1;
228
+
229
+ const startX = Math.max(0, (W - totalW) >> 1);
230
+ const startY = 5;
231
+
232
+ let cx = startX;
233
+ for (let ci = 0; ci < glyphs.length; ci++) {
234
+ const glyph = glyphs[ci];
235
+ const gw = glyph[0]?.length ?? 3;
236
+
237
+ for (let row = 0; row < 5; row++) {
238
+ const glyphRow = glyph[row] ?? '';
239
+ for (let gc = 0; gc < glyphRow.length; gc++) {
240
+ const ch = glyphRow[gc];
241
+ if (ch === ' ' || ch === '') continue;
242
+
243
+ const x = cx + gc;
244
+ const y = startY + row;
245
+
246
+ let col;
247
+ if (style === 'rainbow') {
248
+ const hue = (ci / glyphs.length + t * 0.2) % 1;
249
+ const palette = [FC.RED, FC.YEL, FC.GRN, FC.CYN, FC.BLU, FC.MAG];
250
+ col = palette[Math.floor(hue * palette.length)];
251
+ } else if (style === 'wave') {
252
+ const wave = Math.sin(t + ci * 0.8 + row * 0.3);
253
+ col = wave > 0.3 ? colors[0] : wave > -0.3 ? FC.WHT : colors[1];
254
+ } else if (style === 'neon') {
255
+ const pulse = Math.sin(t * 2 + ci * 0.5) > 0 ? colors[0] : FC.WHT;
256
+ col = pulse;
257
+ } else {
258
+ col = colors[row < 2 ? 0 : 1];
259
+ }
260
+
261
+ if (style === 'shadow') {
262
+ cv.set(x+1, y+1, '░', FC.BLK, BC.blk);
263
+ }
264
+ if (style === 'outline') {
265
+ for (const [dx,dy] of [[-1,0],[1,0],[0,-1],[0,1]]) {
266
+ cv.set(x+dx, y+dy, '▒', FC.BLK, BC.blk);
267
+ }
268
+ }
269
+
270
+ cv.set(x, y, '█', col, BC.blk);
271
+ }
272
+ }
273
+ cx += gw + 1;
274
+ }
275
+
276
+ // decorative border
277
+ const borderStyle = ['┌','─','┐','│','└','┘'][0];
278
+ cv.put(0, 0, '╔' + '═'.repeat(W-2) + '╗', FC.BLK, BC.blk);
279
+ cv.put(0, H-1, '╚' + '═'.repeat(W-2) + '╝', FC.BLK, BC.blk);
280
+ for (let y=1; y<H-1; y++) { cv.set(0,y,'║',FC.BLK,BC.blk); cv.set(W-1,y,'║',FC.BLK,BC.blk); }
281
+
282
+ // controls
283
+ cv.put(2, 1, ` TEXT ART ↑↓ color ←→ style TAB preset Type to edit ENTER set`, FC.YEL, BC.blk);
284
+ cv.put(2, 2, ` Style: ${STYLES[styleIdx].padEnd(10)} Color: ${colorIdx+1}/${COLORS.length} Text: "${text}"`, FC.BLK, BC.blk);
285
+
286
+ // input display
287
+ const cursor = Math.floor(animTick/500)%2 === 0 ? '▌' : ' ';
288
+ cv.put(2, H-3, ` > ${inputBuf}${cursor}`.padEnd(W-3,' '), FC.CYN, BC.blk);
289
+ cv.put(2, H-2, ` BACKSPACE delete ENTER render TAB next preset`, FC.BLK, BC.blk);
290
+
291
+ return cv.render();
292
+ }
293
+
294
+ return { start, input, tick, frame,
295
+ on: em.on.bind(em), off: em.off.bind(em),
296
+ get state() { return state; }, get score() { return score; },
297
+ };
298
+ }
299
+
300
+ // ─── ANIMATIONS ──────────────────────────────────────────────────────────────
301
+
302
+ function makeAnimate(opts = {}) {
303
+ const em = makeEmitter();
304
+ const W = opts.width ?? 80;
305
+ const H = opts.height ?? 24;
306
+
307
+ const PRESETS = ['matrix','fire','starfield','plasma','rain','snow','worms'];
308
+ let presetIdx, t, state, score, speed;
309
+
310
+ // ── per-preset state ──
311
+ let matrix_cols, fire_buf, stars, worms;
312
+
313
+ function initPreset(name) {
314
+ t = 0;
315
+ if (name === 'matrix') {
316
+ // each column: { y (head position), speed, chars[] }
317
+ const KATAKANA = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
318
+ matrix_cols = Array.from({length: W}, (_, x) => ({
319
+ y: -rng(H),
320
+ speed: 0.3 + Math.random() * 0.7,
321
+ chars: Array.from({length: H+5}, () => KATAKANA[rng(KATAKANA.length)]),
322
+ bright: rng(H),
323
+ }));
324
+ }
325
+ if (name === 'fire') {
326
+ fire_buf = new Float32Array((W) * (H+2)).fill(0);
327
+ }
328
+ if (name === 'starfield') {
329
+ stars = Array.from({length: 120}, () => ({
330
+ x: (Math.random() * 2 - 1),
331
+ y: (Math.random() * 2 - 1),
332
+ z: Math.random(),
333
+ }));
334
+ }
335
+ if (name === 'worms') {
336
+ worms = Array.from({length: 8}, (_, i) => ({
337
+ x: rng(W), y: rng(H),
338
+ dx: (rng(3)-1)||1, dy: (rng(3)-1)||1,
339
+ col: [FC.GRN,FC.CYN,FC.MAG,FC.YEL,FC.RED,FC.BLU,FC.WHT,FC.grn][i],
340
+ tail: [],
341
+ tailLen: 12 + rng(20),
342
+ }));
343
+ }
344
+ }
345
+
346
+ function start() {
347
+ presetIdx = PRESETS.indexOf(opts.preset ?? 'matrix');
348
+ if (presetIdx < 0) presetIdx = 0;
349
+ speed = opts.speed ?? 1;
350
+ state = 'running';
351
+ score = 0;
352
+ initPreset(PRESETS[presetIdx]);
353
+ em.emit('start');
354
+ }
355
+
356
+ function input(key) {
357
+ if (key === 'left' || key === 'p') {
358
+ presetIdx = (presetIdx - 1 + PRESETS.length) % PRESETS.length;
359
+ initPreset(PRESETS[presetIdx]);
360
+ }
361
+ if (key === 'right' || key === 'n') {
362
+ presetIdx = (presetIdx + 1) % PRESETS.length;
363
+ initPreset(PRESETS[presetIdx]);
364
+ }
365
+ if (key === 'space') { state = state === 'running' ? 'paused' : 'running'; }
366
+ if (key === '+' || key === '=') speed = clamp(speed + 0.2, 0.2, 3);
367
+ if (key === '-') speed = clamp(speed - 0.2, 0.2, 3);
368
+ if (key === 'r') { initPreset(PRESETS[presetIdx]); }
369
+ }
370
+
371
+ function tick(dt = 16) {
372
+ if (state !== 'running') return;
373
+ t += dt * speed / 1000;
374
+ score++;
375
+
376
+ const name = PRESETS[presetIdx];
377
+
378
+ if (name === 'matrix') {
379
+ for (const col of matrix_cols) {
380
+ col.y += col.speed * speed;
381
+ if (col.y > H + 5) {
382
+ col.y = -rng(H) - 3;
383
+ col.speed = 0.3 + Math.random() * 0.7;
384
+ col.bright = rng(H);
385
+ // refresh chars
386
+ const KATAKANA = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
387
+ col.chars = Array.from({length: H+5}, () => KATAKANA[rng(KATAKANA.length)]);
388
+ }
389
+ // occasionally mutate a char
390
+ if (Math.random() < 0.05) {
391
+ const KATAKANA = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
392
+ col.chars[rng(col.chars.length)] = KATAKANA[rng(KATAKANA.length)];
393
+ }
394
+ }
395
+ }
396
+
397
+ if (name === 'fire') {
398
+ // seed bottom row
399
+ for (let x=0; x<W; x++) {
400
+ fire_buf[(H+1)*W+x] = Math.random() < 0.6 ? 28 + rng(8) : 0;
401
+ }
402
+ // propagate upward
403
+ for (let y=H; y>=0; y--) {
404
+ for (let x=0; x<W; x++) {
405
+ const below = fire_buf[(y+1)*W+x];
406
+ const wind = rng(3) - 1;
407
+ const decay = rng(3);
408
+ const tx = clamp(x + wind, 0, W-1);
409
+ fire_buf[y*W+tx] = Math.max(0, below - decay);
410
+ }
411
+ }
412
+ }
413
+
414
+ if (name === 'starfield') {
415
+ for (const s of stars) {
416
+ s.z -= 0.01 * speed;
417
+ if (s.z <= 0) {
418
+ s.x = (Math.random() * 2 - 1);
419
+ s.y = (Math.random() * 2 - 1);
420
+ s.z = 1;
421
+ }
422
+ }
423
+ }
424
+
425
+ if (name === 'worms') {
426
+ for (const w of worms) {
427
+ w.tail.unshift([w.x, w.y]);
428
+ if (w.tail.length > w.tailLen) w.tail.pop();
429
+ // random direction change
430
+ if (Math.random() < 0.15) {
431
+ w.dx = (rng(3)-1);
432
+ w.dy = (rng(3)-1);
433
+ if (!w.dx && !w.dy) w.dx = 1;
434
+ }
435
+ w.x = clamp(w.x + w.dx, 0, W-1);
436
+ w.y = clamp(w.y + w.dy, 0, H-2);
437
+ if (w.x <= 0 || w.x >= W-1) w.dx = -w.dx;
438
+ if (w.y <= 0 || w.y >= H-2) w.dy = -w.dy;
439
+ }
440
+ }
441
+ }
442
+
443
+ function frame() {
444
+ const cv = makeCanvas(W, H);
445
+ cv.clear(' ', FC.wht, BC.blk);
446
+
447
+ const name = PRESETS[presetIdx];
448
+
449
+ // ── MATRIX ──
450
+ if (name === 'matrix') {
451
+ for (let x=0; x<W; x++) {
452
+ const col = matrix_cols[x];
453
+ const headY = Math.floor(col.y);
454
+ for (let y=0; y<H; y++) {
455
+ const dist = headY - y;
456
+ if (dist < 0 || dist > H) continue;
457
+ const charIdx = ((y - Math.floor(col.y - H)) + col.chars.length) % col.chars.length;
458
+ const ch = col.chars[charIdx] ?? '0';
459
+ if (dist === 0) {
460
+ cv.set(x, y, ch, FC.WHT, BC.blk); // bright head
461
+ } else if (dist < 3) {
462
+ cv.set(x, y, ch, FC.GRN, BC.blk);
463
+ } else if (dist < 8) {
464
+ cv.set(x, y, ch, FC.grn, BC.blk);
465
+ } else if (dist < 15) {
466
+ cv.set(x, y, ch, FC.BLK, BC.blk);
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ // ── FIRE ──
473
+ if (name === 'fire') {
474
+ const FIRE_CHARS = ' .\'`^",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
475
+ const FIRE_COLORS = [
476
+ FC.BLK, FC.red, FC.red, FC.RED, FC.RED,
477
+ FC.yel, FC.YEL, FC.YEL, FC.WHT, FC.WHT,
478
+ ];
479
+ for (let y=0; y<H; y++) {
480
+ for (let x=0; x<W; x++) {
481
+ const v = fire_buf[y*W+x];
482
+ if (v <= 0) continue;
483
+ const vi = clamp(Math.floor(v), 0, FIRE_CHARS.length-1);
484
+ const ci = clamp(Math.floor(v / 35 * FIRE_COLORS.length), 0, FIRE_COLORS.length-1);
485
+ cv.set(x, y, FIRE_CHARS[vi], FIRE_COLORS[ci], BC.blk);
486
+ }
487
+ }
488
+ }
489
+
490
+ // ── STARFIELD ──
491
+ if (name === 'starfield') {
492
+ const STAR_CHARS = ['.', '+', '*', '✦', '★', '✸'];
493
+ for (const s of stars) {
494
+ const sx = Math.floor((s.x / s.z + 1) / 2 * W);
495
+ const sy = Math.floor((s.y / s.z + 1) / 2 * H);
496
+ if (sx < 0 || sx >= W || sy < 0 || sy >= H) continue;
497
+ const brightness = 1 - s.z;
498
+ const ci = Math.floor(brightness * (STAR_CHARS.length-1));
499
+ const col = brightness > 0.8 ? FC.WHT : brightness > 0.5 ? FC.BLK : FC.BLK;
500
+ cv.set(sx, sy, STAR_CHARS[ci], col, BC.blk);
501
+ }
502
+ // center crosshair
503
+ cv.put((W>>1)-1, H>>1, '─┼─', FC.BLK, BC.blk);
504
+ cv.set(W>>1, (H>>1)-1, '│', FC.BLK, BC.blk);
505
+ cv.set(W>>1, (H>>1)+1, '│', FC.BLK, BC.blk);
506
+ }
507
+
508
+ // ── PLASMA ──
509
+ if (name === 'plasma') {
510
+ const PLASMA_CHARS = ' ░▒▓█';
511
+ const palettes = [
512
+ [FC.red,FC.RED,FC.yel,FC.YEL,FC.WHT],
513
+ [FC.blu,FC.BLU,FC.cyn,FC.CYN,FC.WHT],
514
+ [FC.mag,FC.MAG,FC.red,FC.RED,FC.WHT],
515
+ [FC.grn,FC.GRN,FC.yel,FC.YEL,FC.WHT],
516
+ ];
517
+ const pal = palettes[Math.floor(t*0.1) % palettes.length];
518
+ for (let y=0; y<H; y++) {
519
+ for (let x=0; x<W; x++) {
520
+ const v =
521
+ Math.sin(x / 6 + t) +
522
+ Math.sin(y / 3 + t * 0.7) +
523
+ Math.sin((x + y) / 9 + t * 0.5) +
524
+ Math.sin(Math.sqrt(((x-W/2)**2 + (y-H/2)**2)) / 4 + t);
525
+ const n = (v + 4) / 8;
526
+ const ci = clamp(Math.floor(n * PLASMA_CHARS.length), 0, PLASMA_CHARS.length-1);
527
+ const fc = pal[clamp(Math.floor(n * pal.length), 0, pal.length-1)];
528
+ cv.set(x, y, PLASMA_CHARS[ci], fc, BC.blk);
529
+ }
530
+ }
531
+ }
532
+
533
+ // ── RAIN ──
534
+ if (name === 'rain') {
535
+ // simple: re-derive each frame from t
536
+ for (let x=0; x<W; x++) {
537
+ const offset = Math.sin(x * 1.7) * 10 + x * 0.3;
538
+ for (let y=0; y<H; y++) {
539
+ const drop = (y + offset + t * 8) % H;
540
+ if (drop < 1) {
541
+ cv.set(x, y, '│', FC.CYN, BC.blk);
542
+ } else if (drop < 3) {
543
+ cv.set(x, y, '│', FC.BLU, BC.blk);
544
+ } else if (drop < 5) {
545
+ cv.set(x, y, '╵', FC.blu, BC.blk);
546
+ }
547
+ }
548
+ }
549
+ // puddle bottom
550
+ for (let x=0; x<W; x++) {
551
+ const splash = Math.sin(t * 3 + x * 0.5) > 0.8;
552
+ cv.set(x, H-1, splash ? '~' : '_', FC.BLU, BC.blk);
553
+ }
554
+ }
555
+
556
+ // ── SNOW ──
557
+ if (name === 'snow') {
558
+ for (let x=0; x<W; x++) {
559
+ for (let y=0; y<H; y++) {
560
+ const drift = Math.sin(t * 0.5 + x * 0.3 + y * 0.1) * 3;
561
+ const offset = x * 2.3 + y * 1.7 + drift;
562
+ const phase = (t * 2 + offset) % H;
563
+ if (phase < 0.8) {
564
+ const ch = ['*','·','❄','❅'][rng(4)];
565
+ cv.set(x, y, ch, FC.WHT, BC.blk);
566
+ }
567
+ }
568
+ }
569
+ // snow pile
570
+ for (let x=0; x<W; x++) {
571
+ const pile = 1 + Math.floor(Math.sin(x * 0.3) * 1 + 1);
572
+ for (let p=0; p<pile; p++) cv.set(x, H-1-p, '▓', FC.WHT, BC.blk);
573
+ }
574
+ }
575
+
576
+ // ── WORMS ──
577
+ if (name === 'worms') {
578
+ for (const w of worms) {
579
+ for (let i=0; i<w.tail.length; i++) {
580
+ const [tx,ty] = w.tail[i];
581
+ const alpha = 1 - i / w.tail.length;
582
+ const ch = i===0 ? '●' : alpha > 0.5 ? '▪' : '·';
583
+ const col = i===0 ? FC.WHT : w.col;
584
+ cv.set(tx, ty, ch, col, BC.blk);
585
+ }
586
+ }
587
+ }
588
+
589
+ // ── HUD ──
590
+ const hud = ` ${name.toUpperCase().padEnd(10)} ← → change SPACE pause +/- speed R reset`;
591
+ cv.put(0, 0, hud, FC.YEL, BC.blk);
592
+
593
+ return cv.render();
594
+ }
595
+
596
+ return { start, input, tick, frame,
597
+ on: em.on.bind(em), off: em.off.bind(em),
598
+ get state() { return state; }, get score() { return score; },
599
+ };
600
+ }
601
+
602
+ // ─── BOX ART ─────────────────────────────────────────────────────────────────
603
+
604
+ function makeBoxArt(opts = {}) {
605
+ const em = makeEmitter();
606
+ const W = 72, H = 28;
607
+
608
+ const BOX_STYLES = [
609
+ { name:'Single', tl:'┌',tr:'┐',bl:'└',br:'┘',h:'─',v:'│',tee:'┬',bee:'┴',lef:'├',rig:'┤',crs:'┼' },
610
+ { name:'Double', tl:'╔',tr:'╗',bl:'╚',br:'╝',h:'═',v:'║',tee:'╦',bee:'╩',lef:'╠',rig:'╣',crs:'╬' },
611
+ { name:'Thick', tl:'┏',tr:'┓',bl:'┗',br:'┛',h:'━',v:'┃',tee:'┳',bee:'┻',lef:'┣',rig:'┫',crs:'╋' },
612
+ { name:'Rounded', tl:'╭',tr:'╮',bl:'╰',br:'╯',h:'─',v:'│',tee:'┬',bee:'┴',lef:'├',rig:'┤',crs:'┼' },
613
+ { name:'Ascii', tl:'+',tr:'+',bl:'+',br:'+',h:'-',v:'|',tee:'+',bee:'+',lef:'+',rig:'+',crs:'+' },
614
+ { name:'Hash', tl:'#',tr:'#',bl:'#',br:'#',h:'#',v:'#',tee:'#',bee:'#',lef:'#',rig:'#',crs:'#' },
615
+ { name:'Stars', tl:'*',tr:'*',bl:'*',br:'*',h:'*',v:'*',tee:'*',bee:'*',lef:'*',rig:'*',crs:'*' },
616
+ { name:'Fancy', tl:'╓',tr:'╖',bl:'╙',br:'╜',h:'─',v:'║',tee:'╥',bee:'╨',lef:'╟',rig:'╢',crs:'╫' },
617
+ ];
618
+
619
+ const FILLS = [' ', '░', '▒', '▓', '·', '∙', '•', '◦', '·'];
620
+ const FC_LIST = Object.values(FC);
621
+
622
+ let bx, by, bw, bh, styleIdx, fillIdx, fgIdx, bgIdx;
623
+ let title, inputBuf, inputMode;
624
+ let state, score;
625
+
626
+ function start() {
627
+ bx=4; by=4; bw=40; bh=14;
628
+ styleIdx=0; fillIdx=0; fgIdx=0; bgIdx=0;
629
+ title = opts.title ?? 'MY BOX';
630
+ inputBuf= ''; inputMode=null;
631
+ state='playing'; score=0;
632
+ em.emit('start');
633
+ }
634
+
635
+ function input(key) {
636
+ if (inputMode) {
637
+ if (key==='escape') { inputMode=null; inputBuf=''; return; }
638
+ if (key==='enter') { title=inputBuf; inputMode=null; inputBuf=''; return; }
639
+ if (key==='backspace') { inputBuf=inputBuf.slice(0,-1); return; }
640
+ if (key.length===1 && inputBuf.length<30) { inputBuf+=key; return; }
641
+ return;
642
+ }
643
+ // resize
644
+ if (key==='right') bw = clamp(bw+2, 6, W-bx-2);
645
+ if (key==='left') bw = clamp(bw-2, 6, W-bx-2);
646
+ if (key==='down') bh = clamp(bh+1, 4, H-by-2);
647
+ if (key==='up') bh = clamp(bh-1, 4, H-by-2);
648
+ // cycle options
649
+ if (key==='s') styleIdx = (styleIdx+1) % BOX_STYLES.length;
650
+ if (key==='f') fillIdx = (fillIdx+1) % FILLS.length;
651
+ if (key==='c') fgIdx = (fgIdx+1) % FC_LIST.length;
652
+ if (key==='b') bgIdx = (bgIdx+1) % Object.values(BC).length;
653
+ if (key==='t') { inputMode='title'; inputBuf=title; }
654
+ // move box
655
+ if (key==='w') by = clamp(by-1, 2, H-bh-2);
656
+ if (key==='a') bx = clamp(bx-1, 0, W-bw-2);
657
+ if (key==='z') by = clamp(by+1, 2, H-bh-2);
658
+ if (key==='x') bx = clamp(bx+1, 0, W-bw-2);
659
+ // export
660
+ if (key==='e') { score++; em.emit('score', score); }
661
+ }
662
+
663
+ function tick(dt=16) {}
664
+
665
+ function frame() {
666
+ const cv = makeCanvas(W, H);
667
+ cv.clear(' ', FC.wht, BC.blk);
668
+
669
+ cv.put(0, 0, ` BOX ART S=style F=fill C=fg-color B=bg-color T=title WASD/↑↓←→ move/resize E=export`, FC.YEL, BC.blk);
670
+ cv.put(0, 1, '─'.repeat(W), FC.BLK, BC.blk);
671
+
672
+ const style = BOX_STYLES[styleIdx];
673
+ const fill = FILLS[fillIdx];
674
+ const fgColor = FC_LIST[fgIdx];
675
+ const bgVals = Object.values(BC);
676
+ const bgColor = bgVals[bgIdx];
677
+
678
+ // fill interior
679
+ if (fillIdx > 0) {
680
+ cv.fill(bx+1, by+1, bw-2, bh-2, fill, fgColor, BC.blk);
681
+ }
682
+
683
+ // draw box
684
+ cv.set(bx, by, style.tl, fgColor, BC.blk);
685
+ cv.set(bx+bw-1, by, style.tr, fgColor, BC.blk);
686
+ cv.set(bx, by+bh-1, style.bl, fgColor, BC.blk);
687
+ cv.set(bx+bw-1, by+bh-1, style.br, fgColor, BC.blk);
688
+ for (let x=bx+1; x<bx+bw-1; x++) {
689
+ cv.set(x, by, style.h, fgColor, BC.blk);
690
+ cv.set(x, by+bh-1, style.h, fgColor, BC.blk);
691
+ }
692
+ for (let y=by+1; y<by+bh-1; y++) {
693
+ cv.set(bx, y, style.v, fgColor, BC.blk);
694
+ cv.set(bx+bw-1, y, style.v, fgColor, BC.blk);
695
+ }
696
+
697
+ // title
698
+ if (inputMode === 'title') {
699
+ const t = `[ ${inputBuf}▌ ]`;
700
+ const tx = bx + ((bw - t.length) >> 1);
701
+ cv.put(tx, by, t, FC.YEL, BC.blk);
702
+ } else if (title) {
703
+ const t = `[ ${title} ]`;
704
+ const tx = bx + ((bw - t.length) >> 1);
705
+ cv.put(tx, by, t, FC.YEL, BC.blk);
706
+ }
707
+
708
+ // info panel
709
+ const px = 1, py = H - 6;
710
+ cv.put(px, py, `Style: ${style.name.padEnd(10)} tl:${style.tl} h:${style.h} v:${style.v}`, FC.wht, BC.blk);
711
+ cv.put(px, py+1, `Fill: "${fill}"`, FC.wht, BC.blk);
712
+ cv.put(px, py+2, `Size: ${bw}×${bh} at ${bx},${by}`, FC.wht, BC.blk);
713
+ cv.put(px, py+3, `Exports: ${score}`, FC.BLK, BC.blk);
714
+ cv.put(px, py+4,
715
+ `\x1b[${fgColor}m████\x1b[0m FG \x1b[${bgColor}m████\x1b[0m BG`, FC.wht, BC.blk);
716
+
717
+ return cv.render();
718
+ }
719
+
720
+ return { start, input, tick, frame,
721
+ on: em.on.bind(em), off: em.off.bind(em),
722
+ get state() { return state; }, get score() { return score; },
723
+ };
724
+ }
725
+
726
+ // ─── IMAGE → ASCII ───────────────────────────────────────────────────────────
727
+ /**
728
+ * ImageToAscii(pixelGrid, opts)
729
+ *
730
+ * Converts a 2-D pixel grid to ASCII art string.
731
+ * pixelGrid: Array<Array<{r,g,b}>> (rows of pixels)
732
+ *
733
+ * opts:
734
+ * width: target character width (default 80)
735
+ * color: use ANSI colors (default true)
736
+ * charset: 'block'|'shade'|'ascii'|'braille' (default 'shade')
737
+ * invert: invert brightness (default false)
738
+ *
739
+ * Returns: ANSI string ready to write to stdout.
740
+ *
741
+ * Example with jimp:
742
+ * const Jimp = require('jimp');
743
+ * const { ImageToAscii } = require('./kitascii');
744
+ * const img = await Jimp.read('photo.jpg');
745
+ * img.resize(80, 40);
746
+ * const grid = [];
747
+ * img.scan(0,0,img.width,img.height,(x,y,idx)=>{
748
+ * if (!grid[y]) grid[y]=[];
749
+ * grid[y][x]={ r:img.bitmap.data[idx], g:img.bitmap.data[idx+1], b:img.bitmap.data[idx+2] };
750
+ * });
751
+ * console.log(ImageToAscii(grid, { color:true }));
752
+ */
753
+
754
+ const CHARSETS = {
755
+ shade: ' ░▒▓█',
756
+ block: ' ▏▎▍▌▋▊▉█',
757
+ ascii: ' .\'`^",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$',
758
+ braille:'⠀⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿',
759
+ minimal:' ·∙•●',
760
+ };
761
+
762
+ function ImageToAscii(pixelGrid, opts = {}) {
763
+ if (!pixelGrid || !pixelGrid.length) return '';
764
+
765
+ const charset = CHARSETS[opts.charset ?? 'shade'];
766
+ const color = opts.color ?? true;
767
+ const invert = opts.invert ?? false;
768
+
769
+ const srcH = pixelGrid.length;
770
+ const srcW = pixelGrid[0].length;
771
+ const tgtW = opts.width ?? 80;
772
+ // terminal chars are ~2:1 height:width, so scale height by 0.5
773
+ const tgtH = Math.round(tgtW * (srcH / srcW) * 0.5);
774
+
775
+ const lines = [];
776
+
777
+ for (let ty = 0; ty < tgtH; ty++) {
778
+ let line = '';
779
+ const sy = Math.floor(ty / tgtH * srcH);
780
+ const row = pixelGrid[clamp(sy, 0, srcH-1)];
781
+
782
+ for (let tx = 0; tx < tgtW; tx++) {
783
+ const sx = Math.floor(tx / tgtW * srcW);
784
+ const px = row[clamp(sx, 0, srcW-1)] ?? {r:0,g:0,b:0};
785
+ const {r, g, b} = px;
786
+
787
+ // luminance (perceptual)
788
+ const lum = 0.2126*r + 0.7152*g + 0.0722*b;
789
+ const n = invert ? 1 - lum/255 : lum/255;
790
+ const ci = clamp(Math.floor(n * charset.length), 0, charset.length-1);
791
+ const ch = charset[ci];
792
+
793
+ if (color) {
794
+ // find closest ANSI 8-color
795
+ const ansiCol = closestAnsi(r, g, b);
796
+ line += `\x1b[${ansiCol}m${ch}`;
797
+ } else {
798
+ line += ch;
799
+ }
800
+ }
801
+ lines.push(line + RST);
802
+ }
803
+
804
+ return lines.join('\n');
805
+ }
806
+
807
+ function closestAnsi(r, g, b) {
808
+ const ANSI_RGB = [
809
+ [0,0,0],[128,0,0],[0,128,0],[128,128,0],
810
+ [0,0,128],[128,0,128],[0,128,128],[192,192,192],
811
+ [128,128,128],[255,0,0],[0,255,0],[255,255,0],
812
+ [0,0,255],[255,0,255],[0,255,255],[255,255,255],
813
+ ];
814
+ const FC_VALS = [30,31,32,33,34,35,36,37,90,91,92,93,94,95,96,97];
815
+ let best=Infinity, bestIdx=0;
816
+ for (let i=0;i<ANSI_RGB.length;i++) {
817
+ const [ar,ag,ab]=ANSI_RGB[i];
818
+ const d=(r-ar)**2+(g-ag)**2+(b-ab)**2;
819
+ if(d<best){best=d;bestIdx=i;}
820
+ }
821
+ return FC_VALS[bestIdx];
822
+ }
823
+
824
+ // ─── EXPORT ──────────────────────────────────────────────────────────────────
825
+
826
+ const kitdef = {
827
+ TextArt: (opts) => { const g = makeTextArt(opts); g.start(); return g; },
828
+ Animate: (opts) => { const g = makeAnimate(opts); g.start(); return g; },
829
+ BoxArt: (opts) => { const g = makeBoxArt(opts); g.start(); return g; },
830
+
831
+ // standalone function (not a game loop)
832
+ ImageToAscii,
833
+
834
+ // animation presets
835
+ animPresets: ['matrix','fire','starfield','plasma','rain','snow','worms'],
836
+ makeTextArt, makeAnimate, makeBoxArt, ImageToAscii
837
+ };
838
+
839
+ module.exports = { kitdef };