@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
package/src/tui/game/dk-core.js
CHANGED
|
@@ -1,39 +1,113 @@
|
|
|
1
|
-
/* Donkey Kong — PURE game
|
|
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.
|
|
2
3
|
*
|
|
3
|
-
* This is a
|
|
4
|
+
* This is a deterministic discrete map. initGame(opts) builds a board
|
|
4
5
|
* procedurally from the terminal's width/height (so it scales + can refuse a
|
|
5
|
-
* too-small terminal)
|
|
6
|
-
*
|
|
7
|
-
* state, so unit tests pass a fixed seed and
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* game truth and trivially unit-testable.
|
|
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
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
15
45
|
|
|
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
46
|
export const MIN_W = 28; // minimum playfield width
|
|
20
47
|
export const MIN_H = 9; // minimum playfield height
|
|
21
|
-
|
|
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
|
|
22
72
|
const SLOPE_EVERY = 6; // a 1-row step every ~6 columns on a girder
|
|
23
73
|
const PRINCESS_REACH = 2; // |dx| within which touching the princess wins
|
|
24
74
|
|
|
25
|
-
|
|
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. */
|
|
26
92
|
export function cooldownForLevel(level) {
|
|
27
|
-
return Math.max(
|
|
93
|
+
return Math.max(45, 132 - (level - 1) * 18);
|
|
28
94
|
}
|
|
29
|
-
/**
|
|
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. */
|
|
30
100
|
export function ladderChanceForLevel(level) {
|
|
31
101
|
return Math.min(0.6, 0.18 + (level - 1) * 0.1);
|
|
32
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
|
+
}
|
|
33
109
|
|
|
34
110
|
// ---- 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
111
|
function rng(seed) {
|
|
38
112
|
let t = (seed + 0x6d2b79f5) | 0;
|
|
39
113
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
@@ -42,20 +116,15 @@ function rng(seed) {
|
|
|
42
116
|
return [v, t >>> 0];
|
|
43
117
|
}
|
|
44
118
|
|
|
45
|
-
// ---- Board geometry
|
|
119
|
+
// ---- Board geometry (KEPT from the old core — integer grid, good) ----------
|
|
46
120
|
export function boardSize(width, height) {
|
|
47
|
-
const HUD_ROWS = 2; // scan-progress line + score/lives line (rendered by
|
|
48
|
-
const KEY_ROW = 1; // in-game keybar (rendered by
|
|
49
|
-
const BOARD_W = Math.max(MIN_W, Math.min(width - 2, 64));
|
|
50
|
-
const BOARD_H = height - HUD_ROWS - KEY_ROW;
|
|
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;
|
|
51
125
|
return { BOARD_W, BOARD_H };
|
|
52
126
|
}
|
|
53
127
|
|
|
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
128
|
function buildSlope(baseRow, w, down, amp) {
|
|
60
129
|
const offs = new Array(w);
|
|
61
130
|
for (let x = 0; x < w; x++) {
|
|
@@ -66,8 +135,7 @@ function buildSlope(baseRow, w, down, amp) {
|
|
|
66
135
|
return offs;
|
|
67
136
|
}
|
|
68
137
|
|
|
69
|
-
/**
|
|
70
|
-
* the platform a body at (x,y) would land on when falling. Returns -1 if none. */
|
|
138
|
+
/** Girder index whose standing row is at-or-just-below row y at column x. */
|
|
71
139
|
function girderBelow(platforms, x, y) {
|
|
72
140
|
let best = -1, bestRow = Infinity;
|
|
73
141
|
for (let i = 0; i < platforms.length; i++) {
|
|
@@ -83,92 +151,90 @@ function isGirderAt(platforms, x, y) {
|
|
|
83
151
|
return false;
|
|
84
152
|
}
|
|
85
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
|
+
|
|
86
160
|
/** The ladder occupying column x within [yTop..yBottom] at row y, or null. */
|
|
87
161
|
function ladderAt(ladders, x, y) {
|
|
88
162
|
for (const l of ladders) if (l.col === x && y >= l.yTop && y <= l.yBottom) return l;
|
|
89
163
|
return null;
|
|
90
164
|
}
|
|
91
165
|
|
|
92
|
-
/** Inclusive "is
|
|
93
|
-
* tests (the moving body crossed cell v this frame). */
|
|
166
|
+
/** Inclusive "is v between a and b" regardless of order — swept hit test. */
|
|
94
167
|
function between(v, a, b) {
|
|
95
168
|
return v >= Math.min(a, b) && v <= Math.max(a, b);
|
|
96
169
|
}
|
|
97
170
|
|
|
98
171
|
// ---- initGame --------------------------------------------------------------
|
|
99
172
|
/**
|
|
100
|
-
* Build a fresh
|
|
101
|
-
* { tooSmall:true }
|
|
102
|
-
*
|
|
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.
|
|
103
179
|
*/
|
|
104
|
-
export function initGame({
|
|
180
|
+
export function initGame({
|
|
181
|
+
width = 80, height = 14, level = 1, score = 0, lives = 3, best = 0, seed,
|
|
182
|
+
status = "getready",
|
|
183
|
+
} = {}) {
|
|
105
184
|
const { BOARD_W, BOARD_H } = boardSize(width, height);
|
|
106
185
|
if (BOARD_W < MIN_W || BOARD_H < MIN_H) return { tooSmall: true, BOARD_W, BOARD_H };
|
|
107
186
|
|
|
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
187
|
const N = Math.max(4, Math.min(5, Math.floor(BOARD_H / 3)));
|
|
111
|
-
const gap = Math.floor((BOARD_H - 2) / N);
|
|
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.
|
|
188
|
+
const gap = Math.floor((BOARD_H - 2) / N);
|
|
115
189
|
const amp = Math.max(0, Math.min(2, Math.floor((gap - 1) / 2)));
|
|
116
190
|
const platforms = [];
|
|
117
191
|
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
192
|
const baseRow = BOARD_H - 2 - amp - i * gap; // 0 = bottom-most girder
|
|
121
|
-
const down = i % 2 === 0;
|
|
193
|
+
const down = i % 2 === 0;
|
|
122
194
|
platforms.push({ row: baseRow, slopeOffsets: buildSlope(baseRow, BOARD_W, down, amp) });
|
|
123
195
|
}
|
|
124
196
|
// platforms[0] is the LOWEST (largest row); platforms[N-1] the HIGHEST.
|
|
125
197
|
|
|
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
198
|
const ladders = [];
|
|
130
199
|
for (let i = 0; i < N - 1; i++) {
|
|
131
200
|
const lower = platforms[i];
|
|
132
201
|
const upper = platforms[i + 1];
|
|
133
202
|
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;
|
|
135
|
-
const yTop = upper.slopeOffsets[col] - 1;
|
|
203
|
+
const yBottom = lower.slopeOffsets[col] - 1;
|
|
204
|
+
const yTop = upper.slopeOffsets[col] - 1;
|
|
136
205
|
ladders.push({ col, yTop: Math.min(yTop, yBottom), yBottom: Math.max(yTop, yBottom) });
|
|
137
206
|
}
|
|
138
207
|
|
|
139
208
|
const top = platforms[N - 1];
|
|
140
209
|
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).
|
|
210
|
+
const donkeyKong = { x: dkCol, y: top.slopeOffsets[dkCol] - 1, throwCooldown: 2, animPhase: 0 };
|
|
143
211
|
const princessCol = Math.floor(BOARD_W / 2);
|
|
144
|
-
const princess = { x: princessCol, y: top.slopeOffsets[princessCol] - 1 };
|
|
212
|
+
const princess = { x: princessCol, y: top.slopeOffsets[princessCol] - 1, animPhase: 0 };
|
|
145
213
|
|
|
146
|
-
const
|
|
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
|
-
};
|
|
214
|
+
const player = makePlayer(platforms);
|
|
158
215
|
|
|
159
216
|
return {
|
|
217
|
+
schema: SCHEMA,
|
|
160
218
|
BOARD_W,
|
|
161
219
|
BOARD_H,
|
|
162
220
|
platforms,
|
|
163
221
|
ladders,
|
|
164
|
-
|
|
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,
|
|
165
226
|
donkeyKong,
|
|
166
227
|
princess,
|
|
167
228
|
barrels: [],
|
|
229
|
+
// --- meta / state machine ---
|
|
230
|
+
status, // title | getready | playing | dying | levelclear | over
|
|
231
|
+
statusTimer: status === "getready" ? GETREADY_TICKS : 0,
|
|
168
232
|
score,
|
|
169
233
|
lives,
|
|
170
234
|
level,
|
|
171
|
-
|
|
235
|
+
bonus: bonusForLevel(level),
|
|
236
|
+
bonusDrainTick: 0,
|
|
237
|
+
best: Math.max(best, score) >>> 0,
|
|
172
238
|
rngSeed: (seed == null ? 1234567 + level * 99991 : seed) >>> 0,
|
|
173
239
|
frame: 0,
|
|
174
240
|
message: level > 1 ? `level ${level}` : "",
|
|
@@ -176,221 +242,418 @@ export function initGame({ width = 80, height = 14, level = 1, score = 0, lives
|
|
|
176
242
|
};
|
|
177
243
|
}
|
|
178
244
|
|
|
179
|
-
|
|
180
|
-
function
|
|
181
|
-
const bottom =
|
|
245
|
+
/** Fresh player standing on the lowest girder at the start column. */
|
|
246
|
+
function makePlayer(platforms, invuln = 0) {
|
|
247
|
+
const bottom = platforms[0];
|
|
182
248
|
const startCol = 2;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
249
|
+
const standRow = bottom.slopeOffsets[startCol] - 1;
|
|
250
|
+
return {
|
|
251
|
+
px: toFx(startCol),
|
|
252
|
+
py: toFx(standRow),
|
|
253
|
+
vx: 0,
|
|
186
254
|
vy: 0,
|
|
255
|
+
facing: 1,
|
|
187
256
|
onGround: true,
|
|
188
257
|
onLadder: false,
|
|
189
|
-
|
|
190
|
-
|
|
258
|
+
jumpHeld: false,
|
|
259
|
+
coyote: 0,
|
|
260
|
+
jumpBuffer: 0,
|
|
261
|
+
invuln,
|
|
191
262
|
alive: true,
|
|
263
|
+
// integer mirror read by the renderer (synced each tick)
|
|
264
|
+
x: startCol,
|
|
265
|
+
y: standRow,
|
|
192
266
|
};
|
|
193
267
|
}
|
|
194
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
|
+
|
|
195
275
|
// ---- stepGame --------------------------------------------------------------
|
|
196
276
|
/**
|
|
197
|
-
* Advance one
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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.
|
|
201
281
|
*/
|
|
202
|
-
export function stepGame(prev, { input = {} } = {}) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
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;
|
|
207
286
|
|
|
208
|
-
// Clone (
|
|
287
|
+
// Clone (entities + arrays we mutate).
|
|
209
288
|
const s = {
|
|
210
289
|
...prev,
|
|
211
|
-
|
|
290
|
+
player: { ...prev.player },
|
|
212
291
|
donkeyKong: { ...prev.donkeyKong },
|
|
292
|
+
princess: { ...prev.princess },
|
|
213
293
|
barrels: prev.barrels.map((b) => ({ ...b })),
|
|
214
294
|
frame: prev.frame + 1,
|
|
215
295
|
};
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
}
|
|
219
331
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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";
|
|
241
348
|
} else {
|
|
242
|
-
|
|
243
|
-
|
|
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}`;
|
|
244
353
|
}
|
|
245
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);
|
|
246
387
|
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
// Gate on the FIXED-POINT position, not the rounded cell: once the player rounds
|
|
398
|
+
// into the top cell (cell(py)===yTop) but py hasn't yet reached the exact snap
|
|
399
|
+
// point toFx(yTop), `cell(py) > yTop` is false — the climb would stall a fraction
|
|
400
|
+
// below the girder AND the unlatch can't fire while up is held → a deadlock at
|
|
401
|
+
// the top of every ladder. Comparing px/py in fx lets the climb finish onto the
|
|
402
|
+
// girder (the step-off below snaps + unlatches). Same fix for descending.
|
|
403
|
+
const wantUp = input.up && onLad && p.py > toFx(onLad.yTop);
|
|
404
|
+
const wantDown = input.down && onLad && p.py < toFx(onLad.yBottom);
|
|
405
|
+
if ((wantUp || wantDown) && !input.jump) {
|
|
406
|
+
p.onLadder = true;
|
|
407
|
+
p.vx = 0; p.vy = 0;
|
|
408
|
+
p.px = toFx(onLad.col); // snap to the ladder column while latched
|
|
409
|
+
if (wantUp) p.py = Math.max(toFx(onLad.yTop), p.py - CLIMB_SPEED);
|
|
410
|
+
else p.py = Math.min(toFx(onLad.yBottom), p.py + CLIMB_SPEED);
|
|
411
|
+
// Reached an endpoint EXACTLY → step off onto that girder (off the ladder).
|
|
412
|
+
if (p.py <= toFx(onLad.yTop)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yTop); }
|
|
413
|
+
else if (p.py >= toFx(onLad.yBottom)) { p.onLadder = false; p.onGround = true; p.py = toFx(onLad.yBottom); }
|
|
414
|
+
} else if (p.onLadder) {
|
|
415
|
+
// Left the column, pressed jump, or no climb input → unlatch.
|
|
416
|
+
const stillOn = onLad && cell(p.px) === onLad.col;
|
|
417
|
+
if (!stillOn || input.jump || !(input.up || input.down)) {
|
|
418
|
+
p.onLadder = false;
|
|
419
|
+
// settle onto a girder if one is at/below this cell; else gravity takes over.
|
|
420
|
+
if (isGirderAt(platforms, cell(p.px), cell(p.py) + 1) || isGirderAt(platforms, cell(p.px), cell(p.py))) p.onGround = true;
|
|
421
|
+
}
|
|
251
422
|
}
|
|
252
423
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
258
|
-
else
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
424
|
+
if (!p.onLadder) {
|
|
425
|
+
// 2. HORIZONTAL --------------------------------------------------------
|
|
426
|
+
const accel = p.onGround ? RUN_ACCEL : AIR_ACCEL;
|
|
427
|
+
const max = p.onGround ? RUN_MAX : AIR_MAX;
|
|
428
|
+
if (input.left && !input.right) { p.vx = Math.max(-max, p.vx - accel * dt); p.facing = -1; }
|
|
429
|
+
else if (input.right && !input.left) { p.vx = Math.min(max, p.vx + accel * dt); p.facing = 1; }
|
|
430
|
+
else if (p.onGround) {
|
|
431
|
+
// friction (the slide) — decel toward 0
|
|
432
|
+
if (p.vx > 0) p.vx = Math.max(0, p.vx - GROUND_FRICTION * dt);
|
|
433
|
+
else if (p.vx < 0) p.vx = Math.min(0, p.vx + GROUND_FRICTION * dt);
|
|
434
|
+
}
|
|
435
|
+
p.px = clampFx(p.px + p.vx * dt, 0, maxPx);
|
|
436
|
+
if (p.px === 0 || p.px === maxPx) p.vx = 0; // wall contact
|
|
437
|
+
|
|
438
|
+
// 3. JUMP --------------------------------------------------------------
|
|
439
|
+
if (p.coyote > 0) p.coyote -= 1;
|
|
440
|
+
if (p.jumpBuffer > 0) p.jumpBuffer -= 1;
|
|
441
|
+
if (input.jump) p.jumpBuffer = JUMP_BUFFER_TICKS; // buffer the press
|
|
442
|
+
const canJump = (p.onGround || p.coyote > 0);
|
|
443
|
+
if (p.jumpBuffer > 0 && canJump) {
|
|
444
|
+
p.vy = JUMP_VY;
|
|
445
|
+
p.onGround = false;
|
|
446
|
+
p.coyote = 0;
|
|
447
|
+
p.jumpBuffer = 0;
|
|
448
|
+
p.jumpHeld = true;
|
|
264
449
|
}
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
450
|
+
// Variable height: holding through the rise = full jump (~3 cells); letting
|
|
451
|
+
// go while still rising applies a stronger cut-gravity → a short hop.
|
|
452
|
+
if (!input.jump && p.vy < 0) p.jumpHeld = false;
|
|
453
|
+
if (p.vy >= 0) p.jumpHeld = false;
|
|
454
|
+
|
|
455
|
+
// 4. GRAVITY + SWEPT LANDING ------------------------------------------
|
|
456
|
+
if (!p.onGround) {
|
|
457
|
+
const rising = p.vy < 0;
|
|
458
|
+
const g = rising && !p.jumpHeld ? JUMP_CUT_GRAVITY : GRAVITY;
|
|
459
|
+
p.vy = Math.min(p.vy + g * dt, TERMINAL_VY);
|
|
460
|
+
const prevPy = p.py;
|
|
461
|
+
let nextPy = p.py + p.vy * dt;
|
|
462
|
+
const c = cell(p.px);
|
|
463
|
+
if (p.vy < 0) {
|
|
464
|
+
// Rising: bonk a girder directly overhead (can't pass up THROUGH one).
|
|
465
|
+
const headRow = cell(prevPy) - 1;
|
|
466
|
+
if (headRow >= 0 && isGirderAt(platforms, c, headRow)) {
|
|
467
|
+
// stop the rise just below the girder (stand row of THIS body unchanged)
|
|
468
|
+
p.py = toFx(cell(prevPy));
|
|
469
|
+
p.vy = 0;
|
|
470
|
+
p.jumpHeld = false;
|
|
471
|
+
} else {
|
|
472
|
+
p.py = clampFx(nextPy, 0, maxPy);
|
|
473
|
+
}
|
|
474
|
+
} else if (p.vy > 0) {
|
|
475
|
+
// Falling: land on the HIGHEST girder top crossed top-down this tick
|
|
476
|
+
// (prevPy at/above the stand row, nextPy at/below it → a real landing).
|
|
477
|
+
const y0 = Math.max(0, floorCell(prevPy));
|
|
478
|
+
const y1 = Math.min(BOARD_H - 1, floorCell(nextPy) + 1);
|
|
479
|
+
let landRow = -1;
|
|
480
|
+
for (let gy = y0; gy <= y1; gy++) {
|
|
481
|
+
if (!isGirderAt(platforms, c, gy)) continue;
|
|
482
|
+
const stand = gy - 1; // stand ON TOP of the girder
|
|
483
|
+
if (prevPy <= toFx(stand) && nextPy >= toFx(stand)) { landRow = stand; break; }
|
|
484
|
+
}
|
|
485
|
+
if (landRow >= 0) {
|
|
486
|
+
p.py = toFx(landRow);
|
|
487
|
+
p.vy = 0;
|
|
488
|
+
p.onGround = true;
|
|
489
|
+
p.jumpHeld = false;
|
|
490
|
+
} else {
|
|
491
|
+
p.py = clampFx(nextPy, 0, maxPy);
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
p.py = clampFx(nextPy, 0, maxPy);
|
|
495
|
+
}
|
|
272
496
|
} else {
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
497
|
+
// Grounded: follow the slope at the current column, detect walk-off.
|
|
498
|
+
const c = cell(p.px);
|
|
499
|
+
const gi = girderIndexAt(platforms, c, cell(p.py) + 1);
|
|
500
|
+
if (gi >= 0) {
|
|
501
|
+
p.py = toFx(platforms[gi].slopeOffsets[c] - 1); // re-snap to slope
|
|
502
|
+
p.vy = 0;
|
|
278
503
|
} else {
|
|
279
|
-
|
|
504
|
+
// walked off the edge → coyote window, begin falling
|
|
505
|
+
p.onGround = false;
|
|
506
|
+
p.coyote = COYOTE_TICKS;
|
|
280
507
|
}
|
|
281
508
|
}
|
|
282
509
|
}
|
|
283
|
-
|
|
284
|
-
jm.y = Math.max(0, Math.min(BOARD_H - 1, jm.y));
|
|
510
|
+
syncCell(p);
|
|
285
511
|
|
|
286
|
-
//
|
|
512
|
+
// 5. DK THROW --------------------------------------------------------------
|
|
287
513
|
const dk = s.donkeyKong;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
} else {
|
|
514
|
+
dk.throwCooldown -= 1;
|
|
515
|
+
if (dk.throwCooldown <= 0) {
|
|
291
516
|
const top = platforms[platforms.length - 1];
|
|
517
|
+
const jitter = rnd() < 0.5 ? 0 : 1; // seeded ±1 col so barrels don't stack
|
|
518
|
+
const spawnCol = Math.min(BOARD_W - 1, dk.x + 1 + jitter);
|
|
292
519
|
s.barrels.push({
|
|
293
|
-
id: s.nextBarrelId
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
vx:
|
|
520
|
+
id: s.nextBarrelId++,
|
|
521
|
+
px: toFx(spawnCol),
|
|
522
|
+
py: toFx(top.slopeOffsets[spawnCol] - 1),
|
|
523
|
+
vx: barrelSpeedForLevel(s.level), // roll right
|
|
524
|
+
vy: 0,
|
|
297
525
|
falling: false,
|
|
526
|
+
onLadder: false,
|
|
298
527
|
scored: false,
|
|
528
|
+
spin: 0,
|
|
529
|
+
x: spawnCol,
|
|
530
|
+
y: top.slopeOffsets[spawnCol] - 1,
|
|
299
531
|
});
|
|
300
|
-
s.
|
|
532
|
+
if (s.barrels.length > MAX_BARRELS) s.barrels.shift(); // cap (oldest despawns)
|
|
301
533
|
dk.throwCooldown = cooldownForLevel(s.level);
|
|
534
|
+
dk.animPhase ^= 1; // chest-beat juice (only on a throw)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// 6/7. BARRELS -------------------------------------------------------------
|
|
538
|
+
advanceBarrels(s, rnd);
|
|
539
|
+
|
|
540
|
+
// 8. COLLISIONS + SCORING --------------------------------------------------
|
|
541
|
+
const pCol = cell(p.px), pRow = cell(p.py);
|
|
542
|
+
for (const b of s.barrels) {
|
|
543
|
+
const bCol = cell(b.px), bRow = cell(b.py);
|
|
544
|
+
const endOn = bCol === pCol && bRow === pRow;
|
|
545
|
+
// Swept overlap using pre-move floor cells (no tunneling).
|
|
546
|
+
const bPrevCol = b._pCol ?? bCol, bPrevRow = b._pRow ?? bRow;
|
|
547
|
+
const sameRowSweep = (bRow === pRow || bPrevRow === pRow) && between(pCol, bPrevCol, bCol);
|
|
548
|
+
const sameColSweep = (bCol === pCol || bPrevCol === pCol) && between(pRow, bPrevRow, bRow);
|
|
549
|
+
const playerSweep = bRow === pRow && between(bCol, p._prevCol ?? pCol, pCol);
|
|
550
|
+
const hit = endOn || sameRowSweep || sameColSweep || playerSweep;
|
|
551
|
+
if (hit && p.invuln === 0 && p.alive) {
|
|
552
|
+
s.lives -= 1;
|
|
553
|
+
s.status = "dying";
|
|
554
|
+
s.statusTimer = DEATH_TICKS;
|
|
555
|
+
s.message = "ouch! barrel hit";
|
|
556
|
+
p.vy = -90; // death-pop kick
|
|
557
|
+
rnd.flush();
|
|
558
|
+
return finalize(s, s.rngSeed);
|
|
559
|
+
}
|
|
560
|
+
// Jump-over credit: airborne, barrel below + shares column, once per barrel.
|
|
561
|
+
const airborne = !p.onGround || p.vy !== 0;
|
|
562
|
+
if (!b.scored && airborne && (bCol === pCol || bPrevCol === pCol) && bRow > pRow) {
|
|
563
|
+
b.scored = true;
|
|
564
|
+
s.score += 10;
|
|
565
|
+
s.best = Math.max(s.best, s.score) >>> 0;
|
|
566
|
+
s.message = "+10 jumped!";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 9. BONUS DRAIN -----------------------------------------------------------
|
|
571
|
+
s.bonusDrainTick += 1;
|
|
572
|
+
if (s.bonusDrainTick >= BONUS_TICK) {
|
|
573
|
+
s.bonusDrainTick = 0;
|
|
574
|
+
s.bonus = Math.max(0, s.bonus - BONUS_DRAIN);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 10. WIN ------------------------------------------------------------------
|
|
578
|
+
const dxP = Math.abs(pCol - s.princess.x);
|
|
579
|
+
const dyP = Math.abs(pRow - s.princess.y);
|
|
580
|
+
if (dxP <= PRINCESS_REACH && dyP <= 1) {
|
|
581
|
+
s.status = "levelclear";
|
|
582
|
+
s.statusTimer = LEVELCLEAR_TICKS;
|
|
583
|
+
s.score += 100;
|
|
584
|
+
s.best = Math.max(s.best, s.score) >>> 0;
|
|
585
|
+
s.message = "YOU SAVED HER! +100";
|
|
586
|
+
rnd.flush();
|
|
587
|
+
return finalize(s, s.rngSeed);
|
|
302
588
|
}
|
|
303
589
|
|
|
304
|
-
//
|
|
590
|
+
// 11. invuln decay + record prev cells for next tick's sweep
|
|
591
|
+
if (p.invuln > 0) p.invuln -= 1;
|
|
592
|
+
p._prevCol = pCol;
|
|
593
|
+
rnd.flush();
|
|
594
|
+
return finalize(s, s.rngSeed);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Advance every barrel one tick (roll / fall / descend ladders) in fx. */
|
|
598
|
+
function advanceBarrels(s, rnd) {
|
|
599
|
+
const { platforms, ladders, BOARD_W, BOARD_H } = s;
|
|
305
600
|
const ladderChance = ladderChanceForLevel(s.level);
|
|
601
|
+
const maxPx = toFx(BOARD_W - 1);
|
|
306
602
|
const surviving = [];
|
|
307
603
|
for (const b of s.barrels) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
604
|
+
b._pCol = cell(b.px); b._pRow = cell(b.py); // pre-move cells for the sweep
|
|
605
|
+
b.spin = (b.spin + 1) & 0xffff;
|
|
606
|
+
const c = cell(b.px);
|
|
311
607
|
if (b.falling) {
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
b.
|
|
608
|
+
b.vy = Math.min((b.vy || 0) + GRAVITY, TERMINAL_VY);
|
|
609
|
+
const nextPy = b.py + b.vy;
|
|
610
|
+
const gi = girderIndexAt(platforms, c, floorCell(nextPy) + 1) >= 0
|
|
611
|
+
? girderIndexAt(platforms, c, floorCell(nextPy) + 1)
|
|
612
|
+
: girderBelow(platforms, c, cell(b.py) + 1);
|
|
613
|
+
const landRow = gi >= 0 ? platforms[gi].slopeOffsets[c] - 1 : -1;
|
|
614
|
+
if (landRow >= 0 && nextPy >= toFx(landRow) && toFx(landRow) >= b.py - 1) {
|
|
615
|
+
b.py = toFx(landRow);
|
|
616
|
+
b.vy = 0;
|
|
317
617
|
b.falling = false;
|
|
318
|
-
|
|
618
|
+
b.onLadder = false;
|
|
319
619
|
} else {
|
|
320
|
-
b.
|
|
620
|
+
b.py = nextPy;
|
|
321
621
|
}
|
|
322
622
|
} else {
|
|
323
|
-
//
|
|
324
|
-
const
|
|
325
|
-
if (
|
|
623
|
+
// On a girder: maybe descend a ladder it's over.
|
|
624
|
+
const lad = ladderAt(ladders, c, cell(b.py) + 1);
|
|
625
|
+
if (lad && rnd() < ladderChance) {
|
|
326
626
|
b.falling = true;
|
|
327
|
-
b.
|
|
627
|
+
b.onLadder = true;
|
|
628
|
+
b.px = toFx(lad.col);
|
|
629
|
+
b.vy = GRAVITY;
|
|
630
|
+
b.py = b.py + b.vy;
|
|
328
631
|
} else {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
b.vx = -b.vx;
|
|
632
|
+
const nextPx = b.px + b.vx;
|
|
633
|
+
const nc = cell(nextPx);
|
|
634
|
+
if (nextPx < 0 || nextPx > maxPx) {
|
|
635
|
+
b.vx = -b.vx; // bounce off the wall and fall to the girder below
|
|
333
636
|
b.falling = true;
|
|
334
|
-
b.
|
|
637
|
+
b.vy = GRAVITY;
|
|
638
|
+
b.py = b.py + b.vy;
|
|
335
639
|
} else {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const nextRow = platforms[giHere].slopeOffsets[nx];
|
|
341
|
-
b.x = nx;
|
|
342
|
-
b.y = nextRow - 1;
|
|
640
|
+
const giHere = girderIndexAt(platforms, c, cell(b.py) + 1);
|
|
641
|
+
if (giHere >= 0 && nc >= 0 && nc < BOARD_W && platforms[giHere].slopeOffsets[nc] !== undefined) {
|
|
642
|
+
b.px = nextPx;
|
|
643
|
+
b.py = toFx(platforms[giHere].slopeOffsets[nc] - 1); // follow slope
|
|
343
644
|
} else {
|
|
344
|
-
//
|
|
345
|
-
b.x = nx;
|
|
645
|
+
b.px = nextPx; // rolled off the end → fall
|
|
346
646
|
b.falling = true;
|
|
347
|
-
b.
|
|
647
|
+
b.vy = GRAVITY;
|
|
648
|
+
b.py = b.py + b.vy;
|
|
348
649
|
}
|
|
349
650
|
}
|
|
350
651
|
}
|
|
351
652
|
}
|
|
352
|
-
|
|
653
|
+
syncCell(b);
|
|
654
|
+
if (cell(b.py) < BOARD_H) surviving.push(b); // despawn below the board
|
|
353
655
|
}
|
|
354
656
|
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
657
|
}
|
|
395
658
|
|
|
396
659
|
function finalize(s, seed) {
|
|
@@ -398,7 +661,8 @@ function finalize(s, seed) {
|
|
|
398
661
|
return s;
|
|
399
662
|
}
|
|
400
663
|
|
|
401
|
-
|
|
664
|
+
// ---- nextLevel / serialize -------------------------------------------------
|
|
665
|
+
/** Build the next level after a win, carrying score + lives + best. */
|
|
402
666
|
export function nextLevel(state, dims) {
|
|
403
667
|
return initGame({
|
|
404
668
|
width: dims.width,
|
|
@@ -406,8 +670,25 @@ export function nextLevel(state, dims) {
|
|
|
406
670
|
level: state.level + 1,
|
|
407
671
|
score: state.score,
|
|
408
672
|
lives: state.lives,
|
|
673
|
+
best: state.best,
|
|
674
|
+
status: "getready",
|
|
409
675
|
});
|
|
410
676
|
}
|
|
411
677
|
|
|
412
|
-
|
|
413
|
-
|
|
678
|
+
/** serialize/deserialize: the state is plain JSON already (Int32 fx + arrays).
|
|
679
|
+
* serialize drops the `jumpman` alias (it duplicates `player`); deserialize and
|
|
680
|
+
* stepGame re-point it, so round-tripping is byte-stable and compact. */
|
|
681
|
+
export function serialize(state) {
|
|
682
|
+
const { jumpman, ...rest } = state; // jumpman === player; don't double-encode
|
|
683
|
+
return JSON.parse(JSON.stringify(rest));
|
|
684
|
+
}
|
|
685
|
+
export function deserialize(obj) {
|
|
686
|
+
obj.jumpman = obj.player; // re-establish the renderer alias
|
|
687
|
+
return obj;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Expose internals for focused unit tests.
|
|
691
|
+
export const _internals = {
|
|
692
|
+
girderBelow, isGirderAt, girderIndexAt, ladderAt, rng, between,
|
|
693
|
+
makePlayer, advanceBarrels, syncCell,
|
|
694
|
+
};
|