@modelstatus/cli 0.1.35 → 0.1.37
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/package.json +1 -1
- package/src/index.js +113 -0
- package/src/sources/scan-process.js +238 -0
- package/src/sources/scan-worker.js +148 -0
- package/src/tui/app.js +40 -1
- package/src/tui/game/DkGame.js +18 -184
- package/src/tui/game/dk-core.js +507 -226
- package/src/tui/game/dk-render.js +46 -0
- package/src/tui/game/input.js +169 -0
- package/src/tui/game/loop.js +337 -0
- package/src/tui/game/term.js +330 -0
- package/src/tui/views/scan.js +94 -80
|
@@ -88,6 +88,52 @@ export function renderGame(state, { glyph = GAME_GLYPH } = {}) {
|
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** "#rrggbb" | "#rgb" -> packed 24-bit int (-1 if unparseable). Kept local so
|
|
92
|
+
* the renderer stays dependency-free; identical packing to term.js packHex. */
|
|
93
|
+
function packHex(hex) {
|
|
94
|
+
if (typeof hex !== "string") return -1;
|
|
95
|
+
let h = hex[0] === "#" ? hex.slice(1) : hex;
|
|
96
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
97
|
+
if (h.length !== 6) return -1;
|
|
98
|
+
const n = parseInt(h, 16);
|
|
99
|
+
return Number.isNaN(n) ? -1 : n & 0xffffff;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Packed-RGB color table by entity kind, derived once from GAME_COLORS so the
|
|
103
|
+
// hot path (fillCells, every frame) never re-parses hex strings.
|
|
104
|
+
const PACKED_COLORS = Object.fromEntries(
|
|
105
|
+
Object.entries(GAME_COLORS).map(([k, v]) => [k, packHex(v)]),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* fillCells(state, opts) -> { ch:Uint16Array, fg:Int32Array, width, height }.
|
|
110
|
+
* The cell-buffer path the direct-ANSI Backbuffer.blit() consumes: a flat
|
|
111
|
+
* BOARD_W × BOARD_H grid of char codes + packed-RGB foregrounds, drawn in the
|
|
112
|
+
* same priority order as colorize/renderGame (girders → ladders → goal → actors
|
|
113
|
+
* → jumpman). `empty` cells get fg = -1 (NO_FG sentinel) so the diff renderer
|
|
114
|
+
* leaves them at the default color and emits no SGR for blank space.
|
|
115
|
+
*
|
|
116
|
+
* opts.color=false zeroes all foregrounds to -1 (NO_COLOR/MM_ASCII path).
|
|
117
|
+
*/
|
|
118
|
+
export function fillCells(state, { glyph = GAME_GLYPH, color = true } = {}) {
|
|
119
|
+
if (!state || state.tooSmall) return { ch: new Uint16Array(0), fg: new Int32Array(0), width: 0, height: 0 };
|
|
120
|
+
const grid = buildGrid(state, glyph);
|
|
121
|
+
const w = state.BOARD_W, h = state.BOARD_H;
|
|
122
|
+
const ch = new Uint16Array(w * h);
|
|
123
|
+
const fg = new Int32Array(w * h);
|
|
124
|
+
for (let y = 0; y < h; y++) {
|
|
125
|
+
const row = grid[y];
|
|
126
|
+
const base = y * w;
|
|
127
|
+
for (let x = 0; x < w; x++) {
|
|
128
|
+
const c = row[x];
|
|
129
|
+
ch[base + x] = c.ch.charCodeAt(0) & 0xffff;
|
|
130
|
+
// empty space carries no color (default fg) so blanks emit zero SGR.
|
|
131
|
+
fg[base + x] = !color || c.kind === "empty" ? -1 : (PACKED_COLORS[c.kind] ?? -1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { ch, fg, width: w, height: h };
|
|
135
|
+
}
|
|
136
|
+
|
|
91
137
|
/**
|
|
92
138
|
* colorize(state, opts) -> array (length BOARD_H) of span arrays, where each
|
|
93
139
|
* span is { text, color }. Adjacent same-color cells are merged into one span
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/* Raw-stdin key reader + held-key model for the game loop. NO Ink.
|
|
2
|
+
*
|
|
3
|
+
* Terminals deliver key REPEATS (OS autorepeat) on a held key, NOT a key-up:
|
|
4
|
+
* after an initial ~250-500ms delay, the same byte(s) arrive every ~30-50ms.
|
|
5
|
+
* There is no release event in a raw TTY stream. So "held" is modelled as:
|
|
6
|
+
* a direction is held until HOLD_MS after its LAST received byte. HOLD_MS (90)
|
|
7
|
+
* is sized > the worst repeat gap (~50ms) so the hold never drops mid-press, but
|
|
8
|
+
* a real release expires it within 90ms. Movement applies on the FIRST byte
|
|
9
|
+
* immediately (we do NOT wait out the ~300ms OS repeat-delay) → input latency is
|
|
10
|
+
* one 60Hz step (~16.7ms), well under the 40ms bar.
|
|
11
|
+
*
|
|
12
|
+
* Trigger semantics:
|
|
13
|
+
* - movement (left/right/up/down): LEVEL-triggered — sample() reports them as
|
|
14
|
+
* "down" for the whole held window, so key-held walking/climbing is smooth.
|
|
15
|
+
* - actions (jump/restart/pause/quit): EDGE-triggered — one-shot per physical
|
|
16
|
+
* byte, drained on read so a single press fires exactly once.
|
|
17
|
+
*
|
|
18
|
+
* The parser is pure (parseKeys: Buffer -> token[]) and the held model is pure
|
|
19
|
+
* (InputState, driven by an injected clock), so both are unit-tested with no
|
|
20
|
+
* TTY and no real time. attachStdin() wires a live stdin to an InputState. */
|
|
21
|
+
|
|
22
|
+
export const HOLD_MS = 90; // direction stays "held" this long after its last byte
|
|
23
|
+
|
|
24
|
+
// Movement is level-triggered (held); these are the directions.
|
|
25
|
+
const DIRECTIONS = ["left", "right", "up", "down"];
|
|
26
|
+
// Actions are edge-triggered (one-shot per byte).
|
|
27
|
+
const ACTIONS = ["jump", "restart", "pause", "quit"];
|
|
28
|
+
|
|
29
|
+
// Byte/sequence -> logical token. Arrows are 3-byte CSI; the rest are single
|
|
30
|
+
// chars. wasd + hjkl mirror the arrows; space=jump; r/p/q actions; q/esc/^C quit.
|
|
31
|
+
const ESC = 0x1b, BR = 0x5b; // \x1b '['
|
|
32
|
+
const CSI_FINAL = { 0x41: "up", 0x42: "down", 0x43: "right", 0x44: "left" }; // A B C D
|
|
33
|
+
const CHAR_TOKEN = {
|
|
34
|
+
" ": "jump",
|
|
35
|
+
w: "up", a: "left", s: "down", d: "right",
|
|
36
|
+
h: "left", j: "down", k: "up", l: "right",
|
|
37
|
+
r: "restart", p: "pause", q: "quit",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decode a raw input Buffer (or string) into a flat list of logical tokens.
|
|
42
|
+
* Handles arrow CSI sequences (ESC [ A/B/C/D), bracketed single chars, and a
|
|
43
|
+
* lone ESC (treated as "quit"). Unknown bytes are dropped. Pure + synchronous.
|
|
44
|
+
*/
|
|
45
|
+
export function parseKeys(buf) {
|
|
46
|
+
const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf;
|
|
47
|
+
const out = [];
|
|
48
|
+
let i = 0;
|
|
49
|
+
while (i < b.length) {
|
|
50
|
+
const c = b[i];
|
|
51
|
+
if (c === ESC) {
|
|
52
|
+
// CSI arrow: ESC [ <final>
|
|
53
|
+
if (b[i + 1] === BR && i + 2 < b.length) {
|
|
54
|
+
const tok = CSI_FINAL[b[i + 2]];
|
|
55
|
+
if (tok) { out.push(tok); i += 3; continue; }
|
|
56
|
+
// Unknown CSI (e.g. ESC [ 1 ; 2 A modified) — skip the ESC[ and let the
|
|
57
|
+
// final byte fall through; cheap + safe for our small key set.
|
|
58
|
+
i += 2; continue;
|
|
59
|
+
}
|
|
60
|
+
// Lone ESC (not part of a recognized sequence) = quit / back.
|
|
61
|
+
out.push("quit");
|
|
62
|
+
i += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (c === 0x03) { out.push("quit"); i += 1; continue; } // Ctrl-C
|
|
66
|
+
if (c === 0x7f || c === 0x08) { out.push("quit"); i += 1; continue; } // backspace/del
|
|
67
|
+
const tok = CHAR_TOKEN[String.fromCharCode(c)];
|
|
68
|
+
if (tok) out.push(tok);
|
|
69
|
+
i += 1;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Held-key + edge-action state machine. Injectable `now` so tests drive a fake
|
|
76
|
+
* clock; defaults to a monotonic ms clock in production.
|
|
77
|
+
*
|
|
78
|
+
* feed(buf): parse bytes, refresh direction hold-stamps, queue edge actions.
|
|
79
|
+
* sample(): return the per-step intent the loop applies:
|
|
80
|
+
* { left, right, up, down, jump } booleans, where directions are LEVEL
|
|
81
|
+
* (held within HOLD_MS of last byte) and jump is EDGE (true once after a
|
|
82
|
+
* space byte, then consumed). Action queue (restart/pause/quit) is drained
|
|
83
|
+
* separately via takeActions() so the loop can react out-of-band.
|
|
84
|
+
*/
|
|
85
|
+
export class InputState {
|
|
86
|
+
constructor({ now = monotonicNow, holdMs = HOLD_MS } = {}) {
|
|
87
|
+
this.now = now;
|
|
88
|
+
this.holdMs = holdMs;
|
|
89
|
+
// last-byte timestamp per direction (-Infinity = never pressed; a real press
|
|
90
|
+
// at clock-time 0 must still register, so 0 is NOT the "never" sentinel).
|
|
91
|
+
this.lastAt = { left: -Infinity, right: -Infinity, up: -Infinity, down: -Infinity };
|
|
92
|
+
this._jump = false; // edge: pending jump for the next sample()
|
|
93
|
+
this._actions = []; // edge: restart/pause/quit queue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Ingest a raw buffer of bytes (one stdin 'data' event, or several). */
|
|
97
|
+
feed(buf) {
|
|
98
|
+
const t = this.now();
|
|
99
|
+
for (const tok of parseKeys(buf)) this.push(tok, t);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Apply one already-decoded token at time `t` (exposed for testing). */
|
|
103
|
+
push(tok, t = this.now()) {
|
|
104
|
+
if (DIRECTIONS.includes(tok)) {
|
|
105
|
+
this.lastAt[tok] = t; // refresh the hold; movement is level-triggered
|
|
106
|
+
} else if (tok === "jump") {
|
|
107
|
+
this._jump = true; // edge — fires once on the next sample()
|
|
108
|
+
} else if (ACTIONS.includes(tok)) {
|
|
109
|
+
this._actions.push(tok);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True if direction `dir` is currently held (within HOLD_MS of last byte). */
|
|
114
|
+
isHeld(dir, t = this.now()) {
|
|
115
|
+
return t - this.lastAt[dir] < this.holdMs;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The per-step movement intent. Directions are level-triggered (held);
|
|
120
|
+
* jump is edge-triggered (consumed). Opposite directions don't cancel here —
|
|
121
|
+
* the core clamps movement — but a simultaneous left+right is reported as-is
|
|
122
|
+
* (matches a player mashing both; harmless to the core).
|
|
123
|
+
*/
|
|
124
|
+
sample(t = this.now()) {
|
|
125
|
+
const intent = {
|
|
126
|
+
left: this.isHeld("left", t),
|
|
127
|
+
right: this.isHeld("right", t),
|
|
128
|
+
up: this.isHeld("up", t),
|
|
129
|
+
down: this.isHeld("down", t),
|
|
130
|
+
jump: this._jump,
|
|
131
|
+
};
|
|
132
|
+
this._jump = false; // edge consumed
|
|
133
|
+
return intent;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Drain queued one-shot actions (restart/pause/quit). */
|
|
137
|
+
takeActions() {
|
|
138
|
+
const a = this._actions;
|
|
139
|
+
this._actions = [];
|
|
140
|
+
return a;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Reset all held + queued state (e.g. on restart / pause-resume). */
|
|
144
|
+
reset() {
|
|
145
|
+
this.lastAt = { left: -Infinity, right: -Infinity, up: -Infinity, down: -Infinity };
|
|
146
|
+
this._jump = false;
|
|
147
|
+
this._actions = [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function monotonicNow() {
|
|
152
|
+
// Monotonic, ms — immune to wall-clock jumps.
|
|
153
|
+
return Number(process.hrtime.bigint() / 1000000n);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wire a live raw stdin to an InputState. Returns a detach() that removes the
|
|
158
|
+
* listener (the loop calls it on teardown). Term.start() already put stdin in
|
|
159
|
+
* raw mode; we only add the data pump here so input.js stays free of TTY setup.
|
|
160
|
+
*/
|
|
161
|
+
export function attachStdin(inputState, inp = process.stdin) {
|
|
162
|
+
const onData = (chunk) => inputState.feed(chunk);
|
|
163
|
+
inp.on("data", onData);
|
|
164
|
+
return function detach() {
|
|
165
|
+
try { inp.removeListener("data", onData); } catch {}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const _internals = { DIRECTIONS, ACTIONS, CHAR_TOKEN, CSI_FINAL, monotonicNow };
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/* Donkey Kong — Ink-free, direct-ANSI, 60Hz fixed-timestep GAME LOOP.
|
|
2
|
+
*
|
|
3
|
+
* This is the third leg of the rebuild: term.js (double-buffered diff renderer)
|
|
4
|
+
* + input.js (raw-stdin held-key model) + dk-core.js (pure 60Hz sub-cell engine)
|
|
5
|
+
* are stitched together here into a runnable loop. NO Ink, NO React.
|
|
6
|
+
*
|
|
7
|
+
* Reached two ways (see src/index.js + src/tui/views/scan.js):
|
|
8
|
+
* - `mm play` → cmdPlay, scanStore = null (static play header)
|
|
9
|
+
* - the Scan-tab P key → Ink is UNMOUNTED first, then runGame is awaited with
|
|
10
|
+
* a live scanStore (the background scan subprocess
|
|
11
|
+
* keeps running across the unmount), then Ink remounts.
|
|
12
|
+
*
|
|
13
|
+
* Fixed timestep (the no-lag core): the loop fires on a ~16.7ms cadence; a real-
|
|
14
|
+
* time accumulator steps the PURE core EXACTLY at 60Hz (dt=1 per step) with a
|
|
15
|
+
* MAX_STEPS spiral-of-death clamp, so the physics is deterministic and never
|
|
16
|
+
* fast-forwards after a stall. Render + input run every loop tick. A near-static
|
|
17
|
+
* frame writes ~0 bytes (Backbuffer.diff()), so idle never repaints.
|
|
18
|
+
*
|
|
19
|
+
* High score: the ENGINE stays IO-free (it only tracks state.best). Persistence
|
|
20
|
+
* is the loop's job — load dkHighScore from config.json at start, write it back
|
|
21
|
+
* on game-over / clean exit if beaten. One config file, one write path.
|
|
22
|
+
*/
|
|
23
|
+
import { Backbuffer, Term, packHex, COLOR_ON } from "./term.js";
|
|
24
|
+
import { InputState, attachStdin } from "./input.js";
|
|
25
|
+
import {
|
|
26
|
+
initGame, stepGame, boardSize, FRAME_MS, MIN_W, MIN_H,
|
|
27
|
+
} from "./dk-core.js";
|
|
28
|
+
import { fillCells, GAME_GLYPH, GAME_COLORS } from "./dk-render.js";
|
|
29
|
+
import { loadConfig, setConfigValue } from "../../config.js";
|
|
30
|
+
|
|
31
|
+
const STEP_MS = FRAME_MS; // 1000/60 — the core's fixed tick (≈16.67ms)
|
|
32
|
+
const MAX_STEPS = 5; // spiral-of-death clamp (drop backlog after a stall)
|
|
33
|
+
export const HUD_ROWS = 2; // scan strip + stats line (above the board)
|
|
34
|
+
export const KEY_ROW = 1; // in-game keybar (below the board)
|
|
35
|
+
|
|
36
|
+
const ASCII = process.env.MM_ASCII === "1" || process.env.TERM === "dumb";
|
|
37
|
+
|
|
38
|
+
// --- HUD glyphs / colors (self-contained — no Ink) --------------------------
|
|
39
|
+
const G = ASCII
|
|
40
|
+
? { heart: "v", spark: "*", check: "OK", pause: "||" }
|
|
41
|
+
: { heart: "♥", spark: "✦", check: "✓", pause: "⏸" };
|
|
42
|
+
|
|
43
|
+
// Mirror the ui.js palette so the HUD matches the rest of the TUI.
|
|
44
|
+
const COL = {
|
|
45
|
+
faint: packHex("#5b6673"),
|
|
46
|
+
dim: packHex("#8b98a5"),
|
|
47
|
+
fg: packHex("#cbd5e1"),
|
|
48
|
+
strong: packHex("#f9fafb"),
|
|
49
|
+
accent: packHex("#22d3ee"),
|
|
50
|
+
red: packHex("#ff5f56"),
|
|
51
|
+
green: packHex("#16a34a"),
|
|
52
|
+
amber: packHex("#d97706"),
|
|
53
|
+
violet: packHex("#a78bfa"),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const fmtNum = (n) => (n == null ? "0" : Number(n).toLocaleString("en-US"));
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run the game to completion. Returns when the player quits (q / esc / ^C-via-
|
|
60
|
+
* teardown) — the caller (cmdPlay or the Scan-tab handler) then restores/remounts.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} o
|
|
63
|
+
* @param {number} o.width terminal columns
|
|
64
|
+
* @param {number} o.height terminal rows
|
|
65
|
+
* @param {number} [o.level] starting level (default 1)
|
|
66
|
+
* @param {object|null} [o.scanStore] the startScanProcess handle (reads .stats); null = `mm play`
|
|
67
|
+
* @param {Function} [o.onExit] optional callback fired right before resolve
|
|
68
|
+
* @param {object} [o._inject] test seams: { out, inp, proc, now, schedule, autoExitAfterMs }
|
|
69
|
+
* @returns {Promise<{score:number, level:number, best:number, status:string}>}
|
|
70
|
+
*/
|
|
71
|
+
export function runGame({ width, height, level = 1, scanStore = null, onExit, _inject = {} } = {}) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const proc = _inject.proc || process;
|
|
74
|
+
const out = _inject.out || proc.stdout || process.stdout;
|
|
75
|
+
const inp = _inject.inp || proc.stdin || process.stdin;
|
|
76
|
+
const now = _inject.now || (() => Number(process.hrtime.bigint() / 1000000n));
|
|
77
|
+
// schedule(fn, ms) -> handle; cancel(handle). Defaults to setTimeout so the
|
|
78
|
+
// loop self-paces; tests inject a virtual clock.
|
|
79
|
+
const schedule = _inject.schedule || ((fn, ms) => setTimeout(fn, ms));
|
|
80
|
+
const cancel = _inject.cancel || ((h) => clearTimeout(h));
|
|
81
|
+
|
|
82
|
+
let best = 0;
|
|
83
|
+
try { best = Number(loadConfig().dkHighScore) || 0; } catch { best = 0; }
|
|
84
|
+
|
|
85
|
+
const dims = { width, height };
|
|
86
|
+
const term = new Term({ out, inp, proc, color: COLOR_ON });
|
|
87
|
+
const input = new InputState({ now });
|
|
88
|
+
|
|
89
|
+
// Allocate the buffer for the WHOLE frame: HUD rows + board + key row, BOARD_W
|
|
90
|
+
// wide (boardSize caps width; the chrome rows are clipped to BOARD_W too).
|
|
91
|
+
const { BOARD_W, BOARD_H } = boardSize(width, height);
|
|
92
|
+
const tooSmall = BOARD_W < MIN_W || BOARD_H < MIN_H;
|
|
93
|
+
|
|
94
|
+
let game = initGame({ ...dims, level, best });
|
|
95
|
+
|
|
96
|
+
// --- too-small terminal: a single static line, NEVER enter the loop. ----
|
|
97
|
+
if (tooSmall || (game && game.tooSmall)) {
|
|
98
|
+
// Don't enter alt screen / raw mode — just print a hint on the normal
|
|
99
|
+
// buffer and resolve so the caller can show its own chrome.
|
|
100
|
+
try { out.write(` terminal too small for the game — resize to ~${MIN_W + 2}x${MIN_H + 3}, then run again.\n`); } catch {}
|
|
101
|
+
onExit && onExit();
|
|
102
|
+
resolve({ score: 0, level, best, status: "toosmall" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let bb = new Backbuffer(BOARD_W, HUD_ROWS + BOARD_H + KEY_ROW, { color: COLOR_ON });
|
|
107
|
+
let curW = BOARD_W, curH = BOARD_H;
|
|
108
|
+
|
|
109
|
+
term.start();
|
|
110
|
+
const detach = attachStdin(input, inp);
|
|
111
|
+
|
|
112
|
+
let running = true;
|
|
113
|
+
let timer = null;
|
|
114
|
+
let prevT = now();
|
|
115
|
+
let acc = 0;
|
|
116
|
+
let paused = false;
|
|
117
|
+
let highSaved = false;
|
|
118
|
+
|
|
119
|
+
// SIGWINCH: recompute board, realloc the buffer, force ONE full repaint.
|
|
120
|
+
term.onResize(() => {
|
|
121
|
+
const sz = term.size();
|
|
122
|
+
const nb = boardSize(sz.width, sz.height);
|
|
123
|
+
if (nb.BOARD_W === curW && nb.BOARD_H === curH) return; // no real change
|
|
124
|
+
if (nb.BOARD_W < MIN_W || nb.BOARD_H < MIN_H) return; // ignore too-small grow-back
|
|
125
|
+
curW = nb.BOARD_W; curH = nb.BOARD_H;
|
|
126
|
+
// Rebuild the game at the new size, preserving score/lives/level/best.
|
|
127
|
+
game = initGame({ width: sz.width, height: sz.height, level: game.level, score: game.score, lives: game.lives, best: game.best });
|
|
128
|
+
bb = new Backbuffer(curW, HUD_ROWS + curH + KEY_ROW, { color: COLOR_ON });
|
|
129
|
+
acc = 0; // drop accumulated time so we don't burst-step after a resize
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function persistHigh() {
|
|
133
|
+
if (highSaved) return;
|
|
134
|
+
highSaved = true;
|
|
135
|
+
const score = (game && Math.max(game.best || 0, game.score || 0)) || 0;
|
|
136
|
+
if (score > best) {
|
|
137
|
+
try { setConfigValue("dkHighScore", score); best = score; } catch { /* best effort */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function finish(status) {
|
|
142
|
+
if (!running) return;
|
|
143
|
+
running = false;
|
|
144
|
+
if (timer != null) { try { cancel(timer); } catch {} timer = null; }
|
|
145
|
+
persistHigh();
|
|
146
|
+
try { detach(); } catch {}
|
|
147
|
+
try { term.restore(); } catch {}
|
|
148
|
+
onExit && onExit();
|
|
149
|
+
resolve({ score: game ? game.score : 0, level: game ? game.level : level, best, status: status || "quit" });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The actual frame body. Returns true to keep looping, false if it finished.
|
|
153
|
+
function runTick() {
|
|
154
|
+
if (!running) return false;
|
|
155
|
+
const t = now();
|
|
156
|
+
let dt = t - prevT;
|
|
157
|
+
prevT = t;
|
|
158
|
+
if (dt < 0) dt = 0;
|
|
159
|
+
if (dt > 250) dt = 250; // clamp a huge wall-clock jump (suspend/resume)
|
|
160
|
+
|
|
161
|
+
// --- input: edge actions (quit/pause/restart) + movement intent --------
|
|
162
|
+
for (const action of input.takeActions()) {
|
|
163
|
+
if (action === "quit") { finish("quit"); return false; }
|
|
164
|
+
if (action === "pause") { paused = !paused; input.reset(); }
|
|
165
|
+
if (action === "restart") {
|
|
166
|
+
// 'over' is a hard no-op in the core, so RESTART re-inits a fresh game
|
|
167
|
+
// carrying best. Also lets the player bail a run mid-play.
|
|
168
|
+
game = initGame({ ...sizeDims(), level: 1, best: game.best });
|
|
169
|
+
input.reset();
|
|
170
|
+
acc = 0;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- fixed-timestep core advance --------------------------------------
|
|
175
|
+
if (!paused) {
|
|
176
|
+
acc += dt;
|
|
177
|
+
let steps = 0;
|
|
178
|
+
while (acc >= STEP_MS && steps < MAX_STEPS) {
|
|
179
|
+
// Sample once per core step so a same-step keypress is seen and input
|
|
180
|
+
// latency is one tick. jump is edge (consumed); movement is level.
|
|
181
|
+
const intent = input.sample(t);
|
|
182
|
+
// On the title/over frames the press doubles as start/restart.
|
|
183
|
+
intent.start = intent.jump;
|
|
184
|
+
game = stepGame(game, { input: intent, dt: 1 });
|
|
185
|
+
acc -= STEP_MS;
|
|
186
|
+
steps++;
|
|
187
|
+
if (game.status === "over" && !highSaved) persistHigh();
|
|
188
|
+
}
|
|
189
|
+
if (steps >= MAX_STEPS) acc = 0; // drop the backlog after a stall
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- render ------------------------------------------------------------
|
|
193
|
+
render();
|
|
194
|
+
|
|
195
|
+
// schedule the next tick for the remaining slice of a frame
|
|
196
|
+
const elapsed = now() - t;
|
|
197
|
+
const wait = Math.max(0, STEP_MS - elapsed);
|
|
198
|
+
timer = schedule(guardedTick, wait);
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sizeDims() {
|
|
203
|
+
const sz = term.size();
|
|
204
|
+
return { width: sz.width, height: sz.height };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function render() {
|
|
208
|
+
bb.clearBack();
|
|
209
|
+
drawScanStrip(bb, scanStore, curW);
|
|
210
|
+
drawStats(bb, game, best, paused, curW);
|
|
211
|
+
// board → blit the fillCells grid under the HUD rows.
|
|
212
|
+
const cells = fillCells(game, { glyph: GAME_GLYPH, colors: GAME_COLORS, color: COLOR_ON });
|
|
213
|
+
bb.blit(cells, 0, HUD_ROWS, cells.width, cells.height);
|
|
214
|
+
// Respawn blink: hide the player glyph on alternate frames during invuln.
|
|
215
|
+
maybeBlinkPlayer(bb, game, curW);
|
|
216
|
+
drawBanner(bb, game, curW, curH);
|
|
217
|
+
drawKeybar(bb, curW, HUD_ROWS + curH);
|
|
218
|
+
const s = bb.render();
|
|
219
|
+
if (s) term.write(s);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Single guarded entry: every scheduled call goes through here, so a throw
|
|
223
|
+
// ANYWHERE in the frame still restores the terminal (the #1 safety risk) and
|
|
224
|
+
// kills nothing leaks. restore() is idempotent so the exit handler is safe too.
|
|
225
|
+
function guardedTick() {
|
|
226
|
+
try {
|
|
227
|
+
runTick();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (!running) return;
|
|
230
|
+
running = false;
|
|
231
|
+
if (timer != null) { try { cancel(timer); } catch {} timer = null; }
|
|
232
|
+
try { detach(); } catch {}
|
|
233
|
+
try { term.restore(); } catch {}
|
|
234
|
+
try { (proc.stderr || process.stderr).write("game loop error: " + (err && err.stack || err) + "\n"); } catch {}
|
|
235
|
+
onExit && onExit();
|
|
236
|
+
resolve({ score: game ? game.score : 0, level: game ? game.level : level, best, status: "error" });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
timer = schedule(guardedTick, 0);
|
|
241
|
+
|
|
242
|
+
// Auto-exit hook for tests (drives a finite run without a real `q`).
|
|
243
|
+
if (_inject.autoExitAfterMs != null) {
|
|
244
|
+
schedule(() => finish("auto"), _inject.autoExitAfterMs);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- HUD composition --------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/** Row 0: live scan progress (or a static "play" header when scanStore=null). */
|
|
252
|
+
function drawScanStrip(bb, scanStore, w) {
|
|
253
|
+
if (!scanStore) {
|
|
254
|
+
bb.setText(0, 0, clip(" Donkey Kong — press q to quit", w), COL.faint);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const st = scanStore.stats || scanStore; // tolerate a bare stats object
|
|
258
|
+
const phase = st.phase || "scanning";
|
|
259
|
+
if (phase === "done") {
|
|
260
|
+
const n = st.candidateCount || 0;
|
|
261
|
+
let x = bb.setText(0, 0, " " + G.check + " scan complete", COL.green);
|
|
262
|
+
bb.setText(x, 0, clip(` · ${fmtNum(n)} model${n === 1 ? "" : "s"} · q to view results`, w - x), COL.dim);
|
|
263
|
+
} else if (phase === "error") {
|
|
264
|
+
bb.setText(0, 0, clip(" scan unavailable (" + (st.error || "error") + ") — game still plays", w), COL.amber);
|
|
265
|
+
} else {
|
|
266
|
+
const files = st.filesScanned || 0;
|
|
267
|
+
const models = st.candidateCount || 0;
|
|
268
|
+
const dirs = st.dirsSeen || 0;
|
|
269
|
+
bb.setText(0, 0, clip(` scanning · files ${fmtNum(files)} · models ${fmtNum(models)} · dirs ${fmtNum(dirs)}`, w), COL.dim);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Row 1: lives / score / level / best / transient message. */
|
|
274
|
+
function drawStats(bb, game, best, paused, w) {
|
|
275
|
+
if (!game) return;
|
|
276
|
+
const lives = Math.max(0, game.lives || 0);
|
|
277
|
+
const heart = lives > 0 ? G.heart.repeat(lives) : "—";
|
|
278
|
+
let x = bb.setText(0, 1, " ", COL.fg);
|
|
279
|
+
x = bb.setText(x, 1, heart, COL.red);
|
|
280
|
+
x = bb.setText(x, 1, " score ", COL.dim);
|
|
281
|
+
x = bb.setText(x, 1, fmtNum(game.score), COL.accent);
|
|
282
|
+
x = bb.setText(x, 1, ` lvl ${game.level}`, COL.dim);
|
|
283
|
+
if (paused) x = bb.setText(x, 1, " " + G.pause + " paused", COL.amber);
|
|
284
|
+
x = bb.setText(x, 1, ` best ${fmtNum(Math.max(best || 0, game.best || 0, game.score || 0))}`, COL.faint);
|
|
285
|
+
// Bonus timer (when playing).
|
|
286
|
+
if (game.status === "playing" || game.status === "getready") {
|
|
287
|
+
x = bb.setText(x, 1, ` bonus ${fmtNum(game.bonus || 0)}`, COL.violet);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Hide the player glyph on alternate frames during the respawn-invuln blink. */
|
|
292
|
+
function maybeBlinkPlayer(bb, game, w) {
|
|
293
|
+
const p = game && game.player;
|
|
294
|
+
if (!p || !(p.invuln > 0)) return;
|
|
295
|
+
if ((p.invuln >> 2) & 1) return; // visible this phase
|
|
296
|
+
// blank the player's cell (it was drawn last by fillCells, so it's on top)
|
|
297
|
+
bb.setCell(p.x, HUD_ROWS + p.y, 32 /* space */, -1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Centered banner overlay for transient statuses (GET READY / GAME OVER / win).
|
|
301
|
+
* Drawn into the board region so the board stays BOARD_H tall (it overwrites a
|
|
302
|
+
* board row rather than adding one). */
|
|
303
|
+
function drawBanner(bb, game, w, boardH) {
|
|
304
|
+
if (!game) return;
|
|
305
|
+
let text = null, color = COL.strong;
|
|
306
|
+
if (game.status === "getready") { text = game.message || "GET READY"; color = COL.accent; }
|
|
307
|
+
else if (game.status === "levelclear") { text = game.message || "YOU SAVED HER!"; color = COL.violet; }
|
|
308
|
+
else if (game.status === "over") { text = game.message || "GAME OVER"; color = COL.red; }
|
|
309
|
+
else if (game.status === "title") { text = G.spark + " DONKEY KONG " + G.spark; color = COL.accent; }
|
|
310
|
+
if (!text) return;
|
|
311
|
+
const row = HUD_ROWS + Math.floor(boardH / 2);
|
|
312
|
+
const x = Math.max(0, Math.floor((w - text.length) / 2));
|
|
313
|
+
bb.setText(x, row, clip(text, w - x), color);
|
|
314
|
+
// sub-line for terminal states
|
|
315
|
+
if (game.status === "over") {
|
|
316
|
+
const sub = "r restart · q back";
|
|
317
|
+
const sx = Math.max(0, Math.floor((w - sub.length) / 2));
|
|
318
|
+
bb.setText(sx, Math.min(HUD_ROWS + boardH - 1, row + 1), clip(sub, w - sx), COL.dim);
|
|
319
|
+
} else if (game.status === "title") {
|
|
320
|
+
const sub = "press SPACE to start · q quit";
|
|
321
|
+
const sx = Math.max(0, Math.floor((w - sub.length) / 2));
|
|
322
|
+
bb.setText(sx, Math.min(HUD_ROWS + boardH - 1, row + 1), clip(sub, w - sx), COL.dim);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Bottom row: in-game keybar. */
|
|
327
|
+
function drawKeybar(bb, w, y) {
|
|
328
|
+
const bar = ASCII
|
|
329
|
+
? " <- -> move ^v climb spc jump p pause r restart q back"
|
|
330
|
+
: " ←→ move ↑↓ climb spc jump p pause r restart q back";
|
|
331
|
+
bb.setText(0, y, clip(bar, w), COL.faint);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function clip(s, w) {
|
|
335
|
+
if (w <= 0) return "";
|
|
336
|
+
return s.length > w ? s.slice(0, w) : s;
|
|
337
|
+
}
|