Compare commits

10 Commits

Author SHA1 Message Date
Thomas
4407932a2d Add Mars surface atmosphere particels 2026-03-05 19:21:41 +00:00
Thomas
635869f226 Update shell 2026-03-05 17:38:46 +00:00
Thomas
b54a53b9c8 Fix jetpack use resetting recharge 2026-03-05 17:24:20 +00:00
Thomas
27691a28dd Improve levlegen 2026-03-05 17:22:21 +00:00
Thomas
6b32199f25 Disable origin cache headers to prevent stale JS/WASM mismatch
static-web-server's default cache-control sends max-age=31536000 (1 year)
for .js files but only 1 day for .wasm. After redeployment, Cloudflare CDN
serves the cached old .js with a fresh .wasm, causing EM_ASM address table
mismatches and runtime crashes. Disable built-in cache headers at the origin
so Cloudflare respects new content on each deploy.

Also update AGENTS.md: add deploy commands, fix emsdk path, document the
Cloudflare cache-purge requirement, and correct stale MAX_ENTITY_SPAWNS
and MAX_EXIT_ZONES values.
2026-03-02 21:33:07 +00:00
Thomas
b3055f4bd3 Add TODO: elevator and teleporter level transition styles 2026-03-02 21:07:30 +00:00
Thomas
a97c9b5aaf Add TODO: skip spacecraft transition for non-surface levels 2026-03-02 21:06:47 +00:00
Thomas
46209b94bb Add TODO: Mars Surface ambient dust particles 2026-03-02 21:03:16 +00:00
Thomas
b5cdf1804f Add bouncer launch pad to design document 2026-03-02 21:02:07 +00:00
Thomas
492f13306d Add TODO: editor level select should also load into game 2026-03-02 20:58:03 +00:00
12 changed files with 601 additions and 67 deletions

View File

@@ -21,7 +21,9 @@ make DEBUG=1 # Alternative debug flag
make web # WASM build → dist-web/
make web-serve # WASM build + HTTP server on :8080
make windows # Cross-compile → dist-win64/
make k8s # Build web + container image + deploy to local k3s
make clean # Remove all build artifacts
./deploy.sh # Full deploy: clean build → container → k3s rollout
```
Compiler flags: `-Wall -Wextra -std=c11 -I include -I src`
@@ -33,11 +35,15 @@ There are no test or lint targets. Verify changes by building with `make` and co
- **WASM builds** require the Emscripten SDK. The `emsdk/` directory in the project root is
gitignored; source the environment before building:
```bash
source ~/emsdk/emsdk_env.sh # or wherever emsdk is installed
source emsdk/emsdk_env.sh
make web
```
- **Windows cross-compilation** requires MinGW (`x86_64-w64-mingw32-gcc`) and vendored
SDL2 development libraries in `deps/win64/` (also gitignored).
- **Web deployment** goes through Cloudflare CDN (`jnr.schick-web.site`). After deploying
a new build, **purge the Cloudflare cache** so stale `.js`/`.wasm` files are not served.
Emscripten's `.js` and `.wasm` outputs are tightly coupled (EM_ASM address tables must
match); serving a cached `.js` with a fresh `.wasm` causes runtime crashes.
## Project Structure
@@ -264,5 +270,5 @@ incremental progress.
| `TICK_RATE` | 60 | Fixed timestep Hz |
| `DEFAULT_GRAVITY` | 980.0f | px/s² |
| `MAX_ENTITIES` | 512 | Entity pool size |
| `MAX_ENTITY_SPAWNS` | 128 | Per-level spawn slots |
| `MAX_EXIT_ZONES` | 8 | Per-level exit zones |
| `MAX_ENTITY_SPAWNS` | 512 | Per-level spawn slots |
| `MAX_EXIT_ZONES` | 16 | Per-level exit zones |

View File

@@ -18,3 +18,9 @@
FROM docker.io/joseluisq/static-web-server:2
COPY dist-web/ /public/
# Disable the default cache-control headers which cache .js for 1 year.
# The .js and .wasm files must always be fetched together (EM_ASM address
# table in JS must match the compiled WASM), so aggressive caching causes
# "No EM_ASM constant found" errors after redeployments.
ENV SERVER_CACHE_CONTROL_HEADERS=false

223
DESIGN.md
View File

@@ -83,11 +83,37 @@ Already implemented: `GRAVITY`, `WIND`, `BG_COLOR`, `MUSIC`, `PARALLAX_FAR`, `PA
- **Laser Turret** — State machine (IDLE → CHARGING → FIRING → COOLDOWN). Per-pixel beam raycast. Fixed variant aims left; tracking variant rotates toward player at 1.5 rad/s.
### Planned
- **Robot** — Slow, heavy ground patrol. Sturdy (4 HP), armored appearance.
Walks deliberately, doesn't flinch from knockback. Punishes careless
approaches — player must keep distance or use high-damage weapons.
Exclusive to space station levels.
- **Rocket Turret** — Stationary launcher, two-stage attack. Stage 1: rocket
plops up out of the turret with a visible arc (telegraph, ~0.6 s hang time),
giving the player time to react. Stage 2: rocket ignites boosters and tracks
the player with homing guidance. Moderate turn rate so skilled players can
dodge or bait it into walls. Destroyable in flight. Exclusive to space
station levels.
- **Shielder** — Has a directional shield, must be hit from behind or above
- **Boss** — Large, multi-phase encounters. One per world area.
---
## Hazards & Mechanics
### Implemented
- **Flame Vent** — Floor-mounted grate, toggles on/off on a timer
- **Force Field** — Vertical energy barrier, toggled by switch/timer
- **Moving Platform** — Horizontal/vertical patrol between two points
- **Laser Turret** — See Enemies above (also functions as a hazard)
### Planned
- **Bouncer** — Launch pad that shoots the player into the air on contact.
Two variants: straight (vertical impulse only) and angled (rotatable,
placed at arbitrary angles to launch the player diagonally or sideways).
Could also affect enemies and projectiles for puzzle potential.
---
## Weapons / Projectiles
Data-driven system: each weapon type is a `ProjectileDef` struct describing speed,
@@ -137,6 +163,32 @@ Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `WIND`, `BG_COLOR`, `
7. **Space Freighter** — Normal gravity, tight corridors, turret enemies
8. **Ice World** — Normal gravity, strong winds, slippery surface
### Old Space Station Campaign
Abandoned orbital station overrun by malfunctioning security systems. Only
robotic enemies remain — no organic creatures. Cold, industrial aesthetic
with metal walls, exposed pipes, warning lights, and airlock doors.
**Enemy roster (station-exclusive):**
- Robots — slow, heavy patrols that absorb punishment
- Turrets — standard rotating turrets from earlier levels
- Rocket Turrets — two-stage homing rockets (plop-up telegraph → boost + track)
**Level sequence:**
- **station01.lvl** (Docking Bay) — Spacecraft lands at the station exterior.
Normal gravity, wide open hangar area easing the player in. A few robots
and a turret introduce the new enemy types. Exit leads inside.
- **station02.lvl** (Reactor Core) — Vertical level, tight corridors around
a central reactor shaft. Rocket turrets cover long sightlines, robots
block narrow passages. Elevator transition into generated levels.
- **generate:old_station** (Security Decks) — Procedurally generated
interior levels (2-3 before the boss arena). Increasing density of
robots, turrets, and rocket turrets. Tight rooms, low ceilings,
interlocking corridors. Difficulty scales with depth.
- **station03.lvl** (Command Bridge / Boss Arena) — Final station level.
Large arena with a boss encounter (station security chief or haywire
defense mainframe). Heavy use of rocket turrets as stage hazards.
---
## World Map
@@ -215,6 +267,177 @@ Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `WIND`, `BG_COLOR`, `
---
## Game Analytics & Highscores
Track comprehensive play session analytics and submit them to a backend service
for leaderboards and gameplay insights. Data flows from C → JS (via EM_JS) →
backend API (via fetch). Desktop builds can write to a local file as fallback.
### Metrics to track (GameStats struct)
**Per-run (reset on new game / restart):**
| Metric | Type | Notes |
|-------------------------|----------|---------------------------------------------|
| `levels_completed` | int | Incremented on each level exit trigger |
| `enemies_killed` | int | Total kills across all levels |
| `kills_by_type[N]` | int[] | Kills broken down by enemy type |
| `deaths` | int | Player death/respawn count |
| `time_elapsed_ms` | uint32_t | Wall-clock play time (accumulate dt) |
| `shots_fired` | int | Total projectiles spawned by player |
| `shots_hit` | int | Player projectiles that connected with enemy |
| `damage_taken` | int | Total HP lost (before death resets) |
| `damage_dealt` | int | Total HP dealt to enemies |
| `dashes_used` | int | Dash activations |
| `jumps` | int | Jump count |
| `distance_traveled` | float | Horizontal pixels traversed |
| `pickups_collected` | int | Health, jetpack, weapon pickups |
| `longest_kill_streak` | int | Max kills without taking damage |
| `current_kill_streak` | int | (internal, not submitted) |
**Per-level snapshot (ring buffer or array, flushed on level exit):**
- Level name / generator tag
- Time spent in level
- Kills in level
- Deaths in level
- Health remaining on exit
### Data flow
```
C (GameStats) JS (shell.html) Backend API
│ │ │
├─ on victory/game-over ────→│ │
│ EM_JS: submit_run() ├── POST /api/runs ────────→│
│ │ { stats JSON } │── store in DB
│ │ │
├─ on leaderboard open ─────→│ │
│ EM_JS: fetch_leaderboard()├── GET /api/leaderboard ──→│
│ │←─ JSON [ top N runs ] ─────│
│←─ KEEPALIVE callback ──────│ │
│ write to C memory │ │
```
### Backend service
Small HTTP API deployed alongside the game on k3s. Receives run data as
JSON, stores in a lightweight DB (SQLite or Postgres), serves leaderboard
queries. Endpoints:
- `POST /api/runs` — submit a completed run (all metrics above)
- `GET /api/leaderboard` — top N runs, sortable by score/time/kills
- `GET /api/stats` — aggregate stats (total runs, total kills, avg time)
A composite score formula ranks runs for the leaderboard, e.g.:
`score = (enemies_killed * 100) + (levels_completed * 500) - (deaths * 200) - (time_elapsed_ms / 1000)`
### Integration points in C
- **GameStats struct** in `main.c` or a new `src/game/stats.h` module
- **Kill counting:** hook into entity death in `level.c` damage handling
- **Shot tracking:** increment in `player.c` shoot logic
- **Time tracking:** accumulate `dt` each frame in `MODE_PLAY`
- **Distance:** accumulate abs(vel.x * dt) each frame
- **Jumps/dashes:** increment in `player.c` at point of activation
- **Level snapshots:** capture on exit trigger before level_free
- **Submission:** call `EM_JS` function from the victory path in `main.c`
### Anti-tamper: HMAC signature
All submissions are signed client-side in C/WASM before reaching JS,
preventing casual forgery (console injection, proxy interception, modified
payloads). The signature lives in compiled WASM — not trivially visible
like plain JS — raising the effort required to cheat.
**Scheme: HMAC-SHA256**
1. A shared secret key is embedded in the C source (compiled into WASM).
Not truly hidden from a determined reverse-engineer, but opaque to
casual inspection.
2. On submission, the C code:
- Serializes the `GameStats` struct to a canonical JSON string
- Requests a one-time nonce from the backend (`GET /api/nonce`)
- Computes `HMAC-SHA256(secret, nonce + json_payload)`
- Passes the JSON, nonce, and hex signature to JS via `EM_JS`
3. JS sends all three to the backend in the POST body.
4. Backend recomputes the HMAC with its copy of the secret, verifies the
signature matches, and checks the nonce hasn't been used before (replay
protection).
**What this prevents:**
- Browser console `fetch("/api/runs", { body: fakeStats })` — no valid sig
- Proxy/MITM payload modification — signature won't match
- Replay attacks — nonce is single-use
**What this does NOT prevent:**
- Reverse-engineering the WASM binary to extract the key
- Memory editing during gameplay (modifying stats before signing)
- A fully custom WASM build that signs fabricated data
**Implementation:**
- SHA-256 and HMAC can be implemented in ~200 lines of C (no external
dependency). Alternatively use a small embedded library like micro-ecc
or TweetNaCl.
- The secret key should be split across multiple static arrays and
reassembled at runtime to deter simple string scanning of the binary.
- Native builds (Linux/Windows) use the same HMAC logic, posting via
libcurl or a lightweight HTTP client.
### Anti-tamper: server-side plausibility checks
The backend validates every submission against known game constraints.
This is independent of the HMAC and catches cheats that produce valid
signatures (memory edits, custom builds).
**Hard limits (reject immediately):**
- `levels_completed` cannot exceed the total campaign length
- `enemies_killed` cannot exceed max possible spawns per level chain
- `shots_hit` cannot exceed `shots_fired`
- `damage_dealt` cannot exceed `enemies_killed * max_enemy_hp`
- `accuracy` (`shots_hit / shots_fired`) above 99% is flagged
- `time_elapsed_ms` below a minimum threshold per level is impossible
(speed-of-light check: level_width / max_player_speed)
**Soft limits (flag for review, don't reject):**
- Zero deaths across the entire campaign
- Kill streak equal to total kills (never took damage)
- Unusually low time relative to levels completed
- Distance traveled below expected minimum for the level chain
**Rate limiting:**
- Max 1 submission per IP per 60 seconds
- Max 10 submissions per IP per hour
- Duplicate payload detection (exact same stats = reject)
**Schema:**
```
runs table:
id, player_name, submitted_at, ip_hash,
signature_valid (bool), plausibility_flags (bitmask),
levels_completed, enemies_killed, kills_by_type (json),
deaths, time_elapsed_ms, shots_fired, shots_hit,
damage_taken, damage_dealt, dashes_used, jumps,
distance_traveled, pickups_collected, longest_kill_streak,
level_snapshots (json), composite_score
```
Flagged runs are stored but excluded from the public leaderboard.
### Requirements before this works
1. The game needs an ending — either a final boss level with empty exit
target, or a depth limit on the station generator
2. A `MODE_VICTORY` game state showing final stats + leaderboard
3. The backend service (container image + k8s manifests)
4. Player name input (simple text prompt in JS, or anonymous with a
generated handle)
5. HMAC-SHA256 implementation in C (or vendored micro-library)
6. Nonce endpoint on the backend
7. Plausibility rule set derived from game constants (MAX_ENTITIES,
level chain length, player speed, enemy HP values)
---
## Reference Games
- Jazz Jackrabbit 2 (movement feel, weapon variety, level design)
- Metal Slug (run-and-gun, enemy variety, visual flair)

View File

@@ -13,7 +13,7 @@ ifdef WASM
-sSDL2_IMAGE_FORMATS='["png"]' \
-sSDL2_MIXER_FORMATS='["ogg"]' \
-sALLOW_MEMORY_GROWTH=1 \
-sEXPORTED_FUNCTIONS='["_main","_editor_upload_flag_ptr","_editor_save_flag_ptr","_editor_load_flag_ptr","_editor_load_vfs_file","_malloc","_free"]' \
-sEXPORTED_FUNCTIONS='["_main","_editor_upload_flag_ptr","_editor_save_flag_ptr","_editor_load_flag_ptr","_editor_load_vfs_file","_game_load_level","_malloc","_free"]' \
-sEXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \
--preload-file assets \
--shell-file web/shell.html

30
TODO.md
View File

@@ -138,3 +138,33 @@ Mars themes in all generic segment generators.
- Makefile web-serve: removed `2>/dev/null` so real errors are visible.
- `s_mars_depth` and `s_station_depth` reset when game loops back to beginning.
- Added `gen_bg_decoration()` call to Mars Base generator.
## ~~Editor: level select should also load into game~~ ✓
Implemented: shell dropdown calls `game_load_level()` (exported from `main.c`)
which defers a level load into MODE_PLAY on the next frame. Tears down the
current mode (editor or play), loads the selected `.lvl` file, and seeds
`s_edit_path` so pressing E opens the same level in the editor.
## ~~Mars Surface atmosphere particles~~ ✓
Implemented: `particle_emit_atmosphere_dust()` emits ambient dust motes each
frame on Mars Surface levels (keyed on `PARALLAX_STYLE_MARS`). Three
sub-layers for depth: large slow "far" motes, small quick "near" specks,
and occasional interior spawns to prevent edge seams. All particles drift
with the wind system via `gravity_scale` — wind pushes them across the
viewport. Reddish-tan color palette with per-particle variation. Low drag,
long lifetimes (3-7 s), subtle alpha fade. ~2-3 particles/frame at 60 Hz,
well within the 1024-particle pool budget.
## Skip spacecraft transition for non-surface levels
The spacecraft fly-in animation should only play on surface levels (moon01,
mars01, etc.). Interior/base levels (mars02, mars03, generated mars_base,
generated station) should skip it — the player is already indoors.
## New level transition styles: elevator and teleporter
Two new transition animations to complement the spacecraft fly-in:
- **Elevator** — Doors slide shut, brief pause (screen shake / rumble),
doors slide open onto the new level. Good for base/station interior
transitions (mars02 → mars_base, between generated station levels).
- **Teleporter** — Energy charge-up effect around the player, flash/warp
distortion, player materialises in the new level. Good for cross-planet
jumps or generated-to-handcrafted transitions.

View File

@@ -529,3 +529,65 @@ void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir) {
};
particle_emit(&dust);
}
/* Spawn a single dust mote with the given visual properties. */
static void spawn_dust_mote(Vec2 pos, Vec2 vel,
float life_min, float life_max,
float size_min, float size_max,
float drag, float gscale,
uint8_t r, uint8_t g, uint8_t b, int vary) {
Particle *p = alloc_particle();
p->pos = pos;
p->vel = vel;
p->life = randf_range(life_min, life_max);
p->max_life = p->life;
p->size = randf_range(size_min, size_max);
p->drag = drag;
p->gravity_scale = gscale;
p->active = true;
p->color.r = clamp_u8(r + (int)randf_range(-vary, vary));
p->color.g = clamp_u8(g + (int)randf_range(-vary, vary));
p->color.b = clamp_u8(b + (int)randf_range(-vary, vary));
p->color.a = 255; /* alpha applied during render from life ratio */
}
void particle_emit_atmosphere_dust(Vec2 cam_pos, Vec2 vp) {
/* Ambient Mars dust — subtle motes drifting across the viewport.
* Two sub-layers for depth: large slow "far" motes and small quick
* "near" specks. Wind carries them; gravity_scale controls how much
* environmental forces (wind + gravity) affect each particle.
* Particles spawn along the upwind viewport edge and drift inward;
* occasional interior spawns prevent a visible edge seam. */
float wind = physics_get_wind();
float margin = 32.0f;
float dir = (wind >= 0.0f) ? 1.0f : -1.0f; /* velocity sign */
/* Upwind edge X for the two edge-spawned layers */
float edge_far = (wind >= 0.0f) ? cam_pos.x - margin
: cam_pos.x + vp.x + margin;
float edge_near = (wind >= 0.0f) ? cam_pos.x - margin * 0.5f
: cam_pos.x + vp.x + margin * 0.5f;
/* Far dust motes — large, slow, translucent (1/frame) */
spawn_dust_mote(
vec2(edge_far, cam_pos.y + randf() * vp.y),
vec2(dir * randf_range(8.0f, 25.0f), randf_range(-6.0f, 6.0f)),
4.0f, 7.0f, 1.5f, 3.0f, 0.3f, 0.08f,
180, 140, 100, 25);
/* Near dust specks — small, quicker, brighter (1/frame) */
spawn_dust_mote(
vec2(edge_near, cam_pos.y + randf() * vp.y),
vec2(dir * randf_range(15.0f, 40.0f), randf_range(-10.0f, 10.0f)),
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
200, 160, 120, 20);
/* Occasional interior spawn — prevents edge seam on calm wind */
if (rand() % 3 == 0) {
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-5.0f, 5.0f), randf_range(-8.0f, 3.0f)),
3.0f, 6.0f, 1.0f, 2.5f, 0.4f, 0.06f,
160, 130, 95, 25);
}
}

View File

@@ -89,4 +89,9 @@ void particle_emit_muzzle_flash(Vec2 pos, Vec2 shoot_dir);
/* Wall slide dust (small puffs while scraping against a wall) */
void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir);
/* Ambient atmosphere dust (call each frame for Mars Surface levels).
* Spawns subtle dust motes around the camera viewport that drift with wind.
* cam_pos = camera top-left world position, vp = viewport size in pixels. */
void particle_emit_atmosphere_dust(Vec2 cam_pos, Vec2 vp);
#endif /* JNR_PARTICLE_H */

View File

@@ -578,6 +578,15 @@ void level_update(Level *level, float dt) {
}
}
/* Emit ambient atmosphere dust on Mars Surface levels before the
* particle update pass so new motes get their first physics step
* this frame — consistent with other per-frame emitters. */
if (level->map.parallax_style == PARALLAX_STYLE_MARS) {
particle_emit_atmosphere_dust(
level->camera.pos,
level->camera.viewport);
}
/* Update particles */
particle_update(dt);

View File

@@ -127,6 +127,72 @@ static void add_entity(Tilemap *map, const char *type, int tile_x, int tile_y) {
map->entity_spawn_count++;
}
/* ═══════════════════════════════════════════════════
* Segment boundary connectivity helpers
*
* After all segments are generated, ensure_passage()
* scans each column boundary and carves a passable
* opening if none exists. This prevents rooms from
* being sealed off by adjacent segment tile writes.
* ═══════════════════════════════════════════════════ */
/* Check if a column has a vertical run of at least `need` empty rows
* within [row_lo, row_hi]. Returns true if passable. */
static bool column_has_gap(const uint16_t *layer, int map_w,
int col, int row_lo, int row_hi, int need) {
int run = 0;
for (int y = row_lo; y <= row_hi; y++) {
if (layer[y * map_w + col] == TILE_EMPTY ||
layer[y * map_w + col] == TILE_PLAT) {
run++;
if (run >= need) return true;
} else {
run = 0;
}
}
return false;
}
/* Carve a 2-column-wide, 4-row-tall opening centered on ground_row.
* The opening spans columns [col, col+1] and rows [ground_row-3, ground_row].
* This guarantees the player (12x16 px, ~1x2 tiles) can walk through. */
static void carve_passage(uint16_t *layer, int map_w, int map_h,
int col, int ground_row) {
int top = ground_row - 3;
if (top < 1) top = 1;
int bot = ground_row;
if (bot >= map_h) bot = map_h - 1;
for (int y = top; y <= bot; y++) {
set_tile(layer, map_w, map_h, col, y, TILE_EMPTY);
set_tile(layer, map_w, map_h, col + 1, y, TILE_EMPTY);
}
}
/* Scan all segment boundaries and ensure connectivity.
* seg_x[] holds the starting x of each segment, seg_count entries.
* seg_ground[] holds the ground row for each segment. */
static void ensure_segment_connectivity(uint16_t *layer, int map_w, int map_h,
const int *seg_x, const int *seg_gr,
int seg_count) {
for (int i = 0; i < seg_count - 1; i++) {
int boundary_col = seg_x[i + 1]; /* first column of next segment */
int left_col = boundary_col - 1; /* last column of this segment */
int right_col = boundary_col;
/* Use the lower (larger row number) ground of the two segments
* so the opening is at floor level for both sides. */
int gr = (seg_gr[i] > seg_gr[i + 1]) ? seg_gr[i] : seg_gr[i + 1];
/* Check both columns at the boundary */
bool left_ok = column_has_gap(layer, map_w, left_col, 1, gr, 3);
bool right_ok = column_has_gap(layer, map_w, right_col, 1, gr, 3);
if (!left_ok || !right_ok) {
carve_passage(layer, map_w, map_h, left_col, gr - 1);
}
}
}
/* ═══════════════════════════════════════════════════
* Segment generators
*
@@ -323,15 +389,17 @@ static void gen_corridor(Tilemap *map, int x0, int w, int ground_row,
fill_rect(col, mw, mh, x0, ceil_row, x0, ground_row - 1, TILE_SOLID_1);
fill_rect(col, mw, mh, x0 + w - 1, ceil_row, x0 + w - 1, ground_row - 1, TILE_SOLID_1);
/* Opening in left wall (1 tile above ground to enter) */
set_tile(col, mw, mh, x0, ground_row - 1, TILE_EMPTY);
set_tile(col, mw, mh, x0, ground_row - 2, TILE_EMPTY);
set_tile(col, mw, mh, x0, ground_row - 3, TILE_EMPTY);
/* Opening in left wall — 2 tiles wide so adjacent segment can't seal it */
for (int r = ground_row - 3; r < ground_row; r++) {
set_tile(col, mw, mh, x0, r, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, r, TILE_EMPTY);
}
/* Opening in right wall */
set_tile(col, mw, mh, x0 + w - 1, ground_row - 1, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, ground_row - 2, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, ground_row - 3, TILE_EMPTY);
/* Opening in right wall — 2 tiles wide */
for (int r = ground_row - 3; r < ground_row; r++) {
set_tile(col, mw, mh, x0 + w - 1, r, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, r, TILE_EMPTY);
}
/* Theme-dependent corridor hazards */
if (theme == THEME_MARS_BASE) {
@@ -492,14 +560,19 @@ static void gen_shaft(Tilemap *map, int x0, int w, int ground_row,
fill_rect(col, mw, mh, x0, ceil_row, x0, ground_row - 1, TILE_SOLID_1);
fill_rect(col, mw, mh, x0 + w - 1, ceil_row, x0 + w - 1, ground_row - 1, TILE_SOLID_1);
/* Opening at top */
/* Opening at top — 2 tiles wide on each side */
set_tile(col, mw, mh, x0, ceil_row, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, ceil_row, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, ceil_row, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, ceil_row, TILE_EMPTY);
/* Openings at bottom to enter */
/* Openings at bottom to enter — 2 tiles wide so adjacent segments
* cannot seal them by filling their edge column solid. */
for (int r = ground_row - 3; r < ground_row; r++) {
set_tile(col, mw, mh, x0, r, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, r, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, r, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, r, TILE_EMPTY);
}
/* Alternating platforms up the shaft */
@@ -1137,11 +1210,13 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
/* ── Phase 4: generate segments ── */
int cursor = 2; /* start after left buffer */
int mh = map->height;
int seg_start_x[MAX_FINAL_SEGS]; /* track segment x-offsets for connectivity pass */
/* Left border wall */
fill_rect(map->collision_layer, map->width, mh, 0, 0, 1, mh - 1, TILE_SOLID_1);
for (int i = 0; i < num_segs; i++) {
seg_start_x[i] = cursor;
int w = seg_widths[i];
LevelTheme theme = seg_themes[i];
int gr = seg_ground[i];
@@ -1172,6 +1247,10 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
fill_rect(map->collision_layer, map->width, mh,
map->width - 2, 0, map->width - 1, mh - 1, TILE_SOLID_1);
/* ── Phase 4b: ensure no segment boundary is sealed ── */
ensure_segment_connectivity(map->collision_layer, map->width, mh,
seg_start_x, seg_ground, num_segs);
/* ── Phase 5: add visual variety to solid tiles ── */
for (int y = 0; y < map->height; y++) {
for (int x = 0; x < map->width; x++) {
@@ -1333,11 +1412,14 @@ static void gen_station_bulkhead(Tilemap *map, int x0, int w, float difficulty)
int wall_x = x0 + w / 2;
fill_rect(col, mw, mh, wall_x, STATION_CEIL_ROW + 1, wall_x, STATION_FLOOR_ROW - 1, TILE_SOLID_2);
/* Doorway opening (3 tiles tall) */
int door_y = rng_range(STATION_CEIL_ROW + 3, STATION_FLOOR_ROW - 4);
for (int y = door_y; y < door_y + 3; y++) {
/* Doorway opening (4 tiles tall, always reachable from floor).
* Constrain the door bottom to touch the floor so the player
* can walk through without needing to jump. */
int door_top = STATION_FLOOR_ROW - 4;
for (int y = door_top; y < STATION_FLOOR_ROW; y++) {
set_tile(col, mw, mh, wall_x, y, TILE_EMPTY);
}
int door_y = door_top;
/* Turret guarding the doorway — always present */
add_entity(map, "turret", wall_x - 2, door_y - 1);
@@ -1382,13 +1464,21 @@ static void gen_station_platforms(Tilemap *map, int x0, int w, float difficulty)
}
}
/* Floating platforms across the gap */
/* Floating platforms across the gap.
* First and last platforms are pinned near floor level so the
* player can step onto/off the pit section from solid ground. */
int num_plats = rng_range(3, 5);
int spacing = (pit_end - pit_start) / (num_plats + 1);
if (spacing < 2) spacing = 2;
for (int i = 0; i < num_plats; i++) {
int px = pit_start + (i + 1) * spacing - 1;
int py = rng_range(STATION_CEIL_ROW + 4, STATION_FLOOR_ROW - 2);
int py;
if (i == 0 || i == num_plats - 1) {
/* Entry/exit platforms at floor level for walkability */
py = STATION_FLOOR_ROW - 1;
} else {
py = rng_range(STATION_CEIL_ROW + 4, STATION_FLOOR_ROW - 2);
}
int pw = rng_range(2, 3);
for (int j = 0; j < pw && px + j < x0 + w; j++) {
set_tile(col, mw, mh, px + j, py, TILE_PLAT);
@@ -1483,14 +1573,23 @@ static void gen_station_vent(Tilemap *map, int x0, int w, float difficulty) {
int vent_ceil = STATION_CEIL_ROW + 3;
fill_rect(col, mw, mh, x0, STATION_CEIL_ROW + 1, x0 + w - 1, vent_ceil, TILE_SOLID_2);
/* Opening at left */
/* Opening at left — both at vent level and at floor level so the
* player can enter from the standard corridor floor. */
for (int y = vent_ceil - 1; y <= vent_ceil + 2 && y < STATION_FLOOR_ROW; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
}
/* Opening at right */
for (int y = STATION_FLOOR_ROW - 3; y < STATION_FLOOR_ROW; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, y, TILE_EMPTY);
}
/* Opening at right — same treatment */
for (int y = vent_ceil - 1; y <= vent_ceil + 2 && y < STATION_FLOOR_ROW; y++) {
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
}
for (int y = STATION_FLOOR_ROW - 3; y < STATION_FLOOR_ROW; y++) {
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
/* Flame vents along the floor — always present, more at higher difficulty */
int num_vents = 1 + (int)(difficulty * 2);
@@ -1641,6 +1740,8 @@ bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
/* ── Phase 4: generate segments ── */
int cursor = 2;
int smh = map->height;
int sseg_start_x[20];
int sseg_ground[20];
/* Left border wall */
fill_rect(map->collision_layer, map->width, smh, 0, 0, 1, smh - 1, TILE_SOLID_1);
@@ -1650,6 +1751,8 @@ bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
};
for (int i = 0; i < num_segs; i++) {
sseg_start_x[i] = cursor;
sseg_ground[i] = STATION_FLOOR_ROW;
int w = seg_widths[i];
float diff = config->difficulty;
@@ -1670,6 +1773,10 @@ bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
fill_rect(map->collision_layer, map->width, smh,
map->width - 2, 0, map->width - 1, smh - 1, TILE_SOLID_1);
/* ── Phase 4b: ensure no segment boundary is sealed ── */
ensure_segment_connectivity(map->collision_layer, map->width, smh,
sseg_start_x, sseg_ground, num_segs);
/* ── Phase 5: visual variety ── */
for (int y = 0; y < map->height; y++) {
for (int x = 0; x < map->width; x++) {
@@ -1788,9 +1895,15 @@ static void gen_mb_entry(Tilemap *map, int x0, int w, float difficulty) {
fill_rect(col, mw, mh, x0, MB_CEIL_ROW + 1, x0, MB_MID_UPPER - 1, TILE_SOLID_1);
fill_rect(col, mw, mh, x0 + w - 1, MB_CEIL_ROW + 1, x0 + w - 1, MB_MID_UPPER - 1, TILE_SOLID_1);
/* Opening on the right wall to exit to the next segment */
for (int y = MB_MID_UPPER - 4; y < MB_MID_UPPER; y++) {
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
/* Openings on the right wall at all standard heights so the
* next segment is reachable regardless of its layout. */
int entry_open_rows[] = { MB_MID_UPPER, MB_MID_LOWER, MB_FLOOR_ROW };
for (int h = 0; h < 3; h++) {
int base = entry_open_rows[h];
for (int y = base - 4; y < base; y++) {
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
}
/* Health pickup */
@@ -1814,14 +1927,16 @@ static void gen_mb_shaft(Tilemap *map, int x0, int w, float difficulty) {
fill_rect(col, mw, mh, x0, MB_CEIL_ROW + 1, x0, MB_FLOOR_ROW - 1, TILE_SOLID_1);
fill_rect(col, mw, mh, x0 + w - 1, MB_CEIL_ROW + 1, x0 + w - 1, MB_FLOOR_ROW - 1, TILE_SOLID_1);
/* Openings at top and bottom of walls for connectivity */
for (int y = MB_CEIL_ROW + 1; y < MB_CEIL_ROW + 5; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
}
for (int y = MB_FLOOR_ROW - 4; y < MB_FLOOR_ROW; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
/* Openings at all standard heights — 2 tiles wide */
int shaft_open_rows[] = { MB_CEIL_ROW + 4, MB_MID_UPPER, MB_MID_LOWER, MB_FLOOR_ROW };
for (int h = 0; h < 4; h++) {
int base = shaft_open_rows[h];
for (int y = base - 4; y < base; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
}
/* Alternating platforms climbing up the shaft */
@@ -1916,10 +2031,25 @@ static void gen_mb_corridor(Tilemap *map, int x0, int w, float difficulty) {
/* Side walls with openings */
fill_rect(col, mw, mh, x0, ceil_row + 2, x0, floor_row - 1, TILE_SOLID_1);
fill_rect(col, mw, mh, x0 + w - 1, ceil_row + 2, x0 + w - 1, floor_row - 1, TILE_SOLID_1);
/* Door openings */
/* Door openings at this corridor's level — 2 tiles wide */
for (int y = floor_row - 4; y < floor_row; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
/* Also open at standard heights so adjacent segments at different
* levels can still be reached. Vertical shafts or platforms inside
* the adjacent segment handle the height transition. */
int mb_open_rows[] = { MB_MID_UPPER, MB_MID_LOWER, MB_FLOOR_ROW };
for (int h = 0; h < 3; h++) {
int base = mb_open_rows[h];
for (int y = base - 4; y < base; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
}
/* Charger patrol */
@@ -1971,18 +2101,16 @@ static void gen_mb_turret_hall(Tilemap *map, int x0, int w, float difficulty) {
set_tile(col, mw, mh, hole2_x + j, MB_MID_LOWER + 1, TILE_EMPTY);
}
/* Door openings on sides at each level */
for (int y = MB_MID_UPPER - 4; y < MB_MID_UPPER; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
}
for (int y = MB_MID_LOWER - 4; y < MB_MID_LOWER; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
}
for (int y = MB_FLOOR_ROW - 4; y < MB_FLOOR_ROW; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
/* Door openings on sides at all standard heights — 2 tiles wide */
int th_open_rows[] = { MB_MID_UPPER, MB_MID_LOWER, MB_FLOOR_ROW };
for (int h = 0; h < 3; h++) {
int base = th_open_rows[h];
for (int y = base - 4; y < base; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
}
/* Laser turrets on walls at each level — the gauntlet */
@@ -2040,14 +2168,16 @@ static void gen_mb_hive(Tilemap *map, int x0, int w, float difficulty) {
set_tile(col, mw, mh, hole_x + j, MB_MID_LOWER + 1, TILE_EMPTY);
}
/* Door openings on sides */
for (int y = MB_MID_LOWER - 4; y < MB_MID_LOWER; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
}
for (int y = MB_FLOOR_ROW - 4; y < MB_FLOOR_ROW; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
/* Door openings at all standard heights — 2 tiles wide */
int hive_open_rows[] = { MB_MID_UPPER, MB_MID_LOWER, MB_FLOOR_ROW };
for (int h = 0; h < 3; h++) {
int base = hive_open_rows[h];
for (int y = base - 4; y < base; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
}
/* Spawner in the upper area — the hive */
@@ -2114,14 +2244,16 @@ static void gen_mb_arena(Tilemap *map, int x0, int w, float difficulty) {
/* Ground floor for walking */
fill_rect(col, mw, mh, x0 + 1, MB_FLOOR_ROW - 1, x0 + w - 2, MB_FLOOR_ROW - 1, TILE_SOLID_1);
/* Door openings on sides */
for (int y = MB_FLOOR_ROW - 5; y < MB_FLOOR_ROW - 1; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
}
for (int y = MB_CEIL_ROW + 1; y < MB_CEIL_ROW + 5; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
/* Door openings at all standard heights — 2 tiles wide */
int arena_open_rows[] = { MB_CEIL_ROW + 4, MB_MID_UPPER, MB_MID_LOWER, MB_FLOOR_ROW };
for (int h = 0; h < 4; h++) {
int base = arena_open_rows[h];
for (int y = base - 4; y < base; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
}
/* Enemies — mixed chargers, grunts, flyers across levels */
@@ -2262,6 +2394,8 @@ bool levelgen_generate_mars_base(Tilemap *map, const LevelGenConfig *config) {
/* ── Phase 4: generate segments ── */
int cursor = 2;
int mb_seg_start_x[12];
int mb_seg_ground[12];
/* Left border wall */
fill_rect(map->collision_layer, map->width, map->height,
@@ -2272,6 +2406,8 @@ bool levelgen_generate_mars_base(Tilemap *map, const LevelGenConfig *config) {
};
for (int i = 0; i < num_segs; i++) {
mb_seg_start_x[i] = cursor;
mb_seg_ground[i] = MB_FLOOR_ROW;
int w = seg_widths[i];
float diff = config->difficulty;
@@ -2292,6 +2428,10 @@ bool levelgen_generate_mars_base(Tilemap *map, const LevelGenConfig *config) {
fill_rect(map->collision_layer, map->width, map->height,
map->width - 2, 0, map->width - 1, map->height - 1, TILE_SOLID_1);
/* ── Phase 4b: ensure no segment boundary is sealed ── */
ensure_segment_connectivity(map->collision_layer, map->width, map->height,
mb_seg_start_x, mb_seg_ground, num_segs);
/* ── Phase 5: visual variety (random solid variants for interior tiles) ── */
for (int y = 0; y < map->height; y++) {
for (int x = 0; x < map->width; x++) {

View File

@@ -301,8 +301,11 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
pd->dash_charges--;
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
/* Start recharge timer only if not already recharging */
if (pd->dash_recharge_timer <= 0) {
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
}
pd->dash_timer = PLAYER_DASH_DURATION;
/* Determine dash direction from input */

View File

@@ -50,6 +50,23 @@ static int s_mars_depth = 0;
#define PAUSE_ITEM_COUNT 3
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */
#ifdef __EMSCRIPTEN__
/* JS-initiated level load request (level-select dropdown in shell). */
static int s_js_load_request = 0;
static char s_js_load_path[ASSET_PATH_MAX] = {0};
#endif
#ifdef __EMSCRIPTEN__
/* Called from the JS shell level-select dropdown to load a level into
* gameplay mode. Sets a deferred request that game_update() picks up on
* the next frame so we don't mutate game state from an arbitrary call site. */
EMSCRIPTEN_KEEPALIVE
void game_load_level(const char *path) {
snprintf(s_js_load_path, sizeof(s_js_load_path), "%s", path);
s_js_load_request = 1;
}
#endif
static const char *theme_name(LevelTheme t) {
switch (t) {
case THEME_PLANET_SURFACE: return "Planet Surface";
@@ -343,6 +360,39 @@ static void pause_update(void) {
}
static void game_update(float dt) {
#ifdef __EMSCRIPTEN__
/* Handle deferred level load from JS shell dropdown. */
if (s_js_load_request && s_js_load_path[0]) {
s_js_load_request = 0;
/* Tear down whatever mode we are in. */
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED) {
level_free(&s_level);
} else if (s_mode == MODE_EDITOR) {
editor_free(&s_editor);
}
s_mode = MODE_PLAY;
s_testing_from_editor = false;
if (!load_level_file(s_js_load_path)) {
fprintf(stderr, "Failed to load level from shell: %s\n",
s_js_load_path);
/* Fall back to the first campaign level. */
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
}
/* Also seed the editor path so pressing E opens this level. */
snprintf(s_edit_path, sizeof(s_edit_path), "%s", s_js_load_path);
s_js_load_path[0] = '\0';
SDL_SetWindowTitle(g_engine.window, "Jump 'n Run");
return;
}
#endif
if (s_mode == MODE_EDITOR) {
editor_update(&s_editor, dt);

View File

@@ -117,7 +117,7 @@
<button class="ctrl-btn" id="btn-save" title="Save level (download .lvl)">Save</button>
<button class="ctrl-btn" id="btn-load" title="Load .lvl from disk">Load</button>
<span class="ctrl-sep">|</span>
<select id="level-select" title="Open a built-in level in the editor">
<select id="level-select" title="Load a built-in level">
<option value="">-- Open level --</option>
</select>
<span class="ctrl-sep">|</span>
@@ -263,12 +263,12 @@
var path = this.value;
if (!path) return;
if (typeof _editor_load_vfs_file === 'function') {
/* Pass the path string to C */
/* Load the level into gameplay (MODE_PLAY) via main.c */
if (typeof _game_load_level === 'function') {
var len = lengthBytesUTF8(path) + 1;
var buf = _malloc(len);
stringToUTF8(path, buf, len);
_editor_load_vfs_file(buf);
_game_load_level(buf);
_free(buf);
}