@modelstatus/cli 0.1.34 → 0.1.36
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 +219 -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-process.js +238 -0
- package/src/sources/scan-runner.js +127 -0
- package/src/sources/scan-worker.js +148 -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 +45 -2
- package/src/tui/game/DkGame.js +21 -0
- package/src/tui/game/dk-core.js +688 -0
- package/src/tui/game/dk-render.js +160 -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/add.js +1 -1
- package/src/tui/views/integrations.js +224 -0
- package/src/tui/views/inventory.js +31 -2
- package/src/tui/views/scan.js +116 -6
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/* Donkey Kong — PURE game engine. NO React, NO ink, NO Math.random, NO Date.now,
|
|
2
|
+
* NO I/O. The single source of game truth, unit-tested in isolation.
|
|
3
|
+
*
|
|
4
|
+
* This is a deterministic discrete map. initGame(opts) builds a board
|
|
5
|
+
* procedurally from the terminal's width/height (so it scales + can refuse a
|
|
6
|
+
* too-small terminal). stepGame(prev, { input, dt }) advances exactly one tick
|
|
7
|
+
* (1/60s) and returns a NEW state. All randomness flows through a seeded
|
|
8
|
+
* mulberry32 RNG carried in the state, so unit tests pass a fixed seed and
|
|
9
|
+
* assert EXACT positions, barrel paths and collisions — and two runtimes (node
|
|
10
|
+
* and the bun-compiled binary) produce byte-identical state.
|
|
11
|
+
*
|
|
12
|
+
* === WHY A REWRITE (sub-cell fixed-point) ===
|
|
13
|
+
* The old core stepped a single integer cell per 100ms frame and jumped a
|
|
14
|
+
* 4-frame triangle. That coarse 10fps single-cell grid is the "stiffness". This
|
|
15
|
+
* engine runs true 60Hz with SUB-CELL fixed-point positions so the player
|
|
16
|
+
* accelerates/slides across girders and the jump is a readable parabolic arc.
|
|
17
|
+
*
|
|
18
|
+
* === COORDINATE / FIXED-POINT MODEL ===
|
|
19
|
+
* Public grid stays INTEGER: x = column (0 = left), y = row (0 = TOP), up = -y.
|
|
20
|
+
* Girders/ladders/DK/princess and the RENDERER all read integer cells. The
|
|
21
|
+
* renderer reads jumpman.x/y and barrels[].x/y, which we keep as integer fields
|
|
22
|
+
* SYNCED each tick from the sub-cell px/py (round-to-nearest). So dk-render.js
|
|
23
|
+
* is unchanged in contract.
|
|
24
|
+
*
|
|
25
|
+
* Internally the player + barrels carry Int32 fixed-point position/velocity in
|
|
26
|
+
* units of 1/SCALE cell (SCALE = 256, a power of two → exact, no float drift
|
|
27
|
+
* across machines/runtimes). The render cell is round-to-nearest: (fx+128)>>8.
|
|
28
|
+
* Integer fixed-point with >>/* is bit-identical under V8 and JSC; that is the
|
|
29
|
+
* determinism guarantee the tests assert.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// ---- Fixed-point helpers ---------------------------------------------------
|
|
33
|
+
export const SCALE = 256; // 1 cell = 256 fixed-point units (power of two → exact)
|
|
34
|
+
export const toFx = (n) => (n * SCALE) | 0; // integer cell -> fx
|
|
35
|
+
export const cell = (fx) => (fx + (SCALE >> 1)) >> 8; // fx -> round-to-nearest cell
|
|
36
|
+
export const floorCell = (fx) => fx >> 8; // fx -> floor cell (>> on Int32 = floor for our range)
|
|
37
|
+
const clampFx = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
38
|
+
|
|
39
|
+
// ---- Tunables (per 1/60s tick unless noted) --------------------------------
|
|
40
|
+
export const TICK_HZ = 60;
|
|
41
|
+
export const FIXED_DT = 1; // the loop ALWAYS steps with dt=1 (true fixed timestep)
|
|
42
|
+
// Legacy alias so old imports of FRAME_MS keep working (now = one 60Hz tick).
|
|
43
|
+
export const FRAME_MS = 1000 / TICK_HZ;
|
|
44
|
+
export const JUMP_LEN = 4; // legacy export (old triangle length); unused by physics
|
|
45
|
+
|
|
46
|
+
export const MIN_W = 28; // minimum playfield width
|
|
47
|
+
export const MIN_H = 9; // minimum playfield height
|
|
48
|
+
|
|
49
|
+
// Player horizontal
|
|
50
|
+
export const RUN_ACCEL = 22; // fx/tick (ramps to top speed in ~6 ticks ≈ 100ms)
|
|
51
|
+
export const RUN_MAX = 130; // fx/tick (~0.5 cell/tick → brisk but readable)
|
|
52
|
+
export const GROUND_FRICTION = 30; // fx/tick decel when no input (the "slide")
|
|
53
|
+
export const AIR_ACCEL = 14; // weaker air-control
|
|
54
|
+
export const AIR_MAX = 130;
|
|
55
|
+
|
|
56
|
+
// Gravity / jump
|
|
57
|
+
export const GRAVITY = 14; // fx/tick added to vy while airborne (full jump → apex ≈ 3 cells)
|
|
58
|
+
export const JUMP_CUT_GRAVITY = 42; // stronger gravity while RISING after release → short hop (~1.5 cells)
|
|
59
|
+
export const TERMINAL_VY = 220; // fx/tick max fall speed
|
|
60
|
+
export const JUMP_VY = -150; // initial up-velocity → apex ≈ 3 cells, hang ≈ 21 ticks
|
|
61
|
+
export const COYOTE_TICKS = 5; // jump still allowed N ticks after walking off
|
|
62
|
+
export const JUMP_BUFFER_TICKS = 6; // a press N ticks before landing still fires on land
|
|
63
|
+
|
|
64
|
+
// Ladder
|
|
65
|
+
export const CLIMB_SPEED = 70; // fx/tick (gravity suspended while latched)
|
|
66
|
+
|
|
67
|
+
// Barrels
|
|
68
|
+
export const BARREL_ROLL = 95; // fx/tick base roll speed
|
|
69
|
+
export const MAX_BARRELS = 24; // hard cap (oldest despawns) — bounded state growth
|
|
70
|
+
|
|
71
|
+
// Geometry / scoring
|
|
72
|
+
const SLOPE_EVERY = 6; // a 1-row step every ~6 columns on a girder
|
|
73
|
+
const PRINCESS_REACH = 2; // |dx| within which touching the princess wins
|
|
74
|
+
|
|
75
|
+
// State-machine timers (ticks)
|
|
76
|
+
export const GETREADY_TICKS = 90; // ≈1.5s orient beat
|
|
77
|
+
export const DEATH_TICKS = 60; // death-pop / pause before respawn
|
|
78
|
+
export const LEVELCLEAR_TICKS = 120; // banner + bonus count-up
|
|
79
|
+
export const RESPAWN_INVULN = 120; // ≈2s of blink after a respawn
|
|
80
|
+
|
|
81
|
+
// Bonus timer
|
|
82
|
+
export const BONUS_DRAIN = 10; // points removed each drain
|
|
83
|
+
export const BONUS_TICK = 24; // drain every 24 ticks (~0.4s)
|
|
84
|
+
export function bonusForLevel(level) {
|
|
85
|
+
return 5000 + level * 1000;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SCHEMA = 2;
|
|
89
|
+
|
|
90
|
+
// ---- Level scaling (pure, tested monotonic) --------------------------------
|
|
91
|
+
/** DK barrel-throw cadence in TICKS — faster (shorter) as you climb. */
|
|
92
|
+
export function cooldownForLevel(level) {
|
|
93
|
+
return Math.max(45, 132 - (level - 1) * 18);
|
|
94
|
+
}
|
|
95
|
+
/** Barrel roll speed (fx/tick) — faster each level. */
|
|
96
|
+
export function barrelSpeedForLevel(level) {
|
|
97
|
+
return Math.min(170, BARREL_ROLL + (level - 1) * 12);
|
|
98
|
+
}
|
|
99
|
+
/** Probability a rolling barrel takes a ladder down per eligibility check. */
|
|
100
|
+
export function ladderChanceForLevel(level) {
|
|
101
|
+
return Math.min(0.6, 0.18 + (level - 1) * 0.1);
|
|
102
|
+
}
|
|
103
|
+
/** By construction the jump apex (~3 cells) clears a 1-cell barrel + player at
|
|
104
|
+
* every level — exposed so a test can assert the invariant numerically. */
|
|
105
|
+
export function jumpClearsBarrel(/* level */) {
|
|
106
|
+
const apexCells = (JUMP_VY * JUMP_VY) / (2 * GRAVITY) / SCALE; // ≈ 3.14
|
|
107
|
+
return apexCells > 2; // barrel(1) + player(1) with margin
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- Seeded RNG (mulberry32) — pure, threaded through state ----------------
|
|
111
|
+
function rng(seed) {
|
|
112
|
+
let t = (seed + 0x6d2b79f5) | 0;
|
|
113
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
114
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
115
|
+
const v = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
116
|
+
return [v, t >>> 0];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- Board geometry (KEPT from the old core — integer grid, good) ----------
|
|
120
|
+
export function boardSize(width, height) {
|
|
121
|
+
const HUD_ROWS = 2; // scan-progress line + score/lives line (rendered by loop)
|
|
122
|
+
const KEY_ROW = 1; // in-game keybar (rendered by loop)
|
|
123
|
+
const BOARD_W = Math.max(MIN_W, Math.min(width - 2, 64));
|
|
124
|
+
const BOARD_H = height - HUD_ROWS - KEY_ROW;
|
|
125
|
+
return { BOARD_W, BOARD_H };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildSlope(baseRow, w, down, amp) {
|
|
129
|
+
const offs = new Array(w);
|
|
130
|
+
for (let x = 0; x < w; x++) {
|
|
131
|
+
const steps = Math.floor(x / SLOPE_EVERY);
|
|
132
|
+
const off = down ? steps : -steps;
|
|
133
|
+
offs[x] = baseRow + Math.max(-amp, Math.min(amp, off));
|
|
134
|
+
}
|
|
135
|
+
return offs;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Girder index whose standing row is at-or-just-below row y at column x. */
|
|
139
|
+
function girderBelow(platforms, x, y) {
|
|
140
|
+
let best = -1, bestRow = Infinity;
|
|
141
|
+
for (let i = 0; i < platforms.length; i++) {
|
|
142
|
+
const row = platforms[i].slopeOffsets[x];
|
|
143
|
+
if (row >= y && row < bestRow) { best = i; bestRow = row; }
|
|
144
|
+
}
|
|
145
|
+
return best;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True if column x at row y is a girder cell of ANY platform. */
|
|
149
|
+
function isGirderAt(platforms, x, y) {
|
|
150
|
+
for (const p of platforms) if (p.slopeOffsets[x] === y) return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The platform index whose girder row is exactly `y` at column `x`, or -1. */
|
|
155
|
+
function girderIndexAt(platforms, x, y) {
|
|
156
|
+
for (let i = 0; i < platforms.length; i++) if (platforms[i].slopeOffsets[x] === y) return i;
|
|
157
|
+
return -1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The ladder occupying column x within [yTop..yBottom] at row y, or null. */
|
|
161
|
+
function ladderAt(ladders, x, y) {
|
|
162
|
+
for (const l of ladders) if (l.col === x && y >= l.yTop && y <= l.yBottom) return l;
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Inclusive "is v between a and b" regardless of order — swept hit test. */
|
|
167
|
+
function between(v, a, b) {
|
|
168
|
+
return v >= Math.min(a, b) && v <= Math.max(a, b);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- initGame --------------------------------------------------------------
|
|
172
|
+
/**
|
|
173
|
+
* Build a fresh game. Carries score/lives/best across level-ups. Returns
|
|
174
|
+
* { tooSmall:true } when the terminal can't host a playable board so the loop
|
|
175
|
+
* can refuse to enter without ever ticking.
|
|
176
|
+
*
|
|
177
|
+
* status starts at 'getready' (a brief orient beat) — the loop can set 'title'
|
|
178
|
+
* for the cold-start title card; both are valid entry states.
|
|
179
|
+
*/
|
|
180
|
+
export function initGame({
|
|
181
|
+
width = 80, height = 14, level = 1, score = 0, lives = 3, best = 0, seed,
|
|
182
|
+
status = "getready",
|
|
183
|
+
} = {}) {
|
|
184
|
+
const { BOARD_W, BOARD_H } = boardSize(width, height);
|
|
185
|
+
if (BOARD_W < MIN_W || BOARD_H < MIN_H) return { tooSmall: true, BOARD_W, BOARD_H };
|
|
186
|
+
|
|
187
|
+
const N = Math.max(4, Math.min(5, Math.floor(BOARD_H / 3)));
|
|
188
|
+
const gap = Math.floor((BOARD_H - 2) / N);
|
|
189
|
+
const amp = Math.max(0, Math.min(2, Math.floor((gap - 1) / 2)));
|
|
190
|
+
const platforms = [];
|
|
191
|
+
for (let i = 0; i < N; i++) {
|
|
192
|
+
const baseRow = BOARD_H - 2 - amp - i * gap; // 0 = bottom-most girder
|
|
193
|
+
const down = i % 2 === 0;
|
|
194
|
+
platforms.push({ row: baseRow, slopeOffsets: buildSlope(baseRow, BOARD_W, down, amp) });
|
|
195
|
+
}
|
|
196
|
+
// platforms[0] is the LOWEST (largest row); platforms[N-1] the HIGHEST.
|
|
197
|
+
|
|
198
|
+
const ladders = [];
|
|
199
|
+
for (let i = 0; i < N - 1; i++) {
|
|
200
|
+
const lower = platforms[i];
|
|
201
|
+
const upper = platforms[i + 1];
|
|
202
|
+
const col = Math.max(2, Math.min(BOARD_W - 3, Math.floor(((i + 1) / N) * BOARD_W) + (i % 2 ? -3 : 3)));
|
|
203
|
+
const yBottom = lower.slopeOffsets[col] - 1;
|
|
204
|
+
const yTop = upper.slopeOffsets[col] - 1;
|
|
205
|
+
ladders.push({ col, yTop: Math.min(yTop, yBottom), yBottom: Math.max(yTop, yBottom) });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const top = platforms[N - 1];
|
|
209
|
+
const dkCol = 2;
|
|
210
|
+
const donkeyKong = { x: dkCol, y: top.slopeOffsets[dkCol] - 1, throwCooldown: 2, animPhase: 0 };
|
|
211
|
+
const princessCol = Math.floor(BOARD_W / 2);
|
|
212
|
+
const princess = { x: princessCol, y: top.slopeOffsets[princessCol] - 1, animPhase: 0 };
|
|
213
|
+
|
|
214
|
+
const player = makePlayer(platforms);
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
schema: SCHEMA,
|
|
218
|
+
BOARD_W,
|
|
219
|
+
BOARD_H,
|
|
220
|
+
platforms,
|
|
221
|
+
ladders,
|
|
222
|
+
player,
|
|
223
|
+
// `jumpman` is a same-object alias of `player` so dk-render.js (which reads
|
|
224
|
+
// state.jumpman.x/y) stays unchanged in contract. Re-pointed after each clone.
|
|
225
|
+
jumpman: player,
|
|
226
|
+
donkeyKong,
|
|
227
|
+
princess,
|
|
228
|
+
barrels: [],
|
|
229
|
+
// --- meta / state machine ---
|
|
230
|
+
status, // title | getready | playing | dying | levelclear | over
|
|
231
|
+
statusTimer: status === "getready" ? GETREADY_TICKS : 0,
|
|
232
|
+
score,
|
|
233
|
+
lives,
|
|
234
|
+
level,
|
|
235
|
+
bonus: bonusForLevel(level),
|
|
236
|
+
bonusDrainTick: 0,
|
|
237
|
+
best: Math.max(best, score) >>> 0,
|
|
238
|
+
rngSeed: (seed == null ? 1234567 + level * 99991 : seed) >>> 0,
|
|
239
|
+
frame: 0,
|
|
240
|
+
message: level > 1 ? `level ${level}` : "",
|
|
241
|
+
nextBarrelId: 1,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Fresh player standing on the lowest girder at the start column. */
|
|
246
|
+
function makePlayer(platforms, invuln = 0) {
|
|
247
|
+
const bottom = platforms[0];
|
|
248
|
+
const startCol = 2;
|
|
249
|
+
const standRow = bottom.slopeOffsets[startCol] - 1;
|
|
250
|
+
return {
|
|
251
|
+
px: toFx(startCol),
|
|
252
|
+
py: toFx(standRow),
|
|
253
|
+
vx: 0,
|
|
254
|
+
vy: 0,
|
|
255
|
+
facing: 1,
|
|
256
|
+
onGround: true,
|
|
257
|
+
onLadder: false,
|
|
258
|
+
jumpHeld: false,
|
|
259
|
+
coyote: 0,
|
|
260
|
+
jumpBuffer: 0,
|
|
261
|
+
invuln,
|
|
262
|
+
alive: true,
|
|
263
|
+
// integer mirror read by the renderer (synced each tick)
|
|
264
|
+
x: startCol,
|
|
265
|
+
y: standRow,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Sync the integer cell mirror the renderer reads from sub-cell fx. */
|
|
270
|
+
function syncCell(body) {
|
|
271
|
+
body.x = cell(body.px);
|
|
272
|
+
body.y = cell(body.py);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- stepGame --------------------------------------------------------------
|
|
276
|
+
/**
|
|
277
|
+
* Advance one tick. PURE: returns a NEW state. `input` is a flat intent object:
|
|
278
|
+
* { left, right, up, down, jump, start, restart } (booleans).
|
|
279
|
+
* `jump` is EDGE-triggered (true only on the press tick); held is derived.
|
|
280
|
+
* dt is a tick multiplier (default 1); the loop ALWAYS passes dt=1.
|
|
281
|
+
*/
|
|
282
|
+
export function stepGame(prev, { input = {}, dt = FIXED_DT } = {}) {
|
|
283
|
+
if (prev.tooSmall) return prev;
|
|
284
|
+
// 'over' is frozen — a hard no-op (preserves the loop-freeze contract).
|
|
285
|
+
if (prev.status === "over") return prev;
|
|
286
|
+
|
|
287
|
+
// Clone (entities + arrays we mutate).
|
|
288
|
+
const s = {
|
|
289
|
+
...prev,
|
|
290
|
+
player: { ...prev.player },
|
|
291
|
+
donkeyKong: { ...prev.donkeyKong },
|
|
292
|
+
princess: { ...prev.princess },
|
|
293
|
+
barrels: prev.barrels.map((b) => ({ ...b })),
|
|
294
|
+
frame: prev.frame + 1,
|
|
295
|
+
};
|
|
296
|
+
s.jumpman = s.player; // keep the renderer's alias pointing at the live player
|
|
297
|
+
|
|
298
|
+
switch (s.status) {
|
|
299
|
+
case "title": return stepTitle(s, input);
|
|
300
|
+
case "getready": return stepGetReady(s, input);
|
|
301
|
+
case "playing": return stepPlaying(s, input, dt);
|
|
302
|
+
case "dying": return stepDying(s, dt);
|
|
303
|
+
case "levelclear": return stepLevelClear(s, dt);
|
|
304
|
+
default: return s;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- transient-status steps ------------------------------------------------
|
|
309
|
+
function stepTitle(s, input) {
|
|
310
|
+
s.donkeyKong.animPhase = (s.frame >> 4) & 1; // slow idle chest-beat on the title only
|
|
311
|
+
if (input.start) {
|
|
312
|
+
s.status = "getready";
|
|
313
|
+
s.statusTimer = GETREADY_TICKS;
|
|
314
|
+
s.player = makePlayer(s.platforms);
|
|
315
|
+
s.jumpman = s.player;
|
|
316
|
+
s.message = "GET READY";
|
|
317
|
+
}
|
|
318
|
+
return s;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function stepGetReady(s, _input) {
|
|
322
|
+
s.message = "GET READY";
|
|
323
|
+
s.statusTimer -= 1;
|
|
324
|
+
if (s.statusTimer <= 0) {
|
|
325
|
+
s.status = "playing";
|
|
326
|
+
s.statusTimer = 0;
|
|
327
|
+
s.message = s.level > 1 ? `level ${s.level}` : "";
|
|
328
|
+
}
|
|
329
|
+
return s;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function stepDying(s, _dt) {
|
|
333
|
+
// Death-pop: a small hop then fall, purely visual; barrels keep rolling.
|
|
334
|
+
const p = s.player;
|
|
335
|
+
p.vy = Math.min(p.vy + GRAVITY, TERMINAL_VY);
|
|
336
|
+
p.py = clampFx(p.py + p.vy, 0, toFx(s.BOARD_H - 1));
|
|
337
|
+
syncCell(p);
|
|
338
|
+
advanceBarrels(s, makeRnd(s)); // juice: hazards stay live during the death beat
|
|
339
|
+
s.statusTimer -= 1;
|
|
340
|
+
if (s.statusTimer <= 0) {
|
|
341
|
+
if (s.lives > 0) {
|
|
342
|
+
// respawn with invulnerability (blink window)
|
|
343
|
+
s.player = makePlayer(s.platforms, RESPAWN_INVULN);
|
|
344
|
+
s.jumpman = s.player;
|
|
345
|
+
s.status = "getready";
|
|
346
|
+
s.statusTimer = Math.floor(GETREADY_TICKS / 2);
|
|
347
|
+
s.message = "GET READY";
|
|
348
|
+
} else {
|
|
349
|
+
s.status = "over";
|
|
350
|
+
s.statusTimer = 0;
|
|
351
|
+
s.best = Math.max(s.best, s.score) >>> 0;
|
|
352
|
+
s.message = `GAME OVER — score ${s.score}`;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return finalize(s, s.rngSeed);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function stepLevelClear(s, _dt) {
|
|
359
|
+
// Count the remaining bonus up into the score over the banner (juice).
|
|
360
|
+
if (s.bonus > 0) {
|
|
361
|
+
const award = Math.min(s.bonus, 100);
|
|
362
|
+
s.bonus -= award;
|
|
363
|
+
s.score += award;
|
|
364
|
+
s.best = Math.max(s.best, s.score) >>> 0;
|
|
365
|
+
}
|
|
366
|
+
s.statusTimer -= 1;
|
|
367
|
+
if (s.statusTimer <= 0) {
|
|
368
|
+
return nextLevel(s, { width: s.BOARD_W + 2, height: s.BOARD_H + 3 });
|
|
369
|
+
}
|
|
370
|
+
return s;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- the playing pipeline --------------------------------------------------
|
|
374
|
+
function makeRnd(s) {
|
|
375
|
+
let seed = s.rngSeed;
|
|
376
|
+
const rnd = () => { const [v, n] = rng(seed); seed = n; return v; };
|
|
377
|
+
rnd.flush = () => { s.rngSeed = seed >>> 0; };
|
|
378
|
+
return rnd;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function stepPlaying(s, input, dt) {
|
|
382
|
+
const { platforms, ladders, BOARD_W, BOARD_H } = s;
|
|
383
|
+
const rnd = makeRnd(s);
|
|
384
|
+
const p = s.player;
|
|
385
|
+
const maxPx = toFx(BOARD_W - 1);
|
|
386
|
+
const maxPy = toFx(BOARD_H - 1);
|
|
387
|
+
|
|
388
|
+
// 1. LADDER LATCH ----------------------------------------------------------
|
|
389
|
+
// Latch when the player's cell sits on a ladder run and up/down is pressed.
|
|
390
|
+
const col = cell(p.px);
|
|
391
|
+
const row = cell(p.py);
|
|
392
|
+
const onLad = ladderAt(ladders, col, row);
|
|
393
|
+
// Latch when the player's cell sits on a ladder run and is pressing up/down
|
|
394
|
+
// (and isn't trying to jump). Up only latches if there's run above; down only
|
|
395
|
+
// if there's run below — so standing at an endpoint and pressing "off" doesn't
|
|
396
|
+
// glue you to the ladder.
|
|
397
|
+
const wantUp = input.up && onLad && row > onLad.yTop;
|
|
398
|
+
const wantDown = input.down && onLad && row < onLad.yBottom;
|
|
399
|
+
if ((wantUp || wantDown) && !input.jump) {
|
|
400
|
+
p.onLadder = true;
|
|
401
|
+
p.vx = 0; p.vy = 0;
|
|
402
|
+
p.px = toFx(onLad.col); // snap to the ladder column while latched
|
|
403
|
+
if (wantUp) p.py = Math.max(toFx(onLad.yTop), p.py - CLIMB_SPEED);
|
|
404
|
+
else p.py = Math.min(toFx(onLad.yBottom), p.py + CLIMB_SPEED);
|
|
405
|
+
// Reached an endpoint EXACTLY → step off onto that girder (off the ladder).
|
|
406
|
+
if (p.py <= toFx(onLad.yTop)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yTop); }
|
|
407
|
+
else if (p.py >= toFx(onLad.yBottom)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yBottom); }
|
|
408
|
+
} else if (p.onLadder) {
|
|
409
|
+
// Left the column, pressed jump, or no climb input → unlatch.
|
|
410
|
+
const stillOn = onLad && cell(p.px) === onLad.col;
|
|
411
|
+
if (!stillOn || input.jump || !(input.up || input.down)) {
|
|
412
|
+
p.onLadder = false;
|
|
413
|
+
// settle onto a girder if one is at/below this cell; else gravity takes over.
|
|
414
|
+
if (isGirderAt(platforms, cell(p.px), cell(p.py) + 1) || isGirderAt(platforms, cell(p.px), cell(p.py))) p.onGround = true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!p.onLadder) {
|
|
419
|
+
// 2. HORIZONTAL --------------------------------------------------------
|
|
420
|
+
const accel = p.onGround ? RUN_ACCEL : AIR_ACCEL;
|
|
421
|
+
const max = p.onGround ? RUN_MAX : AIR_MAX;
|
|
422
|
+
if (input.left && !input.right) { p.vx = Math.max(-max, p.vx - accel * dt); p.facing = -1; }
|
|
423
|
+
else if (input.right && !input.left) { p.vx = Math.min(max, p.vx + accel * dt); p.facing = 1; }
|
|
424
|
+
else if (p.onGround) {
|
|
425
|
+
// friction (the slide) — decel toward 0
|
|
426
|
+
if (p.vx > 0) p.vx = Math.max(0, p.vx - GROUND_FRICTION * dt);
|
|
427
|
+
else if (p.vx < 0) p.vx = Math.min(0, p.vx + GROUND_FRICTION * dt);
|
|
428
|
+
}
|
|
429
|
+
p.px = clampFx(p.px + p.vx * dt, 0, maxPx);
|
|
430
|
+
if (p.px === 0 || p.px === maxPx) p.vx = 0; // wall contact
|
|
431
|
+
|
|
432
|
+
// 3. JUMP --------------------------------------------------------------
|
|
433
|
+
if (p.coyote > 0) p.coyote -= 1;
|
|
434
|
+
if (p.jumpBuffer > 0) p.jumpBuffer -= 1;
|
|
435
|
+
if (input.jump) p.jumpBuffer = JUMP_BUFFER_TICKS; // buffer the press
|
|
436
|
+
const canJump = (p.onGround || p.coyote > 0);
|
|
437
|
+
if (p.jumpBuffer > 0 && canJump) {
|
|
438
|
+
p.vy = JUMP_VY;
|
|
439
|
+
p.onGround = false;
|
|
440
|
+
p.coyote = 0;
|
|
441
|
+
p.jumpBuffer = 0;
|
|
442
|
+
p.jumpHeld = true;
|
|
443
|
+
}
|
|
444
|
+
// Variable height: holding through the rise = full jump (~3 cells); letting
|
|
445
|
+
// go while still rising applies a stronger cut-gravity → a short hop.
|
|
446
|
+
if (!input.jump && p.vy < 0) p.jumpHeld = false;
|
|
447
|
+
if (p.vy >= 0) p.jumpHeld = false;
|
|
448
|
+
|
|
449
|
+
// 4. GRAVITY + SWEPT LANDING ------------------------------------------
|
|
450
|
+
if (!p.onGround) {
|
|
451
|
+
const rising = p.vy < 0;
|
|
452
|
+
const g = rising && !p.jumpHeld ? JUMP_CUT_GRAVITY : GRAVITY;
|
|
453
|
+
p.vy = Math.min(p.vy + g * dt, TERMINAL_VY);
|
|
454
|
+
const prevPy = p.py;
|
|
455
|
+
let nextPy = p.py + p.vy * dt;
|
|
456
|
+
const c = cell(p.px);
|
|
457
|
+
if (p.vy < 0) {
|
|
458
|
+
// Rising: bonk a girder directly overhead (can't pass up THROUGH one).
|
|
459
|
+
const headRow = cell(prevPy) - 1;
|
|
460
|
+
if (headRow >= 0 && isGirderAt(platforms, c, headRow)) {
|
|
461
|
+
// stop the rise just below the girder (stand row of THIS body unchanged)
|
|
462
|
+
p.py = toFx(cell(prevPy));
|
|
463
|
+
p.vy = 0;
|
|
464
|
+
p.jumpHeld = false;
|
|
465
|
+
} else {
|
|
466
|
+
p.py = clampFx(nextPy, 0, maxPy);
|
|
467
|
+
}
|
|
468
|
+
} else if (p.vy > 0) {
|
|
469
|
+
// Falling: land on the HIGHEST girder top crossed top-down this tick
|
|
470
|
+
// (prevPy at/above the stand row, nextPy at/below it → a real landing).
|
|
471
|
+
const y0 = Math.max(0, floorCell(prevPy));
|
|
472
|
+
const y1 = Math.min(BOARD_H - 1, floorCell(nextPy) + 1);
|
|
473
|
+
let landRow = -1;
|
|
474
|
+
for (let gy = y0; gy <= y1; gy++) {
|
|
475
|
+
if (!isGirderAt(platforms, c, gy)) continue;
|
|
476
|
+
const stand = gy - 1; // stand ON TOP of the girder
|
|
477
|
+
if (prevPy <= toFx(stand) && nextPy >= toFx(stand)) { landRow = stand; break; }
|
|
478
|
+
}
|
|
479
|
+
if (landRow >= 0) {
|
|
480
|
+
p.py = toFx(landRow);
|
|
481
|
+
p.vy = 0;
|
|
482
|
+
p.onGround = true;
|
|
483
|
+
p.jumpHeld = false;
|
|
484
|
+
} else {
|
|
485
|
+
p.py = clampFx(nextPy, 0, maxPy);
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
p.py = clampFx(nextPy, 0, maxPy);
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
// Grounded: follow the slope at the current column, detect walk-off.
|
|
492
|
+
const c = cell(p.px);
|
|
493
|
+
const gi = girderIndexAt(platforms, c, cell(p.py) + 1);
|
|
494
|
+
if (gi >= 0) {
|
|
495
|
+
p.py = toFx(platforms[gi].slopeOffsets[c] - 1); // re-snap to slope
|
|
496
|
+
p.vy = 0;
|
|
497
|
+
} else {
|
|
498
|
+
// walked off the edge → coyote window, begin falling
|
|
499
|
+
p.onGround = false;
|
|
500
|
+
p.coyote = COYOTE_TICKS;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
syncCell(p);
|
|
505
|
+
|
|
506
|
+
// 5. DK THROW --------------------------------------------------------------
|
|
507
|
+
const dk = s.donkeyKong;
|
|
508
|
+
dk.throwCooldown -= 1;
|
|
509
|
+
if (dk.throwCooldown <= 0) {
|
|
510
|
+
const top = platforms[platforms.length - 1];
|
|
511
|
+
const jitter = rnd() < 0.5 ? 0 : 1; // seeded ±1 col so barrels don't stack
|
|
512
|
+
const spawnCol = Math.min(BOARD_W - 1, dk.x + 1 + jitter);
|
|
513
|
+
s.barrels.push({
|
|
514
|
+
id: s.nextBarrelId++,
|
|
515
|
+
px: toFx(spawnCol),
|
|
516
|
+
py: toFx(top.slopeOffsets[spawnCol] - 1),
|
|
517
|
+
vx: barrelSpeedForLevel(s.level), // roll right
|
|
518
|
+
vy: 0,
|
|
519
|
+
falling: false,
|
|
520
|
+
onLadder: false,
|
|
521
|
+
scored: false,
|
|
522
|
+
spin: 0,
|
|
523
|
+
x: spawnCol,
|
|
524
|
+
y: top.slopeOffsets[spawnCol] - 1,
|
|
525
|
+
});
|
|
526
|
+
if (s.barrels.length > MAX_BARRELS) s.barrels.shift(); // cap (oldest despawns)
|
|
527
|
+
dk.throwCooldown = cooldownForLevel(s.level);
|
|
528
|
+
dk.animPhase ^= 1; // chest-beat juice (only on a throw)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 6/7. BARRELS -------------------------------------------------------------
|
|
532
|
+
advanceBarrels(s, rnd);
|
|
533
|
+
|
|
534
|
+
// 8. COLLISIONS + SCORING --------------------------------------------------
|
|
535
|
+
const pCol = cell(p.px), pRow = cell(p.py);
|
|
536
|
+
for (const b of s.barrels) {
|
|
537
|
+
const bCol = cell(b.px), bRow = cell(b.py);
|
|
538
|
+
const endOn = bCol === pCol && bRow === pRow;
|
|
539
|
+
// Swept overlap using pre-move floor cells (no tunneling).
|
|
540
|
+
const bPrevCol = b._pCol ?? bCol, bPrevRow = b._pRow ?? bRow;
|
|
541
|
+
const sameRowSweep = (bRow === pRow || bPrevRow === pRow) && between(pCol, bPrevCol, bCol);
|
|
542
|
+
const sameColSweep = (bCol === pCol || bPrevCol === pCol) && between(pRow, bPrevRow, bRow);
|
|
543
|
+
const playerSweep = bRow === pRow && between(bCol, p._prevCol ?? pCol, pCol);
|
|
544
|
+
const hit = endOn || sameRowSweep || sameColSweep || playerSweep;
|
|
545
|
+
if (hit && p.invuln === 0 && p.alive) {
|
|
546
|
+
s.lives -= 1;
|
|
547
|
+
s.status = "dying";
|
|
548
|
+
s.statusTimer = DEATH_TICKS;
|
|
549
|
+
s.message = "ouch! barrel hit";
|
|
550
|
+
p.vy = -90; // death-pop kick
|
|
551
|
+
rnd.flush();
|
|
552
|
+
return finalize(s, s.rngSeed);
|
|
553
|
+
}
|
|
554
|
+
// Jump-over credit: airborne, barrel below + shares column, once per barrel.
|
|
555
|
+
const airborne = !p.onGround || p.vy !== 0;
|
|
556
|
+
if (!b.scored && airborne && (bCol === pCol || bPrevCol === pCol) && bRow > pRow) {
|
|
557
|
+
b.scored = true;
|
|
558
|
+
s.score += 10;
|
|
559
|
+
s.best = Math.max(s.best, s.score) >>> 0;
|
|
560
|
+
s.message = "+10 jumped!";
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// 9. BONUS DRAIN -----------------------------------------------------------
|
|
565
|
+
s.bonusDrainTick += 1;
|
|
566
|
+
if (s.bonusDrainTick >= BONUS_TICK) {
|
|
567
|
+
s.bonusDrainTick = 0;
|
|
568
|
+
s.bonus = Math.max(0, s.bonus - BONUS_DRAIN);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 10. WIN ------------------------------------------------------------------
|
|
572
|
+
const dxP = Math.abs(pCol - s.princess.x);
|
|
573
|
+
const dyP = Math.abs(pRow - s.princess.y);
|
|
574
|
+
if (dxP <= PRINCESS_REACH && dyP <= 1) {
|
|
575
|
+
s.status = "levelclear";
|
|
576
|
+
s.statusTimer = LEVELCLEAR_TICKS;
|
|
577
|
+
s.score += 100;
|
|
578
|
+
s.best = Math.max(s.best, s.score) >>> 0;
|
|
579
|
+
s.message = "YOU SAVED HER! +100";
|
|
580
|
+
rnd.flush();
|
|
581
|
+
return finalize(s, s.rngSeed);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 11. invuln decay + record prev cells for next tick's sweep
|
|
585
|
+
if (p.invuln > 0) p.invuln -= 1;
|
|
586
|
+
p._prevCol = pCol;
|
|
587
|
+
rnd.flush();
|
|
588
|
+
return finalize(s, s.rngSeed);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Advance every barrel one tick (roll / fall / descend ladders) in fx. */
|
|
592
|
+
function advanceBarrels(s, rnd) {
|
|
593
|
+
const { platforms, ladders, BOARD_W, BOARD_H } = s;
|
|
594
|
+
const ladderChance = ladderChanceForLevel(s.level);
|
|
595
|
+
const maxPx = toFx(BOARD_W - 1);
|
|
596
|
+
const surviving = [];
|
|
597
|
+
for (const b of s.barrels) {
|
|
598
|
+
b._pCol = cell(b.px); b._pRow = cell(b.py); // pre-move cells for the sweep
|
|
599
|
+
b.spin = (b.spin + 1) & 0xffff;
|
|
600
|
+
const c = cell(b.px);
|
|
601
|
+
if (b.falling) {
|
|
602
|
+
b.vy = Math.min((b.vy || 0) + GRAVITY, TERMINAL_VY);
|
|
603
|
+
const nextPy = b.py + b.vy;
|
|
604
|
+
const gi = girderIndexAt(platforms, c, floorCell(nextPy) + 1) >= 0
|
|
605
|
+
? girderIndexAt(platforms, c, floorCell(nextPy) + 1)
|
|
606
|
+
: girderBelow(platforms, c, cell(b.py) + 1);
|
|
607
|
+
const landRow = gi >= 0 ? platforms[gi].slopeOffsets[c] - 1 : -1;
|
|
608
|
+
if (landRow >= 0 && nextPy >= toFx(landRow) && toFx(landRow) >= b.py - 1) {
|
|
609
|
+
b.py = toFx(landRow);
|
|
610
|
+
b.vy = 0;
|
|
611
|
+
b.falling = false;
|
|
612
|
+
b.onLadder = false;
|
|
613
|
+
} else {
|
|
614
|
+
b.py = nextPy;
|
|
615
|
+
}
|
|
616
|
+
} else {
|
|
617
|
+
// On a girder: maybe descend a ladder it's over.
|
|
618
|
+
const lad = ladderAt(ladders, c, cell(b.py) + 1);
|
|
619
|
+
if (lad && rnd() < ladderChance) {
|
|
620
|
+
b.falling = true;
|
|
621
|
+
b.onLadder = true;
|
|
622
|
+
b.px = toFx(lad.col);
|
|
623
|
+
b.vy = GRAVITY;
|
|
624
|
+
b.py = b.py + b.vy;
|
|
625
|
+
} else {
|
|
626
|
+
const nextPx = b.px + b.vx;
|
|
627
|
+
const nc = cell(nextPx);
|
|
628
|
+
if (nextPx < 0 || nextPx > maxPx) {
|
|
629
|
+
b.vx = -b.vx; // bounce off the wall and fall to the girder below
|
|
630
|
+
b.falling = true;
|
|
631
|
+
b.vy = GRAVITY;
|
|
632
|
+
b.py = b.py + b.vy;
|
|
633
|
+
} else {
|
|
634
|
+
const giHere = girderIndexAt(platforms, c, cell(b.py) + 1);
|
|
635
|
+
if (giHere >= 0 && nc >= 0 && nc < BOARD_W && platforms[giHere].slopeOffsets[nc] !== undefined) {
|
|
636
|
+
b.px = nextPx;
|
|
637
|
+
b.py = toFx(platforms[giHere].slopeOffsets[nc] - 1); // follow slope
|
|
638
|
+
} else {
|
|
639
|
+
b.px = nextPx; // rolled off the end → fall
|
|
640
|
+
b.falling = true;
|
|
641
|
+
b.vy = GRAVITY;
|
|
642
|
+
b.py = b.py + b.vy;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
syncCell(b);
|
|
648
|
+
if (cell(b.py) < BOARD_H) surviving.push(b); // despawn below the board
|
|
649
|
+
}
|
|
650
|
+
s.barrels = surviving;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function finalize(s, seed) {
|
|
654
|
+
s.rngSeed = seed >>> 0;
|
|
655
|
+
return s;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---- nextLevel / serialize -------------------------------------------------
|
|
659
|
+
/** Build the next level after a win, carrying score + lives + best. */
|
|
660
|
+
export function nextLevel(state, dims) {
|
|
661
|
+
return initGame({
|
|
662
|
+
width: dims.width,
|
|
663
|
+
height: dims.height,
|
|
664
|
+
level: state.level + 1,
|
|
665
|
+
score: state.score,
|
|
666
|
+
lives: state.lives,
|
|
667
|
+
best: state.best,
|
|
668
|
+
status: "getready",
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** serialize/deserialize: the state is plain JSON already (Int32 fx + arrays).
|
|
673
|
+
* serialize drops the `jumpman` alias (it duplicates `player`); deserialize and
|
|
674
|
+
* stepGame re-point it, so round-tripping is byte-stable and compact. */
|
|
675
|
+
export function serialize(state) {
|
|
676
|
+
const { jumpman, ...rest } = state; // jumpman === player; don't double-encode
|
|
677
|
+
return JSON.parse(JSON.stringify(rest));
|
|
678
|
+
}
|
|
679
|
+
export function deserialize(obj) {
|
|
680
|
+
obj.jumpman = obj.player; // re-establish the renderer alias
|
|
681
|
+
return obj;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Expose internals for focused unit tests.
|
|
685
|
+
export const _internals = {
|
|
686
|
+
girderBelow, isGirderAt, girderIndexAt, ladderAt, rng, between,
|
|
687
|
+
makePlayer, advanceBarrels, syncCell,
|
|
688
|
+
};
|