Compare commits
34 Commits
af0a9904c2
...
fix/tls-ve
| Author | SHA1 | Date | |
|---|---|---|---|
| b918ca451f | |||
| ec4bc9bb82 | |||
| 4a2d199904 | |||
| 58bf89f2f2 | |||
| 4bef8f37b4 | |||
| 096b0eb096 | |||
| 89e0c483ad | |||
| 7d0e134a56 | |||
| 198f639289 | |||
| f71d140af3 | |||
| 651ac7703f | |||
| 587fd210a2 | |||
| 79e9d0e2ad | |||
| 81ebbd9eec | |||
| 90e3d5aec0 | |||
| 68856fb8c5 | |||
| 7605f0ca8c | |||
| 6d64c6426f | |||
| 93ae351959 | |||
|
|
c57ac68a04 | ||
|
|
5793af4896 | ||
| 702dbd4f9a | |||
| 322dd184ab | |||
|
|
a23ecaf4c1 | ||
|
|
4407932a2d | ||
|
|
635869f226 | ||
|
|
b54a53b9c8 | ||
|
|
27691a28dd | ||
|
|
6b32199f25 | ||
|
|
b3055f4bd3 | ||
|
|
a97c9b5aaf | ||
|
|
46209b94bb | ||
|
|
b5cdf1804f | ||
|
|
492f13306d |
28
.gitea/workflows/ci.yaml
Normal file
28
.gitea/workflows/ci.yaml
Normal 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
|
||||||
48
.gitea/workflows/deploy.yaml
Normal file
48
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.kimchi
|
||||||
|
IMAGE: git.kimchi/tas/major_tom
|
||||||
|
NAMESPACE: jnr-web
|
||||||
|
DEPLOYMENT: jnr-web
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git config --global http.https://git.kimchi.sslVerify false
|
||||||
|
git clone --depth 1 https://${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}@git.kimchi/tas/major_tom.git .
|
||||||
|
|
||||||
|
- name: Build and push container image
|
||||||
|
run: |
|
||||||
|
mkdir -p /etc/containers
|
||||||
|
printf '[registries.insecure]\nregistries = ["git.kimchi"]\n' > /etc/containers/registries.conf
|
||||||
|
|
||||||
|
IMAGE_TAG="${{ env.IMAGE }}:sha-${GITHUB_SHA::8}"
|
||||||
|
IMAGE_LATEST="${{ env.IMAGE }}:latest"
|
||||||
|
|
||||||
|
buildah bud --tls-verify=false -f Containerfile -t "$IMAGE_TAG" -t "$IMAGE_LATEST" .
|
||||||
|
buildah login --tls-verify=false "${{ env.REGISTRY }}" -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_PASSWORD }}"
|
||||||
|
buildah push --tls-verify=false "$IMAGE_TAG"
|
||||||
|
buildah push --tls-verify=false "$IMAGE_LATEST"
|
||||||
|
|
||||||
|
echo "IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Deploy to k3s
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.kube
|
||||||
|
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
||||||
|
chmod 600 ~/.kube/config
|
||||||
|
|
||||||
|
kubectl set image deployment/${{ env.DEPLOYMENT }} \
|
||||||
|
${{ env.DEPLOYMENT }}="${{ env.IMAGE_TAG }}" \
|
||||||
|
-n ${{ env.NAMESPACE }}
|
||||||
|
|
||||||
|
kubectl rollout status deployment/${{ env.DEPLOYMENT }} \
|
||||||
|
-n ${{ env.NAMESPACE }} --timeout=60s
|
||||||
12
AGENTS.md
12
AGENTS.md
@@ -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
|
||||||
|
|
||||||
@@ -264,5 +270,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 |
|
||||||
|
|||||||
@@ -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
225
DESIGN.md
@@ -83,11 +83,37 @@ Already implemented: `GRAVITY`, `WIND`, `BG_COLOR`, `MUSIC`, `PARALLAX_FAR`, `PA
|
|||||||
- **Laser Turret** — State machine (IDLE → CHARGING → FIRING → COOLDOWN). Per-pixel beam raycast. Fixed variant aims left; tracking variant rotates toward player at 1.5 rad/s.
|
- **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)
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -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
44
TODO.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
@@ -529,3 +529,96 @@ void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir) {
|
|||||||
};
|
};
|
||||||
particle_emit(&dust);
|
particle_emit(&dust);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Spawn a single dust mote with the given visual properties. */
|
||||||
|
static void spawn_dust_mote(Vec2 pos, Vec2 vel,
|
||||||
|
float life_min, float life_max,
|
||||||
|
float size_min, float size_max,
|
||||||
|
float drag, float gscale,
|
||||||
|
uint8_t r, uint8_t g, uint8_t b, int vary) {
|
||||||
|
Particle *p = alloc_particle();
|
||||||
|
p->pos = pos;
|
||||||
|
p->vel = vel;
|
||||||
|
p->life = randf_range(life_min, life_max);
|
||||||
|
p->max_life = p->life;
|
||||||
|
p->size = randf_range(size_min, size_max);
|
||||||
|
p->drag = drag;
|
||||||
|
p->gravity_scale = gscale;
|
||||||
|
p->active = true;
|
||||||
|
p->color.r = clamp_u8(r + (int)randf_range(-vary, vary));
|
||||||
|
p->color.g = clamp_u8(g + (int)randf_range(-vary, vary));
|
||||||
|
p->color.b = clamp_u8(b + (int)randf_range(-vary, vary));
|
||||||
|
p->color.a = 255; /* alpha applied during render from life ratio */
|
||||||
|
}
|
||||||
|
|
||||||
|
void particle_emit_atmosphere_dust(Vec2 cam_pos, Vec2 vp) {
|
||||||
|
/* Ambient Mars dust — subtle motes drifting across the viewport.
|
||||||
|
* Two sub-layers for depth: large slow "far" motes and small quick
|
||||||
|
* "near" specks. Wind carries them; gravity_scale controls how much
|
||||||
|
* environmental forces (wind + gravity) affect each particle.
|
||||||
|
* When wind is strong, particles spawn along the upwind viewport edge
|
||||||
|
* and drift inward. When wind is calm, particles spawn across the
|
||||||
|
* full viewport to avoid clustering on one side. */
|
||||||
|
float wind = physics_get_wind();
|
||||||
|
float margin = 32.0f;
|
||||||
|
float abs_wind = (wind >= 0.0f) ? wind : -wind;
|
||||||
|
int has_wind = abs_wind > 5.0f; /* threshold for edge-spawning */
|
||||||
|
|
||||||
|
if (has_wind) {
|
||||||
|
float dir = (wind >= 0.0f) ? 1.0f : -1.0f;
|
||||||
|
|
||||||
|
/* Upwind edge X for the two edge-spawned layers */
|
||||||
|
float edge_far = (wind >= 0.0f) ? cam_pos.x - margin
|
||||||
|
: cam_pos.x + vp.x + margin;
|
||||||
|
float edge_near = (wind >= 0.0f) ? cam_pos.x - margin * 0.5f
|
||||||
|
: cam_pos.x + vp.x + margin * 0.5f;
|
||||||
|
|
||||||
|
/* Far dust motes — large, slow, translucent (1/frame) */
|
||||||
|
spawn_dust_mote(
|
||||||
|
vec2(edge_far, cam_pos.y + randf() * vp.y),
|
||||||
|
vec2(dir * randf_range(8.0f, 25.0f), randf_range(-6.0f, 6.0f)),
|
||||||
|
4.0f, 7.0f, 1.5f, 3.0f, 0.3f, 0.08f,
|
||||||
|
180, 140, 100, 25);
|
||||||
|
|
||||||
|
/* Near dust specks — small, quicker, brighter (1/frame) */
|
||||||
|
spawn_dust_mote(
|
||||||
|
vec2(edge_near, cam_pos.y + randf() * vp.y),
|
||||||
|
vec2(dir * randf_range(15.0f, 40.0f), randf_range(-10.0f, 10.0f)),
|
||||||
|
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
|
||||||
|
200, 160, 120, 20);
|
||||||
|
|
||||||
|
/* Occasional interior spawn — prevents edge seam */
|
||||||
|
if (rand() % 3 == 0) {
|
||||||
|
spawn_dust_mote(
|
||||||
|
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
|
||||||
|
vec2(randf_range(-5.0f, 5.0f), randf_range(-8.0f, 3.0f)),
|
||||||
|
3.0f, 6.0f, 1.0f, 2.5f, 0.4f, 0.06f,
|
||||||
|
160, 130, 95, 25);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* Calm wind — spawn across the full viewport to distribute evenly */
|
||||||
|
|
||||||
|
/* Far dust motes — large, slow, translucent (1/frame) */
|
||||||
|
spawn_dust_mote(
|
||||||
|
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
|
||||||
|
vec2(randf_range(-10.0f, 10.0f), randf_range(-6.0f, 6.0f)),
|
||||||
|
4.0f, 7.0f, 1.5f, 3.0f, 0.3f, 0.08f,
|
||||||
|
180, 140, 100, 25);
|
||||||
|
|
||||||
|
/* Near dust specks — small, quicker, brighter (1/frame) */
|
||||||
|
spawn_dust_mote(
|
||||||
|
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
|
||||||
|
vec2(randf_range(-15.0f, 15.0f), randf_range(-10.0f, 10.0f)),
|
||||||
|
2.5f, 5.0f, 0.8f, 1.5f, 0.2f, 0.12f,
|
||||||
|
200, 160, 120, 20);
|
||||||
|
|
||||||
|
/* Extra interior mote for density parity with windy path */
|
||||||
|
if (rand() % 3 == 0) {
|
||||||
|
spawn_dust_mote(
|
||||||
|
vec2(cam_pos.x + randf() * vp.x, cam_pos.y + randf() * vp.y),
|
||||||
|
vec2(randf_range(-5.0f, 5.0f), randf_range(-8.0f, 3.0f)),
|
||||||
|
3.0f, 6.0f, 1.0f, 2.5f, 0.4f, 0.06f,
|
||||||
|
160, 130, 95, 25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,4 +89,9 @@ 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);
|
||||||
|
|
||||||
|
/* 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 */
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
205
src/game/analytics.c
Normal 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
18
src/game/analytics.h
Normal 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 */
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -210,7 +212,10 @@ static void damage_entity(Entity *target, int damage) {
|
|||||||
|
|
||||||
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) {
|
||||||
@@ -263,7 +268,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +303,9 @@ 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 {
|
||||||
damage_player(player, a->damage, a);
|
damage_player(player, a->damage, a);
|
||||||
@@ -366,6 +376,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 +577,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 +590,15 @@ void level_update(Level *level, float dt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Emit ambient atmosphere dust on Mars Surface levels before the
|
||||||
|
* particle update pass so new motes get their first physics step
|
||||||
|
* this frame — consistent with other per-frame emitters. */
|
||||||
|
if (level->map.parallax_style == PARALLAX_STYLE_MARS) {
|
||||||
|
particle_emit_atmosphere_dust(
|
||||||
|
level->camera.pos,
|
||||||
|
level->camera.viewport);
|
||||||
|
}
|
||||||
|
|
||||||
/* Update particles */
|
/* Update particles */
|
||||||
particle_update(dt);
|
particle_update(dt);
|
||||||
|
|
||||||
@@ -759,6 +780,20 @@ void level_render(Level *level, float interpolation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Draw score in top-right corner */
|
||||||
|
{
|
||||||
|
GameStats *stats = stats_get_active();
|
||||||
|
if (stats) {
|
||||||
|
stats_update_score(stats);
|
||||||
|
char score_buf[16];
|
||||||
|
snprintf(score_buf, sizeof(score_buf), "%d", stats->score);
|
||||||
|
int text_w = font_text_width(score_buf);
|
||||||
|
font_draw_text(g_engine.renderer, score_buf,
|
||||||
|
SCREEN_WIDTH - text_w - 8, 8,
|
||||||
|
(SDL_Color){255, 220, 80, 255});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Flush the renderer */
|
/* Flush the renderer */
|
||||||
renderer_flush(cam);
|
renderer_flush(cam);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
|
int entry_open_rows[] = { MB_MID_UPPER, MB_MID_LOWER, MB_FLOOR_ROW };
|
||||||
|
for (int h = 0; h < 3; h++) {
|
||||||
|
int base = entry_open_rows[h];
|
||||||
|
for (int y = base - 4; y < base; y++) {
|
||||||
|
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
|
||||||
|
set_tile(col, mw, mh, x0 + w - 2, y, TILE_EMPTY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Health pickup */
|
/* 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 };
|
||||||
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
|
for (int h = 0; h < 4; h++) {
|
||||||
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
|
int base = shaft_open_rows[h];
|
||||||
}
|
for (int y = base - 4; y < base; y++) {
|
||||||
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, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 };
|
||||||
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
|
for (int h = 0; h < 3; h++) {
|
||||||
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
|
int base = th_open_rows[h];
|
||||||
}
|
for (int y = base - 4; y < base; y++) {
|
||||||
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, 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 };
|
||||||
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
|
for (int h = 0; h < 3; h++) {
|
||||||
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
|
int base = hive_open_rows[h];
|
||||||
}
|
for (int y = base - 4; y < base; y++) {
|
||||||
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, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 };
|
||||||
set_tile(col, mw, mh, x0, y, TILE_EMPTY);
|
for (int h = 0; h < 4; h++) {
|
||||||
set_tile(col, mw, mh, x0 + w - 1, y, TILE_EMPTY);
|
int base = arena_open_rows[h];
|
||||||
}
|
for (int y = base - 4; y < base; y++) {
|
||||||
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, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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 */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
/* Brief invincibility during dash */
|
||||||
|
pd->inv_timer = PLAYER_DASH_DURATION;
|
||||||
|
self->flags |= ENTITY_INVINCIBLE;
|
||||||
|
|
||||||
|
/* Jetpack burst downward */
|
||||||
|
Vec2 exhaust_pos = vec2(
|
||||||
|
body->pos.x + body->size.x * 0.5f,
|
||||||
|
body->pos.y + body->size.y * 0.5f
|
||||||
|
);
|
||||||
|
particle_emit_jetpack_burst(exhaust_pos, pd->dash_dir);
|
||||||
|
audio_play_sound(s_sfx_dash, 96);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pd->down_tap_timer = 0.3f; /* window for second tap */
|
||||||
|
}
|
||||||
|
if (pd->down_tap_timer > 0) {
|
||||||
|
pd->down_tap_timer -= dt;
|
||||||
|
}
|
||||||
|
|
||||||
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
|
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
|
||||||
pd->dash_charges--;
|
pd->dash_charges--;
|
||||||
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
|
stats_record_dash();
|
||||||
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
|
/* Start recharge timer only if not already recharging */
|
||||||
|
if (pd->dash_recharge_timer <= 0) {
|
||||||
|
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
|
||||||
|
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
|
||||||
|
}
|
||||||
pd->dash_timer = PLAYER_DASH_DURATION;
|
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,
|
||||||
|
|||||||
@@ -67,6 +67,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
29
src/game/stats.c
Normal 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
43
src/game/stats.h
Normal 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
351
src/game/transition.c
Normal 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.0–1.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
68
src/game/transition.h
Normal 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 */
|
||||||
232
src/main.c
232
src/main.c
@@ -4,6 +4,9 @@
|
|||||||
#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 +25,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;
|
||||||
@@ -46,10 +50,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";
|
||||||
@@ -221,6 +250,21 @@ static void load_mars_base_level(void) {
|
|||||||
s_level_path[0] = '\0';
|
s_level_path[0] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Analytics session helpers ── */
|
||||||
|
static void begin_session(void) {
|
||||||
|
stats_reset(&s_stats);
|
||||||
|
stats_set_active(&s_stats);
|
||||||
|
analytics_session_start();
|
||||||
|
s_session_active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void end_session(const char *reason) {
|
||||||
|
if (!s_session_active) return;
|
||||||
|
s_session_active = false;
|
||||||
|
stats_set_active(NULL);
|
||||||
|
analytics_session_end(&s_stats, reason);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Switch to editor mode ── */
|
/* ── Switch to editor mode ── */
|
||||||
static void enter_editor(void) {
|
static void enter_editor(void) {
|
||||||
if (s_mode == MODE_PLAY) {
|
if (s_mode == MODE_PLAY) {
|
||||||
@@ -283,20 +327,66 @@ static void restart_level(void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Level load dispatch — loads the next level based on target string ── */
|
||||||
|
static void dispatch_level_load(const char *target) {
|
||||||
|
if (target[0] == '\0') {
|
||||||
|
/* Empty target = victory / end of game. */
|
||||||
|
printf("Level complete! (no next level)\n");
|
||||||
|
end_session("completed");
|
||||||
|
level_free(&s_level);
|
||||||
|
s_station_depth = 0;
|
||||||
|
s_mars_depth = 0;
|
||||||
|
if (!load_level_file("assets/levels/moon01.lvl")) {
|
||||||
|
g_engine.running = false;
|
||||||
|
}
|
||||||
|
begin_session();
|
||||||
|
} else if (strcmp(target, "generate") == 0) {
|
||||||
|
printf("Transitioning to generated level\n");
|
||||||
|
level_free(&s_level);
|
||||||
|
s_gen_seed = (uint32_t)time(NULL);
|
||||||
|
load_generated_level();
|
||||||
|
} else if (strcmp(target, "generate:station") == 0) {
|
||||||
|
printf("Transitioning to space station level\n");
|
||||||
|
level_free(&s_level);
|
||||||
|
s_gen_seed = (uint32_t)time(NULL);
|
||||||
|
load_station_level();
|
||||||
|
} else if (strcmp(target, "generate:mars_base") == 0) {
|
||||||
|
printf("Transitioning to Mars Base level\n");
|
||||||
|
level_free(&s_level);
|
||||||
|
s_gen_seed = (uint32_t)time(NULL);
|
||||||
|
load_mars_base_level();
|
||||||
|
} else {
|
||||||
|
printf("Transitioning to: %s\n", target);
|
||||||
|
char path[ASSET_PATH_MAX];
|
||||||
|
snprintf(path, sizeof(path), "%s", target);
|
||||||
|
level_free(&s_level);
|
||||||
|
if (!load_level_file(path)) {
|
||||||
|
fprintf(stderr, "Failed to load next level: %s\n", path);
|
||||||
|
if (!load_level_file("assets/levels/moon01.lvl")) {
|
||||||
|
g_engine.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════
|
||||||
* Game callbacks
|
* 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 +420,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 +436,53 @@ 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) {
|
||||||
|
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,6 +500,28 @@ 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 ── */
|
||||||
|
|
||||||
/* Pause on escape (return to editor during test play) */
|
/* Pause on escape (return to editor during test play) */
|
||||||
@@ -387,60 +549,41 @@ static void game_update(float dt) {
|
|||||||
bool r_pressed = input_key_held(SDL_SCANCODE_R);
|
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");
|
||||||
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;
|
TransitionStyle out_style = s_level.map.transition_out;
|
||||||
s_mars_depth = 0;
|
|
||||||
if (!load_level_file("assets/levels/moon01.lvl")) {
|
if (out_style == TRANS_ELEVATOR || out_style == TRANS_TELEPORTER) {
|
||||||
g_engine.running = false;
|
/* Animated transition: stash target, start outro. */
|
||||||
}
|
snprintf(s_pending_target, sizeof(s_pending_target), "%s", target);
|
||||||
} else if (strcmp(target, "generate") == 0) {
|
transition_start_out(&s_transition, out_style);
|
||||||
/* Procedurally generated next level */
|
s_mode = MODE_TRANSITION;
|
||||||
printf("Transitioning to generated level\n");
|
|
||||||
level_free(&s_level);
|
|
||||||
s_gen_seed = (uint32_t)time(NULL);
|
|
||||||
load_generated_level();
|
|
||||||
} else if (strcmp(target, "generate:station") == 0) {
|
|
||||||
/* Procedurally generated space station level */
|
|
||||||
printf("Transitioning to space station level\n");
|
|
||||||
level_free(&s_level);
|
|
||||||
s_gen_seed = (uint32_t)time(NULL);
|
|
||||||
load_station_level();
|
|
||||||
} else if (strcmp(target, "generate:mars_base") == 0) {
|
|
||||||
/* Procedurally generated Mars Base level */
|
|
||||||
printf("Transitioning to Mars Base level\n");
|
|
||||||
level_free(&s_level);
|
|
||||||
s_gen_seed = (uint32_t)time(NULL);
|
|
||||||
load_mars_base_level();
|
|
||||||
} 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 +636,23 @@ 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");
|
||||||
|
|
||||||
/* 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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user