Compare commits
10 Commits
af0a9904c2
...
4407932a2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4407932a2d | ||
|
|
635869f226 | ||
|
|
b54a53b9c8 | ||
|
|
27691a28dd | ||
|
|
6b32199f25 | ||
|
|
b3055f4bd3 | ||
|
|
a97c9b5aaf | ||
|
|
46209b94bb | ||
|
|
b5cdf1804f | ||
|
|
492f13306d |
12
AGENTS.md
12
AGENTS.md
@@ -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 |
|
||||
|
||||
@@ -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
223
DESIGN.md
@@ -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)
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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
30
TODO.md
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
50
src/main.c
50
src/main.c
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user