keyvoid 1.0.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,530 @@
1
+ // ─── Arcade Skin ──────────────────────────────────────────────────────
2
+ // An interactive retro 8-bit alien shooter.
3
+ // Blocked keys shoot lasers at scrolling alien sprites.
4
+
5
+ import { renderUnvoidButton } from '../components/unvoid-button.js';
6
+ import { renderStatusBar, getStatusBarHeight } from '../components/status-bar.js';
7
+ import { palettes, RESET, BOLD } from '../../utils/colors.js';
8
+ import { centerText } from '../../utils/terminal.js';
9
+ import { renderBigNumber } from '../../utils/big-digits.js';
10
+
11
+ const P = palettes.arcade;
12
+
13
+ let initialized = false;
14
+ let lastKeyCount = 0;
15
+ let score = 0;
16
+ let playerHearts = 3;
17
+ let dangerFlash = 0;
18
+ let isGameOver = false;
19
+ let spaceHeldTicks = 0;
20
+
21
+ // Game state
22
+ const aliens = [];
23
+ const lasers = [];
24
+ const particles = [];
25
+ const turretFlashes = [0, 0, 0, 0];
26
+
27
+ const ALIEN_SPRITES = [
28
+ [
29
+ ' ▄▄████▄▄ ',
30
+ '██████████',
31
+ '██▄████▄██',
32
+ ' ▀ ▀ '
33
+ ],
34
+ [
35
+ ' ▄████▄ ',
36
+ '▄████████▄',
37
+ '▀████████▀',
38
+ ' ▄ ▄ '
39
+ ]
40
+ ];
41
+ const ALIEN_W = 10;
42
+ const ALIEN_H = 4;
43
+
44
+ function spawnAliens(cols, rows) {
45
+ aliens.length = 0;
46
+ const maxAliens = 2; // Always start easy
47
+ for (let i = 0; i < maxAliens; i++) {
48
+ aliens.push({
49
+ x: 5 + i * 18,
50
+ y: 1 + (i % 3) * 6, // Push Y down so they have room
51
+ dir: (i % 2 === 0) ? 1 : -1,
52
+ type: i % 2,
53
+ hp: 5, // Require 5 hits
54
+ flashTicks: 0, // Flash white when hit
55
+ active: true
56
+ });
57
+ }
58
+ }
59
+
60
+ export function renderArcade(cols, rows, state, tick) {
61
+ const lines = Array(rows).fill(`${P.bg}${' '.repeat(cols)}${RESET}`);
62
+ const statusHeight = getStatusBarHeight(state);
63
+ const playAreaH = rows - statusHeight - 4; // leave room for unvoid button
64
+
65
+ if (!initialized) {
66
+ spawnAliens(cols, rows);
67
+ initialized = true;
68
+ }
69
+
70
+ const { keyCount = 0, lastKeyCode = -1 } = state;
71
+
72
+ // ── GAME OVER STATE ──
73
+ if (isGameOver) {
74
+ if (state.isSpaceDown) {
75
+ spaceHeldTicks++;
76
+ if (spaceHeldTicks >= 180) { // 3 seconds at 60fps
77
+ // Restart Game
78
+ isGameOver = false;
79
+ score = 0;
80
+ playerHearts = 3;
81
+ aliens.length = 0;
82
+ lasers.length = 0;
83
+ particles.length = 0;
84
+ spawnAliens(cols, rows);
85
+ spaceHeldTicks = 0;
86
+ lastKeyCount = keyCount;
87
+ }
88
+ } else {
89
+ spaceHeldTicks = Math.max(0, spaceHeldTicks - 5); // Decay quickly when released
90
+ }
91
+ lastKeyCount = keyCount;
92
+ } else {
93
+ // ── GAMEPLAY MECHANICS ──
94
+
95
+ // Continuous Spawning Mechanics (Scales with Score)
96
+ // Base: start with 2. Every 400 pts (killing ~8 aliens), add 1 to the max concurrent limit.
97
+ const maxAliens = Math.min(Math.floor(cols / 12), 2 + Math.floor(score / 400));
98
+ const activeAliens = aliens.filter(a => a.active).length;
99
+ if (activeAliens < maxAliens && tick % 30 === 0) {
100
+ aliens.push({
101
+ x: Math.floor(Math.random() * (cols - ALIEN_W - 4)) + 2,
102
+ y: 2,
103
+ dir: Math.random() > 0.5 ? 1.5 : -1.5,
104
+ type: Math.floor(Math.random() * 2),
105
+ hp: 5,
106
+ flashTicks: 0,
107
+ active: true
108
+ });
109
+ }
110
+
111
+ // 1. Shoot lasers on keypress
112
+ if (keyCount > lastKeyCount) {
113
+ const diff = keyCount - lastKeyCount;
114
+
115
+ // Determine horizontal spawn position based on keycode
116
+ let targetPct = 0.5;
117
+ if (lastKeyCode !== -1) {
118
+ // macOS QWERTY layout estimation map
119
+ const leftKeys = new Set([0,1,2,6,7,8,12,13,14,18,19,20,53,48,50]);
120
+ const midLeftKeys = new Set([3,4,5,9,11,15,17,21,22,23]);
121
+ const midRightKeys = new Set([32,34,45,46,16,38,40,43,28,26,49]);
122
+ const rightKeys = new Set([31,35,37,33,30,42,41,39,44,47,25,29,27,24,36,51,123,124,125,126,56,60]);
123
+
124
+ if (leftKeys.has(lastKeyCode)) targetPct = 0.2;
125
+ else if (midLeftKeys.has(lastKeyCode)) targetPct = 0.4;
126
+ else if (midRightKeys.has(lastKeyCode)) targetPct = 0.6;
127
+ else if (rightKeys.has(lastKeyCode)) targetPct = 0.8;
128
+ else {
129
+ // Deterministic hash so unknown keys aim consistently
130
+ targetPct = 0.1 + ((lastKeyCode * 37) % 100) / 100.0 * 0.8;
131
+ }
132
+ }
133
+
134
+ // Cap laser spam to prevent lag
135
+ let activeTurret = 0;
136
+ if (targetPct === 0.2) activeTurret = 0;
137
+ else if (targetPct === 0.4) activeTurret = 1;
138
+ else if (targetPct === 0.6) activeTurret = 2;
139
+ else activeTurret = 3; // Right or fallback
140
+
141
+ turretFlashes[activeTurret] = 4; // 4 ticks of bright flash
142
+
143
+ for(let i = 0; i < Math.min(diff, 5); i++) {
144
+ // slight jitter +/- 3 chars around the key's central column target
145
+ const jitter = (Math.random() - 0.5) * 6;
146
+ const spawnX = Math.floor(cols * targetPct + jitter);
147
+
148
+ lasers.push({
149
+ x: Math.max(2, Math.min(cols - 4, spawnX)),
150
+ y: playAreaH - 3, // Spawn directly above the tank UI
151
+ speed: 0.8 + Math.random() * 0.5
152
+ });
153
+ }
154
+ lastKeyCount = keyCount;
155
+ }
156
+
157
+ // 2. Update Aliens
158
+ // Difficulty Scaling: Fall speed increases as you score points!
159
+ const currentFallSpeed = 0.15 + Math.min(0.25, (score * 0.00005));
160
+
161
+ aliens.forEach(a => {
162
+ if (!a.active) return;
163
+ a.x += 0.3 * a.dir;
164
+ a.y += currentFallSpeed;
165
+
166
+ // Bounds check
167
+ if (a.x >= cols - ALIEN_W) {
168
+ a.x = cols - ALIEN_W;
169
+ a.dir = -1;
170
+ a.y += 1;
171
+ } else if (a.x <= 0) {
172
+ a.x = 0;
173
+ a.dir = 1;
174
+ a.y += 1;
175
+ }
176
+
177
+ // Reached the bottom (Tank Base line) = Player takes damage!
178
+ if (a.y > playAreaH - 6) {
179
+ a.active = false;
180
+ playerHearts -= 1;
181
+ dangerFlash = 15; // flash screen red
182
+
183
+ if (playerHearts <= 0) {
184
+ playerHearts = 0; // clamp
185
+ isGameOver = true;
186
+ } else {
187
+ // Penalize score if not game over yet
188
+ score = Math.max(0, score - 500);
189
+ aliens.forEach(al => al.active = false); // Board wipe for breather
190
+ }
191
+ }
192
+ });
193
+
194
+ // 3. Update Lasers & Collisions
195
+ for (let i = lasers.length - 1; i >= 0; i--) {
196
+ let l = lasers[i];
197
+ l.y -= l.speed;
198
+
199
+ // Remove if off-screen
200
+ if (l.y < 0) {
201
+ lasers.splice(i, 1);
202
+ continue;
203
+ }
204
+
205
+ // Check collision
206
+ let hit = false;
207
+ for (let j = 0; j < aliens.length; j++) {
208
+ let a = aliens[j];
209
+ if (!a.active) continue;
210
+
211
+ if (l.x >= Math.floor(a.x) && l.x < Math.floor(a.x) + ALIEN_W) {
212
+ if (Math.floor(l.y) >= a.y && Math.floor(l.y) <= a.y + ALIEN_H) {
213
+ // IT'S A HIT!
214
+ hit = true;
215
+ a.hp -= 1;
216
+
217
+ if (a.hp <= 0) {
218
+ a.active = false;
219
+ score += 100;
220
+
221
+ // Big explosion
222
+ for (let p = 0; p < 12; p++) {
223
+ particles.push({
224
+ x: l.x,
225
+ y: l.y,
226
+ vx: (Math.random() - 0.5) * 2.0,
227
+ vy: (Math.random() - 0.5) * 2.0,
228
+ life: 15 + Math.random() * 15
229
+ });
230
+ }
231
+ } else {
232
+ // Small explosion + flash
233
+ a.flashTicks = 3;
234
+ for (let p = 0; p < 3; p++) {
235
+ particles.push({
236
+ x: l.x,
237
+ y: l.y + ALIEN_H,
238
+ vx: (Math.random() - 0.5) * 1.0,
239
+ vy: Math.random() * 1.0,
240
+ life: 5 + Math.random() * 5
241
+ });
242
+ }
243
+ }
244
+ break;
245
+ }
246
+ }
247
+ }
248
+ if (hit) lasers.splice(i, 1);
249
+ }
250
+
251
+ // Flash ticks countdown
252
+ aliens.forEach(a => {
253
+ if (a.flashTicks > 0) a.flashTicks--;
254
+ });
255
+
256
+ // 4. Update Particles
257
+ for (let i = particles.length - 1; i >= 0; i--) {
258
+ let p = particles[i];
259
+ p.x += p.vx;
260
+ p.y += p.vy;
261
+ p.vy += 0.05; // gravity
262
+ p.life--;
263
+ if (p.life <= 0) particles.splice(i, 1);
264
+ }
265
+
266
+ } // <--- End of GAMEPLAY MECHANICS `if (!isGameOver)` block
267
+
268
+
269
+ // ── Rendering Engine ──
270
+ if (dangerFlash > 0) dangerFlash--;
271
+
272
+ // Create a red-tinted background if we took damage
273
+ const currentBg = dangerFlash > 0
274
+ ? `\x1b[48;2;${Math.min(150, dangerFlash * 10)};0;0m`
275
+ : P.bg;
276
+
277
+ const buffer = [];
278
+ for (let r = 0; r < playAreaH; r++) {
279
+ buffer.push(Array(cols).fill(' '));
280
+ }
281
+ const colorBuffer = [];
282
+ for (let r = 0; r < playAreaH; r++) {
283
+ colorBuffer.push(Array(cols).fill(currentBg));
284
+ }
285
+
286
+ // Draw Background Score Big Numbers (Rendered first so it acts as background)
287
+ const scoreDigits = renderBigNumber(score);
288
+ if (scoreDigits && scoreDigits.length > 0) {
289
+ const scoreW = scoreDigits[0].length;
290
+ const scoreX = Math.floor(cols / 2) - Math.floor(scoreW / 2);
291
+ const scoreY = 1;
292
+
293
+ for (let r = 0; r < scoreDigits.length; r++) {
294
+ if (scoreY + r >= playAreaH) continue;
295
+ for (let c = 0; c < scoreW; c++) {
296
+ if (scoreX + c >= 0 && scoreX + c < cols) {
297
+ const char = scoreDigits[r][c];
298
+ // Dim the score string so it looks like background
299
+ buffer[scoreY + r][scoreX + c] = char;
300
+ colorBuffer[scoreY + r][scoreX + c] = P.bg + P.accent + BOLD;
301
+ }
302
+ }
303
+ }
304
+ }
305
+
306
+ // Draw Aliens and HP bars
307
+ aliens.forEach(a => {
308
+ if (!a.active) return;
309
+ const sprite = ALIEN_SPRITES[a.type];
310
+ const cx = Math.floor(a.x);
311
+ const cy = Math.floor(a.y);
312
+ // If flashed, turn white, otherwise normal color
313
+ const color = a.flashTicks > 0 ? '\x1b[38;2;255;255;255m' : ((a.type === 0) ? P.alien : P.primary);
314
+
315
+ // Draw HP Bar
316
+ const hpColor = a.hp > 2 ? '\x1b[38;2;0;255;0m' : '\x1b[38;2;255;0;0m';
317
+ const hpBar = '[' + '■'.repeat(Math.max(0, a.hp)) + ' '.repeat(Math.max(0, 5 - a.hp)) + ']';
318
+ const hpX = cx + 1;
319
+ const hpY = cy - 1;
320
+ if (hpY >= 0 && hpY < playAreaH) {
321
+ for (let c = 0; c < hpBar.length; c++) {
322
+ if (hpX + c >= 0 && hpX + c < cols) {
323
+ buffer[hpY][hpX + c] = hpBar[c];
324
+ colorBuffer[hpY][hpX + c] = P.bg + hpColor;
325
+ }
326
+ }
327
+ }
328
+
329
+ for (let r = 0; r < ALIEN_H; r++) {
330
+ if (cy + r >= playAreaH) continue;
331
+ if (cy + r < 0) continue;
332
+ for (let c = 0; c < ALIEN_W; c++) {
333
+ if (cx + c >= 0 && cx + c < cols) {
334
+ const char = sprite[r][c];
335
+ if (char !== ' ') {
336
+ buffer[cy + r][cx + c] = char;
337
+ colorBuffer[cy + r][cx + c] = currentBg + color;
338
+ }
339
+ }
340
+ }
341
+ }
342
+ });
343
+
344
+ // Draw Explicit Top-Left Score
345
+ const scoreText = ` 🎯 SCORE: ${score} `;
346
+ for (let c = 0; c < scoreText.length; c++) {
347
+ if (c < cols) {
348
+ buffer[0][c] = scoreText[c];
349
+ colorBuffer[0][c] = currentBg + '\x1b[38;2;255;255;0m' + BOLD;
350
+ }
351
+ }
352
+
353
+ // Draw Health Bar Top-Right
354
+ const hText = `HEALTH: ${'♥'.repeat(playerHearts)}${'♡'.repeat(3 - playerHearts)}`;
355
+ const hPad = cols - hText.length - 1;
356
+ for (let c = 0; c < hText.length; c++) {
357
+ if (hPad + c < cols && hPad + c >= 0) {
358
+ buffer[0][hPad + c] = hText[c];
359
+ colorBuffer[0][hPad + c] = currentBg + '\x1b[38;2;255;50;50m' + BOLD;
360
+ }
361
+ }
362
+
363
+ // Draw Lasers
364
+ lasers.forEach(l => {
365
+ const cx = Math.floor(l.x);
366
+ const cy = Math.floor(l.y);
367
+ if (cy >= 0 && cy < playAreaH && cx >= 0 && cx < cols) {
368
+ buffer[cy][cx] = '┃';
369
+ colorBuffer[cy][cx] = currentBg + P.laser + BOLD;
370
+ }
371
+ });
372
+
373
+ // Draw Particles
374
+ const pChars = ['*', '#', '+', '·'];
375
+ particles.forEach((p, idx) => {
376
+ const cx = Math.floor(p.x);
377
+ const cy = Math.floor(p.y);
378
+ if (cy >= 0 && cy < playAreaH && cx >= 0 && cx < cols) {
379
+ buffer[cy][cx] = pChars[idx % pChars.length];
380
+ colorBuffer[cy][cx] = currentBg + P.explosion;
381
+ }
382
+ });
383
+
384
+ // Draw Tank UI Base at Bottom
385
+ // 4 Turrets dividing spacing equally
386
+ for (let i = 0; i < 4; i++) {
387
+ if (turretFlashes[i] > 0) turretFlashes[i]--;
388
+ }
389
+
390
+ const baseRow1 = playAreaH - 2;
391
+ const baseRow2 = playAreaH - 1;
392
+ const positions = [0.2, 0.4, 0.6, 0.8];
393
+ const keysLabel = ['LEFT KEYS', 'MID-LEFT', 'MID-RIGHT', 'RIGHT KEYS'];
394
+
395
+ // Draw solid base connection line
396
+ for (let c = 0; c < cols; c++) {
397
+ if (baseRow2 >= 0 && baseRow2 < playAreaH) {
398
+ buffer[baseRow2][c] = '▄';
399
+ colorBuffer[baseRow2][c] = currentBg + '\x1b[38;2;80;90;100m';
400
+ }
401
+ }
402
+
403
+ // Draw each of the 4 turrets
404
+ positions.forEach((pct, idx) => {
405
+ const isFlashing = turretFlashes[idx] > 0;
406
+ const tColor = isFlashing ? '\x1b[38;2;255;255;0m' : '\x1b[38;2;120;150;180m'; // Yellow if shooting, blue-grey otherwise
407
+ const txtColor = isFlashing ? '\x1b[38;2;255;255;255m' : '\x1b[38;2;80;100;120m'; // High contrast text if flashing
408
+
409
+ const cx = Math.floor(cols * pct);
410
+
411
+ // Cannon tip
412
+ if (baseRow1 >= 0 && cx >= 0 && cx < cols) {
413
+ buffer[baseRow1][cx] = '▲';
414
+ colorBuffer[baseRow1][cx] = currentBg + tColor;
415
+ }
416
+
417
+ // Treads & labels
418
+ const label = `[====${keysLabel[idx]}====]`;
419
+ const startX = cx - Math.floor(label.length / 2);
420
+
421
+ for (let i = 0; i < label.length; i++) {
422
+ const drawX = startX + i;
423
+ if (baseRow2 >= 0 && drawX >= 0 && drawX < cols) {
424
+ buffer[baseRow2][drawX] = label[i];
425
+ colorBuffer[baseRow2][drawX] = currentBg + (label[i] === '=' || label[i] === '[' || label[i] === ']' ? tColor : txtColor);
426
+ }
427
+ }
428
+ });
429
+
430
+ // Compile buffer to ANSI strings
431
+ for (let r = 0; r < playAreaH; r++) {
432
+ let rowStr = '';
433
+ let currCol = currentBg;
434
+ let currRun = '';
435
+
436
+ for (let c = 0; c < cols; c++) {
437
+ const char = buffer[r][c];
438
+ const colorCode = colorBuffer[r][c];
439
+
440
+ if (colorCode !== currCol) {
441
+ rowStr += currCol + currRun;
442
+ currCol = colorCode;
443
+ currRun = char;
444
+ } else {
445
+ currRun += char;
446
+ }
447
+ }
448
+ rowStr += currCol + currRun + RESET;
449
+ lines[r] = rowStr;
450
+ }
451
+
452
+ // Draw Game Over Overlay
453
+ if (isGameOver) {
454
+ const goLines = [
455
+ ' ▄██████▄ ▄▀▀▀▄ █▄ ▄█ ▄██████▄ ',
456
+ ' ▐█▀ ▀█ █ ▄ █ ██▀██ ▐█▀ ▀█ ',
457
+ ' ▐█ ▄▄▄▄ ▐█▄▄▄▄▄█▌ █ ▀ █ ▐█▄▄▄▄▄▄▌ ',
458
+ ' ▐█▌ ██ █ █ █ █ ▐█ █▌ ',
459
+ ' ▀██████▀ █ █ █ █ ▀██████▀ ',
460
+ ' ',
461
+ ' ▄██████▄ ▀█ █▀ ▄██████▄ ██▀▀▀▀',
462
+ ' ██▀ ▀██ ▀▄ ▄▀ ██▀ ▀██ ██ ',
463
+ ' ██ ██ ▀█ █▀ ██▄▄▄▄▄▄██ █████ ',
464
+ ' ██▄ ▄██ ▀▄▄▀ ██▀▀▀▀▀▀▀▀ ██ ',
465
+ ' ▀██████▀ ▀▀ ▀██████▀ ██████',
466
+ '',
467
+ ` FINAL SCORE: ${score} `,
468
+ '',
469
+ ` HOLD SPACE TO RESTART `
470
+ ];
471
+
472
+ const goH = goLines.length;
473
+ const overlayY = Math.floor((playAreaH - goH) / 2) - 1;
474
+
475
+ for (let r = 0; r < goH; r++) {
476
+ const screenY = overlayY + r;
477
+ if (screenY < 0 || screenY >= playAreaH) continue;
478
+
479
+ let color = r >= 12 ? P.laser : P.alien; // Fixed undefined color reference
480
+ if (r === 12) color = '\x1b[38;2;255;255;255m';
481
+
482
+ let text = goLines[r];
483
+
484
+ if (r === 14) {
485
+ const heldPct = Math.min(1.0, spaceHeldTicks / 180);
486
+ const barLen = Math.floor(heldPct * 22);
487
+ const bar = '█'.repeat(barLen) + '·'.repeat(22 - barLen);
488
+ text = ` HOLD SPACE: [${bar}] `;
489
+ color = (tick % 40 < 20 || heldPct > 0) ? '\x1b[38;2;255;255;0m' : '\x1b[38;2;100;100;0m';
490
+ }
491
+
492
+ lines[screenY] = centerText(
493
+ text === '' ? '' : `${color}${BOLD}${text}${RESET}`,
494
+ cols,
495
+ `${currentBg} `
496
+ );
497
+ }
498
+ }
499
+
500
+ // ── UI Overlays ──
501
+ // (Score rendering moved into the buffer so it renders deeply behind aliens)
502
+
503
+ // UNVOID Button
504
+ const buttonRow = playAreaH + 1;
505
+ const button = renderUnvoidButton(cols, buttonRow, tick);
506
+ for (let i = 0; i < button.lines.length; i++) {
507
+ if (buttonRow + i < rows - statusHeight) {
508
+ lines[buttonRow + i] = `${P.bg}${button.lines[i].replace(/\x1b\[0m/g, RESET + P.bg)}${RESET}`;
509
+ }
510
+ }
511
+
512
+ // Status Bar
513
+ const statusLines = renderStatusBar(cols, { ...state, skinName: 'arcade' }, tick);
514
+ const statusStartRow = rows - statusLines.length;
515
+ for (let i = 0; i < statusLines.length; i++) {
516
+ if (statusStartRow + i < rows) {
517
+ lines[statusStartRow + i] = `${P.bg}${statusLines[i].replace(/\x1b\[0m/g, RESET + P.bg)}${RESET}`;
518
+ }
519
+ }
520
+
521
+ return {
522
+ lines,
523
+ buttonRegion: {
524
+ x: button.x,
525
+ y: buttonRow + 1,
526
+ width: button.width,
527
+ height: button.height,
528
+ },
529
+ };
530
+ }