@modelstatus/cli 0.1.33 → 0.1.35
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/api.js +6 -0
- package/src/ci.js +2 -2
- package/src/index.js +106 -12
- package/src/integrations.js +121 -0
- package/src/sources/aws-lambda.js +95 -0
- package/src/sources/configscan.js +8 -2
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +156 -0
- package/src/sources/index.js +70 -13
- package/src/sources/scan-runner.js +127 -0
- package/src/sources/supabase-edge.js +183 -0
- package/src/sources/supabase.js +5 -0
- package/src/sources/vercel.js +74 -0
- package/src/tui/app.js +5 -1
- package/src/tui/game/DkGame.js +187 -0
- package/src/tui/game/dk-core.js +413 -0
- package/src/tui/game/dk-render.js +114 -0
- package/src/tui/views/add.js +1 -1
- package/src/tui/views/integrations.js +224 -0
- package/src/tui/views/inventory.js +36 -3
- package/src/tui/views/scan.js +103 -7
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/* Donkey Kong — thin Ink wrapper. h = React.createElement (NO JSX, per ui.js).
|
|
2
|
+
*
|
|
3
|
+
* Holds the game state + a synchronously-mutated inputRef (the stale-closure-safe
|
|
4
|
+
* pattern from useSearch / scan.js refIdxRef), runs the frame loop via useTick
|
|
5
|
+
* GATED so it NEVER ticks at idle, and composes a fixed-height block:
|
|
6
|
+
* row 0: scan-progress HUD (live scan fields → SweepBar) }
|
|
7
|
+
* row 1: game stats (♥ lives · score · level · best · message) } HUD_ROWS = 2
|
|
8
|
+
* rows 2..2+BOARD_H-1: the board (one colored <Text> per row)
|
|
9
|
+
* last row: in-game KeyBar
|
|
10
|
+
* Total rows = BOARD_H + 3 == the terminal `height` (boardSize math), so the
|
|
11
|
+
* surrounding window chrome never jumps and Ink keeps diffing.
|
|
12
|
+
*
|
|
13
|
+
* The component owns its own useInput (gated on `active` + `gameMode`) so it is
|
|
14
|
+
* independently mountable + testable; when embedded in ScanView the wrapper
|
|
15
|
+
* simply passes `active` through and relies on ui.setCapturing to stop the
|
|
16
|
+
* global app keys leaking. The SCAN is never touched — we only READ the hook
|
|
17
|
+
* fields the design surfaces (scan.filesScanned / scan.candidates.length /
|
|
18
|
+
* scan.phase). */
|
|
19
|
+
import React from "react";
|
|
20
|
+
import { Box, Text, useInput } from "ink";
|
|
21
|
+
import { h, C, LIGHT, GLYPH, KeyBar, SweepBar, useTick, cell, fmtNum } from "../ui.js";
|
|
22
|
+
import { GAME_GLYPH, GAME_COLORS, colorize } from "./dk-render.js";
|
|
23
|
+
import { initGame, stepGame, nextLevel, FRAME_MS, boardSize, MIN_W, MIN_H } from "./dk-core.js";
|
|
24
|
+
|
|
25
|
+
const BG_ON = !(process.env.MM_ASCII === "1" || process.env.TERM === "dumb") && process.env.NO_COLOR == null;
|
|
26
|
+
|
|
27
|
+
const GAME_KEYS = [
|
|
28
|
+
{ k: "←→", label: "move" },
|
|
29
|
+
{ k: "↑↓", label: "climb" },
|
|
30
|
+
{ k: "spc", label: "jump" },
|
|
31
|
+
{ k: "p", label: "pause" },
|
|
32
|
+
{ k: "r", label: "restart" },
|
|
33
|
+
{ k: "q", label: "back to scan" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const freshInput = () => ({ left: false, right: false, up: false, down: false, jump: false });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* DonkeyKong overlay.
|
|
40
|
+
* props:
|
|
41
|
+
* - width, height: the SAME numbers ScanView gets (drive boardSize)
|
|
42
|
+
* - scan: the useStreamingScan return (read-only — filesScanned, candidates, phase)
|
|
43
|
+
* - ui: shell helpers ({ showToast, setCapturing, setHandlesBack })
|
|
44
|
+
* - onExit: called when the player quits the game (q / esc / backspace)
|
|
45
|
+
* - active: input gate (mirrors ScanView's `active`)
|
|
46
|
+
* - level: starting level (default 1)
|
|
47
|
+
*/
|
|
48
|
+
export function DonkeyKong({ width = 78, height = 14, scan = {}, ui = {}, onExit = () => {}, active = true, level = 1 }) {
|
|
49
|
+
const dims = { width, height };
|
|
50
|
+
const { BOARD_W, BOARD_H } = boardSize(width, height);
|
|
51
|
+
const playable = BOARD_W >= MIN_W && BOARD_H >= MIN_H;
|
|
52
|
+
|
|
53
|
+
const [game, setGame] = React.useState(() => initGame({ ...dims, level }));
|
|
54
|
+
const [paused, setPaused] = React.useState(false);
|
|
55
|
+
const pausedRef = React.useRef(false);
|
|
56
|
+
const inputRef = React.useRef(freshInput());
|
|
57
|
+
const highRef = React.useRef(0);
|
|
58
|
+
|
|
59
|
+
// Track the running session high score (kept in a ref so it survives restarts
|
|
60
|
+
// within this mount; shown in the HUD).
|
|
61
|
+
if (game && game.score > highRef.current) highRef.current = game.score;
|
|
62
|
+
|
|
63
|
+
const scanLive = scan.phase ? scan.phase !== "done" : true;
|
|
64
|
+
// The loop NEVER ticks at idle: only while (a) the overlay is up [it's mounted
|
|
65
|
+
// = up], (b) not paused, (c) the game isn't on a terminal frozen frame, and
|
|
66
|
+
// (d) the scan is still live (or just finishing). Per the design's idle rule.
|
|
67
|
+
const playing = game && game.status === "playing";
|
|
68
|
+
const ticking = playable && active && !paused && playing && (scanLive || game.frame < 1);
|
|
69
|
+
const tick = useTick(FRAME_MS, ticking);
|
|
70
|
+
|
|
71
|
+
// One advance per frame; read + clear the synchronous input ref each tick so a
|
|
72
|
+
// keypress in the same tick is seen (and isn't applied twice).
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
if (!ticking) return;
|
|
75
|
+
setGame((g) => {
|
|
76
|
+
const ng = stepGame(g, { input: inputRef.current });
|
|
77
|
+
return ng;
|
|
78
|
+
});
|
|
79
|
+
inputRef.current = freshInput();
|
|
80
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
+
}, [tick]);
|
|
82
|
+
|
|
83
|
+
// Auto level-up a beat after a win: hold the "you saved her" frame for ~1s,
|
|
84
|
+
// then rebuild the next level carrying score + lives.
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
if (game && game.status === "won") {
|
|
87
|
+
const t = setTimeout(() => setGame((g) => (g.status === "won" ? nextLevel(g, dims) : g)), 1000);
|
|
88
|
+
return () => clearTimeout(t);
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}, [game && game.status, game && game.level]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
92
|
+
|
|
93
|
+
function exit() {
|
|
94
|
+
ui.setCapturing?.(false);
|
|
95
|
+
ui.setHandlesBack?.(false);
|
|
96
|
+
onExit();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Single input owner for the overlay. q / esc / backspace exit; r restarts;
|
|
100
|
+
// p toggles ONLY the game loop (the scan keeps running). Movement keys set the
|
|
101
|
+
// synchronous intent flags consumed next tick. Ctrl-C is NOT swallowed.
|
|
102
|
+
useInput(
|
|
103
|
+
(input, key) => {
|
|
104
|
+
if (!active) return;
|
|
105
|
+
if (key.ctrl && input === "c") return; // let the global handler exit the app
|
|
106
|
+
if (input === "q" || key.escape || key.backspace || key.delete) return exit();
|
|
107
|
+
if (input === "r") { setGame(initGame({ ...dims, level: 1 })); inputRef.current = freshInput(); return; }
|
|
108
|
+
if (input === "p") { pausedRef.current = !pausedRef.current; setPaused(pausedRef.current); return; }
|
|
109
|
+
const ir = inputRef.current;
|
|
110
|
+
if (key.leftArrow || input === "h") ir.left = true;
|
|
111
|
+
else if (key.rightArrow || input === "l") ir.right = true;
|
|
112
|
+
else if (key.upArrow || input === "k") ir.up = true;
|
|
113
|
+
else if (key.downArrow || input === "j") ir.down = true;
|
|
114
|
+
else if (input === " ") ir.jump = true;
|
|
115
|
+
},
|
|
116
|
+
{ isActive: active },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// --- too-small guard: a single line, no loop, nothing animates -----------
|
|
120
|
+
if (!playable) {
|
|
121
|
+
return h(
|
|
122
|
+
Box,
|
|
123
|
+
{ flexDirection: "column" },
|
|
124
|
+
h(Text, { color: "#d97706" }, " terminal too small for the game — resize to ~30x12, q to go back"),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- HUD row 1: live scan progress ----
|
|
129
|
+
const files = scan.filesScanned || 0;
|
|
130
|
+
const models = (scan.candidates && scan.candidates.length) || 0;
|
|
131
|
+
const dirs = scan.dirsSeen || 0;
|
|
132
|
+
const barW = Math.max(10, Math.min(22, BOARD_W - 28));
|
|
133
|
+
// SweepBar renders a <Box>, which can't nest inside a <Text> — so the live HUD
|
|
134
|
+
// row is a flex <Box> with the bar as a sibling of the text spans.
|
|
135
|
+
const hud1 = scanLive
|
|
136
|
+
? h(
|
|
137
|
+
Box,
|
|
138
|
+
{ flexDirection: "row" },
|
|
139
|
+
h(Text, { color: C.FG_FAINT }, " scanning "),
|
|
140
|
+
h(SweepBar, { tick, width: barW }),
|
|
141
|
+
h(Text, { color: C.FG_DIM }, ` files ${fmtNum(files)} · models ${fmtNum(models)} · dirs ${fmtNum(dirs)}`),
|
|
142
|
+
)
|
|
143
|
+
: h(
|
|
144
|
+
Text,
|
|
145
|
+
{},
|
|
146
|
+
h(Text, { color: "#16a34a" }, ` ${GLYPH.check} scan complete`),
|
|
147
|
+
h(Text, { color: C.FG_DIM }, ` · ${fmtNum(models)} models · q to view results`),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// ---- HUD row 2: game stats + transient message ----
|
|
151
|
+
const livesStr = "♥".repeat(Math.max(0, game.lives)) || "—";
|
|
152
|
+
const statusTag =
|
|
153
|
+
game.status === "over" ? ` ${game.message} · r restart · q back`
|
|
154
|
+
: game.status === "won" ? ` ${GLYPH.spark} ${game.message}`
|
|
155
|
+
: game.message ? ` ${game.message}` : "";
|
|
156
|
+
const hud2 = h(
|
|
157
|
+
Text,
|
|
158
|
+
{},
|
|
159
|
+
h(Text, { color: LIGHT.red }, ` ${livesStr}`),
|
|
160
|
+
h(Text, { color: C.FG_DIM }, " score "),
|
|
161
|
+
h(Text, { color: C.ACCENT, bold: true }, fmtNum(game.score)),
|
|
162
|
+
h(Text, { color: C.FG_DIM }, ` lvl ${game.level}` + (paused ? " ⏸ paused" : "")),
|
|
163
|
+
h(Text, { color: C.FG_FAINT }, ` best ${fmtNum(Math.max(highRef.current, game.score))}`),
|
|
164
|
+
h(Text, { color: game.status === "over" ? "#dc2626" : C.FG_STRONG }, statusTag),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// ---- board: one colored <Text> per row, padded to BOARD_W ----
|
|
168
|
+
const useColor = BG_ON;
|
|
169
|
+
const spanRows = colorize(game, { glyph: GAME_GLYPH, colors: GAME_COLORS });
|
|
170
|
+
const boardNodes = spanRows.map((spans, y) =>
|
|
171
|
+
h(
|
|
172
|
+
Text,
|
|
173
|
+
{ key: "b" + y, wrap: "truncate" },
|
|
174
|
+
...(useColor
|
|
175
|
+
? spans.map((sp, i) => h(Text, { key: i, color: sp.color }, sp.text))
|
|
176
|
+
: [h(Text, { key: 0, color: C.FG }, spans.map((sp) => sp.text).join("").slice(0, BOARD_W).padEnd(BOARD_W))]),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
// Defensive height pin: render EXACTLY BOARD_H board rows.
|
|
180
|
+
while (boardNodes.length < BOARD_H) boardNodes.push(h(Text, { key: "bp" + boardNodes.length, color: C.FG }, cell("", BOARD_W)));
|
|
181
|
+
|
|
182
|
+
const keybar = h(KeyBar, { keys: GAME_KEYS, width });
|
|
183
|
+
|
|
184
|
+
return h(Box, { flexDirection: "column" }, hud1, hud2, ...boardNodes.slice(0, BOARD_H), keybar);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default DonkeyKong;
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/* Donkey Kong — PURE game core. NO React, NO ink, NO Math.random, NO I/O.
|
|
2
|
+
*
|
|
3
|
+
* This is a small, deterministic state machine: initGame(opts) builds a board
|
|
4
|
+
* procedurally from the terminal's width/height (so it scales + can refuse a
|
|
5
|
+
* too-small terminal), and stepGame(state, { input }) advances exactly one
|
|
6
|
+
* frame. All randomness flows through a seeded mulberry32 RNG carried in the
|
|
7
|
+
* state, so unit tests pass a fixed seed and assert exact barrel paths /
|
|
8
|
+
* collisions. The renderer (dk-render.js) and the Ink wrapper (DkGame.js) never
|
|
9
|
+
* mutate state — they only read it — keeping this file the single source of
|
|
10
|
+
* game truth and trivially unit-testable.
|
|
11
|
+
*
|
|
12
|
+
* Coordinate system: integer grid. x = column (0 = left), y = row (0 = TOP).
|
|
13
|
+
* "Up" decreases y. Girders ("platforms") are horizontal-ish rows that drift
|
|
14
|
+
* (classic DK zig-zag); ladders are vertical runs linking adjacent girders. */
|
|
15
|
+
|
|
16
|
+
// ---- Tunables --------------------------------------------------------------
|
|
17
|
+
export const FRAME_MS = 100; // 10fps game loop (the wrapper gates this on useTick)
|
|
18
|
+
export const JUMP_LEN = 4; // frames a jump arc lasts
|
|
19
|
+
export const MIN_W = 28; // minimum playfield width
|
|
20
|
+
export const MIN_H = 9; // minimum playfield height
|
|
21
|
+
const GRAVITY = 1; // rows/frame fallen when unsupported
|
|
22
|
+
const SLOPE_EVERY = 6; // a 1-row step every ~6 columns on a girder
|
|
23
|
+
const PRINCESS_REACH = 2; // |dx| within which touching the princess wins
|
|
24
|
+
|
|
25
|
+
/** DK barrel-throw cadence by level: faster (shorter cooldown) as you climb. */
|
|
26
|
+
export function cooldownForLevel(level) {
|
|
27
|
+
return Math.max(7, 22 - (level - 1) * 3);
|
|
28
|
+
}
|
|
29
|
+
/** Probability (0..1) a rolling barrel takes a ladder down; rises with level. */
|
|
30
|
+
export function ladderChanceForLevel(level) {
|
|
31
|
+
return Math.min(0.6, 0.18 + (level - 1) * 0.1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---- Seeded RNG (mulberry32) — pure, threaded through state ----------------
|
|
35
|
+
// Returns [value in [0,1), nextSeed] so the core never holds hidden mutable
|
|
36
|
+
// state; stepGame folds the new seed back into the returned state.
|
|
37
|
+
function rng(seed) {
|
|
38
|
+
let t = (seed + 0x6d2b79f5) | 0;
|
|
39
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
40
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
41
|
+
const v = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
42
|
+
return [v, t >>> 0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- Board geometry --------------------------------------------------------
|
|
46
|
+
export function boardSize(width, height) {
|
|
47
|
+
const HUD_ROWS = 2; // scan-progress line + score/lives line (rendered by wrapper)
|
|
48
|
+
const KEY_ROW = 1; // in-game keybar (rendered by wrapper)
|
|
49
|
+
const BOARD_W = Math.max(MIN_W, Math.min(width - 2, 64)); // 1-col rail gutter each side
|
|
50
|
+
const BOARD_H = height - HUD_ROWS - KEY_ROW; // identical total rows to the list view
|
|
51
|
+
return { BOARD_W, BOARD_H };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the per-column y of one girder: a base row that steps 1 cell every
|
|
55
|
+
* SLOPE_EVERY columns, alternating drift direction per level (classic zig-zag),
|
|
56
|
+
* bounded to a `±amp` envelope around baseRow so adjacent girders never overlap
|
|
57
|
+
* and none drifts off the board. Returned as an Int array of length BOARD_W so
|
|
58
|
+
* collision/standing math reads the exact row a girder occupies at any column. */
|
|
59
|
+
function buildSlope(baseRow, w, down, amp) {
|
|
60
|
+
const offs = new Array(w);
|
|
61
|
+
for (let x = 0; x < w; x++) {
|
|
62
|
+
const steps = Math.floor(x / SLOPE_EVERY);
|
|
63
|
+
const off = down ? steps : -steps;
|
|
64
|
+
offs[x] = baseRow + Math.max(-amp, Math.min(amp, off));
|
|
65
|
+
}
|
|
66
|
+
return offs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The girder index whose standing row is at-or-just-below y at column x, i.e.
|
|
70
|
+
* the platform a body at (x,y) would land on when falling. Returns -1 if none. */
|
|
71
|
+
function girderBelow(platforms, x, y) {
|
|
72
|
+
let best = -1, bestRow = Infinity;
|
|
73
|
+
for (let i = 0; i < platforms.length; i++) {
|
|
74
|
+
const row = platforms[i].slopeOffsets[x];
|
|
75
|
+
if (row >= y && row < bestRow) { best = i; bestRow = row; }
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True if column x at row y is a girder cell of ANY platform. */
|
|
81
|
+
function isGirderAt(platforms, x, y) {
|
|
82
|
+
for (const p of platforms) if (p.slopeOffsets[x] === y) return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The ladder occupying column x within [yTop..yBottom] at row y, or null. */
|
|
87
|
+
function ladderAt(ladders, x, y) {
|
|
88
|
+
for (const l of ladders) if (l.col === x && y >= l.yTop && y <= l.yBottom) return l;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Inclusive "is `v` between `a` and `b`" regardless of order — for swept hit
|
|
93
|
+
* tests (the moving body crossed cell v this frame). */
|
|
94
|
+
function between(v, a, b) {
|
|
95
|
+
return v >= Math.min(a, b) && v <= Math.max(a, b);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- initGame --------------------------------------------------------------
|
|
99
|
+
/**
|
|
100
|
+
* Build a fresh level. Carries score/lives across level-ups. Returns
|
|
101
|
+
* { tooSmall:true } (and nothing else) when the terminal can't host a playable
|
|
102
|
+
* board, so the wrapper can refuse to enter game mode without ever ticking.
|
|
103
|
+
*/
|
|
104
|
+
export function initGame({ width = 80, height = 14, level = 1, score = 0, lives = 3, seed } = {}) {
|
|
105
|
+
const { BOARD_W, BOARD_H } = boardSize(width, height);
|
|
106
|
+
if (BOARD_W < MIN_W || BOARD_H < MIN_H) return { tooSmall: true, BOARD_W, BOARD_H };
|
|
107
|
+
|
|
108
|
+
// Lay out N girders evenly across the height. Highest girder (index 0) sits
|
|
109
|
+
// near the top (DK + princess); lowest (last) near the bottom (Jumpman start).
|
|
110
|
+
const N = Math.max(4, Math.min(5, Math.floor(BOARD_H / 3)));
|
|
111
|
+
const gap = Math.floor((BOARD_H - 2) / N); // rows between girder bands
|
|
112
|
+
// Slope amplitude must stay strictly inside the gap so neighboring girders
|
|
113
|
+
// never overlap (which would break standing/collision math) and the lowest
|
|
114
|
+
// girder's lowest column stays on-board. ±1 is plenty of zig-zag at our sizes.
|
|
115
|
+
const amp = Math.max(0, Math.min(2, Math.floor((gap - 1) / 2)));
|
|
116
|
+
const platforms = [];
|
|
117
|
+
for (let i = 0; i < N; i++) {
|
|
118
|
+
// Reserve `amp` rows of headroom at the bottom so the down-sloped lowest
|
|
119
|
+
// girder can't exceed BOARD_H - 1.
|
|
120
|
+
const baseRow = BOARD_H - 2 - amp - i * gap; // 0 = bottom-most girder
|
|
121
|
+
const down = i % 2 === 0; // alternate slope direction per level band
|
|
122
|
+
platforms.push({ row: baseRow, slopeOffsets: buildSlope(baseRow, BOARD_W, down, amp) });
|
|
123
|
+
}
|
|
124
|
+
// platforms[0] is the LOWEST (largest row); platforms[N-1] the HIGHEST.
|
|
125
|
+
|
|
126
|
+
// Ladders: 1 per gap, at a staggered column, linking the STANDING ROW of the
|
|
127
|
+
// lower girder (girderRow - 1) to the standing row of the upper girder, so a
|
|
128
|
+
// climber moves directly between the two walkable rows. yTop < yBottom.
|
|
129
|
+
const ladders = [];
|
|
130
|
+
for (let i = 0; i < N - 1; i++) {
|
|
131
|
+
const lower = platforms[i];
|
|
132
|
+
const upper = platforms[i + 1];
|
|
133
|
+
const col = Math.max(2, Math.min(BOARD_W - 3, Math.floor(((i + 1) / N) * BOARD_W) + (i % 2 ? -3 : 3)));
|
|
134
|
+
const yBottom = lower.slopeOffsets[col] - 1; // stand on the lower girder
|
|
135
|
+
const yTop = upper.slopeOffsets[col] - 1; // stand on the upper girder
|
|
136
|
+
ladders.push({ col, yTop: Math.min(yTop, yBottom), yBottom: Math.max(yTop, yBottom) });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const top = platforms[N - 1];
|
|
140
|
+
const dkCol = 2;
|
|
141
|
+
const donkeyKong = { x: dkCol, y: top.slopeOffsets[dkCol] - 1, throwCooldown: 2 };
|
|
142
|
+
// Princess one row above DK's girder, near top-center (the goal zone).
|
|
143
|
+
const princessCol = Math.floor(BOARD_W / 2);
|
|
144
|
+
const princess = { x: princessCol, y: top.slopeOffsets[princessCol] - 1 };
|
|
145
|
+
|
|
146
|
+
const bottom = platforms[0];
|
|
147
|
+
const startCol = 2;
|
|
148
|
+
const jumpman = {
|
|
149
|
+
x: startCol,
|
|
150
|
+
y: bottom.slopeOffsets[startCol] - 1, // stand ON the lowest girder
|
|
151
|
+
vy: 0,
|
|
152
|
+
onGround: true,
|
|
153
|
+
onLadder: false,
|
|
154
|
+
facing: 1,
|
|
155
|
+
jumpFrames: 0,
|
|
156
|
+
alive: true,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
BOARD_W,
|
|
161
|
+
BOARD_H,
|
|
162
|
+
platforms,
|
|
163
|
+
ladders,
|
|
164
|
+
jumpman,
|
|
165
|
+
donkeyKong,
|
|
166
|
+
princess,
|
|
167
|
+
barrels: [],
|
|
168
|
+
score,
|
|
169
|
+
lives,
|
|
170
|
+
level,
|
|
171
|
+
status: "playing", // playing | won | dead | over
|
|
172
|
+
rngSeed: (seed == null ? 1234567 + level * 99991 : seed) >>> 0,
|
|
173
|
+
frame: 0,
|
|
174
|
+
message: level > 1 ? `level ${level}` : "",
|
|
175
|
+
nextBarrelId: 1,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- respawn (after a death, board/barrels keep going) ---------------------
|
|
180
|
+
function respawnJumpman(state) {
|
|
181
|
+
const bottom = state.platforms[0];
|
|
182
|
+
const startCol = 2;
|
|
183
|
+
state.jumpman = {
|
|
184
|
+
x: startCol,
|
|
185
|
+
y: bottom.slopeOffsets[startCol] - 1,
|
|
186
|
+
vy: 0,
|
|
187
|
+
onGround: true,
|
|
188
|
+
onLadder: false,
|
|
189
|
+
facing: 1,
|
|
190
|
+
jumpFrames: 0,
|
|
191
|
+
alive: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- stepGame --------------------------------------------------------------
|
|
196
|
+
/**
|
|
197
|
+
* Advance one frame. PURE: returns a NEW state (shallow-cloned then mutated on
|
|
198
|
+
* the clone) so React setState(g => stepGame(g, …)) is safe. `input` is a flat
|
|
199
|
+
* intent object the wrapper fills from keypresses and clears each frame:
|
|
200
|
+
* { left, right, up, down, jump } (booleans).
|
|
201
|
+
*/
|
|
202
|
+
export function stepGame(prev, { input = {} } = {}) {
|
|
203
|
+
// Frozen states ignore gameplay input (the wrapper handles r/q out-of-band).
|
|
204
|
+
if (prev.status === "over" || prev.status === "won" || prev.tooSmall) {
|
|
205
|
+
return prev;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Clone (deep-enough: entities + arrays we mutate).
|
|
209
|
+
const s = {
|
|
210
|
+
...prev,
|
|
211
|
+
jumpman: { ...prev.jumpman },
|
|
212
|
+
donkeyKong: { ...prev.donkeyKong },
|
|
213
|
+
barrels: prev.barrels.map((b) => ({ ...b })),
|
|
214
|
+
frame: prev.frame + 1,
|
|
215
|
+
};
|
|
216
|
+
const { platforms, ladders, BOARD_W, BOARD_H } = s;
|
|
217
|
+
let seed = s.rngSeed;
|
|
218
|
+
const rnd = () => { const [v, n] = rng(seed); seed = n; return v; };
|
|
219
|
+
|
|
220
|
+
const jm = s.jumpman;
|
|
221
|
+
const prevX = jm.x;
|
|
222
|
+
const prevY = jm.y;
|
|
223
|
+
|
|
224
|
+
// ---- 1. Jumpman intent: horizontal move + ladder + jump ----
|
|
225
|
+
if (input.left) { jm.x = Math.max(0, jm.x - 1); jm.facing = -1; }
|
|
226
|
+
if (input.right) { jm.x = Math.min(BOARD_W - 1, jm.x + 1); jm.facing = 1; }
|
|
227
|
+
|
|
228
|
+
// Ladder logic. A ladder spans the walkable run [yTop..yBottom] where BOTH
|
|
229
|
+
// ends are standing rows (yTop = upper girder's standing row, yBottom = lower
|
|
230
|
+
// girder's). When the player's column + row sit on the run, up/down moves one
|
|
231
|
+
// row along it (gravity suspended). At yTop, up steps onto the upper girder
|
|
232
|
+
// (off the ladder); at yBottom, down is a no-op (already on the lower girder).
|
|
233
|
+
const onLad = ladderAt(ladders, jm.x, jm.y);
|
|
234
|
+
if (jm.jumpFrames === 0) {
|
|
235
|
+
if (input.up && onLad && jm.y > onLad.yTop) {
|
|
236
|
+
jm.y -= 1; jm.onGround = false;
|
|
237
|
+
jm.onLadder = jm.y > onLad.yTop; // off the ladder once on the upper girder's row
|
|
238
|
+
} else if (input.down && onLad && jm.y < onLad.yBottom) {
|
|
239
|
+
jm.y += 1; jm.onGround = false;
|
|
240
|
+
jm.onLadder = jm.y < onLad.yBottom; // off the ladder once on the lower girder's row
|
|
241
|
+
} else {
|
|
242
|
+
// Standing at an endpoint counts as on the girder, not the ladder.
|
|
243
|
+
jm.onLadder = !!onLad && jm.y > onLad.yTop && jm.y < onLad.yBottom;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Jump: only from the ground (not mid-air, not on a ladder).
|
|
248
|
+
if (input.jump && jm.onGround && jm.jumpFrames === 0 && !jm.onLadder) {
|
|
249
|
+
jm.jumpFrames = JUMP_LEN;
|
|
250
|
+
jm.onGround = false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- 2. Jump arc + gravity ----
|
|
254
|
+
if (jm.jumpFrames > 0) {
|
|
255
|
+
// Symmetric parabola: rise on the first half, fall on the second.
|
|
256
|
+
const half = Math.ceil(JUMP_LEN / 2);
|
|
257
|
+
if (jm.jumpFrames > half) jm.y -= 1; // going up
|
|
258
|
+
else jm.y += 1; // coming down
|
|
259
|
+
jm.jumpFrames -= 1;
|
|
260
|
+
if (jm.jumpFrames === 0) {
|
|
261
|
+
// settle onto the girder at the landing column
|
|
262
|
+
const gi = girderBelow(platforms, jm.x, jm.y);
|
|
263
|
+
if (gi >= 0) jm.y = platforms[gi].slopeOffsets[jm.x] - 1;
|
|
264
|
+
}
|
|
265
|
+
} else if (!jm.onLadder) {
|
|
266
|
+
// Gravity: stand on a girder if the cell directly below is one; else fall to
|
|
267
|
+
// the nearest girder beneath us.
|
|
268
|
+
const onSomeGirder = isGirderAt(platforms, jm.x, jm.y + 1);
|
|
269
|
+
if (onSomeGirder) {
|
|
270
|
+
jm.onGround = true;
|
|
271
|
+
jm.vy = 0;
|
|
272
|
+
} else {
|
|
273
|
+
const gi = girderBelow(platforms, jm.x, jm.y + 1);
|
|
274
|
+
const standRow = gi >= 0 ? platforms[gi].slopeOffsets[jm.x] : -1;
|
|
275
|
+
if (standRow >= 0 && standRow - 1 > jm.y) {
|
|
276
|
+
jm.y = Math.min(jm.y + GRAVITY, standRow - 1);
|
|
277
|
+
jm.onGround = jm.y === standRow - 1;
|
|
278
|
+
} else {
|
|
279
|
+
jm.onGround = false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Clamp inside the board.
|
|
284
|
+
jm.y = Math.max(0, Math.min(BOARD_H - 1, jm.y));
|
|
285
|
+
|
|
286
|
+
// ---- 3. DK throws barrels ----
|
|
287
|
+
const dk = s.donkeyKong;
|
|
288
|
+
if (dk.throwCooldown > 0) {
|
|
289
|
+
dk.throwCooldown -= 1;
|
|
290
|
+
} else {
|
|
291
|
+
const top = platforms[platforms.length - 1];
|
|
292
|
+
s.barrels.push({
|
|
293
|
+
id: s.nextBarrelId,
|
|
294
|
+
x: dk.x + 1,
|
|
295
|
+
y: top.slopeOffsets[dk.x + 1] - 1,
|
|
296
|
+
vx: 1, // roll to the right off the top girder
|
|
297
|
+
falling: false,
|
|
298
|
+
scored: false,
|
|
299
|
+
});
|
|
300
|
+
s.nextBarrelId += 1;
|
|
301
|
+
dk.throwCooldown = cooldownForLevel(s.level);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- 4. Barrels roll / fall / descend ladders ----
|
|
305
|
+
const ladderChance = ladderChanceForLevel(s.level);
|
|
306
|
+
const surviving = [];
|
|
307
|
+
for (const b of s.barrels) {
|
|
308
|
+
// Pre-move position, so collision (below) can catch a barrel that rolled
|
|
309
|
+
// THROUGH the player's cell in one frame, not just where it ended up.
|
|
310
|
+
b.px = b.x; b.py = b.y;
|
|
311
|
+
if (b.falling) {
|
|
312
|
+
// Free-fall until it lands on the next girder below, then resume rolling.
|
|
313
|
+
const gi = girderBelow(platforms, b.x, b.y + 1);
|
|
314
|
+
const landRow = gi >= 0 ? platforms[gi].slopeOffsets[b.x] - 1 : Infinity;
|
|
315
|
+
if (b.y + GRAVITY >= landRow && landRow !== Infinity) {
|
|
316
|
+
b.y = landRow;
|
|
317
|
+
b.falling = false;
|
|
318
|
+
// continue in the same horizontal direction
|
|
319
|
+
} else {
|
|
320
|
+
b.y += GRAVITY;
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
// Maybe descend a ladder it's standing over.
|
|
324
|
+
const l = ladderAt(ladders, b.x, b.y + 1);
|
|
325
|
+
if (l && rnd() < ladderChance) {
|
|
326
|
+
b.falling = true;
|
|
327
|
+
b.y += GRAVITY;
|
|
328
|
+
} else {
|
|
329
|
+
const nx = b.x + b.vx;
|
|
330
|
+
if (nx < 0 || nx >= BOARD_W) {
|
|
331
|
+
// hit the wall: fall to the girder below and reverse
|
|
332
|
+
b.vx = -b.vx;
|
|
333
|
+
b.falling = true;
|
|
334
|
+
b.y += GRAVITY;
|
|
335
|
+
} else {
|
|
336
|
+
// Follow the girder's slope: snap onto the girder row at the new col
|
|
337
|
+
// if there is one; otherwise it rolled off the low end → fall.
|
|
338
|
+
const giHere = (() => { for (let i = 0; i < platforms.length; i++) if (platforms[i].slopeOffsets[b.x] === b.y + 1) return i; return -1; })();
|
|
339
|
+
if (giHere >= 0) {
|
|
340
|
+
const nextRow = platforms[giHere].slopeOffsets[nx];
|
|
341
|
+
b.x = nx;
|
|
342
|
+
b.y = nextRow - 1;
|
|
343
|
+
} else {
|
|
344
|
+
// no girder under us anymore → start falling
|
|
345
|
+
b.x = nx;
|
|
346
|
+
b.falling = true;
|
|
347
|
+
b.y += GRAVITY;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (b.y < BOARD_H) surviving.push(b); // despawn below the board
|
|
353
|
+
}
|
|
354
|
+
s.barrels = surviving;
|
|
355
|
+
|
|
356
|
+
// ---- 5. Collisions + jump-over scoring (swept to avoid tunneling) ----
|
|
357
|
+
for (const b of s.barrels) {
|
|
358
|
+
// Hit if the barrel ends on the player's cell, OR it rolled/fell THROUGH the
|
|
359
|
+
// player this frame (swept on both axes using the captured pre-move pos), OR
|
|
360
|
+
// the player walked into where the barrel ended up (player's x sweep).
|
|
361
|
+
const endOn = b.x === jm.x && b.y === jm.y;
|
|
362
|
+
const barrelSwept =
|
|
363
|
+
(b.py === jm.y && b.y === jm.y && between(jm.x, b.px, b.x)) || // rolled across the row
|
|
364
|
+
(b.px === jm.x && b.x === jm.x && between(jm.y, b.py, b.y)); // fell down the column
|
|
365
|
+
const playerSwept = b.y === jm.y && between(b.x, prevX, jm.x);
|
|
366
|
+
if ((endOn || barrelSwept || playerSwept) && jm.alive) {
|
|
367
|
+
s.lives -= 1;
|
|
368
|
+
s.message = "ouch! barrel hit";
|
|
369
|
+
if (s.lives <= 0) { s.status = "over"; s.message = `GAME OVER — score ${s.score}`; return finalize(s, seed); }
|
|
370
|
+
respawnJumpman(s);
|
|
371
|
+
return finalize(s, seed); // brief pause; next frame resumes
|
|
372
|
+
}
|
|
373
|
+
// Jump-over credit: while airborne (mid-jump), a barrel that is below the
|
|
374
|
+
// player and shares his column has been cleared — +10, once per barrel.
|
|
375
|
+
// Checked against the barrel's pre- OR post-move column so a fast barrel
|
|
376
|
+
// that rolled under during the same frame still earns the credit.
|
|
377
|
+
if (!b.scored && jm.jumpFrames > 0 && (b.x === jm.x || b.px === jm.x) && b.y > jm.y) {
|
|
378
|
+
b.scored = true;
|
|
379
|
+
s.score += 10;
|
|
380
|
+
s.message = "+10 jumped!";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- 6. Win: reach the princess ----
|
|
385
|
+
const dxP = Math.abs(jm.x - s.princess.x);
|
|
386
|
+
const dyP = Math.abs(jm.y - s.princess.y);
|
|
387
|
+
if (dxP <= PRINCESS_REACH && dyP <= 1) {
|
|
388
|
+
s.score += 100;
|
|
389
|
+
s.status = "won";
|
|
390
|
+
s.message = "you saved her! +100";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return finalize(s, seed);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function finalize(s, seed) {
|
|
397
|
+
s.rngSeed = seed >>> 0;
|
|
398
|
+
return s;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Build the next level after a win, carrying score+lives. */
|
|
402
|
+
export function nextLevel(state, dims) {
|
|
403
|
+
return initGame({
|
|
404
|
+
width: dims.width,
|
|
405
|
+
height: dims.height,
|
|
406
|
+
level: state.level + 1,
|
|
407
|
+
score: state.score,
|
|
408
|
+
lives: state.lives,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Expose a couple of internals for focused unit tests.
|
|
413
|
+
export const _internals = { girderBelow, isGirderAt, ladderAt, rng };
|