34 Commits

Author SHA1 Message Date
b918ca451f Add insecure registry config and --tls-verify=false to buildah bud
The previous --tls-verify=false on login/push wasn't sufficient.
Register git.kimchi as an insecure registry and also skip TLS
verification during the image build step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:23:47 +00:00
ec4bc9bb82 Add --tls-verify=false to buildah steps
Some checks failed
CI / build (pull_request) Successful in 49s
Deploy / deploy (push) Has been cancelled
2026-03-16 11:05:08 +00:00
tas
4a2d199904 Update .gitea/workflows/ci.yaml
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-03-16 10:59:04 +00:00
58bf89f2f2 Fix level-select dropdown loading into game instead of editor
Some checks failed
CI / build (pull_request) Failing after 0s
Deploy / deploy (push) Has been cancelled
When the editor is active, the JS shell level-select dropdown was
unconditionally switching to MODE_PLAY. Now it detects MODE_EDITOR
and calls editor_load() to load the selected level into the editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:50:51 +00:00
tas
4bef8f37b4 Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-03-15 17:50:59 +00:00
tas
096b0eb096 Update .gitea/workflows/ci.yaml
Some checks failed
Deploy / deploy (push) Failing after 0s
2026-03-15 17:50:35 +00:00
tas
89e0c483ad Update .gitea/workflows/ci.yaml
Some checks failed
Deploy / deploy (push) Failing after 1s
2026-03-15 17:35:50 +00:00
tas
7d0e134a56 Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-03-15 17:25:26 +00:00
tas
198f639289 Update .gitea/workflows/deploy.yaml 2026-03-15 17:25:11 +00:00
tas
f71d140af3 Install buildah in deploy workflow
Some checks failed
Deploy / deploy (push) Failing after 1m41s
2026-03-15 17:15:49 +00:00
tas
651ac7703f use buildah for build
Some checks failed
Deploy / deploy (push) Failing after 6s
2026-03-15 16:56:18 +00:00
587fd210a2 Fix #13: optimize deploy with multi-stage Containerfile
Some checks failed
CI / build (pull_request) Failing after 1m13s
Deploy / deploy (push) Failing after 55s
Move the WASM build into a multi-stage Containerfile so the emscripten
compilation happens inside the Docker build. This eliminates the separate
container action step, enables Docker layer caching for faster rebuilds,
and makes the Containerfile self-contained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:47:56 +00:00
79e9d0e2ad Show current score in top-right corner of HUD
Some checks failed
Deploy / deploy (push) Failing after 58s
Display the player's current score using the font renderer,
right-aligned with an 8px margin from the top-right screen edge.

Closes #5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:43:19 +00:00
81ebbd9eec Fix score formula to match design specification
Some checks failed
Deploy / deploy (push) Has been cancelled
The score computation used wrong weights and terms compared to DESIGN.md.
Updated to: enemies_killed*100 + levels_completed*500 - deaths*200 - time_elapsed

Closes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:42:56 +00:00
90e3d5aec0 Add free downward jetpack on double-tap down mid-air
Some checks failed
Deploy / deploy (push) Has been cancelled
Double pressing the down arrow while airborne triggers a free downward
dash that doesn't consume jetpack charges. Uses a 0.3s window for the
double-tap detection, resets on landing.

Closes #9

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:42:31 +00:00
68856fb8c5 add deploy action
Some checks failed
Deploy / deploy (push) Failing after 3m54s
2026-03-14 19:54:21 +00:00
7605f0ca8c Add new level transition state machine 2026-03-14 16:29:10 +00:00
6d64c6426f Fix atmosphere wind particles appearing inside Mars station
mars02.lvl (Mars Base interior) incorrectly used PARALLAX_STYLE 5
(Mars surface), causing atmosphere dust particles to spawn indoors.
Changed to PARALLAX_STYLE 2 (Interior) to match the level's setting.

Closes #4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 15:53:23 +00:00
93ae351959 Add Gitea Actions CI workflow for pull request builds 2026-03-14 14:47:56 +00:00
root
c57ac68a04 Fix atmosphere particles clustering on left side of screen
When wind is zero or near-zero, dust particles were always spawning at
the left viewport edge (the "upwind" edge for wind >= 0). Without wind
force to carry them across, they accumulated on the left side. Now
particles spawn across the full viewport when wind is calm (< 5 px/s²),
with random drift directions for even distribution.

Closes #2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:14:52 +00:00
Thomas
5793af4896 Configure analytics URL and obfuscate API key
Set the Horchposten backend URL in the shell data attribute and replace
the plaintext key lookup with XOR-encoded byte arrays decoded at runtime,
so the API key never appears as a readable string in source, HTML, or the
compiled WASM binary.
2026-03-08 19:52:34 +00:00
tas
702dbd4f9a Merge pull request 'Add analytics integration with Horchposten backend' (#1) from feature/analytics-integration into main
Reviewed-on: #1
2026-03-08 19:42:58 +00:00
322dd184ab Fix review issues in analytics integration
1. Race condition: session_end now waits for an in-flight
   session_start promise before sending, so quick restarts
   don't drop the end call.

2. beforeunload: replaced sendBeacon (which can't set headers)
   with fetch(..., keepalive: true) so the X-API-Key header
   is included and the backend doesn't 401.

3. Stats double-counting: removed stats_record_damage_dealt
   and stats_record_kill from damage_entity (which was called
   for all damage including player deaths). Now only recorded
   at player-sourced call sites (projectile hits, stomps).

4. Removed const-cast: analytics_session_end now takes
   GameStats* (non-const) since stats_update_score mutates it.

5. beforeunload now uses stashed stats from the last C-side
   session_end call instead of hardcoded zeroes. Session ID is
   cleared synchronously before async fetch to prevent races.

6. Removed unused stdint.h include from stats.h.
2026-03-08 19:29:11 +00:00
root
a23ecaf4c1 Add analytics integration with Horchposten backend
Implements session-based analytics tracking that sends gameplay
stats to the Horchposten API. Adds stats.{c,h} for accumulating
per-session metrics (kills, deaths, shots, dashes, jumps, pickups,
damage, time) and analytics.{c,h} with EM_JS bridge for fetch()
calls to the backend. Client ID is persisted in localStorage.
Session start/end hooks are wired into all game lifecycle events
(level transitions, restart, quit, tab close via sendBeacon).
Analytics URL/key are configured via data attributes on the canvas
container. Non-WASM builds compile with no-op stubs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:11:39 +00:00
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
29 changed files with 1758 additions and 116 deletions

28
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,28 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends \
build-essential \
libsdl2-dev \
libsdl2-image-dev \
libsdl2-mixer-dev \
pkg-config
- name: Checkout
run: |
git config --global http.https://git.kimchi.sslVerify false
git clone --depth 1 https://${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}@git.kimchi/tas/major_tom.git .
- name: Build (native Linux)
run: make

View File

@@ -0,0 +1,48 @@
name: Deploy
on:
push:
branches: [main]
env:
REGISTRY: git.kimchi
IMAGE: git.kimchi/tas/major_tom
NAMESPACE: jnr-web
DEPLOYMENT: jnr-web
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
run: |
git config --global http.https://git.kimchi.sslVerify false
git clone --depth 1 https://${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}@git.kimchi/tas/major_tom.git .
- name: Build and push container image
run: |
mkdir -p /etc/containers
printf '[registries.insecure]\nregistries = ["git.kimchi"]\n' > /etc/containers/registries.conf
IMAGE_TAG="${{ env.IMAGE }}:sha-${GITHUB_SHA::8}"
IMAGE_LATEST="${{ env.IMAGE }}:latest"
buildah bud --tls-verify=false -f Containerfile -t "$IMAGE_TAG" -t "$IMAGE_LATEST" .
buildah login --tls-verify=false "${{ env.REGISTRY }}" -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_PASSWORD }}"
buildah push --tls-verify=false "$IMAGE_TAG"
buildah push --tls-verify=false "$IMAGE_LATEST"
echo "IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV"
- name: Deploy to k3s
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl set image deployment/${{ env.DEPLOYMENT }} \
${{ env.DEPLOYMENT }}="${{ env.IMAGE_TAG }}" \
-n ${{ env.NAMESPACE }}
kubectl rollout status deployment/${{ env.DEPLOYMENT }} \
-n ${{ env.NAMESPACE }} --timeout=60s

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

@@ -1,7 +1,4 @@
# JNR Web — static file server for the Emscripten build
#
# Prerequisites:
# make web (produces dist-web/)
# JNR Web — multi-stage build: compile WASM then serve static files
#
# Build:
# podman build -t jnr-web .
@@ -15,6 +12,24 @@
# podman save jnr-web | sudo k3s ctr images import -
# sudo k3s kubectl apply -f k8s/
# ── Stage 1: Build WASM artifacts ──
FROM docker.io/emscripten/emsdk:3.1.51 AS builder
WORKDIR /src
COPY Makefile ./
COPY include/ include/
COPY src/ src/
COPY assets/ assets/
COPY web/ web/
RUN make web
# ── Stage 2: Serve static files ──
FROM docker.io/joseluisq/static-web-server:2
COPY dist-web/ /public/
COPY --from=builder /src/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

225
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,
@@ -120,7 +146,7 @@ adding a new def. See `src/game/projectile.h` for the full definition.
## Levels
### Format (.lvl)
Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `WIND`, `BG_COLOR`, `MUSIC`, `PARALLAX_FAR`, `PARALLAX_NEAR`, `TILEDEF`, `ENTITY`, `EXIT`, `LAYER`
Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `WIND`, `BG_COLOR`, `MUSIC`, `PARALLAX_FAR`, `PARALLAX_NEAR`, `TRANSITION_IN`, `TRANSITION_OUT`, `TILEDEF`, `ENTITY`, `EXIT`, `LAYER`
**Needed additions:**
- `STORM`, `DRAG` — Remaining atmosphere settings
@@ -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

44
TODO.md
View File

@@ -138,3 +138,47 @@ 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~~ ✓
Implemented: `src/game/transition.h` / `transition.c` module with two-phase
transition state machine (outro on old level → level swap → intro on new level).
- **Elevator** — Two horizontal doors slide inward from top/bottom (0.6 s),
hold closed with screen-shake rumble (0.3 s), then slide apart on the new
level (0.6 s). Smooth ease-in-out motion, dark gray industrial color,
bright seam at the meeting edge. Used for base/station interior transitions.
- **Teleporter** — Scanline dissolve: 3 px-tall horizontal bands sweep
across the screen in alternating directions with staggered top-to-bottom
timing (0.5 s), then white flash (0.15 s). Intro reverses the sweep
bottom-to-top (0.5 s). Uses `teleport.wav` sound effect.
New `MODE_TRANSITION` game state in `main.c` pauses gameplay during the
animation. Level-load dispatch extracted into `dispatch_level_load()` helper,
called both from instant transitions and from the transition state machine.
New `.lvl` directives `TRANSITION_IN` and `TRANSITION_OUT` with values
`none`, `spacecraft`, `elevator`, `teleporter`. Parsed in `tilemap.c`,
saved by editor and level generator dump. All three procedural generators
(generic, station, mars_base) set `TRANS_ELEVATOR` for interior themes.
Handcrafted levels updated: mars02, mars03, level01, level02.

View File

@@ -8,6 +8,7 @@ SPAWN 3 18
GRAVITY 400
BG_COLOR 15 15 30
MUSIC assets/sounds/algardalgar.ogg
TRANSITION_OUT elevator
# Spacecraft landing intro (arriving from moon)
ENTITY spacecraft 1 14

View File

@@ -7,6 +7,8 @@ SPAWN 3 18
GRAVITY 600
BG_COLOR 10 10 25
MUSIC assets/sounds/algardalgar.ogg
TRANSITION_IN elevator
TRANSITION_OUT elevator
# Enemies
ENTITY grunt 12 18

View File

@@ -10,8 +10,9 @@ SIZE 40 46
SPAWN 3 7
GRAVITY 700
BG_COLOR 20 10 6
PARALLAX_STYLE 5
PARALLAX_STYLE 2
MUSIC assets/sounds/kaffe_og_kage.ogg
TRANSITION_OUT elevator
ENTITY spacecraft 1 3

View File

@@ -12,6 +12,8 @@ GRAVITY 700
BG_COLOR 15 8 5
PARALLAX_STYLE 3
MUSIC assets/sounds/kaffe_og_kage.ogg
TRANSITION_IN elevator
TRANSITION_OUT elevator
# Gun pickup right at spawn — the player needs it
ENTITY powerup_gun 5 18

View File

@@ -28,6 +28,14 @@
/* ── Level transitions ─────────────────────────────── */
#define MAX_EXIT_ZONES 16 /* max exit zones per level */
typedef enum TransitionStyle {
TRANS_NONE, /* instant cut (default) */
TRANS_SPACECRAFT, /* handled by spacecraft entity */
TRANS_ELEVATOR, /* doors close, rumble, doors open */
TRANS_TELEPORTER, /* scanline dissolve, flash, materialize */
TRANS_STYLE_COUNT
} TransitionStyle;
/* ── Rendering ──────────────────────────────────────── */
#define MAX_SPRITES 2048 /* max queued sprites per frame */

View File

@@ -529,3 +529,96 @@ 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.
* When wind is strong, particles spawn along the upwind viewport edge
* and drift inward. When wind is calm, particles spawn across the
* full viewport to avoid clustering on one side. */
float wind = physics_get_wind();
float margin = 32.0f;
float abs_wind = (wind >= 0.0f) ? wind : -wind;
int has_wind = abs_wind > 5.0f; /* threshold for edge-spawning */
if (has_wind) {
float dir = (wind >= 0.0f) ? 1.0f : -1.0f;
/* 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 */
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);
}
} else {
/* Calm wind — spawn across the full viewport to distribute evenly */
/* Far dust motes — large, slow, translucent (1/frame) */
spawn_dust_mote(
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-10.0f, 10.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(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
vec2(randf_range(-15.0f, 15.0f), randf_range(-10.0f, 10.0f)),
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
200, 160, 120, 20);
/* Extra interior mote for density parity with windy path */
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

@@ -6,6 +6,14 @@
#include <stdlib.h>
#include <string.h>
/* ── Transition style name → enum mapping ── */
static TransitionStyle parse_transition_style(const char *name) {
if (strcmp(name, "spacecraft") == 0) return TRANS_SPACECRAFT;
if (strcmp(name, "elevator") == 0) return TRANS_ELEVATOR;
if (strcmp(name, "teleporter") == 0) return TRANS_TELEPORTER;
return TRANS_NONE;
}
/* Read a full line from f into a dynamically growing buffer.
* *buf and *cap track the heap buffer; the caller must free *buf.
* Returns the line length, or -1 on EOF/error. */
@@ -120,6 +128,16 @@ bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) {
}
} else if (strncmp(line, "PLAYER_UNARMED", 14) == 0) {
map->player_unarmed = true;
} else if (strncmp(line, "TRANSITION_IN ", 14) == 0) {
char tname[32] = {0};
if (sscanf(line + 14, "%31s", tname) == 1) {
map->transition_in = parse_transition_style(tname);
}
} else if (strncmp(line, "TRANSITION_OUT ", 15) == 0) {
char tname[32] = {0};
if (sscanf(line + 15, "%31s", tname) == 1) {
map->transition_out = parse_transition_style(tname);
}
} else if (strncmp(line, "EXIT ", 5) == 0) {
if (map->exit_zone_count < MAX_EXIT_ZONES) {
ExitZone *ez = &map->exit_zones[map->exit_zone_count];

View File

@@ -58,6 +58,8 @@ typedef struct Tilemap {
char parallax_near_path[ASSET_PATH_MAX]; /* near bg image path */
int parallax_style; /* procedural bg style (0=default) */
bool player_unarmed; /* if true, player starts without gun */
TransitionStyle transition_in; /* transition animation for level entry */
TransitionStyle transition_out; /* transition animation for level exit */
EntitySpawn entity_spawns[MAX_ENTITY_SPAWNS];
int entity_spawn_count;
ExitZone exit_zones[MAX_EXIT_ZONES];

205
src/game/analytics.c Normal file
View File

@@ -0,0 +1,205 @@
#include "game/analytics.h"
#include <stdio.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
/* ── EM_JS bridge: JavaScript functions callable from C ─────────── */
/* Initialize client_id in localStorage and store the analytics
* API URL + key. Called once at startup. */
EM_JS(void, js_analytics_init, (), {
/* Generate or retrieve a persistent client UUID.
* crypto.randomUUID() requires a secure context (HTTPS) and is
* absent in older browsers, so fall back to a manual v4 UUID. */
if (!localStorage.getItem('jnr_client_id')) {
var uuid;
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
uuid = crypto.randomUUID();
} else {
uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
localStorage.setItem('jnr_client_id', uuid);
}
/* Store config on the Module for later use by other EM_JS calls.
* ANALYTICS_URL and ANALYTICS_KEY are replaced at build time via
* -D defines, falling back to sensible defaults. */
Module._analyticsClientId = localStorage.getItem('jnr_client_id');
Module._analyticsSessionId = null;
Module._analyticsStartPending = null; /* Promise while start is in-flight */
Module._analyticsLastStats = null; /* stashed for beforeunload fallback */
/* Runtime config: URL from data attribute, key decoded at runtime.
* The key is XOR-encoded across two byte arrays so it never appears
* as a plain string in the WASM binary, emitted JS, or HTML. */
var container = document.getElementById('canvas-container');
Module._analyticsUrl = (container && container.dataset.analyticsUrl)
? container.dataset.analyticsUrl
: (typeof ANALYTICS_URL !== 'undefined' ? ANALYTICS_URL : '');
var _a = [53,75,96,19,114,122,112,34,28,62,24,5,57,34,126,14,
112,73,105,121,122,79,50,0,77,33,82,58,61,19,44,0];
var _b = [82,15,4,95,36,32,29,18,95,14,87,95,115,70,12,76,
55,5,4,12,28,30,65,78,4,72,26,92,84,90,70,54];
var _k = '';
for (var i = 0; i < _a.length; i++) _k += String.fromCharCode(_a[i] ^ _b[i]);
Module._analyticsKey = _k;
if (!Module._analyticsUrl) {
console.log('[analytics] No analytics URL configured analytics disabled');
} else {
console.log('[analytics] Initialized, client_id=' + Module._analyticsClientId);
}
});
/* Start a new session. Sends POST /api/analytics/session/start/.
* Stores the in-flight promise so session_end can wait for it. */
EM_JS(void, js_analytics_session_start, (), {
if (!Module._analyticsUrl) return;
var body = JSON.stringify({
client_id: Module._analyticsClientId,
device: {
platform: navigator.platform || '',
language: navigator.language || '',
screen_width: screen.width,
screen_height: screen.height,
device_pixel_ratio: window.devicePixelRatio || 1,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
webgl_renderer: (function() {
try {
var c = document.createElement('canvas');
var gl = c.getContext('webgl') || c.getContext('experimental-webgl');
if (gl) {
var ext = gl.getExtension('WEBGL_debug_renderer_info');
return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : '';
}
} catch(e) {}
return '';
})(),
touch_support: ('ontouchstart' in window)
}
});
Module._analyticsStartPending = fetch(Module._analyticsUrl + '/api/analytics/session/start/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': Module._analyticsKey
},
body: body
})
.then(function(r) { return r.json(); })
.then(function(data) {
Module._analyticsSessionId = data.session_id || null;
Module._analyticsStartPending = null;
console.log('[analytics] Session started: ' + Module._analyticsSessionId);
})
.catch(function(err) {
Module._analyticsStartPending = null;
console.error('[analytics] Session start failed:', err);
});
});
/* Internal helper: send the session-end POST (used by both the C wrapper
* and the beforeunload fallback). */
EM_JS(void, js_analytics_send_end, (int score, int level_reached,
int lives_used, int duration_secs,
const char *end_reason_ptr), {
/* Helper that performs the actual end request given a session id. */
function doEnd(sid, endReason, score, levelReached, livesUsed, durationSecs) {
var body = JSON.stringify({
score: score,
level_reached: levelReached > 0 ? levelReached : 1,
lives_used: livesUsed,
duration_seconds: durationSecs,
end_reason: endReason
});
/* Stash stats for the beforeunload fallback */
Module._analyticsLastStats = body;
return fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': Module._analyticsKey
},
body: body,
keepalive: true
})
.then(function(r) { return r.json(); })
.then(function(data) {
console.log('[analytics] Session ended: ' + sid +
(data.new_high_score ? ' (NEW HIGH SCORE!)' : ''));
})
.catch(function(err) {
console.error('[analytics] Session end failed:', err);
});
}
if (!Module._analyticsUrl) return;
var endReason = UTF8ToString(end_reason_ptr);
/* If session start is still in-flight, wait for it before ending. */
if (Module._analyticsStartPending) {
var pending = Module._analyticsStartPending;
/* Clear session synchronously so duplicate end calls are harmless */
Module._analyticsStartPending = null;
pending.then(function() {
var sid = Module._analyticsSessionId;
if (sid) {
Module._analyticsSessionId = null;
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
}
});
return;
}
if (!Module._analyticsSessionId) return;
var sid = Module._analyticsSessionId;
/* Clear synchronously before the async request to prevent races */
Module._analyticsSessionId = null;
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
});
/* ── C wrappers ─────────────────────────────────────────────────── */
void analytics_init(void) {
js_analytics_init();
}
void analytics_session_start(void) {
js_analytics_session_start();
}
void analytics_session_end(GameStats *stats, const char *end_reason) {
stats_update_score(stats);
js_analytics_send_end(
stats->score,
stats->levels_completed > 0 ? stats->levels_completed : 1,
stats->deaths,
(int)stats->time_elapsed,
end_reason
);
}
#else
/* ── Non-WASM stubs ─────────────────────────────────────────────── */
void analytics_init(void) {
printf("[analytics] Analytics disabled (native build)\n");
}
void analytics_session_start(void) {}
void analytics_session_end(GameStats *stats, const char *end_reason) {
(void)stats;
(void)end_reason;
}
#endif /* __EMSCRIPTEN__ */

18
src/game/analytics.h Normal file
View File

@@ -0,0 +1,18 @@
#ifndef JNR_ANALYTICS_H
#define JNR_ANALYTICS_H
#include "game/stats.h"
/* Initialize analytics subsystem (load/generate client_id).
* No-op on non-WASM builds. */
void analytics_init(void);
/* Start a new analytics session. Sends POST to the backend. */
void analytics_session_start(void);
/* End the current analytics session with final stats.
* Computes the composite score before sending.
* end_reason: "death", "quit", "timeout", or "completed". */
void analytics_session_end(GameStats *stats, const char *end_reason);
#endif /* JNR_ANALYTICS_H */

View File

@@ -1,5 +1,6 @@
#include "game/editor.h"
#include "game/entity_registry.h"
#include "game/transition.h"
#include "engine/core.h"
#include "engine/input.h"
#include "engine/renderer.h"
@@ -359,6 +360,16 @@ static bool save_tilemap(const Tilemap *map, const char *path) {
if (map->player_unarmed)
fprintf(f, "PLAYER_UNARMED\n");
/* Transition styles */
if (map->transition_in != TRANS_NONE) {
fprintf(f, "TRANSITION_IN %s\n",
transition_style_name(map->transition_in));
}
if (map->transition_out != TRANS_NONE) {
fprintf(f, "TRANSITION_OUT %s\n",
transition_style_name(map->transition_out));
}
fprintf(f, "\n");
/* Entity spawns */

View File

@@ -8,6 +8,7 @@
#include "game/spacecraft.h"
#include "game/sprites.h"
#include "game/entity_registry.h"
#include "game/stats.h"
#include "engine/core.h"
#include "engine/renderer.h"
#include "engine/physics.h"
@@ -16,6 +17,7 @@
#include "engine/input.h"
#include "engine/camera.h"
#include "engine/assets.h"
#include "engine/font.h"
#include <stdio.h>
#include <string.h>
#include <math.h>
@@ -210,7 +212,10 @@ static void damage_entity(Entity *target, int damage) {
static void damage_player(Entity *player, int damage, Entity *source) {
PlayerData *ppd = (PlayerData *)player->data;
stats_record_damage_taken(damage);
damage_entity(player, damage);
/* Note: damage_taken is recorded here; damage_dealt and kills are
* recorded at the specific call sites that deal player-sourced damage. */
/* Screen shake on player hit (stronger) */
if (s_active_camera) {
@@ -263,7 +268,10 @@ static void handle_collisions(EntityManager *em) {
/* Player bullet hits enemies */
if (from_player && entity_is_enemy(b)) {
if (physics_overlap(&a->body, &b->body)) {
stats_record_damage_dealt(a->damage);
damage_entity(b, a->damage);
stats_record_shot_hit();
if (b->flags & ENTITY_DEAD) stats_record_kill();
hit = true;
}
}
@@ -295,7 +303,9 @@ static void handle_collisions(EntityManager *em) {
a->body.pos.y + a->body.size.y * 0.5f);
if (stomping) {
stats_record_damage_dealt(2);
damage_entity(a, 2);
if (a->flags & ENTITY_DEAD) stats_record_kill();
player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f;
} else {
damage_player(player, a->damage, a);
@@ -366,6 +376,7 @@ static void handle_collisions(EntityManager *em) {
}
if (picked_up) {
stats_record_pickup();
/* Pickup particles */
Vec2 center = vec2(
a->body.pos.x + a->body.size.x * 0.5f,
@@ -566,6 +577,7 @@ void level_update(Level *level, float dt) {
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
stats_record_death();
player_respawn(e, level->map.player_spawn);
Vec2 center = vec2(
e->body.pos.x + e->body.size.x * 0.5f,
@@ -578,6 +590,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);
@@ -759,6 +780,20 @@ void level_render(Level *level, float interpolation) {
}
}
/* Draw score in top-right corner */
{
GameStats *stats = stats_get_active();
if (stats) {
stats_update_score(stats);
char score_buf[16];
snprintf(score_buf, sizeof(score_buf), "%d", stats->score);
int text_w = font_text_width(score_buf);
font_draw_text(g_engine.renderer, score_buf,
SCREEN_WIDTH - text_w - 8, 8,
(SDL_Color){255, 220, 80, 255});
}
}
/* Flush the renderer */
renderer_flush(cam);
}

View File

@@ -1,4 +1,5 @@
#include "game/levelgen.h"
#include "game/transition.h"
#include "engine/parallax.h"
#include <stdio.h>
#include <stdlib.h>
@@ -127,6 +128,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 +390,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 +561,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 +1211,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 +1248,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++) {
@@ -1238,6 +1318,14 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg");
}
/* Transition style — interior themes use elevator, surface uses none
* (spacecraft entity handles surface transitions). */
if (primary_theme == THEME_PLANET_BASE || primary_theme == THEME_MARS_BASE
|| primary_theme == THEME_SPACE_STATION) {
map->transition_in = TRANS_ELEVATOR;
map->transition_out = TRANS_ELEVATOR;
}
/* Tileset */
/* NOTE: tileset texture will be loaded by level_load_generated */
@@ -1333,11 +1421,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 +1473,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 +1582,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 +1749,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 +1760,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 +1782,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++) {
@@ -1719,6 +1835,10 @@ bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
/* Music */
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg");
/* Interior levels use elevator transitions. */
map->transition_in = TRANS_ELEVATOR;
map->transition_out = TRANS_ELEVATOR;
printf("levelgen_station: generated %dx%d level (%d segments, seed=%u, gravity=%.0f)\n",
map->width, map->height, num_segs, s_rng_state, map->gravity);
printf(" segments:");
@@ -1788,9 +1908,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 +1940,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 +2044,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 +2114,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 +2181,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 +2257,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 +2407,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 +2419,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 +2441,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++) {
@@ -2345,6 +2498,10 @@ bool levelgen_generate_mars_base(Tilemap *map, const LevelGenConfig *config) {
/* Music */
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/kaffe_og_kage.ogg");
/* Interior levels use elevator transitions. */
map->transition_in = TRANS_ELEVATOR;
map->transition_out = TRANS_ELEVATOR;
printf("levelgen_mars_base: generated %dx%d level (%d segments, seed=%u)\n",
map->width, map->height, num_segs, s_rng_state);
printf(" segments:");
@@ -2401,6 +2558,16 @@ bool levelgen_dump_lvl(const Tilemap *map, const char *path) {
fprintf(f, "PLAYER_UNARMED\n");
}
/* Transition styles */
if (map->transition_in != TRANS_NONE) {
fprintf(f, "TRANSITION_IN %s\n",
transition_style_name(map->transition_in));
}
if (map->transition_out != TRANS_NONE) {
fprintf(f, "TRANSITION_OUT %s\n",
transition_style_name(map->transition_out));
}
fprintf(f, "\n");
/* Entity spawns */

View File

@@ -1,6 +1,7 @@
#include "game/player.h"
#include "game/sprites.h"
#include "game/projectile.h"
#include "game/stats.h"
#include "engine/input.h"
#include "engine/physics.h"
#include "engine/renderer.h"
@@ -299,10 +300,41 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
return;
}
/* ── Double-tap down for free downward jetpack ── */
if (!body->on_ground && input_pressed(ACTION_DOWN)) {
if (pd->down_tap_timer > 0) {
/* Second tap — trigger free downward dash */
pd->down_tap_timer = 0;
pd->dash_timer = PLAYER_DASH_DURATION;
pd->dash_dir = vec2(0.0f, 1.0f);
/* Brief invincibility during dash */
pd->inv_timer = PLAYER_DASH_DURATION;
self->flags |= ENTITY_INVINCIBLE;
/* Jetpack burst downward */
Vec2 exhaust_pos = vec2(
body->pos.x + body->size.x * 0.5f,
body->pos.y + body->size.y * 0.5f
);
particle_emit_jetpack_burst(exhaust_pos, pd->dash_dir);
audio_play_sound(s_sfx_dash, 96);
return;
}
pd->down_tap_timer = 0.3f; /* window for second tap */
}
if (pd->down_tap_timer > 0) {
pd->down_tap_timer -= dt;
}
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;
stats_record_dash();
/* 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 */
@@ -407,6 +439,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
pd->jumping = true;
pd->jump_buffer_timer = 0;
pd->coyote_timer = 0;
stats_record_jump();
audio_play_sound(s_sfx_jump, 96);
}
@@ -457,6 +490,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
}
projectile_spawn_dir(s_em, bullet_pos, shoot_dir, true);
stats_record_shot_fired();
/* Muzzle flash slightly ahead of bullet origin (at barrel tip) */
Vec2 flash_pos = vec2(
bullet_pos.x + shoot_dir.x * 4.0f,
@@ -471,6 +505,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
/* ── Landing detection ───────────────────── */
if (body->on_ground && !pd->was_on_ground) {
pd->down_tap_timer = 0; /* reset double-tap on landing */
/* Just landed — emit dust at feet */
Vec2 feet = vec2(
body->pos.x + body->size.x * 0.5f,

View File

@@ -67,6 +67,8 @@ typedef struct PlayerData {
AimDir aim_dir; /* current aim direction */
bool looking_up; /* holding up without moving */
float look_up_timer; /* how long up has been held */
/* Down-arrow double-tap (free downward jetpack) */
float down_tap_timer; /* time since last mid-air down press */
/* Death / Respawn */
float respawn_timer; /* countdown after death anim finishes */
Vec2 spawn_point; /* where to respawn */

29
src/game/stats.c Normal file
View File

@@ -0,0 +1,29 @@
#include "game/stats.h"
#include <string.h>
static GameStats *s_active = NULL;
void stats_reset(GameStats *s) {
memset(s, 0, sizeof(GameStats));
}
void stats_update_score(GameStats *s) {
int score = s->enemies_killed * 100
+ s->levels_completed * 500
- s->deaths * 200
- (int)s->time_elapsed;
s->score = score > 0 ? score : 0;
}
void stats_set_active(GameStats *s) { s_active = s; }
GameStats *stats_get_active(void) { return s_active; }
void stats_record_kill(void) { if (s_active) s_active->enemies_killed++; }
void stats_record_death(void) { if (s_active) s_active->deaths++; }
void stats_record_shot_fired(void) { if (s_active) s_active->shots_fired++; }
void stats_record_shot_hit(void) { if (s_active) s_active->shots_hit++; }
void stats_record_dash(void) { if (s_active) s_active->dashes_used++; }
void stats_record_jump(void) { if (s_active) s_active->jumps++; }
void stats_record_pickup(void) { if (s_active) s_active->pickups_collected++; }
void stats_record_damage_taken(int n) { if (s_active) s_active->damage_taken += n; }
void stats_record_damage_dealt(int n) { if (s_active) s_active->damage_dealt += n; }

43
src/game/stats.h Normal file
View File

@@ -0,0 +1,43 @@
#ifndef JNR_STATS_H
#define JNR_STATS_H
/* Per-session gameplay statistics, accumulated during play and
* submitted to the analytics backend when the session ends. */
typedef struct GameStats {
int score; /* composite score */
int levels_completed; /* exit zone triggers */
int enemies_killed; /* total kills */
int deaths; /* player respawn count */
int shots_fired; /* projectiles spawned by player */
int shots_hit; /* player projectiles that hit */
int dashes_used; /* dash activations */
int jumps; /* jump count */
int pickups_collected; /* all powerup pickups */
int damage_taken; /* total HP lost */
int damage_dealt; /* total HP dealt to enemies */
float time_elapsed; /* wall-clock seconds */
} GameStats;
/* Reset all stats to zero (call at session start). */
void stats_reset(GameStats *s);
/* Recompute the composite score from raw metrics. */
void stats_update_score(GameStats *s);
/* Set/get the active stats instance (global pointer used by
* level.c and player.c to record events during gameplay). */
void stats_set_active(GameStats *s);
GameStats *stats_get_active(void);
/* Convenience: increment a stat on the active instance (no-op if NULL). */
void stats_record_kill(void);
void stats_record_death(void);
void stats_record_shot_fired(void);
void stats_record_shot_hit(void);
void stats_record_dash(void);
void stats_record_jump(void);
void stats_record_pickup(void);
void stats_record_damage_taken(int amount);
void stats_record_damage_dealt(int amount);
#endif /* JNR_STATS_H */

351
src/game/transition.c Normal file
View File

@@ -0,0 +1,351 @@
#include "game/transition.h"
#include "engine/core.h"
#include "engine/audio.h"
#include "engine/particle.h"
#include "config.h"
#include <string.h>
#include <math.h>
#include <SDL2/SDL.h>
/* ═══════════════════════════════════════════════════
* Constants
* ═══════════════════════════════════════════════════ */
/* ── Elevator timing ── */
#define ELEVATOR_CLOSE_DURATION 0.6f /* doors slide shut */
#define ELEVATOR_HOLD_DURATION 0.3f /* closed with rumble */
#define ELEVATOR_OPEN_DURATION 0.6f /* doors slide open */
#define ELEVATOR_OUT_DURATION (ELEVATOR_CLOSE_DURATION + ELEVATOR_HOLD_DURATION)
#define ELEVATOR_IN_DURATION (ELEVATOR_HOLD_DURATION + ELEVATOR_OPEN_DURATION)
/* ── Teleporter timing ── */
#define TELEPORT_DISSOLVE_DURATION 0.5f /* scanline sweep out */
#define TELEPORT_FLASH_DURATION 0.15f /* white flash */
#define TELEPORT_MATERIALIZE_DURATION 0.5f /* scanline sweep in */
#define TELEPORT_OUT_DURATION (TELEPORT_DISSOLVE_DURATION + TELEPORT_FLASH_DURATION)
#define TELEPORT_IN_DURATION (TELEPORT_FLASH_DURATION + TELEPORT_MATERIALIZE_DURATION)
/* ── Scanline dissolve parameters ── */
#define SCANLINE_HEIGHT 3 /* pixel height per band */
#define SCANLINE_STAGGER 0.3f /* time spread between first/last band */
/* ── Elevator colors ── */
#define ELEV_R 40
#define ELEV_G 42
#define ELEV_B 48
/* ── Sound effects ── */
static Sound s_sfx_teleport;
static bool s_sfx_loaded = false;
static void ensure_sfx(void) {
if (s_sfx_loaded) return;
s_sfx_teleport = audio_load_sound("assets/sounds/teleport.wav");
s_sfx_loaded = true;
}
/* ═══════════════════════════════════════════════════
* Outro duration for a given style
* ═══════════════════════════════════════════════════ */
static float outro_duration(TransitionStyle style) {
switch (style) {
case TRANS_ELEVATOR: return ELEVATOR_OUT_DURATION;
case TRANS_TELEPORTER: return TELEPORT_OUT_DURATION;
default: return 0.0f;
}
}
static float intro_duration(TransitionStyle style) {
switch (style) {
case TRANS_ELEVATOR: return ELEVATOR_IN_DURATION;
case TRANS_TELEPORTER: return TELEPORT_IN_DURATION;
default: return 0.0f;
}
}
/* ═══════════════════════════════════════════════════
* Public API
* ═══════════════════════════════════════════════════ */
void transition_start_out(TransitionState *ts, TransitionStyle out_style) {
ensure_sfx();
ts->out_style = out_style;
ts->in_style = TRANS_NONE;
ts->phase = TRANS_PHASE_OUT;
ts->timer = 0.0f;
ts->phase_dur = outro_duration(out_style);
ts->sound_played = false;
}
void transition_set_in_style(TransitionState *ts, TransitionStyle in_style) {
ts->in_style = in_style;
}
void transition_update(TransitionState *ts, float dt, Camera *cam) {
if (ts->phase == TRANS_IDLE || ts->phase == TRANS_PHASE_DONE) return;
ts->timer += dt;
/* ── Outro phase ── */
if (ts->phase == TRANS_PHASE_OUT) {
/* Play sound once at start of teleporter dissolve. */
if (ts->out_style == TRANS_TELEPORTER && !ts->sound_played) {
audio_play_sound(s_sfx_teleport, 80);
ts->sound_played = true;
}
/* Elevator rumble during the hold period. */
if (ts->out_style == TRANS_ELEVATOR && cam) {
if (ts->timer > ELEVATOR_CLOSE_DURATION) {
camera_shake(cam, 3.0f, 0.1f);
}
}
if (ts->timer >= ts->phase_dur) {
ts->phase = TRANS_PHASE_LOAD;
}
return;
}
/* ── Load phase: caller must call transition_begin_intro() ── */
if (ts->phase == TRANS_PHASE_LOAD) {
return;
}
/* ── Intro phase ── */
if (ts->phase == TRANS_PHASE_IN) {
/* Elevator rumble at the start of intro (before doors open). */
if (ts->in_style == TRANS_ELEVATOR && cam) {
if (ts->timer < ELEVATOR_HOLD_DURATION) {
camera_shake(cam, 2.5f, 0.1f);
}
}
if (ts->timer >= ts->phase_dur) {
ts->phase = TRANS_PHASE_DONE;
}
return;
}
}
bool transition_needs_load(const TransitionState *ts) {
return ts->phase == TRANS_PHASE_LOAD;
}
void transition_begin_intro(TransitionState *ts) {
ts->phase = TRANS_PHASE_IN;
ts->timer = 0.0f;
ts->phase_dur = intro_duration(ts->in_style);
ts->sound_played = false;
}
bool transition_is_done(const TransitionState *ts) {
return ts->phase == TRANS_PHASE_DONE;
}
void transition_reset(TransitionState *ts) {
ts->phase = TRANS_IDLE;
ts->timer = 0.0f;
ts->phase_dur = 0.0f;
}
/* ═══════════════════════════════════════════════════
* Elevator rendering helpers
* ═══════════════════════════════════════════════════ */
/* Returns door coverage 0.0 (fully open) to 1.0 (fully closed). */
static float elevator_coverage(const TransitionState *ts) {
if (ts->phase == TRANS_PHASE_OUT) {
/* Closing: 0 → 1 over ELEVATOR_CLOSE_DURATION, then hold at 1. */
float t = ts->timer / ELEVATOR_CLOSE_DURATION;
if (t > 1.0f) t = 1.0f;
/* Ease-in-out for smooth motion. */
return t * t * (3.0f - 2.0f * t);
}
if (ts->phase == TRANS_PHASE_IN) {
/* Hold closed during ELEVATOR_HOLD_DURATION, then open. */
float open_t = ts->timer - ELEVATOR_HOLD_DURATION;
if (open_t <= 0.0f) return 1.0f;
float t = open_t / ELEVATOR_OPEN_DURATION;
if (t > 1.0f) t = 1.0f;
float ease = t * t * (3.0f - 2.0f * t);
return 1.0f - ease;
}
/* PHASE_LOAD: fully closed. */
return 1.0f;
}
static void render_elevator(const TransitionState *ts) {
float coverage = elevator_coverage(ts);
if (coverage <= 0.0f) return;
int half_h = (int)(coverage * (float)SCREEN_HEIGHT * 0.5f + 0.5f);
SDL_Renderer *r = g_engine.renderer;
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
SDL_SetRenderDrawColor(r, ELEV_R, ELEV_G, ELEV_B, 255);
/* Top door. */
SDL_Rect top = {0, 0, SCREEN_WIDTH, half_h};
SDL_RenderFillRect(r, &top);
/* Bottom door. */
SDL_Rect bot = {0, SCREEN_HEIGHT - half_h, SCREEN_WIDTH, half_h};
SDL_RenderFillRect(r, &bot);
/* Thin bright seam at the meeting edge (visual detail). */
if (coverage > 0.7f) {
int seam_y = half_h - 1;
SDL_SetRenderDrawColor(r, 100, 110, 130, 255);
SDL_Rect seam_top = {0, seam_y, SCREEN_WIDTH, 1};
SDL_RenderFillRect(r, &seam_top);
int seam_bot_y = SCREEN_HEIGHT - half_h;
SDL_Rect seam_bot = {0, seam_bot_y, SCREEN_WIDTH, 1};
SDL_RenderFillRect(r, &seam_bot);
}
}
/* ═══════════════════════════════════════════════════
* Teleporter rendering helpers
* ═══════════════════════════════════════════════════ */
/* Scanline dissolve progress: each band sweeps across the screen.
* band_idx: which band (0 = top), total_bands: how many bands,
* global_progress: 0.01.0 across the dissolve duration,
* reverse: if true, sweep right-to-left / bottom-to-top. */
static float band_progress(int band_idx, int total_bands,
float global_progress, bool reverse) {
float band_offset;
if (reverse) {
band_offset = (float)(total_bands - 1 - band_idx) / (float)total_bands
* SCANLINE_STAGGER;
} else {
band_offset = (float)band_idx / (float)total_bands * SCANLINE_STAGGER;
}
float local = (global_progress - band_offset) / (1.0f - SCANLINE_STAGGER);
if (local < 0.0f) local = 0.0f;
if (local > 1.0f) local = 1.0f;
return local;
}
static void render_teleporter_scanlines(float global_progress,
bool reverse) {
int total_bands = SCREEN_HEIGHT / SCANLINE_HEIGHT;
SDL_Renderer *r = g_engine.renderer;
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
SDL_SetRenderDrawColor(r, 0, 0, 0, 255);
for (int i = 0; i < total_bands; i++) {
float bp = band_progress(i, total_bands, global_progress, reverse);
if (bp <= 0.0f) continue;
int y = i * SCANLINE_HEIGHT;
int w = (int)(bp * (float)SCREEN_WIDTH + 0.5f);
if (w <= 0) continue;
if (w > SCREEN_WIDTH) w = SCREEN_WIDTH;
/* Alternate sweep direction per band for visual interest. */
int x = (i % 2 == 0) ? 0 : (SCREEN_WIDTH - w);
SDL_Rect band = {x, y, w, SCANLINE_HEIGHT};
SDL_RenderFillRect(r, &band);
}
}
static void render_teleporter_flash(float alpha_f) {
if (alpha_f <= 0.0f) return;
uint8_t alpha = (uint8_t)(alpha_f * 255.0f);
if (alpha == 0) return;
SDL_Renderer *r = g_engine.renderer;
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(r, 255, 255, 255, alpha);
SDL_Rect rect = {0, 0, SCREEN_WIDTH, SCREEN_HEIGHT};
SDL_RenderFillRect(r, &rect);
}
static void render_teleporter(const TransitionState *ts) {
if (ts->phase == TRANS_PHASE_OUT) {
/* Phase 1: scanline dissolve, then flash builds up. */
if (ts->timer < TELEPORT_DISSOLVE_DURATION) {
float progress = ts->timer / TELEPORT_DISSOLVE_DURATION;
render_teleporter_scanlines(progress, false);
} else {
/* Dissolve complete — full black + rising flash. */
render_teleporter_scanlines(1.0f, false);
float flash_t = (ts->timer - TELEPORT_DISSOLVE_DURATION)
/ TELEPORT_FLASH_DURATION;
if (flash_t > 1.0f) flash_t = 1.0f;
render_teleporter_flash(flash_t);
}
return;
}
if (ts->phase == TRANS_PHASE_LOAD) {
/* Fully covered: black + white flash. */
render_teleporter_scanlines(1.0f, false);
render_teleporter_flash(1.0f);
return;
}
if (ts->phase == TRANS_PHASE_IN) {
/* Phase 2: flash fades out, then scanlines recede. */
if (ts->timer < TELEPORT_FLASH_DURATION) {
/* Flash fading over black. */
render_teleporter_scanlines(1.0f, true);
float flash_t = 1.0f - ts->timer / TELEPORT_FLASH_DURATION;
render_teleporter_flash(flash_t);
} else {
/* Scanlines receding to reveal the new level. */
float mat_t = (ts->timer - TELEPORT_FLASH_DURATION)
/ TELEPORT_MATERIALIZE_DURATION;
if (mat_t > 1.0f) mat_t = 1.0f;
/* Progress inverted: 1.0 = fully covered, 0.0 = revealed. */
render_teleporter_scanlines(1.0f - mat_t, true);
}
return;
}
}
/* ═══════════════════════════════════════════════════
* Render dispatch
* ═══════════════════════════════════════════════════ */
void transition_render(const TransitionState *ts) {
if (ts->phase == TRANS_IDLE || ts->phase == TRANS_PHASE_DONE) return;
/* Pick the active style based on which phase we are in. */
TransitionStyle style = (ts->phase == TRANS_PHASE_IN)
? ts->in_style : ts->out_style;
switch (style) {
case TRANS_ELEVATOR: render_elevator(ts); break;
case TRANS_TELEPORTER: render_teleporter(ts); break;
default: break;
}
}
/* ═══════════════════════════════════════════════════
* Name ↔ enum conversion
* ═══════════════════════════════════════════════════ */
TransitionStyle transition_style_from_name(const char *name) {
if (!name) return TRANS_NONE;
if (strcmp(name, "spacecraft") == 0) return TRANS_SPACECRAFT;
if (strcmp(name, "elevator") == 0) return TRANS_ELEVATOR;
if (strcmp(name, "teleporter") == 0) return TRANS_TELEPORTER;
return TRANS_NONE;
}
const char *transition_style_name(TransitionStyle style) {
switch (style) {
case TRANS_SPACECRAFT: return "spacecraft";
case TRANS_ELEVATOR: return "elevator";
case TRANS_TELEPORTER: return "teleporter";
default: return "none";
}
}

68
src/game/transition.h Normal file
View File

@@ -0,0 +1,68 @@
#ifndef JNR_TRANSITION_H
#define JNR_TRANSITION_H
#include <stdbool.h>
#include "engine/camera.h"
#include "config.h"
/* ═══════════════════════════════════════════════════
* Level transition animations
*
* Two-phase system: outro (on the old level) then
* intro (on the new level). The caller is responsible
* for freeing the old level and loading the new one
* between phases (when transition_needs_load() is true).
*
* TransitionStyle enum is defined in config.h so that
* both engine (tilemap) and game code can reference it
* without circular includes.
* ═══════════════════════════════════════════════════ */
typedef enum TransitionPhase {
TRANS_IDLE, /* no transition active */
TRANS_PHASE_OUT, /* outro animation on the old level */
TRANS_PHASE_LOAD, /* ready for the caller to swap levels */
TRANS_PHASE_IN, /* intro animation on the new level */
TRANS_PHASE_DONE, /* transition complete, return to play */
} TransitionPhase;
typedef struct TransitionState {
TransitionStyle out_style; /* outro style (from departing level) */
TransitionStyle in_style; /* intro style (from arriving level) */
TransitionPhase phase;
float timer; /* elapsed time in current phase */
float phase_dur; /* total duration of current phase */
bool sound_played; /* flag to fire a sound once per phase */
} TransitionState;
/* Start the outro phase. out_style comes from the departing level. */
void transition_start_out(TransitionState *ts, TransitionStyle out_style);
/* Set the intro style (call after loading the new level). */
void transition_set_in_style(TransitionState *ts, TransitionStyle in_style);
/* Advance the transition. cam may be NULL during the load gap. */
void transition_update(TransitionState *ts, float dt, Camera *cam);
/* True when the outro is done and the caller should swap levels. */
bool transition_needs_load(const TransitionState *ts);
/* Acknowledge the load — advance to the intro phase. */
void transition_begin_intro(TransitionState *ts);
/* True when the full transition is finished. */
bool transition_is_done(const TransitionState *ts);
/* Render the transition overlay (call AFTER rendering the level). */
void transition_render(const TransitionState *ts);
/* Reset to idle. */
void transition_reset(TransitionState *ts);
/* Parse a style name string ("none", "elevator", etc.). */
TransitionStyle transition_style_from_name(const char *name);
/* Return the directive string for a style. */
const char *transition_style_name(TransitionStyle style);
#endif /* JNR_TRANSITION_H */

View File

@@ -4,6 +4,9 @@
#include "game/level.h"
#include "game/levelgen.h"
#include "game/editor.h"
#include "game/stats.h"
#include "game/analytics.h"
#include "game/transition.h"
#include "config.h"
#include <stdio.h>
#include <string.h>
@@ -22,6 +25,7 @@ typedef enum GameMode {
MODE_PLAY,
MODE_EDITOR,
MODE_PAUSED,
MODE_TRANSITION,
} GameMode;
static Level s_level;
@@ -46,10 +50,35 @@ static int s_station_depth = 0;
static int s_mars_depth = 0;
#define MARS_BASE_GEN_COUNT 2
/* ── Analytics / stats tracking ── */
static GameStats s_stats;
static bool s_session_active = false;
/* ── Pause menu state ── */
#define PAUSE_ITEM_COUNT 3
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */
/* ── Level transition state ── */
static TransitionState s_transition;
static char s_pending_target[ASSET_PATH_MAX] = {0}; /* exit target stashed during transition */
#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";
@@ -221,6 +250,21 @@ static void load_mars_base_level(void) {
s_level_path[0] = '\0';
}
/* ── Analytics session helpers ── */
static void begin_session(void) {
stats_reset(&s_stats);
stats_set_active(&s_stats);
analytics_session_start();
s_session_active = true;
}
static void end_session(const char *reason) {
if (!s_session_active) return;
s_session_active = false;
stats_set_active(NULL);
analytics_session_end(&s_stats, reason);
}
/* ── Switch to editor mode ── */
static void enter_editor(void) {
if (s_mode == MODE_PLAY) {
@@ -283,20 +327,66 @@ static void restart_level(void) {
}
}
/* ── Level load dispatch — loads the next level based on target string ── */
static void dispatch_level_load(const char *target) {
if (target[0] == '\0') {
/* Empty target = victory / end of game. */
printf("Level complete! (no next level)\n");
end_session("completed");
level_free(&s_level);
s_station_depth = 0;
s_mars_depth = 0;
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
begin_session();
} else if (strcmp(target, "generate") == 0) {
printf("Transitioning to generated level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_generated_level();
} else if (strcmp(target, "generate:station") == 0) {
printf("Transitioning to space station level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_station_level();
} else if (strcmp(target, "generate:mars_base") == 0) {
printf("Transitioning to Mars Base level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_mars_base_level();
} else {
printf("Transitioning to: %s\n", target);
char path[ASSET_PATH_MAX];
snprintf(path, sizeof(path), "%s", target);
level_free(&s_level);
if (!load_level_file(path)) {
fprintf(stderr, "Failed to load next level: %s\n", path);
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
}
}
}
/* ═══════════════════════════════════════════════════
* Game callbacks
* ═══════════════════════════════════════════════════ */
static void game_init(void) {
analytics_init();
if (s_use_editor) {
enter_editor();
} else if (s_use_procgen) {
load_generated_level();
begin_session();
} else {
if (!load_level_file("assets/levels/moon01.lvl")) {
fprintf(stderr, "Failed to load level!\n");
g_engine.running = false;
}
begin_session();
}
}
@@ -330,12 +420,15 @@ static void pause_update(void) {
break;
case 1: /* Restart */
s_mode = MODE_PLAY;
end_session("quit");
restart_level();
begin_session();
break;
case 2: /* Quit */
if (s_testing_from_editor) {
return_to_editor();
} else {
end_session("quit");
g_engine.running = false;
}
break;
@@ -343,6 +436,53 @@ 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;
if (s_mode == MODE_EDITOR) {
/* Load the selected level into the editor, not gameplay. */
if (!editor_load(&s_editor, s_js_load_path)) {
fprintf(stderr, "Failed to load level in editor: %s\n",
s_js_load_path);
}
snprintf(s_edit_path, sizeof(s_edit_path), "%s", s_js_load_path);
s_js_load_path[0] = '\0';
return;
}
end_session("quit");
/* Tear down whatever mode we are in. */
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED
|| s_mode == MODE_TRANSITION) {
transition_reset(&s_transition);
level_free(&s_level);
}
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");
begin_session();
return;
}
#endif
if (s_mode == MODE_EDITOR) {
editor_update(&s_editor, dt);
@@ -360,6 +500,28 @@ static void game_update(float dt) {
return;
}
if (s_mode == MODE_TRANSITION) {
transition_update(&s_transition, dt, &s_level.camera);
/* Outro finished — swap levels. */
if (transition_needs_load(&s_transition)) {
dispatch_level_load(s_pending_target);
s_pending_target[0] = '\0';
/* Use the new level's intro style. */
TransitionStyle in_style = s_level.map.transition_in;
transition_set_in_style(&s_transition, in_style);
transition_begin_intro(&s_transition);
}
/* Intro finished — return to play. */
if (transition_is_done(&s_transition)) {
transition_reset(&s_transition);
s_mode = MODE_PLAY;
}
return;
}
/* ── Play mode ── */
/* Pause on escape (return to editor during test play) */
@@ -387,60 +549,41 @@ static void game_update(float dt) {
bool r_pressed = input_key_held(SDL_SCANCODE_R);
if (r_pressed && !r_was_pressed) {
printf("\n=== Regenerating level ===\n");
end_session("quit");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
s_use_procgen = true;
load_generated_level();
begin_session();
}
r_was_pressed = r_pressed;
level_update(&s_level, dt);
/* Accumulate play time */
if (s_session_active) {
s_stats.time_elapsed += dt;
}
/* Check for level exit transition */
if (level_exit_triggered(&s_level)) {
const char *target = s_level.exit_target;
if (target[0] == '\0') {
/* Empty target = victory / end of game */
printf("Level complete! (no next level)\n");
/* Loop back to the beginning, reset progression state */
level_free(&s_level);
s_station_depth = 0;
s_mars_depth = 0;
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
} else if (strcmp(target, "generate") == 0) {
/* Procedurally generated next level */
printf("Transitioning to generated level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_generated_level();
} else if (strcmp(target, "generate:station") == 0) {
/* Procedurally generated space station level */
printf("Transitioning to space station level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_station_level();
} else if (strcmp(target, "generate:mars_base") == 0) {
/* Procedurally generated Mars Base level */
printf("Transitioning to Mars Base level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_mars_base_level();
/* Record the level completion in stats */
if (s_session_active) {
s_stats.levels_completed++;
}
TransitionStyle out_style = s_level.map.transition_out;
if (out_style == TRANS_ELEVATOR || out_style == TRANS_TELEPORTER) {
/* Animated transition: stash target, start outro. */
snprintf(s_pending_target, sizeof(s_pending_target), "%s", target);
transition_start_out(&s_transition, out_style);
s_mode = MODE_TRANSITION;
} else {
/* Load a specific level file */
printf("Transitioning to: %s\n", target);
char path[ASSET_PATH_MAX];
snprintf(path, sizeof(path), "%s", target);
level_free(&s_level);
if (!load_level_file(path)) {
fprintf(stderr, "Failed to load next level: %s\n", path);
/* Fallback to moon01 */
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
}
/* Instant transition (none or spacecraft-driven). */
dispatch_level_load(target);
}
}
}
@@ -493,16 +636,23 @@ static void game_render(float interpolation) {
/* Render frozen game frame, then overlay the pause menu. */
level_render(&s_level, interpolation);
pause_render();
} else if (s_mode == MODE_TRANSITION) {
/* Render the level (frozen) with the transition overlay on top. */
level_render(&s_level, interpolation);
transition_render(&s_transition);
} else {
level_render(&s_level, interpolation);
}
}
static void game_shutdown(void) {
end_session("quit");
/* Always free both — editor may have been initialized even if we're
* currently in play mode (e.g. shutdown during test play). editor_free
* and level_free are safe to call on zeroed/already-freed structs. */
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED || s_testing_from_editor) {
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED
|| s_mode == MODE_TRANSITION || s_testing_from_editor) {
level_free(&s_level);
}
if (s_mode == MODE_EDITOR || s_use_editor) {

View File

@@ -107,7 +107,8 @@
</style>
</head>
<body>
<div id="canvas-container">
<div id="canvas-container"
data-analytics-url="https://horchposten.schick-web.site">
<canvas class="emscripten" id="canvas" tabindex="1"
width="640" height="360"></canvas>
</div>
@@ -117,7 +118,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 +264,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);
}
@@ -282,6 +283,37 @@
document.title = 'Jump \'n Run - Level Editor';
}
}
/* ── Analytics: end session on tab close ────────────── */
/* Fallback for when the WASM shutdown path didn't get a chance to
* run (e.g. user closes tab mid-game). Uses fetch with keepalive
* so the browser can send the request after the page is gone, and
* includes the X-API-Key header that sendBeacon can't carry. */
window.addEventListener('beforeunload', function() {
if (typeof Module !== 'undefined' && Module._analyticsSessionId &&
Module._analyticsUrl) {
var sid = Module._analyticsSessionId;
Module._analyticsSessionId = null;
/* Use stashed stats from the last C-side update if available,
* otherwise send minimal data so the session isn't left open. */
var body = Module._analyticsLastStats || JSON.stringify({
score: 0,
level_reached: 1,
lives_used: 0,
duration_seconds: 0,
end_reason: 'quit'
});
fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': Module._analyticsKey
},
body: body,
keepalive: true
});
}
});
</script>
{{{ SCRIPT }}}
</body>