typescript-virtual-container 1.5.8 → 1.5.10
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.
- package/README.md +39 -29
- package/dist/.tsbuildinfo +1 -1
- package/dist/SSHMimic/executor.js +9 -0
- package/dist/SSHMimic/prompt.js +2 -2
- package/dist/VirtualShell/shell.js +48 -1
- package/dist/VirtualShell/shellParser.js +35 -3
- package/dist/VirtualUserManager/index.d.ts +26 -0
- package/dist/VirtualUserManager/index.js +26 -0
- package/dist/commands/coreutils.d.ts +55 -0
- package/dist/commands/coreutils.js +271 -0
- package/dist/commands/htop.d.ts +2 -2
- package/dist/commands/htop.js +143 -8
- package/dist/commands/manuals-bundle.js +227 -0
- package/dist/commands/pacman.d.ts +8 -0
- package/dist/commands/pacman.js +15 -0
- package/dist/commands/ps.js +22 -8
- package/dist/commands/registry.js +13 -0
- package/dist/commands/runtime.js +42 -2
- package/dist/commands/sh.js +10 -3
- package/dist/index.d.ts +1 -1
- package/dist/modules/linuxRootfs.js +4 -4
- package/dist/modules/nanoEditor.d.ts +1 -1
- package/dist/modules/nanoEditor.js +22 -4
- package/dist/modules/pacmanGame.d.ts +59 -0
- package/dist/modules/pacmanGame.js +655 -0
- package/dist/modules/webTermRenderer.d.ts +8 -0
- package/dist/modules/webTermRenderer.js +163 -29
- package/dist/types/commands.d.ts +2 -0
- package/dist/types/pipeline.d.ts +2 -0
- package/package.json +2 -2
|
@@ -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
|
+
const 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
|
+
const 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
|
}
|