typescript-virtual-container 1.5.8 → 1.5.9

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,655 @@
1
+ // ── ANSI ─────────────────────────────────────────────────────────────────────
2
+ const ESC = "\x1b";
3
+ const CSI = `${ESC}[`;
4
+ const cup = (r, c) => `${CSI}${r};${c}H`;
5
+ const hide = `${CSI}?25l`;
6
+ const show = `${CSI}?25h`;
7
+ const clearScreen = `${CSI}2J${CSI}H`;
8
+ const eraseEol = `${CSI}K`;
9
+ const C = {
10
+ blue: `${ESC}[1;34m`,
11
+ yellow: `${ESC}[1;33m`,
12
+ red: `${ESC}[1;31m`,
13
+ pink: `${ESC}[1;35m`,
14
+ cyan: `${ESC}[1;36m`,
15
+ orange: `${ESC}[33m`,
16
+ white: `${ESC}[1;37m`,
17
+ dim: `${ESC}[2;37m`,
18
+ blink: `${ESC}[5m`,
19
+ r: `${ESC}[0m`,
20
+ };
21
+ // ── Maze ─────────────────────────────────────────────────────────────────────
22
+ // Source: myman-wip-2009-10-30 gpac.txt level 1 (36 cols × 33 rows)
23
+ const MAZE_TEMPLATE = [
24
+ " ╔══════════╗ ╔══════════╗ ", // 0
25
+ " ║. . . .║ ║. . . .║ ", // 1
26
+ " ║ ┌┐ ┌───┐ ║ ║ ┌───┐ ┌┐ ║ ", // 2
27
+ "╔══╝ ││ └──┐│ ╚══════╝ │┌──┘ ││ ╚══╗", // 3
28
+ "║. o││. .││. . . .││. .││o .║", // 4
29
+ "║ ┌──┘└──┐ ││ ┌──────┐ ││ ┌──┘└──┐ ║", // 5
30
+ "║ └──────┘ └┘ └──────┘ └┘ └──────┘ ║", // 6
31
+ "║. . . . . . . . . . . .║", // 7
32
+ "╙──┐ ┌┐ ┌┐ ┌───┐ ┌┐ ┌───┐ ┌┐ ┌┐ ┌──╜", // 8
33
+ "╓──┘ ││ └┘ └───┘ ││ └───┘ └┘ ││ └──╖", // 9
34
+ "║ .││. . . .││. . . .││. ║", // 10
35
+ "║ ┌┐ ││ ┌┐ ┌───┐ ││ ┌───┐ ┌┐ ││ ┌┐ ║", // 11
36
+ "╝ ││ └┘ └┘ │┌──┘ └┘ └──┐│ └┘ └┘ ││ ╚", // 12
37
+ " ││. . .││ ││. . .││ ", // 13
38
+ "══╛╖ ┌┐ ┌──┘│ ╔══ ══╗ │└──┐ ┌┐ ╓╘══", // 14
39
+ " ║ ││ └───┘ ║ ║ └───┘ ││ ║ ", // 15
40
+ " ║.││. . ║ ║ . .││.║ ", // 16
41
+ " ║ ││ ┌───┐ ║ ║ ┌───┐ ││ ║ ", // 17
42
+ "╔══╝ └┘ └──┐│ ╚══════╝ │┌──┘ └┘ ╚══╗", // 18
43
+ "║. . . .││ ││. . . .║", // 19
44
+ "║ ┌┐ ┌┐ ┌┐ ││ ┌──────┐ ││ ┌┐ ┌┐ ┌┐ ║", // 20
45
+ "╝ ││ └┘ ││ └┘ └──┐┌──┘ └┘ ││ └┘ ││ ╚", // 21
46
+ " .││. .││. . .││. . .││. .││. ", // 22
47
+ "╗ ││ ┌┐ ││ ┌┐ ┌┐ ││ ┌┐ ┌┐ ││ ┌┐ ││ ╔", // 23
48
+ "║ └┘ └┘ ││ ││ ││ └┘ ││ ││ ││ └┘ └┘ ║", // 24
49
+ "║. . .││.││.││. .││.││.││. . .║", // 25
50
+ "║ ┌───┐ ││ ││ ││ ┌┐ ││ ││ ││ ┌───┐ ║", // 26
51
+ "║ └──┐│ └┘ └┘ └┘ ││ └┘ └┘ └┘ │┌──┘ ║", // 27
52
+ "║. o││. . . .││. . . .││o .║", // 28
53
+ "╚══╗ ││ ┌───┐ ╔══╛╘══╗ ┌───┐ ││ ╔══╝", // 29
54
+ " ║ └┘ └───┘ ║ ║ └───┘ └┘ ║ ", // 30
55
+ " ║. . . .║ ║. . . .║ ", // 31
56
+ " ╚══════════╝ ╚══════════╝ ", // 32
57
+ ];
58
+ const ROWS = MAZE_TEMPLATE.length;
59
+ const COLS = 36;
60
+ const WALL_SET = new Set([
61
+ "╔", "╗", "╚", "╝", "═", "║", "╙", "╜", "╓", "╖", "╛", "╘", "╒", "╕",
62
+ "┌", "┐", "└", "┘", "─", "│", "╞", "╡", "┼", "≡", "╟", "╢",
63
+ ]);
64
+ function parseMaze(tpl) {
65
+ const grid = [];
66
+ for (let r = 0; r < tpl.length; r++) {
67
+ const row = [];
68
+ const line = tpl[r];
69
+ for (let c = 0; c < COLS; c++) {
70
+ const ch = line[c] ?? " ";
71
+ if (WALL_SET.has(ch))
72
+ row.push("wall");
73
+ else if (ch === ".")
74
+ row.push("dot");
75
+ else if (ch === "o")
76
+ row.push("pellet");
77
+ else
78
+ row.push("empty");
79
+ }
80
+ grid.push(row);
81
+ }
82
+ // Mark ghost house interior (walls at c14,c21 already "wall"; interior = ghost-house)
83
+ for (let r = 15; r <= 17; r++) {
84
+ for (let c = 15; c <= 20; c++) {
85
+ if (grid[r]?.[c] === "empty")
86
+ grid[r][c] = "ghost-house";
87
+ }
88
+ }
89
+ return grid;
90
+ }
91
+ const DR = [0, 1, 0, -1];
92
+ const DC = [1, 0, -1, 0];
93
+ const OPP = [2, 3, 0, 1];
94
+ // ── PacmanGame ────────────────────────────────────────────────────────────────
95
+ export class PacmanGame {
96
+ stream;
97
+ onExit;
98
+ grid;
99
+ visualGrid;
100
+ // Pacman — spawn r22,c16 (open dot, mid corridor left of center pillars)
101
+ pacR = 22;
102
+ pacC = 16;
103
+ pacDir = 2;
104
+ pacNextDir = 2;
105
+ pacMouthOpen = true;
106
+ pacAlive = true;
107
+ ghosts = [];
108
+ score = 0;
109
+ lives = 3;
110
+ level = 1;
111
+ dotsTotal = 0;
112
+ dotsEaten = 0;
113
+ frightDuration = 40; // ticks at 8fps ≈ 5 s
114
+ gameOver = false;
115
+ won = false;
116
+ msgTicks = 0;
117
+ msg = "";
118
+ // Scatter/chase schedule in ticks (8fps): 7s scatter, 20s chase, 7s, 20s, 5s, ∞
119
+ globalMode = "scatter";
120
+ globalModeTick = 0;
121
+ modeSchedule = [56, 160, 56, 160, 40, Number.MAX_SAFE_INTEGER];
122
+ modeIdx = 0;
123
+ tick = 0;
124
+ intervalId = null;
125
+ inputKey = null;
126
+ // Buffer for split ESC sequences (SSH sends \x1b and [A in separate chunks)
127
+ escBuf = "";
128
+ // Death animation
129
+ deathTick = 0;
130
+ deathAnimating = false;
131
+ // Differential render — previous rendered lines
132
+ prevLines = [];
133
+ constructor(opts) {
134
+ this.stream = opts.stream;
135
+ this.onExit = opts.onExit;
136
+ this.grid = parseMaze(MAZE_TEMPLATE);
137
+ this.visualGrid = MAZE_TEMPLATE.map(l => Array.from(l));
138
+ this.countDots();
139
+ this.initGhosts();
140
+ }
141
+ countDots() {
142
+ this.dotsTotal = 0;
143
+ for (const row of this.grid)
144
+ for (const c of row)
145
+ if (c === "dot" || c === "pellet")
146
+ this.dotsTotal++;
147
+ }
148
+ initGhosts() {
149
+ this.ghosts = [
150
+ // Blinky — always outside, top-right scatter
151
+ {
152
+ name: "Blinky", color: C.red,
153
+ r: 14, c: 17, dir: 2,
154
+ mode: "scatter", frightTicks: 0,
155
+ scatterR: 0, scatterC: 35,
156
+ inHouse: false, dotThreshold: 0,
157
+ movePeriod: 1, movePhase: 0,
158
+ },
159
+ // Pinky — house center, top-left scatter, exits immediately
160
+ {
161
+ name: "Pinky", color: C.pink,
162
+ r: 16, c: 17, dir: 3,
163
+ mode: "scatter", frightTicks: 0,
164
+ scatterR: 0, scatterC: 0,
165
+ inHouse: true, dotThreshold: 0,
166
+ movePeriod: 1, movePhase: 0,
167
+ },
168
+ // Inky — house left, bottom-right scatter, exits after 30 dots (arcade value)
169
+ {
170
+ name: "Inky", color: C.cyan,
171
+ r: 16, c: 15, dir: 3,
172
+ mode: "scatter", frightTicks: 0,
173
+ scatterR: 32, scatterC: 35,
174
+ inHouse: true, dotThreshold: 30,
175
+ movePeriod: 1, movePhase: 1,
176
+ },
177
+ // Clyde — house right, bottom-left scatter, exits after 60 dots (arcade value)
178
+ {
179
+ name: "Clyde", color: C.orange,
180
+ r: 16, c: 19, dir: 3,
181
+ mode: "scatter", frightTicks: 0,
182
+ scatterR: 32, scatterC: 0,
183
+ inHouse: true, dotThreshold: 60,
184
+ movePeriod: 1, movePhase: 2,
185
+ },
186
+ ];
187
+ }
188
+ start() {
189
+ this.stream.write(hide + clearScreen);
190
+ this.prevLines = [];
191
+ this.renderFull();
192
+ this.intervalId = setInterval(() => this.gameTick(), 125);
193
+ }
194
+ stop() {
195
+ if (this.intervalId) {
196
+ clearInterval(this.intervalId);
197
+ this.intervalId = null;
198
+ }
199
+ this.stream.write(show + clearScreen + C.r);
200
+ }
201
+ handleInput(chunk) {
202
+ // Prepend any buffered partial ESC sequence from previous chunk (SSH splits \x1b and [A)
203
+ const data = this.escBuf + chunk.toString("utf8");
204
+ this.escBuf = "";
205
+ let i = 0;
206
+ while (i < data.length) {
207
+ const ch = data[i];
208
+ if (ch === "q" || ch === "Q" || ch === "\x03") {
209
+ this.stop();
210
+ this.onExit();
211
+ return;
212
+ }
213
+ if (ch === "\x1b") {
214
+ // Need at least 2 more chars for CSI arrow sequence
215
+ if (i + 2 >= data.length) {
216
+ this.escBuf = data.slice(i);
217
+ break;
218
+ }
219
+ if (data[i + 1] === "[") {
220
+ const code = data[i + 2];
221
+ if (code === "A")
222
+ this.inputKey = 3;
223
+ else if (code === "B")
224
+ this.inputKey = 1;
225
+ else if (code === "C")
226
+ this.inputKey = 0;
227
+ else if (code === "D")
228
+ this.inputKey = 2;
229
+ i += 3;
230
+ continue;
231
+ }
232
+ i++;
233
+ continue;
234
+ }
235
+ if (ch === "w" || ch === "W")
236
+ this.inputKey = 3;
237
+ else if (ch === "s" || ch === "S")
238
+ this.inputKey = 1;
239
+ else if (ch === "a" || ch === "A")
240
+ this.inputKey = 2;
241
+ else if (ch === "d" || ch === "D")
242
+ this.inputKey = 0;
243
+ i++;
244
+ }
245
+ }
246
+ // ── Game loop ─────────────────────────────────────────────────────────────
247
+ gameTick() {
248
+ if (this.gameOver || this.won) {
249
+ this.msgTicks++;
250
+ if (this.msgTicks > 32) {
251
+ this.stop();
252
+ this.onExit();
253
+ }
254
+ else
255
+ this.renderDiff();
256
+ return;
257
+ }
258
+ if (this.deathAnimating) {
259
+ this.deathTick++;
260
+ if (this.deathTick > 16) {
261
+ this.deathAnimating = false;
262
+ this.deathTick = 0;
263
+ if (this.lives <= 0) {
264
+ this.gameOver = true;
265
+ this.msg = "GAME OVER";
266
+ this.msgTicks = 0;
267
+ }
268
+ else {
269
+ this.respawn();
270
+ }
271
+ }
272
+ this.renderDiff();
273
+ return;
274
+ }
275
+ this.tick++;
276
+ // Queue direction
277
+ if (this.inputKey !== null) {
278
+ this.pacNextDir = this.inputKey;
279
+ this.inputKey = null;
280
+ }
281
+ // Global scatter/chase schedule
282
+ if (this.globalMode !== "fright") {
283
+ this.globalModeTick++;
284
+ if (this.globalModeTick >= this.modeSchedule[this.modeIdx]) {
285
+ this.globalModeTick = 0;
286
+ this.modeIdx = Math.min(this.modeIdx + 1, this.modeSchedule.length - 1);
287
+ this.globalMode = this.modeIdx % 2 === 0 ? "scatter" : "chase";
288
+ // Sync all active ghosts to new global mode + force reverse (original behavior)
289
+ for (const g of this.ghosts) {
290
+ if (!g.inHouse && g.mode !== "fright" && g.mode !== "eaten") {
291
+ g.mode = this.globalMode;
292
+ g.dir = (OPP[g.dir] ?? g.dir);
293
+ }
294
+ }
295
+ }
296
+ }
297
+ // Snapshot ghost positions before move (for cross-collision detection)
298
+ const prevGhostPos = this.ghosts.map(g => ({ r: g.r, c: g.c }));
299
+ const prevPacR = this.pacR, prevPacC = this.pacC;
300
+ this.movePacman();
301
+ this.pacMouthOpen = !this.pacMouthOpen;
302
+ for (const g of this.ghosts)
303
+ this.moveGhost(g);
304
+ this.checkCollisions(prevGhostPos, prevPacR, prevPacC);
305
+ this.renderDiff();
306
+ }
307
+ // ── Walkability ───────────────────────────────────────────────────────────
308
+ isWalkable(r, c, ghost = false) {
309
+ if (r < 0 || r >= ROWS)
310
+ return false;
311
+ // Horizontal tunnel wrap handled elsewhere
312
+ const cc = ((c % COLS) + COLS) % COLS;
313
+ const cell = this.grid[r]?.[cc];
314
+ if (cell === "wall")
315
+ return false;
316
+ if (!ghost && cell === "ghost-house")
317
+ return false;
318
+ return cell !== undefined;
319
+ }
320
+ // ── Pacman movement ───────────────────────────────────────────────────────
321
+ movePacman() {
322
+ // Try queued dir
323
+ const qr = this.pacR + DR[this.pacNextDir];
324
+ const qc = ((this.pacC + DC[this.pacNextDir]) % COLS + COLS) % COLS;
325
+ if (this.isWalkable(qr, qc))
326
+ this.pacDir = this.pacNextDir;
327
+ const mr = this.pacR + DR[this.pacDir];
328
+ const mc = ((this.pacC + DC[this.pacDir]) % COLS + COLS) % COLS;
329
+ if (this.isWalkable(mr, mc)) {
330
+ this.pacR = mr;
331
+ this.pacC = mc;
332
+ }
333
+ const cell = this.grid[this.pacR]?.[this.pacC];
334
+ if (cell === "dot") {
335
+ this.grid[this.pacR][this.pacC] = "empty";
336
+ this.score += 10;
337
+ this.dotsEaten++;
338
+ }
339
+ else if (cell === "pellet") {
340
+ this.grid[this.pacR][this.pacC] = "empty";
341
+ this.score += 50;
342
+ this.dotsEaten++;
343
+ this.activateFright();
344
+ }
345
+ if (this.dotsEaten >= this.dotsTotal) {
346
+ this.won = true;
347
+ this.msg = " YOU WIN!";
348
+ this.msgTicks = 0;
349
+ }
350
+ }
351
+ // ── Ghost: fright ─────────────────────────────────────────────────────────
352
+ activateFright() {
353
+ for (const g of this.ghosts) {
354
+ if (g.mode !== "eaten") {
355
+ g.mode = "fright";
356
+ g.frightTicks = this.frightDuration;
357
+ g.movePeriod = 2; // half speed during fright (arcade accurate)
358
+ if (!g.inHouse)
359
+ g.dir = (OPP[g.dir] ?? g.dir);
360
+ }
361
+ }
362
+ // Do not interrupt global mode schedule tick during fright
363
+ }
364
+ // ── Ghost: target tile (original Pac-Man logic) ───────────────────────────
365
+ ghostTarget(g) {
366
+ if (g.mode === "scatter")
367
+ return [g.scatterR, g.scatterC];
368
+ // Chase targets — faithful to original arcade
369
+ switch (g.name) {
370
+ case "Blinky":
371
+ // Direct chase: target = pacman position
372
+ return [this.pacR, this.pacC];
373
+ case "Pinky": {
374
+ // Target 4 tiles ahead of pacman (with original NES up-bug: up = up-left*4)
375
+ let tr = this.pacR + DR[this.pacDir] * 4;
376
+ let tc = this.pacC + DC[this.pacDir] * 4;
377
+ if (this.pacDir === 3)
378
+ tc = this.pacC - 4; // NES bug: facing up → also goes left
379
+ return [tr, tc];
380
+ }
381
+ case "Inky": {
382
+ // Pivot: 2 tiles ahead of pacman, then double-vector from Blinky
383
+ const blinky = this.ghosts[0];
384
+ let pr = this.pacR + DR[this.pacDir] * 2;
385
+ let pc = this.pacC + DC[this.pacDir] * 2;
386
+ if (this.pacDir === 3)
387
+ pc = this.pacC - 2; // NES bug mirror
388
+ // Target = pivot + (pivot - blinky)
389
+ return [pr * 2 - blinky.r, pc * 2 - blinky.c];
390
+ }
391
+ case "Clyde": {
392
+ // Chase if dist > 8 tiles (Euclidean), else scatter corner
393
+ const dr = g.r - this.pacR;
394
+ const dc = g.c - this.pacC;
395
+ if (dr * dr + dc * dc > 64)
396
+ return [this.pacR, this.pacC];
397
+ return [g.scatterR, g.scatterC];
398
+ }
399
+ default:
400
+ return [this.pacR, this.pacC];
401
+ }
402
+ }
403
+ // ── Ghost: movement ───────────────────────────────────────────────────────
404
+ moveGhost(g) {
405
+ // Per-ghost speed throttle
406
+ g.movePhase = (g.movePhase + 1) % g.movePeriod;
407
+ if (g.movePhase !== 0)
408
+ return;
409
+ // Fright countdown — applied AFTER collision check via tickFrightCountdown()
410
+ // (moving it here would let mode flip to chase before collision is tested)
411
+ // In-house: bounce and navigate to exit
412
+ if (g.inHouse) {
413
+ if (this.dotsEaten < g.dotThreshold) {
414
+ // Bounce vertically inside house
415
+ const nextR = g.r + DR[g.dir];
416
+ if (nextR < 15 || nextR > 17)
417
+ g.dir = (OPP[g.dir] ?? g.dir);
418
+ else
419
+ g.r = nextR;
420
+ return;
421
+ }
422
+ // Navigate to exit: r=14,c=17 (open gap in ghost house top wall)
423
+ const exitR = 14, exitC = 17;
424
+ if (g.r === exitR && g.c === exitC) {
425
+ g.inHouse = false;
426
+ g.mode = this.globalMode;
427
+ g.dir = 2; // exit left into corridor
428
+ return;
429
+ }
430
+ // Step toward exit
431
+ if (g.c !== exitC) {
432
+ g.c += g.c < exitC ? 1 : -1;
433
+ }
434
+ else if (g.r > exitR) {
435
+ g.r--;
436
+ }
437
+ return;
438
+ }
439
+ // Eaten: beeline back to house entrance
440
+ if (g.mode === "eaten") {
441
+ const homeR = 14, homeC = 17;
442
+ if (g.r === homeR && g.c === homeC) {
443
+ g.inHouse = true;
444
+ g.r = 16;
445
+ g.c = 17; // reset inside house
446
+ g.mode = this.globalMode;
447
+ g.movePeriod = 1;
448
+ g.dir = 3;
449
+ return;
450
+ }
451
+ // Move one step toward house
452
+ if (g.c !== homeC)
453
+ g.c += g.c < homeC ? 1 : -1;
454
+ else if (g.r !== homeR)
455
+ g.r += g.r < homeR ? 1 : -1;
456
+ return;
457
+ }
458
+ // Normal: pick best direction at each tile
459
+ const candidates = [0, 1, 2, 3].filter(d => d !== OPP[g.dir]);
460
+ const walkable = candidates.filter(d => {
461
+ const nr = g.r + DR[d];
462
+ const nc = ((g.c + DC[d]) % COLS + COLS) % COLS;
463
+ return this.isWalkable(nr, nc, true);
464
+ });
465
+ let chosen = g.dir;
466
+ if (g.mode === "fright") {
467
+ // Random walkable direction
468
+ if (walkable.length > 0)
469
+ chosen = walkable[Math.floor(Math.random() * walkable.length)];
470
+ }
471
+ else {
472
+ const [tR, tC] = this.ghostTarget(g);
473
+ let best = Number.MAX_SAFE_INTEGER;
474
+ // Original priority: up > left > down > right when tied
475
+ for (const d of [3, 2, 1, 0]) {
476
+ if (!walkable.includes(d))
477
+ continue;
478
+ const nr = g.r + DR[d];
479
+ const nc = ((g.c + DC[d]) % COLS + COLS) % COLS;
480
+ const dr = nr - tR, dc = nc - tC;
481
+ const dist = dr * dr + dc * dc; // squared Euclidean (same ranking as sqrt)
482
+ if (dist < best) {
483
+ best = dist;
484
+ chosen = d;
485
+ }
486
+ }
487
+ }
488
+ g.dir = chosen;
489
+ const nr = g.r + DR[g.dir];
490
+ const nc = ((g.c + DC[g.dir]) % COLS + COLS) % COLS;
491
+ if (this.isWalkable(nr, nc, true)) {
492
+ g.r = nr;
493
+ g.c = nc;
494
+ }
495
+ }
496
+ // ── Collision ─────────────────────────────────────────────────────────────
497
+ checkCollisions(prevGhostPos, prevPacR, prevPacC) {
498
+ for (let i = 0; i < this.ghosts.length; i++) {
499
+ const g = this.ghosts[i];
500
+ if (g.inHouse || g.mode === "eaten")
501
+ continue;
502
+ // Same-cell collision
503
+ const sameTile = g.r === this.pacR && g.c === this.pacC;
504
+ // Cross-collision: pacman and ghost swapped positions this tick
505
+ const prev = prevGhostPos[i];
506
+ const crossed = prev.r === this.pacR && prev.c === this.pacC
507
+ && g.r === prevPacR && g.c === prevPacC;
508
+ if (!sameTile && !crossed)
509
+ continue;
510
+ if (g.mode === "fright") {
511
+ g.mode = "eaten";
512
+ this.score += 200;
513
+ }
514
+ else {
515
+ this.lives--;
516
+ this.deathAnimating = true;
517
+ this.deathTick = 0;
518
+ this.pacAlive = false;
519
+ // Tick fright countdowns before returning
520
+ this.tickFrightCountdowns();
521
+ return;
522
+ }
523
+ }
524
+ this.tickFrightCountdowns();
525
+ }
526
+ tickFrightCountdowns() {
527
+ for (const g of this.ghosts) {
528
+ if (g.mode === "fright") {
529
+ g.frightTicks--;
530
+ if (g.frightTicks <= 0) {
531
+ g.mode = this.globalMode;
532
+ g.movePeriod = 1;
533
+ }
534
+ }
535
+ }
536
+ }
537
+ respawn() {
538
+ this.pacR = 22;
539
+ this.pacC = 16;
540
+ this.pacDir = 2;
541
+ this.pacNextDir = 2;
542
+ this.pacAlive = true;
543
+ this.pacMouthOpen = true;
544
+ this.initGhosts();
545
+ }
546
+ // ── Render ────────────────────────────────────────────────────────────────
547
+ buildLines() {
548
+ const lines = [];
549
+ // Header
550
+ const sc = String(this.score).padStart(6, " ");
551
+ const hi = String(Math.max(this.score, 24780)).padStart(6, " ");
552
+ lines.push(`${C.white} 1UP HIGH SCORE${C.r}`);
553
+ lines.push(` ${C.yellow}${sc}${C.r} ${C.white}${hi}${C.r}`);
554
+ // Build render grid from visual template
555
+ const rg = this.visualGrid.map(row => [...row]);
556
+ // Overlay game cells
557
+ for (let r = 0; r < ROWS; r++) {
558
+ for (let c = 0; c < COLS; c++) {
559
+ const cell = this.grid[r]?.[c];
560
+ const vch = rg[r]?.[c] ?? " ";
561
+ if (WALL_SET.has(vch))
562
+ continue;
563
+ if (cell === "dot")
564
+ rg[r][c] = "·";
565
+ else if (cell === "pellet")
566
+ rg[r][c] = "■";
567
+ else
568
+ rg[r][c] = " ";
569
+ }
570
+ }
571
+ // Ghosts
572
+ for (const g of this.ghosts) {
573
+ if (g.r < 0 || g.r >= ROWS || g.c < 0 || g.c >= COLS)
574
+ continue;
575
+ let sprite;
576
+ if (g.mode === "eaten") {
577
+ sprite = `${C.white}ö${C.r}`;
578
+ }
579
+ else if (g.mode === "fright") {
580
+ const flash = g.frightTicks < 12 && this.tick % 2 === 0;
581
+ sprite = flash ? `${C.white}ᗣ${C.r}` : `${C.blue}ᗣ${C.r}`;
582
+ }
583
+ else {
584
+ const frame = this.tick % 2 === 0 ? "ᗣ" : "ᗡ";
585
+ sprite = `${g.color}${frame}${C.r}`;
586
+ }
587
+ rg[g.r][g.c] = sprite;
588
+ }
589
+ // Pacman
590
+ if (this.pacAlive || this.deathAnimating) {
591
+ let sprite;
592
+ if (this.deathAnimating) {
593
+ const frames = ["ᗧ", "◑", "◐", "◒", "◓", "●", "○", " "];
594
+ sprite = `${C.yellow}${frames[Math.min(this.deathTick >> 1, frames.length - 1)]}${C.r}`;
595
+ }
596
+ else {
597
+ const open = ["ᗧ", "ᗦ", "ᗤ", "ᗣ"][this.pacDir] ?? "ᗧ";
598
+ sprite = `${C.yellow}${this.pacMouthOpen ? open : "◯"}${C.r}`;
599
+ }
600
+ if (this.pacR >= 0 && this.pacR < ROWS && this.pacC >= 0 && this.pacC < COLS)
601
+ rg[this.pacR][this.pacC] = sprite;
602
+ }
603
+ // Colorize maze rows
604
+ for (let r = 0; r < ROWS; r++) {
605
+ let row = "";
606
+ for (let c = 0; c < COLS; c++) {
607
+ const ch = rg[r][c];
608
+ if (ch.includes("\x1b"))
609
+ row += ch;
610
+ else if (WALL_SET.has(ch))
611
+ row += `${C.blue}${ch}${C.r}`;
612
+ else if (ch === "·")
613
+ row += `${C.dim}·${C.r}`;
614
+ else if (ch === "■")
615
+ row += `${C.white}■${C.r}`;
616
+ else
617
+ row += ch;
618
+ }
619
+ lines.push(row);
620
+ }
621
+ // Footer
622
+ const livesStr = `${C.yellow}ᗧ${C.r} `.repeat(Math.max(0, this.lives));
623
+ lines.push("", ` ${livesStr} LEVEL ${C.yellow}${this.level}${C.r}`);
624
+ lines.push(` ${C.dim}WASD/arrows Q=quit${C.r}`);
625
+ // Message overlay
626
+ if (this.msg)
627
+ lines[18] = ` ${C.yellow}${C.blink}${this.msg}${C.r}`;
628
+ return lines;
629
+ }
630
+ renderFull() {
631
+ const lines = this.buildLines();
632
+ let out = hide + clearScreen;
633
+ for (let i = 0; i < lines.length; i++)
634
+ out += cup(i + 1, 1) + (lines[i] ?? "") + eraseEol;
635
+ this.stream.write(out);
636
+ this.prevLines = lines;
637
+ }
638
+ renderDiff() {
639
+ const lines = this.buildLines();
640
+ let out = "";
641
+ for (let i = 0; i < lines.length; i++) {
642
+ const line = lines[i] ?? "";
643
+ if (line !== this.prevLines[i]) {
644
+ out += cup(i + 1, 1) + line + eraseEol;
645
+ }
646
+ }
647
+ // Clear any extra lines from previous render
648
+ for (let i = lines.length; i < this.prevLines.length; i++) {
649
+ out += cup(i + 1, 1) + eraseEol;
650
+ }
651
+ if (out)
652
+ this.stream.write(out);
653
+ this.prevLines = lines;
654
+ }
655
+ }
@@ -18,9 +18,11 @@ export declare class WebTermRenderer {
18
18
  private rows;
19
19
  private cols;
20
20
  private screen;
21
+ private scrollback;
21
22
  private curRow;
22
23
  private curCol;
23
24
  private cursorVisible;
25
+ private _cleared;
24
26
  private bold;
25
27
  private reverse;
26
28
  private fg;
@@ -32,6 +34,7 @@ export declare class WebTermRenderer {
32
34
  private flush;
33
35
  private handleCsi;
34
36
  private handleSgr;
37
+ private scrollUp;
35
38
  private putChar;
36
39
  private makeScreen;
37
40
  /** Render current screen state to an HTML string for a <pre> element. */
@@ -39,4 +42,9 @@ export declare class WebTermRenderer {
39
42
  get cursorRow(): number;
40
43
  get cursorCol(): number;
41
44
  get isCursorVisible(): boolean;
45
+ /** Returns true (once) if CSI 2J was received since last call. */
46
+ consumeCleared(): boolean;
47
+ get scrollbackLength(): number;
48
+ clearScrollback(): void;
49
+ renderScrollbackHtml(): string;
42
50
  }