52 Commits

Author SHA1 Message Date
c62aae16dc update levels
All checks were successful
Deploy / deploy (push) Successful in 1m16s
2026-03-16 20:42:03 +00:00
3b45572d38 Add game state debug log with binary ring buffer
All checks were successful
CI / build (pull_request) Successful in 32s
Deploy / deploy (push) Successful in 1m17s
Implement src/engine/debuglog module that records a comprehensive
snapshot of game state every tick into a 4 MB in-memory ring buffer.

Activated by --debug-log command-line flag. Press F12 during gameplay
to dump the ring buffer to a human-readable debug_log.txt file. The
buffer also auto-flushes every 10 seconds as a safety net.

Each tick snapshot captures: input state (held/pressed/released bitmasks),
full player state (position, velocity, health, dash, aim, timers),
camera position, physics globals, level name, and a variable-length
list of all active entity positions/velocities/health.

New files:
- src/engine/debuglog.h — API and snapshot data structures
- src/engine/debuglog.c — ring buffer, record, and dump logic

Modified files:
- include/config.h — DEBUGLOG_BUFFER_SIZE constant
- src/engine/input.h/c — input_get_snapshot() to pack input bitmasks
- src/engine/core.c — debuglog_record_tick() call after update
- src/main.c — CLI flag, init/shutdown, F12 hotkey, set_level calls

Closes #19
2026-03-16 20:33:03 +00:00
66a7b9e7e6 Fix downward dash not damaging enemies and add post-dash invincibility
Some checks failed
CI / build (pull_request) Successful in 32s
Deploy / deploy (push) Failing after 1m17s
Stomping was guarded by the invincibility check, so during a downward
dash the player could never deal stomp damage. Move the invincibility
guard to only protect against taking damage, not dealing it.

Extend dash invincibility by PLAYER_DASH_INV_GRACE (0.15s) past the
dash duration so the player is briefly protected after landing.

Closes #15
2026-03-16 20:20:05 +00:00
tas
59f76d6aa7 Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m17s
2026-03-16 20:15:19 +00:00
tas
29c620a9e8 Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m17s
2026-03-16 20:04:51 +00:00
27dc726839 Add hit markers and metal explosion for turrets
Some checks failed
CI / build (pull_request) Successful in 31s
Deploy / deploy (push) Failing after 1m19s
Turrets now emit orange-white spark particles when taking non-lethal
damage, giving clear visual feedback on hits. On death, turrets get a
dedicated metal explosion effect (shrapnel, hot sparks, flash, smoke)
instead of the generic death puff, with stronger screen shake.

Closes #14
2026-03-16 20:01:25 +00:00
tas
477c299d9f revert 84a257f9b9
Some checks failed
Deploy / deploy (push) Failing after 1m17s
revert Update .gitea/workflows/deploy.yaml
2026-03-16 19:47:27 +00:00
f65e8dd9ea Improve enemy AI cliff detection, add level-bottom kill, and charger knockback
Some checks failed
Deploy / deploy (push) Failing after 1m14s
Enemies that fall past the bottom of the level are now instantly destroyed,
preventing them from accumulating off-screen. Cliff detection uses a
speed-scaled lookahead so faster enemies reverse earlier. Charger deals
double damage during charge and knockback scales with the attacker's speed.
2026-03-16 19:45:12 +00:00
tas
84a257f9b9 Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m12s
2026-03-16 15:10:17 +00:00
tas
f7c498d7ad Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m14s
2026-03-16 15:07:57 +00:00
7080d7fefc Document tea CLI for remote git server operations
Some checks failed
CI / build (pull_request) Successful in 50s
Deploy / deploy (push) Failing after 1m16s
2026-03-16 14:58:53 +00:00
tas
69614f058c Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 2m47s
2026-03-16 13:29:08 +00:00
tas
cc582e1f0e Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m14s
2026-03-16 13:26:28 +00:00
c44ace5804 Debug container registry auth: test v2 and token endpoints
Some checks failed
CI / build (pull_request) Successful in 31s
Deploy / deploy (push) Failing after 1m14s
Revert to git.kimchi, add curl diagnostics to understand why
auth fails even after login succeeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:21:33 +00:00
ec63ce6701 Use external hostname for container registry push
All checks were successful
CI / build (pull_request) Successful in 32s
The Gitea container registry token service scopes tokens to ROOT_URL
(git.schick-web.site). Pushing to the internal hostname (git.kimchi)
causes auth failures because the token domain doesn't match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:08:11 +00:00
767d821534 Pass credentials directly to buildah push via --creds
Some checks failed
CI / build (pull_request) Successful in 31s
Deploy / deploy (push) Failing after 1m12s
buildah login succeeds but push doesn't pick up the stored auth.
Skip login and pass --creds directly to each push command instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:17:55 +00:00
59b6728ce8 Add debug output to identify which buildah command hangs
Some checks failed
CI / build (pull_request) Successful in 31s
Deploy / deploy (push) Failing after 1m11s
Remove unnecessary registries.conf write (host already has it).
Add set -ex and echo markers between commands to pinpoint the hang.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:33:37 +00:00
4e3e17ced4 Use v2 TOML format for registries.conf
All checks were successful
CI / build (pull_request) Successful in 31s
Match the format already used on the runner host.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:31:54 +00:00
bb23f2e3a1 Add insecure registry config for buildah
All checks were successful
CI / build (pull_request) Successful in 31s
--tls-verify=false on login/push alone was not sufficient to prevent
the deploy from hanging. Register git.kimchi as an insecure registry
via registries.conf and add --tls-verify=false to buildah bud as well.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:31:01 +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
38 changed files with 2696 additions and 216 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,50 @@
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: |
set -ex
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" .
CREDS="${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}"
echo "=== buildah push tag ==="
buildah push --tls-verify=false --creds "$CREDS" "$IMAGE_TAG"
echo "=== buildah push latest ==="
buildah push --tls-verify=false --creds "$CREDS" "$IMAGE_LATEST"
echo "IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV"
- name: Restart deployment on k3s
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
kubectl delete pod -l app=${{ env.DEPLOYMENT }} -n ${{ env.NAMESPACE }}
POD_NAME=$(kubectl get pods -l app=jnr-web -n jnr-web -o jsonpath='{.items[0].metadata.name}')
echo "Deleting running pod" "$POD_NAME"

View File

@@ -21,7 +21,9 @@ make DEBUG=1 # Alternative debug flag
make web # WASM build → dist-web/ make web # WASM build → dist-web/
make web-serve # WASM build + HTTP server on :8080 make web-serve # WASM build + HTTP server on :8080
make windows # Cross-compile → dist-win64/ make windows # Cross-compile → dist-win64/
make k8s # Build web + container image + deploy to local k3s
make clean # Remove all build artifacts 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` 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 - **WASM builds** require the Emscripten SDK. The `emsdk/` directory in the project root is
gitignored; source the environment before building: gitignored; source the environment before building:
```bash ```bash
source ~/emsdk/emsdk_env.sh # or wherever emsdk is installed source emsdk/emsdk_env.sh
make web make web
``` ```
- **Windows cross-compilation** requires MinGW (`x86_64-w64-mingw32-gcc`) and vendored - **Windows cross-compilation** requires MinGW (`x86_64-w64-mingw32-gcc`) and vendored
SDL2 development libraries in `deps/win64/` (also gitignored). 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 ## Project Structure
@@ -250,6 +256,23 @@ incremental progress.
- Draw layers: BG → entities → FG → particles → HUD - Draw layers: BG → entities → FG → particles → HUD
- Camera transforms world coords to screen coords - Camera transforms world coords to screen coords
## Remote Git Server
The remote git server is a Gitea instance. Use the **`tea` CLI** (not `gh`) for all remote
operations: pull requests, issues, releases, and repository management.
```bash
tea pr create --title "..." --description "..." # Create a pull request
tea pr list # List open PRs
tea issue list # List issues
tea issue create --title "..." --description "..."
tea pr merge <number> # Merge a PR
```
Do NOT use `gh` (GitHub CLI) — it will not work with this remote.
After creating a pull request, always check out the `main` branch (`git checkout main`).
## Commit Messages ## Commit Messages
- Imperative mood, concise - Imperative mood, concise
- No co-authored-by or AI attribution - No co-authored-by or AI attribution
@@ -264,5 +287,5 @@ incremental progress.
| `TICK_RATE` | 60 | Fixed timestep Hz | | `TICK_RATE` | 60 | Fixed timestep Hz |
| `DEFAULT_GRAVITY` | 980.0f | px/s² | | `DEFAULT_GRAVITY` | 980.0f | px/s² |
| `MAX_ENTITIES` | 512 | Entity pool size | | `MAX_ENTITIES` | 512 | Entity pool size |
| `MAX_ENTITY_SPAWNS` | 128 | Per-level spawn slots | | `MAX_ENTITY_SPAWNS` | 512 | Per-level spawn slots |
| `MAX_EXIT_ZONES` | 8 | Per-level exit zones | | `MAX_EXIT_ZONES` | 16 | Per-level exit zones |

View File

@@ -1,7 +1,4 @@
# JNR Web — static file server for the Emscripten build # JNR Web — multi-stage build: compile WASM then serve static files
#
# Prerequisites:
# make web (produces dist-web/)
# #
# Build: # Build:
# podman build -t jnr-web . # podman build -t jnr-web .
@@ -15,6 +12,24 @@
# podman save jnr-web | sudo k3s ctr images import - # podman save jnr-web | sudo k3s ctr images import -
# sudo k3s kubectl apply -f k8s/ # 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 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. - **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 ### 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 - **Shielder** — Has a directional shield, must be hit from behind or above
- **Boss** — Large, multi-phase encounters. One per world area. - **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 ## Weapons / Projectiles
Data-driven system: each weapon type is a `ProjectileDef` struct describing speed, 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 ## Levels
### Format (.lvl) ### 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:** **Needed additions:**
- `STORM`, `DRAG` — Remaining atmosphere settings - `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 7. **Space Freighter** — Normal gravity, tight corridors, turret enemies
8. **Ice World** — Normal gravity, strong winds, slippery surface 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 ## 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 ## Reference Games
- Jazz Jackrabbit 2 (movement feel, weapon variety, level design) - Jazz Jackrabbit 2 (movement feel, weapon variety, level design)
- Metal Slug (run-and-gun, enemy variety, visual flair) - Metal Slug (run-and-gun, enemy variety, visual flair)

View File

@@ -13,7 +13,7 @@ ifdef WASM
-sSDL2_IMAGE_FORMATS='["png"]' \ -sSDL2_IMAGE_FORMATS='["png"]' \
-sSDL2_MIXER_FORMATS='["ogg"]' \ -sSDL2_MIXER_FORMATS='["ogg"]' \
-sALLOW_MEMORY_GROWTH=1 \ -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"]' \ -sEXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \
--preload-file assets \ --preload-file assets \
--shell-file web/shell.html --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. - 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. - `s_mars_depth` and `s_station_depth` reset when game loops back to beginning.
- Added `gen_bg_decoration()` call to Mars Base generator. - 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 GRAVITY 400
BG_COLOR 15 15 30 BG_COLOR 15 15 30
MUSIC assets/sounds/algardalgar.ogg MUSIC assets/sounds/algardalgar.ogg
TRANSITION_OUT elevator
# Spacecraft landing intro (arriving from moon) # Spacecraft landing intro (arriving from moon)
ENTITY spacecraft 1 14 ENTITY spacecraft 1 14

View File

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

View File

@@ -1,42 +1,51 @@
# Mars Surface - Red Dusty Plains # Level created with in-game editor
# ================================
# First Mars level: low gravity, wide-open terrain with wind.
# Spacecraft landing intro from moon. Charger enemies patrol
# the dusty landscape. Gun pickup midway through.
TILESET assets/tiles/mars_tileset.png TILESET assets/tiles/mars_tileset.png
SIZE 250 23 SIZE 250 23
SPAWN 3 18 SPAWN 3 19
GRAVITY 370 GRAVITY 370
WIND 25 WIND 25
BG_COLOR 30 12 8 BG_COLOR 30 12 8
PARALLAX_STYLE 5
MUSIC assets/sounds/kaffe_og_kage.ogg MUSIC assets/sounds/kaffe_og_kage.ogg
PARALLAX_STYLE 5
ENTITY spacecraft 1 14 ENTITY spacecraft 1 15
# Charger patrols across flat sections
ENTITY charger 40 18 ENTITY charger 40 18
ENTITY charger 75 18 ENTITY charger 75 18
ENTITY charger 120 14 ENTITY charger 120 11
ENTITY charger 165 18 ENTITY charger 165 17
ENTITY charger 200 18 ENTITY charger 200 18
# Grunts near structures
ENTITY grunt 55 18 ENTITY grunt 55 18
ENTITY grunt 140 18 ENTITY grunt 140 18
# Health and gun pickups
ENTITY powerup_hp 90 15 ENTITY powerup_hp 90 15
ENTITY powerup_gun 130 12 ENTITY powerup_gun 130 11
ENTITY grunt 132 18
ENTITY grunt 116 18
ENTITY grunt 28 19
ENTITY powerup_jet 177 19
ENTITY grunt 174 21
ENTITY grunt 169 21
ENTITY grunt 164 21
ENTITY grunt 159 21
ENTITY grunt 202 18
ENTITY grunt 213 13
ENTITY grunt 20 19
ENTITY powerup_fuel 24 8
ENTITY powerup_fuel 90 11
ENTITY charger 127 18
ENTITY charger 175 21
ENTITY asteroid 54 5
ENTITY asteroid 53 5
ENTITY asteroid 48 5
EXIT 246 17 2 3 assets/levels/mars02.lvl EXIT 246 17 2 3 assets/levels/mars02.lvl
# Tile definitions (Mars tileset)
TILEDEF 1 0 0 1 TILEDEF 1 0 0 1
TILEDEF 2 1 0 1 TILEDEF 2 1 0 1
TILEDEF 3 2 0 1 TILEDEF 3 2 0 1
TILEDEF 4 0 1 2 TILEDEF 4 0 1 2
TILEDEF 5 1 1 0
TILEDEF 6 2 1 0
LAYER collision LAYER collision
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
@@ -48,15 +57,18 @@ LAYER collision
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 2 1 1 1 1 1 2 1 1 1 1 2 1 1 2 1 1 1 1 1 2 1 1 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 3 1 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 3 1 3 1 0 0 0 0 0 1 3 1 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 1 1 3 1 1 3 1 3 1 1 3 1 0 0 0 0 0 0 1 1 3 1 1 3 1 1 3 1 1 1 1 3 1 1 3 1 3 1 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 1 3 1 1 3 1 1 3 0 0 0 0 0 1 5 5 5 5 5 5 5 5 5 3 1 1 3 1 1 1 3 1 1 3 1 5 5 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 1 3 3 1 3 3 1 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 1 3 3 1 3 3 1 3 0 0 0 0 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 1 3 1 3 0 0 0 0 0 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 3 3 1 3 3 1 3 1 3 3 1 3 0 0 0 0 0 0 3 3 1 3 3 1 3 3 1 3 3 3 3 1 3 3 1 3 1 3 3 1 3 3 3 1 3 1 3 3 3 1 3 3 3 1 3 3 1 3 3 1 3 3 1 0 0 0 0 0 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 1 3 1 3 3 1 3 1
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 2 2 3 1 3 3 2 1 1 2 2 3 1 1 1 1 1 2 3 2 1 1 1 1 1 1 1 2 3 3 3 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 1 3 3 2 2 3 2 2 1 3 2 2 2 1 1 1 3 1 2 2 2 2 1 3 3 3 3 3 3 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 1 1 2 1 1 1 1 2 1 1 1 1 1 1 1 1 2 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 2 1 1 1 1 1 1 1 2 1 1 1 2 1 1 1 1 0 0 0 0 0 1 2 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 2 1 1 2 1 1 2 1 1 1 1 1 0 0 0 0 0 1 2 1 1 1 1 1 2 1 1 1 1 2 1 1 2 1 1 1 1 1 1 1 1 2 1 1 1 1 1 2 1 1 1 1 1 1 2 1 1 1 2 1 1 1 2 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 2 1 1 2 1 1 1 1 1 1 1 2 1 1 1 1 2 1 1 1 1 1 1 1 1 1
1 3 1 1 3 1 1 3 1 3 1 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 3 1 1 3 1 1 3 1 0 0 0 0 1 3 1 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 3 1 3 1 0 0 0 0 0 1 3 1 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 1 1 3 1 1 3 1 3 1 1 3 1 0 0 0 0 0 0 1 1 3 1 1 3 1 1 3 1 1 1 1 3 1 1 3 1 3 1 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 1 3 1 1 3 1 1 3 0 0 0 0 0 1 3 1 1 3 1 3 1 1 1 3 1 1 3 1 1 1 3 1 1 3 1 3 1 1 1 3 1 1 3 1 1 3 1 3 1 1 1 3 1 3 1 1 3 1 1 3 1 3 1 1 3 1 1 3 1 1 1 3 1 1 3 1 1 3 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 1 1 3 1 1 3 1 1 3 1 3 1 1 3 1 3
3 1 3 3 1 3 3 1 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 1 3 3 1 3 3 1 3 0 0 0 0 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 1 3 1 3 0 0 0 0 0 3 1 3 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 3 3 1 3 3 1 3 1 3 3 1 3 0 0 0 0 0 0 3 3 1 3 3 1 3 3 1 3 3 3 3 1 3 3 1 3 1 3 3 1 3 3 3 1 3 1 3 3 3 1 3 3 3 1 3 3 1 3 3 1 3 3 1 0 0 0 0 0 3 1 3 3 1 3 1 3 3 3 1 3 3 1 3 3 3 1 3 3 1 3 1 3 3 3 1 3 3 1 3 3 1 3 1 3 3 3 1 3 1 3 3 1 3 3 1 3 1 3 3 1 3 3 1 3 3 3 1 3 3 1 3 3 1 3 1 3 3 3 1 3 1 3 3 3 1 3 1 3 3 3 1 3 3 1 3 3 1 3 1 3 3 1 3 1

View File

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

View File

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

View File

@@ -1,15 +1,12 @@
# Moon Surface - Crater Fields # Level created with in-game editor
# ============================
# Second moon level: tighter platforming over deep craters.
# Spacecraft landing intro. Unarmed — pure jump-and-run with asteroids.
TILESET assets/tiles/moon_tileset.png TILESET assets/tiles/moon_tileset.png
SIZE 200 23 SIZE 200 23
SPAWN 3 18 SPAWN 3 18
GRAVITY 300 GRAVITY 300
BG_COLOR 5 5 15 BG_COLOR 5 5 15
PARALLAX_STYLE 4
MUSIC assets/sounds/algardalgar.ogg MUSIC assets/sounds/algardalgar.ogg
PARALLAX_STYLE 4
PLAYER_UNARMED PLAYER_UNARMED
ENTITY spacecraft 1 14 ENTITY spacecraft 1 14
@@ -20,10 +17,22 @@ ENTITY asteroid 105 3
ENTITY asteroid 130 0 ENTITY asteroid 130 0
ENTITY asteroid 155 2 ENTITY asteroid 155 2
ENTITY asteroid 180 1 ENTITY asteroid 180 1
ENTITY powerup_fuel 43 16
ENTITY powerup_fuel 46 11
ENTITY powerup_fuel 75 15
ENTITY powerup_fuel 94 12
ENTITY powerup_fuel 106 16
ENTITY powerup_fuel 130 12
ENTITY powerup_fuel 166 12
ENTITY asteroid 167 0
ENTITY asteroid 117 0
ENTITY asteroid 96 0
ENTITY asteroid 66 0
ENTITY asteroid 44 0
ENTITY asteroid 40 0
EXIT 196 17 2 3 assets/levels/moon03.lvl EXIT 196 17 2 3 assets/levels/moon03.lvl
# Tile definitions
TILEDEF 1 0 0 1 TILEDEF 1 0 0 1
TILEDEF 2 1 0 1 TILEDEF 2 1 0 1
TILEDEF 3 2 0 1 TILEDEF 3 2 0 1
@@ -53,3 +62,4 @@ LAYER collision
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1
1 2 2 2 1 1 1 2 1 2 2 1 2 2 1 0 0 0 0 0 1 2 1 1 2 1 1 1 2 0 0 0 0 0 0 0 1 1 2 2 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 2 2 1 1 1 2 2 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 2 1 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 2 2 1 2 2 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 1 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 2 1 1 2 2 2 1 1 1 2 0 0 0 0 0 0 1 1 1 1 1 2 1 1 1 2 1 2 1 1 2 2 2 1 1 1 2 1 2 2 1 2 2 1 0 0 0 0 0 1 2 1 1 2 1 1 1 2 0 0 0 0 0 0 0 1 1 2 2 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 2 2 1 1 1 2 2 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 2 1 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 2 2 1 2 2 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 1 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 2 1 1 2 2 2 1 1 1 2 0 0 0 0 0 0 1 1 1 1 1 2 1 1 1 2 1 2 1
1 1 1 3 1 3 1 1 3 1 1 3 1 1 1 0 0 0 0 0 1 3 1 3 1 1 1 1 3 0 0 0 0 0 0 0 1 1 3 1 3 1 1 3 1 1 3 1 1 0 0 0 0 0 0 0 0 0 0 3 1 1 3 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 3 3 1 1 3 3 3 1 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 3 3 1 3 3 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 3 3 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 3 1 1 1 3 3 1 1 3 3 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 3 1 1 1 1 1 0 0 0 0 0 0 3 1 3 1 3 1 1 3 1 1 1 1 1 1 1 1 3 1 3 1 1 3 1 1 3 1 1 1 0 0 0 0 0 1 3 1 3 1 1 1 1 3 0 0 0 0 0 0 0 1 1 3 1 3 1 1 3 1 1 3 1 1 0 0 0 0 0 0 0 0 0 0 3 1 1 3 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 3 3 1 1 3 3 3 1 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 3 3 1 3 3 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 3 3 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 3 1 1 1 3 3 1 1 3 3 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 3 1 1 1 1 1 0 0 0 0 0 0 3 1 3 1 3 1 1 3 1 1 1 1 1

View File

@@ -1,15 +1,12 @@
# Moon Surface - Dark Side # Level created with in-game editor
# ========================
# Third moon level: the hardest moon terrain with massive chasms.
# Spacecraft landing intro. Unarmed until gun powerup near exit.
TILESET assets/tiles/moon_tileset.png TILESET assets/tiles/moon_tileset.png
SIZE 150 23 SIZE 150 23
SPAWN 3 18 SPAWN 3 18
GRAVITY 300 GRAVITY 300
BG_COLOR 5 5 15 BG_COLOR 5 5 15
PARALLAX_STYLE 4
MUSIC assets/sounds/algardalgar.ogg MUSIC assets/sounds/algardalgar.ogg
PARALLAX_STYLE 4
PLAYER_UNARMED PLAYER_UNARMED
ENTITY spacecraft 1 14 ENTITY spacecraft 1 14
@@ -18,13 +15,16 @@ ENTITY asteroid 50 2
ENTITY asteroid 75 1 ENTITY asteroid 75 1
ENTITY asteroid 100 3 ENTITY asteroid 100 3
ENTITY asteroid 125 0 ENTITY asteroid 125 0
# Gun powerup near the exit — the player finally gets armed
ENTITY powerup_gun 130 18 ENTITY powerup_gun 130 18
ENTITY asteroid 25 1
ENTITY asteroid 60 0
ENTITY asteroid 86 0
ENTITY asteroid 98 0
ENTITY asteroid 110 0
ENTITY asteroid 120 1
EXIT 146 17 2 3 assets/levels/mars01.lvl EXIT 146 17 2 3 assets/levels/mars01.lvl
# Tile definitions
TILEDEF 1 0 0 1 TILEDEF 1 0 0 1
TILEDEF 2 1 0 1 TILEDEF 2 1 0 1
TILEDEF 3 2 0 1 TILEDEF 3 2 0 1
@@ -40,17 +40,18 @@ LAYER collision
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 2 1 1 1 2 2 1 2 1 1 1 0 0 0 0 0 0 1 2 1 1 1 1 1 1 1 0 0 0 0 0 0 0 2 2 2 1 1 2 1 2 2 0 0 0 0 0 0 0 0 0 0 1 1 1 2 1 2 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 2 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 2 1 2 1 1 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 2 2 1 1 1 1 0 0 0 0 0 0 2 1 1 1 2 2 1 1 1 2 2 1 1 2 1 1 2 1 1 2 1 1 2 1 1 1 2 2 1 2 1 1 1 0 0 0 0 0 0 1 2 1 1 1 1 1 1 1 0 0 0 0 0 0 0 2 2 2 1 1 2 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 2 1 2 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 2 1 2 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 1 1 0 0 0 0 0 0 0 0 2 1 1 1 2 2 1 1 1 2 2 1 1 2 1 1 2 1 1 2 1
1 1 1 3 3 1 3 1 1 3 1 3 0 0 0 0 0 0 1 3 1 1 1 1 1 1 3 0 0 0 0 0 0 0 3 3 3 1 3 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 3 3 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 3 3 1 3 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 3 3 1 0 0 0 0 0 0 1 3 1 1 1 1 1 3 1 1 1 3 1 1 3 1 1 1 1 3 1 1 1 1 3 3 1 3 1 1 3 1 3 0 0 0 0 0 0 1 3 1 1 1 1 1 1 3 0 0 0 0 0 0 0 3 3 3 1 3 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 3 3 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 3 3 1 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 3 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 3 0 0 0 0 0 0 0 0 1 3 1 1 1 1 1 3 1 1 1 3 1 1 3 1 1 1 1 3 1

View File

@@ -28,6 +28,14 @@
/* ── Level transitions ─────────────────────────────── */ /* ── Level transitions ─────────────────────────────── */
#define MAX_EXIT_ZONES 16 /* max exit zones per level */ #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 ──────────────────────────────────────── */ /* ── Rendering ──────────────────────────────────────── */
#define MAX_SPRITES 2048 /* max queued sprites per frame */ #define MAX_SPRITES 2048 /* max queued sprites per frame */
@@ -41,4 +49,7 @@
#define MAX_ASSETS 128 #define MAX_ASSETS 128
#define ASSET_PATH_MAX 256 #define ASSET_PATH_MAX 256
/* ── Debug log ──────────────────────────────────────── */
#define DEBUGLOG_BUFFER_SIZE (4 * 1024 * 1024) /* 4 MB ring buffer */
#endif /* JNR_CONFIG_H */ #endif /* JNR_CONFIG_H */

View File

@@ -3,6 +3,7 @@
#include "engine/renderer.h" #include "engine/renderer.h"
#include "engine/audio.h" #include "engine/audio.h"
#include "engine/assets.h" #include "engine/assets.h"
#include "engine/debuglog.h"
#include <stdio.h> #include <stdio.h>
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
@@ -118,6 +119,7 @@ static void engine_frame(void) {
if (s_callbacks.update) { if (s_callbacks.update) {
s_callbacks.update(DT); s_callbacks.update(DT);
} }
debuglog_record_tick();
input_consume(); input_consume();
g_engine.tick++; g_engine.tick++;
s_accumulator -= DT; s_accumulator -= DT;

474
src/engine/debuglog.c Normal file
View File

@@ -0,0 +1,474 @@
#include "engine/debuglog.h"
#include "engine/core.h"
#include "engine/input.h"
#include "engine/physics.h"
#include "engine/entity.h"
#include "game/level.h"
#include "game/player.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
/* ═══════════════════════════════════════════════════
* File-scope state
* ═══════════════════════════════════════════════════ */
/* Ring buffer header stored at the start of the memory block. */
typedef struct RingHeader {
uint32_t magic; /* 0x44424C47 = "DBLG" */
uint32_t version;
uint32_t write_cursor; /* byte offset into data region */
uint32_t total_written; /* total snapshots written (wraps) */
uint32_t data_size; /* usable data bytes after header */
} RingHeader;
#define RING_MAGIC 0x44424C47
#define RING_VERSION 1
/* Flush the buffer to disk every this many ticks (10 s at 60 Hz). */
#define FLUSH_INTERVAL 600
static bool s_enabled;
static uint8_t *s_buffer; /* full allocation: header + data */
static RingHeader *s_header;
static uint8_t *s_data; /* start of ring data region */
static Level *s_level;
static char s_level_name[32]; /* human-readable level label */
static int s_flush_counter;
/* Path used for periodic safety-net flushes. */
static const char *s_flush_path = "debug_log_autosave.bin";
/* ═══════════════════════════════════════════════════
* Internal helpers
* ═══════════════════════════════════════════════════ */
/* Write raw bytes into the ring, wrapping at the boundary. */
static void ring_write(const void *src, uint32_t len) {
uint32_t cursor = s_header->write_cursor;
uint32_t cap = s_header->data_size;
const uint8_t *p = (const uint8_t *)src;
uint32_t first = cap - cursor;
if (first >= len) {
memcpy(s_data + cursor, p, len);
} else {
memcpy(s_data + cursor, p, first);
memcpy(s_data, p + first, len - first);
}
s_header->write_cursor = (cursor + len) % cap;
}
/* Read raw bytes from an arbitrary ring offset, wrapping. */
static void ring_read(uint32_t offset, void *dst, uint32_t len) {
uint32_t cap = s_header->data_size;
offset = offset % cap;
uint8_t *out = (uint8_t *)dst;
uint32_t first = cap - offset;
if (first >= len) {
memcpy(out, s_data + offset, len);
} else {
memcpy(out, s_data + offset, first);
memcpy(out + first, s_data, len - first);
}
}
/* Flush the entire buffer (header + data) to a binary file. */
static void flush_to_file(const char *path) {
FILE *f = fopen(path, "wb");
if (!f) {
fprintf(stderr, "Warning: debuglog flush failed — cannot open %s\n", path);
return;
}
uint32_t total = (uint32_t)sizeof(RingHeader) + s_header->data_size;
size_t written = fwrite(s_buffer, 1, total, f);
fclose(f);
if (written != total) {
fprintf(stderr, "Warning: debuglog flush incomplete (%zu / %u bytes)\n",
written, total);
}
}
/* Find the player entity in the current level, or NULL. */
static Entity *find_player(void) {
if (!s_level) return NULL;
EntityManager *em = &s_level->entities;
for (int i = 0; i < MAX_ENTITIES; i++) {
Entity *e = &em->entities[i];
if (e->active && e->type == ENT_PLAYER) return e;
}
return NULL;
}
/* Pack player-specific flags into a single byte.
* Bit layout: 0=on_ground, 1=jumping, 2=dashing, 3=invincible, 4=has_gun */
static uint8_t pack_player_flags(const Entity *player) {
uint8_t f = 0;
if (player->body.on_ground) f |= (1 << 0);
PlayerData *pd = (PlayerData *)player->data;
if (pd) {
if (pd->jumping) f |= (1 << 1);
if (pd->dash_timer > 0) f |= (1 << 2);
if (pd->inv_timer > 0) f |= (1 << 3);
if (pd->has_gun) f |= (1 << 4);
}
return f;
}
/* Pack entity-level flags into a single byte.
* Bit layout: 0=active, 1=facing_left, 2=dead, 3=invincible */
static uint8_t pack_entity_flags(const Entity *e) {
uint8_t f = 0;
if (e->active) f |= (1 << 0);
if (e->flags & ENTITY_FACING_LEFT) f |= (1 << 1);
if (e->flags & ENTITY_DEAD) f |= (1 << 2);
if (e->flags & ENTITY_INVINCIBLE) f |= (1 << 3);
return f;
}
/* Names for entity types — kept in sync with EntityType enum. */
static const char *entity_type_name(uint8_t type) {
switch ((EntityType)type) {
case ENT_NONE: return "NONE";
case ENT_PLAYER: return "PLAYER";
case ENT_ENEMY_GRUNT: return "GRUNT";
case ENT_ENEMY_FLYER: return "FLYER";
case ENT_PROJECTILE: return "PROJ";
case ENT_PICKUP: return "PICKUP";
case ENT_PARTICLE: return "PARTICLE";
case ENT_TURRET: return "TURRET";
case ENT_MOVING_PLATFORM: return "PLATFORM";
case ENT_FLAME_VENT: return "FLAME";
case ENT_FORCE_FIELD: return "FORCEFIELD";
case ENT_POWERUP: return "POWERUP";
case ENT_DRONE: return "DRONE";
case ENT_ASTEROID: return "ASTEROID";
case ENT_SPACECRAFT: return "SPACECRAFT";
case ENT_LASER_TURRET: return "LASER_TURRET";
case ENT_ENEMY_CHARGER: return "CHARGER";
case ENT_SPAWNER: return "SPAWNER";
default: return "???";
}
}
/* Names for aim directions. */
static const char *aim_dir_name(uint8_t aim) {
switch ((AimDir)aim) {
case AIM_FORWARD: return "FORWARD";
case AIM_UP: return "UP";
case AIM_DIAG_UP: return "DIAG_UP";
default: return "???";
}
}
/* Names for input actions. */
static const char *action_name(int a) {
switch ((Action)a) {
case ACTION_LEFT: return "LEFT";
case ACTION_RIGHT: return "RIGHT";
case ACTION_UP: return "UP";
case ACTION_DOWN: return "DOWN";
case ACTION_JUMP: return "JUMP";
case ACTION_SHOOT: return "SHOOT";
case ACTION_DASH: return "DASH";
case ACTION_PAUSE: return "PAUSE";
default: return "???";
}
}
/* ═══════════════════════════════════════════════════
* Public API
* ═══════════════════════════════════════════════════ */
void debuglog_init(void) {
uint32_t total = (uint32_t)sizeof(RingHeader) + DEBUGLOG_BUFFER_SIZE;
s_buffer = calloc(1, total);
if (!s_buffer) {
fprintf(stderr, "Warning: debuglog_init failed to allocate %u bytes\n", total);
return;
}
s_header = (RingHeader *)s_buffer;
s_data = s_buffer + sizeof(RingHeader);
s_header->magic = RING_MAGIC;
s_header->version = RING_VERSION;
s_header->write_cursor = 0;
s_header->total_written = 0;
s_header->data_size = DEBUGLOG_BUFFER_SIZE;
s_enabled = false;
s_level = NULL;
s_flush_counter = 0;
printf("Debug log initialized (%u KB ring buffer)\n",
DEBUGLOG_BUFFER_SIZE / 1024);
}
void debuglog_shutdown(void) {
if (!s_buffer) return;
if (s_enabled && s_header->total_written > 0) {
flush_to_file(s_flush_path);
printf("Debug log flushed to %s on shutdown (%u snapshots)\n",
s_flush_path, s_header->total_written);
}
free(s_buffer);
s_buffer = NULL;
s_header = NULL;
s_data = NULL;
s_enabled = false;
s_level = NULL;
}
void debuglog_enable(void) {
s_enabled = true;
printf("Debug log recording enabled\n");
}
bool debuglog_is_enabled(void) {
return s_enabled;
}
void debuglog_set_level(Level *lvl, const char *name) {
s_level = lvl;
if (name && name[0]) {
snprintf(s_level_name, sizeof(s_level_name), "%s", name);
} else {
s_level_name[0] = '\0';
}
}
void debuglog_record_tick(void) {
if (!s_enabled || !s_buffer) return;
/* Count active entities (skip player — recorded separately). */
uint16_t ent_count = 0;
if (s_level) {
EntityManager *em = &s_level->entities;
for (int i = 0; i < MAX_ENTITIES && ent_count < MAX_ENTITIES; i++) {
Entity *e = &em->entities[i];
if (e->active && e->type != ENT_PLAYER) ent_count++;
}
}
uint32_t frame_size = (uint32_t)sizeof(TickSnapshot)
+ (uint32_t)(ent_count * sizeof(EntitySnapshot));
/* Don't write if the frame won't fit in the ring at all. */
if (frame_size > s_header->data_size) return;
/* Build the header portion on the stack. */
TickSnapshot snap;
memset(&snap, 0, sizeof(snap));
snap.tick = g_engine.tick;
snap.frame_size = frame_size;
/* Input. */
input_get_snapshot(&snap.input);
/* Player state. */
Entity *player = find_player();
if (player) {
snap.player_x = player->body.pos.x;
snap.player_y = player->body.pos.y;
snap.player_vx = player->body.vel.x;
snap.player_vy = player->body.vel.y;
snap.player_health = (int8_t)player->health;
snap.player_flags = pack_player_flags(player);
PlayerData *pd = (PlayerData *)player->data;
if (pd) {
snap.player_dash_timer = pd->dash_timer;
snap.player_dash_charges = (int8_t)pd->dash_charges;
snap.player_inv_timer = pd->inv_timer;
snap.player_coyote = pd->coyote_timer;
snap.player_aim_dir = (uint8_t)pd->aim_dir;
}
}
/* Camera. */
if (s_level) {
snap.cam_x = s_level->camera.pos.x;
snap.cam_y = s_level->camera.pos.y;
}
/* Physics globals. */
snap.gravity = physics_get_gravity();
snap.wind = physics_get_wind();
/* Level name (truncated to 31 chars + NUL). */
if (s_level_name[0]) {
snprintf(snap.level_name, sizeof(snap.level_name), "%s", s_level_name);
}
snap.entity_count = ent_count;
/* Write header into ring. */
ring_write(&snap, sizeof(TickSnapshot));
/* Write entity snapshots. */
if (s_level && ent_count > 0) {
EntityManager *em = &s_level->entities;
for (int i = 0; i < MAX_ENTITIES; i++) {
Entity *e = &em->entities[i];
if (!e->active || e->type == ENT_PLAYER) continue;
EntitySnapshot es;
es.type = (uint8_t)e->type;
es.flags = pack_entity_flags(e);
es.health = (int16_t)e->health;
es.pos_x = e->body.pos.x;
es.pos_y = e->body.pos.y;
es.vel_x = e->body.vel.x;
es.vel_y = e->body.vel.y;
ring_write(&es, sizeof(EntitySnapshot));
}
}
s_header->total_written++;
/* Periodic safety-net flush. */
s_flush_counter++;
if (s_flush_counter >= FLUSH_INTERVAL) {
s_flush_counter = 0;
flush_to_file(s_flush_path);
}
}
void debuglog_dump(const char *path) {
if (!s_buffer || s_header->total_written == 0) {
printf("Debug log: nothing to dump (0 snapshots recorded)\n");
return;
}
FILE *f = fopen(path, "w");
if (!f) {
fprintf(stderr, "Error: cannot open %s for writing\n", path);
return;
}
fprintf(f, "=== Debug Log Dump ===\n");
fprintf(f, "Total snapshots written: %u\n\n", s_header->total_written);
/* Walk backwards from write_cursor to find snapshots.
* We read forward through the ring, starting from the oldest data. */
/* To find the oldest snapshot: if the buffer has wrapped, oldest
* starts at write_cursor. Otherwise it starts at 0. */
uint32_t cap = s_header->data_size;
uint32_t total_data_bytes;
uint32_t read_pos;
/* Estimate how much data we've written total. If total_written
* snapshots have been recorded and we assume average frame size
* is frame_size, the buffer may have wrapped. We use a simpler
* approach: scan forward from the oldest point. */
if (s_header->total_written * sizeof(TickSnapshot) >= cap) {
/* Buffer has likely wrapped. Start reading at write_cursor
* (which is where the oldest data begins). */
read_pos = s_header->write_cursor;
total_data_bytes = cap;
} else {
read_pos = 0;
total_data_bytes = s_header->write_cursor;
}
uint32_t bytes_read = 0;
uint32_t snapshots_dumped = 0;
uint32_t first_tick = 0;
uint32_t last_tick = 0;
while (bytes_read + sizeof(TickSnapshot) <= total_data_bytes) {
TickSnapshot snap;
ring_read(read_pos, &snap, sizeof(TickSnapshot));
/* Sanity check. */
if (snap.frame_size < sizeof(TickSnapshot) ||
snap.frame_size > cap) {
break;
}
if (bytes_read + snap.frame_size > total_data_bytes) {
break;
}
if (snapshots_dumped == 0) first_tick = snap.tick;
last_tick = snap.tick;
fprintf(f, "--- Tick %u ---\n", snap.tick);
/* Input. */
fprintf(f, "Input:");
for (int a = 0; a < ACTION_COUNT && a < 8; a++) {
if (snap.input.held & (1 << a)) {
fprintf(f, " [%s]", action_name(a));
}
if (snap.input.pressed & (1 << a)) {
fprintf(f, " [%s_pressed]", action_name(a));
}
if (snap.input.released & (1 << a)) {
fprintf(f, " [%s_released]", action_name(a));
}
}
fprintf(f, "\n");
/* Player. */
fprintf(f, "Player: pos=(%.1f, %.1f) vel=(%.1f, %.1f) hp=%d",
snap.player_x, snap.player_y,
snap.player_vx, snap.player_vy,
snap.player_health);
fprintf(f, " on_ground=%d", (snap.player_flags >> 0) & 1);
fprintf(f, " jumping=%d", (snap.player_flags >> 1) & 1);
fprintf(f, " dashing=%d", (snap.player_flags >> 2) & 1);
fprintf(f, " inv=%d", (snap.player_flags >> 3) & 1);
fprintf(f, " has_gun=%d", (snap.player_flags >> 4) & 1);
fprintf(f, " aim=%s", aim_dir_name(snap.player_aim_dir));
fprintf(f, " dash_charges=%d dash_timer=%.2f inv_timer=%.2f coyote=%.3f\n",
snap.player_dash_charges, snap.player_dash_timer,
snap.player_inv_timer, snap.player_coyote);
/* Camera + physics. */
fprintf(f, "Camera: (%.1f, %.1f)\n", snap.cam_x, snap.cam_y);
fprintf(f, "Physics: gravity=%.1f wind=%.1f\n",
snap.gravity, snap.wind);
if (snap.level_name[0]) {
fprintf(f, "Level: %s\n", snap.level_name);
}
/* Entities. */
fprintf(f, "Entities (%u active):\n", snap.entity_count);
uint32_t ent_offset = (read_pos + sizeof(TickSnapshot)) % cap;
uint16_t count = snap.entity_count;
if (count > MAX_ENTITIES) count = MAX_ENTITIES;
for (uint16_t i = 0; i < count; i++) {
EntitySnapshot es;
ring_read(ent_offset, &es, sizeof(EntitySnapshot));
ent_offset = (ent_offset + sizeof(EntitySnapshot)) % cap;
fprintf(f, " [%u] %-12s pos=(%.0f, %.0f) vel=(%.0f, %.0f) hp=%d\n",
i, entity_type_name(es.type),
es.pos_x, es.pos_y,
es.vel_x, es.vel_y,
es.health);
}
fprintf(f, "\n");
read_pos = (read_pos + snap.frame_size) % cap;
bytes_read += snap.frame_size;
snapshots_dumped++;
}
/* Write summary at the top. */
if (snapshots_dumped > 0) {
float seconds = (float)(last_tick - first_tick) / TICK_RATE;
fprintf(f, "=== Summary: ticks %u to %u (%u snapshots, %.1f seconds) ===\n",
first_tick, last_tick, snapshots_dumped, seconds);
}
fclose(f);
printf("Debug log dumped to %s (%u snapshots)\n", path, snapshots_dumped);
}

91
src/engine/debuglog.h Normal file
View File

@@ -0,0 +1,91 @@
#ifndef JNR_DEBUGLOG_H
#define JNR_DEBUGLOG_H
#include <stdbool.h>
#include <stdint.h>
#include "config.h"
/* Forward declaration — game layer type, registered via pointer. */
typedef struct Level Level;
/* ═══════════════════════════════════════════════════
* Snapshot data structures
* ═══════════════════════════════════════════════════ */
/* Packed input snapshot — 3 bytes (ACTION_COUNT actions × 3 states). */
typedef struct InputSnapshot {
uint8_t held; /* bitmask of ACTION_* held this tick */
uint8_t pressed; /* bitmask of ACTION_* pressed this tick */
uint8_t released; /* bitmask of ACTION_* released this tick */
} InputSnapshot;
/* Per-entity summary — 20 bytes each. */
typedef struct EntitySnapshot {
uint8_t type; /* EntityType */
uint8_t flags; /* active, facing, dead, invincible */
int16_t health;
float pos_x, pos_y;
float vel_x, vel_y;
} EntitySnapshot;
/* Full tick snapshot — fixed-size header + variable entity list.
* Written sequentially into the ring buffer; frame_size stores
* the total byte count so the reader can skip forward. */
typedef struct TickSnapshot {
uint32_t tick; /* g_engine.tick */
uint32_t frame_size; /* total bytes of this record */
/* Input */
InputSnapshot input; /* 3 bytes */
uint8_t _pad0; /* align to 4 bytes */
/* Player state (expanded) */
float player_x, player_y;
float player_vx, player_vy;
int8_t player_health;
uint8_t player_flags; /* on_ground, jumping, dashing, inv, has_gun */
uint8_t player_aim_dir;
int8_t player_dash_charges;
float player_dash_timer;
float player_inv_timer;
float player_coyote;
/* Camera */
float cam_x, cam_y;
/* Physics globals */
float gravity;
float wind;
/* Level info */
char level_name[32]; /* truncated level path/tag */
/* Entity summary */
uint16_t entity_count; /* number of active entities */
uint16_t _pad1; /* alignment padding */
/* EntitySnapshot entities[] follows immediately in the buffer. */
} TickSnapshot;
/* ═══════════════════════════════════════════════════
* Public API
* ═══════════════════════════════════════════════════ */
/* Allocate ring buffer. Safe to call even if logging stays disabled. */
void debuglog_init(void);
/* Final flush + free. */
void debuglog_shutdown(void);
/* Turn on recording. */
void debuglog_enable(void);
/* Query state. */
bool debuglog_is_enabled(void);
/* Register the active Level pointer so the debuglog can read game state.
* Pass NULL before freeing a level.
* name is an optional label (e.g. file path); NULL or "" to clear. */
void debuglog_set_level(Level *lvl, const char *name);
/* Called once per tick from engine_frame, after update, before consume.
* Captures input + game state into the ring buffer. */
void debuglog_record_tick(void);
/* Dump the ring buffer contents to a human-readable text file. */
void debuglog_dump(const char *path);
#endif /* JNR_DEBUGLOG_H */

View File

@@ -1,4 +1,5 @@
#include "engine/input.h" #include "engine/input.h"
#include "engine/debuglog.h"
#include <string.h> #include <string.h>
static bool s_current[ACTION_COUNT]; static bool s_current[ACTION_COUNT];
@@ -196,6 +197,19 @@ bool input_key_held(SDL_Scancode key) {
return s_key_state && s_key_state[key]; return s_key_state && s_key_state[key];
} }
/* ── Debug log snapshot ───────────────────────────── */
void input_get_snapshot(InputSnapshot *out) {
out->held = 0;
out->pressed = 0;
out->released = 0;
for (int i = 0; i < ACTION_COUNT && i < 8; i++) {
if (s_current[i]) out->held |= (uint8_t)(1 << i);
if (s_latched_pressed[i]) out->pressed |= (uint8_t)(1 << i);
if (s_latched_released[i]) out->released |= (uint8_t)(1 << i);
}
}
void input_shutdown(void) { void input_shutdown(void) {
/* Nothing to clean up */ /* Nothing to clean up */
} }

View File

@@ -50,4 +50,10 @@ int input_mouse_scroll(void);
bool input_key_pressed(SDL_Scancode key); bool input_key_pressed(SDL_Scancode key);
bool input_key_held(SDL_Scancode key); bool input_key_held(SDL_Scancode key);
/* Pack current input state into a compact bitmask snapshot.
* Used by the debug log to record per-tick input without
* exposing internal arrays. */
typedef struct InputSnapshot InputSnapshot; /* defined in debuglog.h */
void input_get_snapshot(InputSnapshot *out);
#endif /* JNR_INPUT_H */ #endif /* JNR_INPUT_H */

View File

@@ -529,3 +529,214 @@ void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir) {
}; };
particle_emit(&dust); particle_emit(&dust);
} }
void particle_emit_hit_sparks(Vec2 pos) {
/* Bright orange-white sparks — fast, short-lived, spray outward */
ParticleBurst sparks = {
.origin = pos,
.count = 8,
.speed_min = 60.0f,
.speed_max = 180.0f,
.life_min = 0.08f,
.life_max = 0.2f,
.size_min = 1.0f,
.size_max = 2.0f,
.spread = (float)M_PI,
.direction = 0,
.drag = 3.0f,
.gravity_scale = 0.4f,
.color = {255, 200, 100, 255}, /* orange-white */
.color_vary = true,
};
particle_emit(&sparks);
/* Brief white flash at impact point */
ParticleBurst flash = {
.origin = pos,
.count = 3,
.speed_min = 5.0f,
.speed_max = 15.0f,
.life_min = 0.03f,
.life_max = 0.06f,
.size_min = 2.0f,
.size_max = 3.5f,
.spread = (float)M_PI,
.direction = 0,
.drag = 8.0f,
.gravity_scale = 0.0f,
.color = {255, 255, 230, 255}, /* bright white */
.color_vary = false,
};
particle_emit(&flash);
}
void particle_emit_metal_explosion(Vec2 pos) {
/* Metal shrapnel — fast grey/silver chunks flying outward */
ParticleBurst shrapnel = {
.origin = pos,
.count = 16,
.speed_min = 60.0f,
.speed_max = 200.0f,
.life_min = 0.2f,
.life_max = 0.6f,
.size_min = 1.5f,
.size_max = 3.5f,
.spread = (float)M_PI,
.direction = 0,
.drag = 2.0f,
.gravity_scale = 0.5f,
.color = {180, 180, 190, 255}, /* silver-grey */
.color_vary = true,
};
particle_emit(&shrapnel);
/* Hot orange sparks — electrical/mechanical innards */
ParticleBurst sparks = {
.origin = pos,
.count = 10,
.speed_min = 40.0f,
.speed_max = 150.0f,
.life_min = 0.15f,
.life_max = 0.35f,
.size_min = 1.0f,
.size_max = 2.5f,
.spread = (float)M_PI,
.direction = 0,
.drag = 2.5f,
.gravity_scale = 0.3f,
.color = {255, 160, 40, 255}, /* hot orange */
.color_vary = true,
};
particle_emit(&sparks);
/* Bright white flash at center — brief pop */
ParticleBurst flash = {
.origin = pos,
.count = 5,
.speed_min = 5.0f,
.speed_max = 20.0f,
.life_min = 0.04f,
.life_max = 0.08f,
.size_min = 3.0f,
.size_max = 5.0f,
.spread = (float)M_PI,
.direction = 0,
.drag = 8.0f,
.gravity_scale = 0.0f,
.color = {255, 240, 200, 255}, /* bright flash */
.color_vary = false,
};
particle_emit(&flash);
/* Smoke cloud — slower, lingers */
ParticleBurst smoke = {
.origin = pos,
.count = 8,
.speed_min = 15.0f,
.speed_max = 50.0f,
.life_min = 0.3f,
.life_max = 0.7f,
.size_min = 2.5f,
.size_max = 4.5f,
.spread = (float)M_PI,
.direction = 0,
.drag = 3.5f,
.gravity_scale = -0.1f, /* floats up */
.color = {120, 120, 130, 180}, /* dark smoke */
.color_vary = true,
};
particle_emit(&smoke);
}
/* 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,15 @@ void particle_emit_muzzle_flash(Vec2 pos, Vec2 shoot_dir);
/* Wall slide dust (small puffs while scraping against a wall) */ /* Wall slide dust (small puffs while scraping against a wall) */
void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir); void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir);
/* Metal hit sparks (turret/machine takes non-lethal damage) */
void particle_emit_hit_sparks(Vec2 pos);
/* Metal explosion (turret/machine death — shrapnel + flash) */
void particle_emit_metal_explosion(Vec2 pos);
/* 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 */ #endif /* JNR_PARTICLE_H */

View File

@@ -6,6 +6,14 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.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. /* Read a full line from f into a dynamically growing buffer.
* *buf and *cap track the heap buffer; the caller must free *buf. * *buf and *cap track the heap buffer; the caller must free *buf.
* Returns the line length, or -1 on EOF/error. */ * 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) { } else if (strncmp(line, "PLAYER_UNARMED", 14) == 0) {
map->player_unarmed = true; 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) { } else if (strncmp(line, "EXIT ", 5) == 0) {
if (map->exit_zone_count < MAX_EXIT_ZONES) { if (map->exit_zone_count < MAX_EXIT_ZONES) {
ExitZone *ez = &map->exit_zones[map->exit_zone_count]; 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 */ char parallax_near_path[ASSET_PATH_MAX]; /* near bg image path */
int parallax_style; /* procedural bg style (0=default) */ int parallax_style; /* procedural bg style (0=default) */
bool player_unarmed; /* if true, player starts without gun */ 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]; EntitySpawn entity_spawns[MAX_ENTITY_SPAWNS];
int entity_spawn_count; int entity_spawn_count;
ExitZone exit_zones[MAX_EXIT_ZONES]; 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/editor.h"
#include "game/entity_registry.h" #include "game/entity_registry.h"
#include "game/transition.h"
#include "engine/core.h" #include "engine/core.h"
#include "engine/input.h" #include "engine/input.h"
#include "engine/renderer.h" #include "engine/renderer.h"
@@ -359,6 +360,16 @@ static bool save_tilemap(const Tilemap *map, const char *path) {
if (map->player_unarmed) if (map->player_unarmed)
fprintf(f, "PLAYER_UNARMED\n"); 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"); fprintf(f, "\n");
/* Entity spawns */ /* Entity spawns */

View File

@@ -5,9 +5,46 @@
#include "engine/renderer.h" #include "engine/renderer.h"
#include "engine/particle.h" #include "engine/particle.h"
#include "engine/audio.h" #include "engine/audio.h"
#include "config.h"
#include <stdlib.h> #include <stdlib.h>
#include <math.h> #include <math.h>
/* ── Shared helpers ───────────────────────────────── */
/* Kill enemy if it fell past the bottom of the level. */
static bool enemy_check_level_bottom(Entity *self, const Tilemap *map,
EntityManager *em) {
float level_bottom = (float)(map->height * TILE_SIZE);
if (self->body.pos.y > level_bottom) {
self->flags |= ENTITY_DEAD;
self->health = 0;
entity_destroy(em, self);
return true;
}
return false;
}
/* Check for cliff ahead of a ground enemy. Returns true if cliff detected.
* Uses a wider lookahead that scales with speed for fast-moving enemies. */
static bool enemy_detect_cliff(const Body *body, float patrol_dir,
float speed, const Tilemap *map) {
if (!body->on_ground) return false;
/* Scale lookahead with speed: at least 4px, up to speed/10 */
float lookahead = speed * 0.1f;
if (lookahead < 4.0f) lookahead = 4.0f;
float check_x = (patrol_dir > 0) ?
body->pos.x + body->size.x + lookahead :
body->pos.x - lookahead;
float check_y = body->pos.y + body->size.y + 4.0f;
int tx = world_to_tile(check_x);
int ty = world_to_tile(check_y);
return !tilemap_is_solid(map, tx, ty);
}
/* ════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════
* GRUNT - ground patrol enemy * GRUNT - ground patrol enemy
* ════════════════════════════════════════════════════ */ * ════════════════════════════════════════════════════ */
@@ -20,6 +57,9 @@ static void grunt_update(Entity *self, float dt, const Tilemap *map) {
Body *body = &self->body; Body *body = &self->body;
/* Kill if fallen off bottom of level */
if (enemy_check_level_bottom(self, map, s_grunt_em)) return;
/* Death sequence */ /* Death sequence */
if (self->flags & ENTITY_DEAD) { if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &anim_grunt_death); animation_set(&self->anim, &anim_grunt_death);
@@ -48,19 +88,10 @@ static void grunt_update(Entity *self, float dt, const Tilemap *map) {
gd->patrol_dir = -gd->patrol_dir; gd->patrol_dir = -gd->patrol_dir;
} }
/* Turn around at ledge: check if there's ground ahead */ /* Turn around at ledge */
if (body->on_ground) { if (enemy_detect_cliff(body, gd->patrol_dir, GRUNT_SPEED, map)) {
float check_x = (gd->patrol_dir > 0) ?
body->pos.x + body->size.x + 2.0f :
body->pos.x - 2.0f;
float check_y = body->pos.y + body->size.y + 4.0f;
int tx = world_to_tile(check_x);
int ty = world_to_tile(check_y);
if (!tilemap_is_solid(map, tx, ty)) {
gd->patrol_dir = -gd->patrol_dir; gd->patrol_dir = -gd->patrol_dir;
} body->vel.x = 0;
} }
/* Animation */ /* Animation */
@@ -146,12 +177,14 @@ static Entity *find_player(EntityManager *em) {
} }
static void flyer_update(Entity *self, float dt, const Tilemap *map) { static void flyer_update(Entity *self, float dt, const Tilemap *map) {
(void)map; /* flyers don't collide with tiles */
FlyerData *fd = (FlyerData *)self->data; FlyerData *fd = (FlyerData *)self->data;
if (!fd) return; if (!fd) return;
Body *body = &self->body; Body *body = &self->body;
/* Kill if fallen off bottom of level */
if (enemy_check_level_bottom(self, map, s_flyer_em)) return;
/* Death sequence */ /* Death sequence */
if (self->flags & ENTITY_DEAD) { if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &anim_flyer_death); animation_set(&self->anim, &anim_flyer_death);
@@ -289,6 +322,9 @@ static void charger_update(Entity *self, float dt, const Tilemap *map) {
Body *body = &self->body; Body *body = &self->body;
/* Kill if fallen off bottom of level */
if (enemy_check_level_bottom(self, map, s_charger_em)) return;
/* Death sequence */ /* Death sequence */
if (self->flags & ENTITY_DEAD) { if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &anim_charger_death); animation_set(&self->anim, &anim_charger_death);
@@ -319,14 +355,10 @@ static void charger_update(Entity *self, float dt, const Tilemap *map) {
} }
/* Reverse at ledge */ /* Reverse at ledge */
if (body->on_ground) { if (enemy_detect_cliff(body, cd->patrol_dir,
float cx = (cd->patrol_dir > 0) ? CHARGER_PATROL_SPEED, map)) {
body->pos.x + body->size.x + 2.0f :
body->pos.x - 2.0f;
float cy = body->pos.y + body->size.y + 4.0f;
if (!tilemap_is_solid(map, world_to_tile(cx), world_to_tile(cy))) {
cd->patrol_dir = -cd->patrol_dir; cd->patrol_dir = -cd->patrol_dir;
} body->vel.x = 0;
} }
/* Detect player — horizontal line-of-sight */ /* Detect player — horizontal line-of-sight */
@@ -510,10 +542,12 @@ static int count_alive_grunts(EntityManager *em) {
} }
static void spawner_update(Entity *self, float dt, const Tilemap *map) { static void spawner_update(Entity *self, float dt, const Tilemap *map) {
(void)map;
SpawnerData *sd = (SpawnerData *)self->data; SpawnerData *sd = (SpawnerData *)self->data;
if (!sd) return; if (!sd) return;
/* Kill if fallen off bottom of level */
if (enemy_check_level_bottom(self, map, s_spawner_em)) return;
/* Death sequence */ /* Death sequence */
if (self->flags & ENTITY_DEAD) { if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &anim_spawner_death); animation_set(&self->anim, &anim_spawner_death);

View File

@@ -8,6 +8,7 @@
#include "game/spacecraft.h" #include "game/spacecraft.h"
#include "game/sprites.h" #include "game/sprites.h"
#include "game/entity_registry.h" #include "game/entity_registry.h"
#include "game/stats.h"
#include "engine/core.h" #include "engine/core.h"
#include "engine/renderer.h" #include "engine/renderer.h"
#include "engine/physics.h" #include "engine/physics.h"
@@ -16,6 +17,7 @@
#include "engine/input.h" #include "engine/input.h"
#include "engine/camera.h" #include "engine/camera.h"
#include "engine/assets.h" #include "engine/assets.h"
#include "engine/font.h"
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <math.h> #include <math.h>
@@ -175,42 +177,54 @@ static Camera *s_active_camera = NULL;
static void damage_entity(Entity *target, int damage) { static void damage_entity(Entity *target, int damage) {
target->health -= damage; target->health -= damage;
if (target->health <= 0) {
target->flags |= ENTITY_DEAD;
/* Death particles — centered on entity */
Vec2 center = vec2( Vec2 center = vec2(
target->body.pos.x + target->body.size.x * 0.5f, target->body.pos.x + target->body.size.x * 0.5f,
target->body.pos.y + target->body.size.y * 0.5f target->body.pos.y + target->body.size.y * 0.5f
); );
if (target->health <= 0) {
target->flags |= ENTITY_DEAD;
/* Death particles — turrets get a metal explosion, others a puff */
if (target->type == ENT_TURRET || target->type == ENT_LASER_TURRET) {
particle_emit_metal_explosion(center);
} else {
SDL_Color death_color; SDL_Color death_color;
if (target->type == ENT_ENEMY_GRUNT) { if (target->type == ENT_ENEMY_GRUNT) {
death_color = (SDL_Color){200, 60, 60, 255}; /* red debris */ death_color = (SDL_Color){200, 60, 60, 255};
} else if (target->type == ENT_ENEMY_FLYER) { } else if (target->type == ENT_ENEMY_FLYER) {
death_color = (SDL_Color){140, 80, 200, 255}; /* purple puff */ death_color = (SDL_Color){140, 80, 200, 255};
} else if (target->type == ENT_TURRET || target->type == ENT_LASER_TURRET) {
death_color = (SDL_Color){160, 160, 160, 255}; /* metal scraps */
} else if (target->type == ENT_ENEMY_CHARGER) { } else if (target->type == ENT_ENEMY_CHARGER) {
death_color = (SDL_Color){220, 140, 40, 255}; /* orange spark */ death_color = (SDL_Color){220, 140, 40, 255};
} else if (target->type == ENT_SPAWNER) { } else if (target->type == ENT_SPAWNER) {
death_color = (SDL_Color){180, 60, 180, 255}; /* purple burst */ death_color = (SDL_Color){180, 60, 180, 255};
} else { } else {
death_color = (SDL_Color){200, 200, 200, 255}; /* grey */ death_color = (SDL_Color){200, 200, 200, 255};
} }
particle_emit_death_puff(center, death_color); particle_emit_death_puff(center, death_color);
}
/* Screen shake on kill */ /* Screen shake on kill — stronger for turret explosions */
if (s_active_camera) { if (s_active_camera) {
camera_shake(s_active_camera, 2.0f, 0.15f); float intensity = (target->type == ENT_TURRET ||
target->type == ENT_LASER_TURRET) ? 3.5f : 2.0f;
camera_shake(s_active_camera, intensity, 0.15f);
} }
audio_play_sound_at(s_sfx_enemy_death, 80, center, 0); audio_play_sound_at(s_sfx_enemy_death, 80, center, 0);
} else if (target->type == ENT_TURRET || target->type == ENT_LASER_TURRET) {
/* Hit marker sparks on non-lethal turret damage */
particle_emit_hit_sparks(center);
} }
} }
static void damage_player(Entity *player, int damage, Entity *source) { static void damage_player(Entity *player, int damage, Entity *source) {
PlayerData *ppd = (PlayerData *)player->data; PlayerData *ppd = (PlayerData *)player->data;
stats_record_damage_taken(damage);
damage_entity(player, 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) */ /* Screen shake on player hit (stronger) */
if (s_active_camera) { if (s_active_camera) {
@@ -223,11 +237,14 @@ static void damage_player(Entity *player, int damage, Entity *source) {
ppd->inv_timer = PLAYER_INV_TIME; ppd->inv_timer = PLAYER_INV_TIME;
player->flags |= ENTITY_INVINCIBLE; player->flags |= ENTITY_INVINCIBLE;
/* Knockback away from source */ /* Knockback away from source, scaled by source speed */
if (source) { if (source) {
float knock_dir = (player->body.pos.x < source->body.pos.x) ? float knock_dir = (player->body.pos.x < source->body.pos.x) ?
-1.0f : 1.0f; -1.0f : 1.0f;
player->body.vel.x = knock_dir * 150.0f; float src_speed = fabsf(source->body.vel.x);
float knock_str = 150.0f;
if (src_speed > knock_str) knock_str = src_speed;
player->body.vel.x = knock_dir * knock_str;
player->body.vel.y = -150.0f; player->body.vel.y = -150.0f;
} }
} }
@@ -263,7 +280,10 @@ static void handle_collisions(EntityManager *em) {
/* Player bullet hits enemies */ /* Player bullet hits enemies */
if (from_player && entity_is_enemy(b)) { if (from_player && entity_is_enemy(b)) {
if (physics_overlap(&a->body, &b->body)) { if (physics_overlap(&a->body, &b->body)) {
stats_record_damage_dealt(a->damage);
damage_entity(b, a->damage); damage_entity(b, a->damage);
stats_record_shot_hit();
if (b->flags & ENTITY_DEAD) stats_record_kill();
hit = true; hit = true;
} }
} }
@@ -286,8 +306,7 @@ static void handle_collisions(EntityManager *em) {
} }
/* ── Enemy contact damage to player ──── */ /* ── Enemy contact damage to player ──── */
if (player && !(player->flags & ENTITY_INVINCIBLE) && if (player && entity_is_enemy(a) && !(a->flags & ENTITY_DEAD)) {
entity_is_enemy(a) && !(a->flags & ENTITY_DEAD)) {
if (physics_overlap(&a->body, &player->body)) { if (physics_overlap(&a->body, &player->body)) {
/* Check if player is stomping (falling onto enemy from above) */ /* Check if player is stomping (falling onto enemy from above) */
bool stomping = (player->body.vel.y > 0) && bool stomping = (player->body.vel.y > 0) &&
@@ -295,10 +314,20 @@ static void handle_collisions(EntityManager *em) {
a->body.pos.y + a->body.size.y * 0.5f); a->body.pos.y + a->body.size.y * 0.5f);
if (stomping) { if (stomping) {
stats_record_damage_dealt(2);
damage_entity(a, 2); damage_entity(a, 2);
if (a->flags & ENTITY_DEAD) stats_record_kill();
player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f; player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f;
} else { } else if (!(player->flags & ENTITY_INVINCIBLE)) {
damage_player(player, a->damage, a); /* Charger deals extra damage and knockback while charging */
int dmg = a->damage;
if (a->type == ENT_ENEMY_CHARGER) {
ChargerData *cd = (ChargerData *)a->data;
if (cd && cd->state == CHARGER_CHARGE) {
dmg = 2;
}
}
damage_player(player, dmg, a);
} }
} }
} }
@@ -366,6 +395,7 @@ static void handle_collisions(EntityManager *em) {
} }
if (picked_up) { if (picked_up) {
stats_record_pickup();
/* Pickup particles */ /* Pickup particles */
Vec2 center = vec2( Vec2 center = vec2(
a->body.pos.x + a->body.size.x * 0.5f, a->body.pos.x + a->body.size.x * 0.5f,
@@ -566,6 +596,7 @@ void level_update(Level *level, float dt) {
for (int i = 0; i < level->entities.count; i++) { for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i]; Entity *e = &level->entities.entities[i];
if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) { if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) {
stats_record_death();
player_respawn(e, level->map.player_spawn); player_respawn(e, level->map.player_spawn);
Vec2 center = vec2( Vec2 center = vec2(
e->body.pos.x + e->body.size.x * 0.5f, e->body.pos.x + e->body.size.x * 0.5f,
@@ -578,6 +609,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 */ /* Update particles */
particle_update(dt); particle_update(dt);
@@ -759,6 +799,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 */ /* Flush the renderer */
renderer_flush(cam); renderer_flush(cam);
} }

View File

@@ -1,4 +1,5 @@
#include "game/levelgen.h" #include "game/levelgen.h"
#include "game/transition.h"
#include "engine/parallax.h" #include "engine/parallax.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.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++; 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 * 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, 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); 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) */ /* Opening in left wall — 2 tiles wide so adjacent segment can't seal it */
set_tile(col, mw, mh, x0, ground_row - 1, TILE_EMPTY); for (int r = ground_row - 3; r < ground_row; r++) {
set_tile(col, mw, mh, x0, ground_row - 2, TILE_EMPTY); set_tile(col, mw, mh, x0, r, TILE_EMPTY);
set_tile(col, mw, mh, x0, ground_row - 3, TILE_EMPTY); set_tile(col, mw, mh, x0 + 1, r, TILE_EMPTY);
}
/* Opening in right wall */ /* Opening in right wall — 2 tiles wide */
set_tile(col, mw, mh, x0 + w - 1, ground_row - 1, TILE_EMPTY); for (int r = ground_row - 3; r < ground_row; r++) {
set_tile(col, mw, mh, x0 + w - 1, ground_row - 2, TILE_EMPTY); set_tile(col, mw, mh, x0 + w - 1, r, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 1, ground_row - 3, TILE_EMPTY); set_tile(col, mw, mh, x0 + w - 2, r, TILE_EMPTY);
}
/* Theme-dependent corridor hazards */ /* Theme-dependent corridor hazards */
if (theme == THEME_MARS_BASE) { 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, 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); 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, 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 - 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++) { 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, 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 - 1, r, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, r, TILE_EMPTY);
} }
/* Alternating platforms up the shaft */ /* Alternating platforms up the shaft */
@@ -1137,11 +1211,13 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
/* ── Phase 4: generate segments ── */ /* ── Phase 4: generate segments ── */
int cursor = 2; /* start after left buffer */ int cursor = 2; /* start after left buffer */
int mh = map->height; int mh = map->height;
int seg_start_x[MAX_FINAL_SEGS]; /* track segment x-offsets for connectivity pass */
/* Left border wall */ /* Left border wall */
fill_rect(map->collision_layer, map->width, mh, 0, 0, 1, mh - 1, TILE_SOLID_1); fill_rect(map->collision_layer, map->width, mh, 0, 0, 1, mh - 1, TILE_SOLID_1);
for (int i = 0; i < num_segs; i++) { for (int i = 0; i < num_segs; i++) {
seg_start_x[i] = cursor;
int w = seg_widths[i]; int w = seg_widths[i];
LevelTheme theme = seg_themes[i]; LevelTheme theme = seg_themes[i];
int gr = seg_ground[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, fill_rect(map->collision_layer, map->width, mh,
map->width - 2, 0, map->width - 1, mh - 1, TILE_SOLID_1); 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 ── */ /* ── Phase 5: add visual variety to solid tiles ── */
for (int y = 0; y < map->height; y++) { for (int y = 0; y < map->height; y++) {
for (int x = 0; x < map->width; x++) { 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"); 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 */ /* Tileset */
/* NOTE: tileset texture will be loaded by level_load_generated */ /* 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; 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); 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) */ /* Doorway opening (4 tiles tall, always reachable from floor).
int door_y = rng_range(STATION_CEIL_ROW + 3, STATION_FLOOR_ROW - 4); * Constrain the door bottom to touch the floor so the player
for (int y = door_y; y < door_y + 3; y++) { * 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); set_tile(col, mw, mh, wall_x, y, TILE_EMPTY);
} }
int door_y = door_top;
/* Turret guarding the doorway — always present */ /* Turret guarding the doorway — always present */
add_entity(map, "turret", wall_x - 2, door_y - 1); 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 num_plats = rng_range(3, 5);
int spacing = (pit_end - pit_start) / (num_plats + 1); int spacing = (pit_end - pit_start) / (num_plats + 1);
if (spacing < 2) spacing = 2; if (spacing < 2) spacing = 2;
for (int i = 0; i < num_plats; i++) { for (int i = 0; i < num_plats; i++) {
int px = pit_start + (i + 1) * spacing - 1; 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); int pw = rng_range(2, 3);
for (int j = 0; j < pw && px + j < x0 + w; j++) { for (int j = 0; j < pw && px + j < x0 + w; j++) {
set_tile(col, mw, mh, px + j, py, TILE_PLAT); 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; 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); 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++) { for (int y = vent_ceil - 1; y <= vent_ceil + 2 && y < STATION_FLOOR_ROW; y++) {
set_tile(col, mw, mh, x0, y, TILE_EMPTY); 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++) { 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); 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 */ /* Flame vents along the floor — always present, more at higher difficulty */
int num_vents = 1 + (int)(difficulty * 2); int num_vents = 1 + (int)(difficulty * 2);
@@ -1641,6 +1749,8 @@ bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
/* ── Phase 4: generate segments ── */ /* ── Phase 4: generate segments ── */
int cursor = 2; int cursor = 2;
int smh = map->height; int smh = map->height;
int sseg_start_x[20];
int sseg_ground[20];
/* Left border wall */ /* Left border wall */
fill_rect(map->collision_layer, map->width, smh, 0, 0, 1, smh - 1, TILE_SOLID_1); 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++) { for (int i = 0; i < num_segs; i++) {
sseg_start_x[i] = cursor;
sseg_ground[i] = STATION_FLOOR_ROW;
int w = seg_widths[i]; int w = seg_widths[i];
float diff = config->difficulty; 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, fill_rect(map->collision_layer, map->width, smh,
map->width - 2, 0, map->width - 1, smh - 1, TILE_SOLID_1); 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 ── */ /* ── Phase 5: visual variety ── */
for (int y = 0; y < map->height; y++) { for (int y = 0; y < map->height; y++) {
for (int x = 0; x < map->width; x++) { for (int x = 0; x < map->width; x++) {
@@ -1719,6 +1835,10 @@ bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
/* Music */ /* Music */
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg"); 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", 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); map->width, map->height, num_segs, s_rng_state, map->gravity);
printf(" segments:"); 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, 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); 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 */ /* Openings on the right wall at all standard heights so the
for (int y = MB_MID_UPPER - 4; y < MB_MID_UPPER; y++) { * 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 - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
}
} }
/* Health pickup */ /* 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, 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); 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 */ /* Openings at all standard heights — 2 tiles wide */
for (int y = MB_CEIL_ROW + 1; y < MB_CEIL_ROW + 5; y++) { 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, 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 - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, 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);
} }
/* Alternating platforms climbing up the shaft */ /* 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 */ /* 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, 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); 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++) { 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, 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 - 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 */ /* 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); set_tile(col, mw, mh, hole2_x + j, MB_MID_LOWER + 1, TILE_EMPTY);
} }
/* Door openings on sides at each level */ /* Door openings on sides at all standard heights — 2 tiles wide */
for (int y = MB_MID_UPPER - 4; y < MB_MID_UPPER; y++) { 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, 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 - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, 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);
} }
/* Laser turrets on walls at each level — the gauntlet */ /* 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); set_tile(col, mw, mh, hole_x + j, MB_MID_LOWER + 1, TILE_EMPTY);
} }
/* Door openings on sides */ /* Door openings at all standard heights — 2 tiles wide */
for (int y = MB_MID_LOWER - 4; y < MB_MID_LOWER; y++) { 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, 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 - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, 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);
} }
/* Spawner in the upper area — the hive */ /* 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 */ /* 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); 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 */ /* Door openings at all standard heights — 2 tiles wide */
for (int y = MB_FLOOR_ROW - 5; y < MB_FLOOR_ROW - 1; y++) { 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, 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 - 1, y, TILE_EMPTY);
set_tile(col, mw, mh, x0 + w - 2, 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);
} }
/* Enemies — mixed chargers, grunts, flyers across levels */ /* 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 ── */ /* ── Phase 4: generate segments ── */
int cursor = 2; int cursor = 2;
int mb_seg_start_x[12];
int mb_seg_ground[12];
/* Left border wall */ /* Left border wall */
fill_rect(map->collision_layer, map->width, map->height, 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++) { 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]; int w = seg_widths[i];
float diff = config->difficulty; 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, fill_rect(map->collision_layer, map->width, map->height,
map->width - 2, 0, map->width - 1, map->height - 1, TILE_SOLID_1); 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) ── */ /* ── Phase 5: visual variety (random solid variants for interior tiles) ── */
for (int y = 0; y < map->height; y++) { for (int y = 0; y < map->height; y++) {
for (int x = 0; x < map->width; x++) { for (int x = 0; x < map->width; x++) {
@@ -2345,6 +2498,10 @@ bool levelgen_generate_mars_base(Tilemap *map, const LevelGenConfig *config) {
/* Music */ /* Music */
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/kaffe_og_kage.ogg"); 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", printf("levelgen_mars_base: generated %dx%d level (%d segments, seed=%u)\n",
map->width, map->height, num_segs, s_rng_state); map->width, map->height, num_segs, s_rng_state);
printf(" segments:"); printf(" segments:");
@@ -2401,6 +2558,16 @@ bool levelgen_dump_lvl(const Tilemap *map, const char *path) {
fprintf(f, "PLAYER_UNARMED\n"); 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"); fprintf(f, "\n");
/* Entity spawns */ /* Entity spawns */

View File

@@ -1,6 +1,7 @@
#include "game/player.h" #include "game/player.h"
#include "game/sprites.h" #include "game/sprites.h"
#include "game/projectile.h" #include "game/projectile.h"
#include "game/stats.h"
#include "engine/input.h" #include "engine/input.h"
#include "engine/physics.h" #include "engine/physics.h"
#include "engine/renderer.h" #include "engine/renderer.h"
@@ -299,10 +300,41 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
return; 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);
/* Invincibility during dash + short grace period after */
pd->inv_timer = PLAYER_DASH_DURATION + PLAYER_DASH_INV_GRACE;
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) { if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
pd->dash_charges--; pd->dash_charges--;
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) pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE; ? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
}
pd->dash_timer = PLAYER_DASH_DURATION; pd->dash_timer = PLAYER_DASH_DURATION;
/* Determine dash direction from input */ /* Determine dash direction from input */
@@ -407,6 +439,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
pd->jumping = true; pd->jumping = true;
pd->jump_buffer_timer = 0; pd->jump_buffer_timer = 0;
pd->coyote_timer = 0; pd->coyote_timer = 0;
stats_record_jump();
audio_play_sound(s_sfx_jump, 96); 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); projectile_spawn_dir(s_em, bullet_pos, shoot_dir, true);
stats_record_shot_fired();
/* Muzzle flash slightly ahead of bullet origin (at barrel tip) */ /* Muzzle flash slightly ahead of bullet origin (at barrel tip) */
Vec2 flash_pos = vec2( Vec2 flash_pos = vec2(
bullet_pos.x + shoot_dir.x * 4.0f, bullet_pos.x + shoot_dir.x * 4.0f,
@@ -471,6 +505,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
/* ── Landing detection ───────────────────── */ /* ── Landing detection ───────────────────── */
if (body->on_ground && !pd->was_on_ground) { if (body->on_ground && !pd->was_on_ground) {
pd->down_tap_timer = 0; /* reset double-tap on landing */
/* Just landed — emit dust at feet */ /* Just landed — emit dust at feet */
Vec2 feet = vec2( Vec2 feet = vec2(
body->pos.x + body->size.x * 0.5f, body->pos.x + body->size.x * 0.5f,

View File

@@ -39,6 +39,7 @@
/* Invincibility after taking damage */ /* Invincibility after taking damage */
#define PLAYER_INV_TIME 1.5f /* seconds of invincibility */ #define PLAYER_INV_TIME 1.5f /* seconds of invincibility */
#define PLAYER_DASH_INV_GRACE 0.15f /* extra invincibility after dash */
/* Aim direction (for shooting) */ /* Aim direction (for shooting) */
typedef enum AimDir { typedef enum AimDir {
@@ -67,6 +68,8 @@ typedef struct PlayerData {
AimDir aim_dir; /* current aim direction */ AimDir aim_dir; /* current aim direction */
bool looking_up; /* holding up without moving */ bool looking_up; /* holding up without moving */
float look_up_timer; /* how long up has been held */ 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 */ /* Death / Respawn */
float respawn_timer; /* countdown after death anim finishes */ float respawn_timer; /* countdown after death anim finishes */
Vec2 spawn_point; /* where to respawn */ 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

@@ -1,9 +1,13 @@
#include "engine/core.h" #include "engine/core.h"
#include "engine/input.h" #include "engine/input.h"
#include "engine/font.h" #include "engine/font.h"
#include "engine/debuglog.h"
#include "game/level.h" #include "game/level.h"
#include "game/levelgen.h" #include "game/levelgen.h"
#include "game/editor.h" #include "game/editor.h"
#include "game/stats.h"
#include "game/analytics.h"
#include "game/transition.h"
#include "config.h" #include "config.h"
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
@@ -22,6 +26,7 @@ typedef enum GameMode {
MODE_PLAY, MODE_PLAY,
MODE_EDITOR, MODE_EDITOR,
MODE_PAUSED, MODE_PAUSED,
MODE_TRANSITION,
} GameMode; } GameMode;
static Level s_level; static Level s_level;
@@ -30,6 +35,7 @@ static GameMode s_mode = MODE_PLAY;
static bool s_use_procgen = false; static bool s_use_procgen = false;
static bool s_dump_lvl = false; static bool s_dump_lvl = false;
static bool s_use_editor = false; static bool s_use_editor = false;
static bool s_use_debuglog = false;
static uint32_t s_gen_seed = 0; static uint32_t s_gen_seed = 0;
static char s_edit_path[256] = {0}; static char s_edit_path[256] = {0};
static char s_level_path[ASSET_PATH_MAX] = {0}; /* path of active play-mode level */ static char s_level_path[ASSET_PATH_MAX] = {0}; /* path of active play-mode level */
@@ -46,10 +52,35 @@ static int s_station_depth = 0;
static int s_mars_depth = 0; static int s_mars_depth = 0;
#define MARS_BASE_GEN_COUNT 2 #define MARS_BASE_GEN_COUNT 2
/* ── Analytics / stats tracking ── */
static GameStats s_stats;
static bool s_session_active = false;
/* ── Pause menu state ── */ /* ── Pause menu state ── */
#define PAUSE_ITEM_COUNT 3 #define PAUSE_ITEM_COUNT 3
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */ 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) { static const char *theme_name(LevelTheme t) {
switch (t) { switch (t) {
case THEME_PLANET_SURFACE: return "Planet Surface"; case THEME_PLANET_SURFACE: return "Planet Surface";
@@ -65,6 +96,7 @@ static const char *theme_name(LevelTheme t) {
static bool load_level_file(const char *path) { static bool load_level_file(const char *path) {
if (!level_load(&s_level, path)) return false; if (!level_load(&s_level, path)) return false;
snprintf(s_level_path, sizeof(s_level_path), "%s", path); snprintf(s_level_path, sizeof(s_level_path), "%s", path);
debuglog_set_level(&s_level, path);
return true; return true;
} }
@@ -163,6 +195,7 @@ static void load_generated_level(void) {
g_engine.running = false; g_engine.running = false;
} }
s_level_path[0] = '\0'; /* generated levels have no file path */ s_level_path[0] = '\0'; /* generated levels have no file path */
debuglog_set_level(&s_level, "generated");
} }
static void load_station_level(void) { static void load_station_level(void) {
@@ -188,6 +221,7 @@ static void load_station_level(void) {
g_engine.running = false; g_engine.running = false;
} }
s_level_path[0] = '\0'; /* generated levels have no file path */ s_level_path[0] = '\0'; /* generated levels have no file path */
debuglog_set_level(&s_level, "generated:station");
} }
static void load_mars_base_level(void) { static void load_mars_base_level(void) {
@@ -219,11 +253,28 @@ static void load_mars_base_level(void) {
g_engine.running = false; g_engine.running = false;
} }
s_level_path[0] = '\0'; s_level_path[0] = '\0';
debuglog_set_level(&s_level, "generated:mars_base");
}
/* ── 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 ── */ /* ── Switch to editor mode ── */
static void enter_editor(void) { static void enter_editor(void) {
if (s_mode == MODE_PLAY) { if (s_mode == MODE_PLAY) {
debuglog_set_level(NULL, NULL);
level_free(&s_level); level_free(&s_level);
} }
s_mode = MODE_EDITOR; s_mode = MODE_EDITOR;
@@ -263,6 +314,7 @@ static void enter_test_play(void) {
/* ── Return from test play to editor ── */ /* ── Return from test play to editor ── */
static void return_to_editor(void) { static void return_to_editor(void) {
debuglog_set_level(NULL, NULL);
level_free(&s_level); level_free(&s_level);
s_mode = MODE_EDITOR; s_mode = MODE_EDITOR;
s_testing_from_editor = false; s_testing_from_editor = false;
@@ -271,6 +323,7 @@ static void return_to_editor(void) {
/* ── Restart current level (file-based or generated) ── */ /* ── Restart current level (file-based or generated) ── */
static void restart_level(void) { static void restart_level(void) {
debuglog_set_level(NULL, NULL);
level_free(&s_level); level_free(&s_level);
if (s_level_path[0]) { if (s_level_path[0]) {
if (!load_level_file(s_level_path)) { if (!load_level_file(s_level_path)) {
@@ -283,20 +336,67 @@ static void restart_level(void) {
} }
} }
/* ── Level load dispatch — loads the next level based on target string ── */
static void dispatch_level_load(const char *target) {
debuglog_set_level(NULL, NULL);
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 * Game callbacks
* ═══════════════════════════════════════════════════ */ * ═══════════════════════════════════════════════════ */
static void game_init(void) { static void game_init(void) {
analytics_init();
if (s_use_editor) { if (s_use_editor) {
enter_editor(); enter_editor();
} else if (s_use_procgen) { } else if (s_use_procgen) {
load_generated_level(); load_generated_level();
begin_session();
} else { } else {
if (!load_level_file("assets/levels/moon01.lvl")) { if (!load_level_file("assets/levels/moon01.lvl")) {
fprintf(stderr, "Failed to load level!\n"); fprintf(stderr, "Failed to load level!\n");
g_engine.running = false; g_engine.running = false;
} }
begin_session();
} }
} }
@@ -330,12 +430,15 @@ static void pause_update(void) {
break; break;
case 1: /* Restart */ case 1: /* Restart */
s_mode = MODE_PLAY; s_mode = MODE_PLAY;
end_session("quit");
restart_level(); restart_level();
begin_session();
break; break;
case 2: /* Quit */ case 2: /* Quit */
if (s_testing_from_editor) { if (s_testing_from_editor) {
return_to_editor(); return_to_editor();
} else { } else {
end_session("quit");
g_engine.running = false; g_engine.running = false;
} }
break; break;
@@ -343,6 +446,54 @@ static void pause_update(void) {
} }
static void game_update(float dt) { 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) {
debuglog_set_level(NULL, NULL);
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) { if (s_mode == MODE_EDITOR) {
editor_update(&s_editor, dt); editor_update(&s_editor, dt);
@@ -360,8 +511,35 @@ static void game_update(float dt) {
return; 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 ── */ /* ── Play mode ── */
/* F12: dump debug log to text file. */
if (input_key_pressed(SDL_SCANCODE_F12) && debuglog_is_enabled()) {
debuglog_dump("debug_log.txt");
}
/* Pause on escape (return to editor during test play) */ /* Pause on escape (return to editor during test play) */
if (input_pressed(ACTION_PAUSE)) { if (input_pressed(ACTION_PAUSE)) {
if (s_testing_from_editor) { if (s_testing_from_editor) {
@@ -377,6 +555,7 @@ static void game_update(float dt) {
if (!s_testing_from_editor && input_key_pressed(SDL_SCANCODE_E)) { if (!s_testing_from_editor && input_key_pressed(SDL_SCANCODE_E)) {
/* Load the current level file into the editor if available */ /* Load the current level file into the editor if available */
snprintf(s_edit_path, sizeof(s_edit_path), "%s", s_level_path); snprintf(s_edit_path, sizeof(s_edit_path), "%s", s_level_path);
debuglog_set_level(NULL, NULL);
level_free(&s_level); level_free(&s_level);
enter_editor(); enter_editor();
return; return;
@@ -387,60 +566,42 @@ static void game_update(float dt) {
bool r_pressed = input_key_held(SDL_SCANCODE_R); bool r_pressed = input_key_held(SDL_SCANCODE_R);
if (r_pressed && !r_was_pressed) { if (r_pressed && !r_was_pressed) {
printf("\n=== Regenerating level ===\n"); printf("\n=== Regenerating level ===\n");
end_session("quit");
debuglog_set_level(NULL, NULL);
level_free(&s_level); level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL); s_gen_seed = (uint32_t)time(NULL);
s_use_procgen = true; s_use_procgen = true;
load_generated_level(); load_generated_level();
begin_session();
} }
r_was_pressed = r_pressed; r_was_pressed = r_pressed;
level_update(&s_level, dt); level_update(&s_level, dt);
/* Accumulate play time */
if (s_session_active) {
s_stats.time_elapsed += dt;
}
/* Check for level exit transition */ /* Check for level exit transition */
if (level_exit_triggered(&s_level)) { if (level_exit_triggered(&s_level)) {
const char *target = s_level.exit_target; const char *target = s_level.exit_target;
if (target[0] == '\0') { /* Record the level completion in stats */
/* Empty target = victory / end of game */ if (s_session_active) {
printf("Level complete! (no next level)\n"); s_stats.levels_completed++;
/* 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 */ TransitionStyle out_style = s_level.map.transition_out;
printf("Transitioning to generated level\n");
level_free(&s_level); if (out_style == TRANS_ELEVATOR || out_style == TRANS_TELEPORTER) {
s_gen_seed = (uint32_t)time(NULL); /* Animated transition: stash target, start outro. */
load_generated_level(); snprintf(s_pending_target, sizeof(s_pending_target), "%s", target);
} else if (strcmp(target, "generate:station") == 0) { transition_start_out(&s_transition, out_style);
/* Procedurally generated space station level */ s_mode = MODE_TRANSITION;
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();
} else { } else {
/* Load a specific level file */ /* Instant transition (none or spacecraft-driven). */
printf("Transitioning to: %s\n", target); dispatch_level_load(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;
}
}
} }
} }
} }
@@ -493,16 +654,24 @@ static void game_render(float interpolation) {
/* Render frozen game frame, then overlay the pause menu. */ /* Render frozen game frame, then overlay the pause menu. */
level_render(&s_level, interpolation); level_render(&s_level, interpolation);
pause_render(); 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 { } else {
level_render(&s_level, interpolation); level_render(&s_level, interpolation);
} }
} }
static void game_shutdown(void) { static void game_shutdown(void) {
end_session("quit");
debuglog_set_level(NULL, NULL);
/* Always free both — editor may have been initialized even if we're /* Always free both — editor may have been initialized even if we're
* currently in play mode (e.g. shutdown during test play). editor_free * currently in play mode (e.g. shutdown during test play). editor_free
* and level_free are safe to call on zeroed/already-freed structs. */ * 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); level_free(&s_level);
} }
if (s_mode == MODE_EDITOR || s_use_editor) { if (s_mode == MODE_EDITOR || s_use_editor) {
@@ -525,6 +694,8 @@ int main(int argc, char *argv[]) {
if (i + 1 < argc) { if (i + 1 < argc) {
s_gen_seed = (uint32_t)atoi(argv[++i]); s_gen_seed = (uint32_t)atoi(argv[++i]);
} }
} else if (strcmp(argv[i], "--debug-log") == 0) {
s_use_debuglog = true;
} else if (strcmp(argv[i], "--edit") == 0 || strcmp(argv[i], "-e") == 0) { } else if (strcmp(argv[i], "--edit") == 0 || strcmp(argv[i], "-e") == 0) {
s_use_editor = true; s_use_editor = true;
/* Optional: next arg is a file path */ /* Optional: next arg is a file path */
@@ -537,6 +708,7 @@ int main(int argc, char *argv[]) {
printf(" --dump, -d Dump generated level to assets/levels/generated.lvl\n"); printf(" --dump, -d Dump generated level to assets/levels/generated.lvl\n");
printf(" --seed N, -s N Set RNG seed for generation\n"); printf(" --seed N, -s N Set RNG seed for generation\n");
printf(" --edit [file], -e [file] Open level editor (optionally load a .lvl file)\n"); printf(" --edit [file], -e [file] Open level editor (optionally load a .lvl file)\n");
printf(" --debug-log Record game state every tick (F12 to dump)\n");
printf("\nIn-game:\n"); printf("\nIn-game:\n");
printf(" R Regenerate level with new random seed\n"); printf(" R Regenerate level with new random seed\n");
printf(" E Open level editor\n"); printf(" E Open level editor\n");
@@ -589,6 +761,11 @@ int main(int argc, char *argv[]) {
return 1; return 1;
} }
debuglog_init();
if (s_use_debuglog) {
debuglog_enable();
}
engine_set_callbacks((GameCallbacks){ engine_set_callbacks((GameCallbacks){
.init = game_init, .init = game_init,
.update = game_update, .update = game_update,
@@ -597,6 +774,7 @@ int main(int argc, char *argv[]) {
}); });
engine_run(); engine_run();
debuglog_shutdown();
engine_shutdown(); engine_shutdown();
return 0; return 0;

View File

@@ -107,7 +107,8 @@
</style> </style>
</head> </head>
<body> <body>
<div id="canvas-container"> <div id="canvas-container"
data-analytics-url="https://horchposten.schick-web.site">
<canvas class="emscripten" id="canvas" tabindex="1" <canvas class="emscripten" id="canvas" tabindex="1"
width="640" height="360"></canvas> width="640" height="360"></canvas>
</div> </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-save" title="Save level (download .lvl)">Save</button>
<button class="ctrl-btn" id="btn-load" title="Load .lvl from disk">Load</button> <button class="ctrl-btn" id="btn-load" title="Load .lvl from disk">Load</button>
<span class="ctrl-sep">|</span> <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> <option value="">-- Open level --</option>
</select> </select>
<span class="ctrl-sep">|</span> <span class="ctrl-sep">|</span>
@@ -263,12 +264,12 @@
var path = this.value; var path = this.value;
if (!path) return; if (!path) return;
if (typeof _editor_load_vfs_file === 'function') { /* Load the level into gameplay (MODE_PLAY) via main.c */
/* Pass the path string to C */ if (typeof _game_load_level === 'function') {
var len = lengthBytesUTF8(path) + 1; var len = lengthBytesUTF8(path) + 1;
var buf = _malloc(len); var buf = _malloc(len);
stringToUTF8(path, buf, len); stringToUTF8(path, buf, len);
_editor_load_vfs_file(buf); _game_load_level(buf);
_free(buf); _free(buf);
} }
@@ -282,6 +283,37 @@
document.title = 'Jump \'n Run - Level Editor'; 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>
{{{ SCRIPT }}} {{{ SCRIPT }}}
</body> </body>