Add moon surface intro level with asteroid hazards and unarmed mechanics

Introduce moon01.lvl as the starting level — a pure jump-and-run intro
with no gun and no enemies, just platforming over gaps and dodging falling
asteroids. The player picks up their gun upon transitioning to level01.

New features:
- Moon tileset and PARALLAX_STYLE_MOON with crater terrain backgrounds
- Asteroid entity (ENT_ASTEROID): falls from sky, damages on contact,
  explodes on ground with particles, respawns after delay
- PLAYER_UNARMED directive disables gun for the level
- Pit rescue mechanic: falling costs 1 HP and auto-dashes upward
- Gun powerup entity type for future armed-pickup levels
- Segment-based procedural level generator with themed rooms
- Extended editor with entity palette and improved tile cycling
- Web shell improvements for Emscripten builds
This commit is contained in:
Thomas
2026-03-01 09:20:49 +00:00
parent ea6e16358f
commit fac7085056
30 changed files with 2139 additions and 83 deletions

View File

@@ -76,9 +76,9 @@ Already implemented: `GRAVITY`, `BG_COLOR`, `MUSIC`, `PARALLAX_FAR`, `PARALLAX_N
### Implemented
- **Grunt** — Red spiky ground patrol. Walks back and forth, turns at edges/walls. 2 HP.
- **Flyer** — Purple bat-like. Bobs in air, chases player when close, shoots fireballs. 1 HP.
- **Turret** — Stationary, rotates to aim at player, fires periodically
### Planned
- **Turret** — Stationary, rotates to aim at player, fires periodically
- **Charger** — Detects player at range, charges in a straight line at high speed
- **Shielder** — Has a directional shield, must be hit from behind or above
- **Spawner** — Stationary, periodically spawns smaller enemies
@@ -125,12 +125,16 @@ Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `BG_COLOR`, `MUSIC`,
- `WIND`, `STORM`, `DRAG` — Atmosphere settings
### Level Ideas
1. **Derelict Station** — Low gravity, dark, flickering lights, abandoned corridors
2. **Desert Planet** — High gravity, sand storm wind, bright orange palette
3. **Gas Giant Moon** — Very low gravity, floating platforms, toxic atmosphere
4. **Asteroid Belt** — Zero-G sections, small disconnected platforms
5. **Space Freighter**Normal gravity, tight corridors, turret enemies
6. **Volcanic World**Normal gravity, rising lava hazard, fire enemies
0. **Intro Level (Moon)** - Low gravity, bright surface, spacey/no obstacles
1. **Mars Surface** - Low gravity, red surface, spacey, little to no obstacles, transition area is entry to base
2. **Mars Base** - Normal gravity, very vertical, narrow, 90 degree turns, lots of enemies
3. **Derelict Station**Low gravity, dark, flickering lights, abandoned corridors
4. **Desert Planet**High gravity, sand storm wind, bright orange palette
5. **Gas Giant Moon** — Very low gravity, floating platforms, toxic atmosphere
6. **Asteroid Belt** — Zero-G sections, small disconnected platforms
7. **Space Freighter** — Normal gravity, tight corridors, turret enemies
8. **Volcanic World** — Normal gravity, rising lava hazard, fire enemies
---
@@ -153,21 +157,21 @@ Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `BG_COLOR`, `MUSIC`,
### High Priority
- [x] Entity spawn directives in .lvl format (`ENTITY` directive)
- [ ] Level exit zones and level transitions
- [x] Level exit zones and level transitions
- [x] Dash mechanic
- [ ] Wall slide / wall jump
- [x] Particle system (death puffs, landing dust, projectile impact sparks, wall slide dust)
- [x] Screen shake on damage / enemy kills
- [x] Sound effects (jump, shoot, hit, enemy death, dash)
- [x] Basic HUD (health hearts + jetpack charges)
### Medium Priority
- [x] In-game level editor (tile/entity placement, save/load, test play)
- [ ] Wind / drag atmosphere properties
- [x] Parallax scrolling backgrounds (procedural stars + nebula, or from image files)
- [x] Per-level background color (`BG_COLOR` directive)
- [x] Music playback per level (`MUSIC` directive)
- [ ] Weapon switching system
- [ ] Pickup entities (health, ammo, credits)
- [x] Pickup entities (health, jetpack refill, drone companion)
- [ ] Better tileset art (space-themed)
- [ ] Player sprite polish (more animation frames)
- [x] Death / respawn system
@@ -212,6 +216,5 @@ Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `BG_COLOR`, `MUSIC`,
## Reference Games
- Jazz Jackrabbit 2 (movement feel, weapon variety, level design)
- Metal Slug (run-and-gun, enemy variety, visual flair)
- Mega Man X (wall jump, dash, tight controls)
- Cave Story (atmosphere, exploration, story integration)
- Mega Man X (dash, tight controls)
- Cowboy Bebop (aesthetic, tone, music style)

View File

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

View File

@@ -16,6 +16,15 @@ ENTITY grunt 9 16
ENTITY flyer 20 10
ENTITY flyer 30 8
# Powerups
ENTITY powerup_hp 13 12
ENTITY powerup_jet 21 14
ENTITY powerup_drone 29 16
# Exit zone: EXIT <tile_x> <tile_y> <tile_w> <tile_h> [target_path]
# Inside the walled room at the far right
EXIT 35 16 2 3 assets/levels/level02.lvl
# Hazards
ENTITY turret 33 4
ENTITY flame_vent 18 19

59
assets/levels/level02.lvl Normal file
View File

@@ -0,0 +1,59 @@
# Level 02 - Space Station
# ========================
TILESET assets/tiles/tileset.png
SIZE 30 23
SPAWN 3 18
GRAVITY 600
BG_COLOR 10 10 25
MUSIC assets/sounds/algardalgar.ogg
# Enemies
ENTITY grunt 12 18
ENTITY flyer 18 10
ENTITY grunt 22 14
# Powerups
ENTITY powerup_hp 10 11
# Exit zone at the far right — transitions to procedural space station
EXIT 27 16 2 3 generate:station
# Hazards
ENTITY turret 25 5
ENTITY flame_vent 14 19
ENTITY platform 8 13
ENTITY platform_v 20 10
# Tile definitions: ID TEX_X TEX_Y FLAGS
# Flag 1 = SOLID, Flag 2 = PLATFORM
TILEDEF 1 0 0 1
TILEDEF 2 1 0 1
TILEDEF 3 2 0 1
TILEDEF 4 0 1 2
# Collision layer (30 wide x 23 tall)
LAYER collision
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 1 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1

70
assets/levels/moon01.lvl Normal file
View File

@@ -0,0 +1,70 @@
# Moon Surface - Landing
# ======================
# First level: player lands on the moon.
# Pure jump-and-run intro with asteroid hazards. No gun, no enemies.
TILESET assets/tiles/moon_tileset.png
SIZE 200 23
SPAWN 3 18
GRAVITY 300
BG_COLOR 5 5 15
PARALLAX_STYLE 4
MUSIC assets/sounds/algardalgar.ogg
PLAYER_UNARMED
ENTITY asteroid 66 1
ENTITY asteroid 70 1
ENTITY asteroid 74 1
ENTITY asteroid 77 1
ENTITY asteroid 89 2
ENTITY asteroid 92 2
ENTITY asteroid 95 2
ENTITY asteroid 97 2
ENTITY asteroid 100 2
ENTITY asteroid 109 0
ENTITY asteroid 112 0
ENTITY asteroid 115 0
ENTITY asteroid 118 0
ENTITY asteroid 141 1
ENTITY asteroid 144 1
ENTITY asteroid 147 1
ENTITY asteroid 150 1
ENTITY asteroid 154 1
ENTITY asteroid 162 2
ENTITY asteroid 165 2
ENTITY asteroid 168 2
ENTITY asteroid 171 2
ENTITY asteroid 174 2
EXIT 196 17 2 3 assets/levels/level01.lvl
# Tile definitions
TILEDEF 1 0 0 1
TILEDEF 2 1 0 1
TILEDEF 3 2 0 1
TILEDEF 4 0 1 2
LAYER collision
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 4 4 4 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 0 0 1 1 1 2 1 1 1 1 1 1 1 1 1 1 2 0 0 0 1 1 1 1 1 1 1 2 1 1 1 1 1 0 0 0 1 1 2 1 1 1 1 1 1 1 1 1 1 2 1 1 1 0 0 0 0 1 1 1 2 1 1 1 1 1 1 1 1 1 1 2 1 1 1 0 0 0 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 2 1 1 1 1 1 1 1 1 1 1 2 1 1 1 0 0 0 0 1 1 1 2 1 1 1 1 1 1 1 1 1 1 2 1 0 0 0 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 2 1 1 1
1 1 1 1 3 1 1 1 1 1 1 1 1 3 1 1 1 1 1 1 1 1 3 1 1 1 0 0 1 1 1 3 1 1 1 1 1 1 1 1 3 1 1 0 0 0 1 1 1 3 1 1 1 1 1 1 1 1 3 0 0 0 1 1 1 1 1 3 1 1 1 1 1 1 1 1 3 1 1 0 0 0 0 1 1 3 1 1 1 1 1 1 1 1 3 1 1 1 1 1 1 0 0 0 1 1 1 1 1 1 1 1 3 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 3 1 1 1 1 1 1 1 1 3 1 1 1 1 1 1 1 0 0 0 0 1 1 1 1 1 1 3 1 1 1 1 1 1 1 1 3 0 0 0 1 1 1 1 1 3 1 1 1 1 1 1 1 1 3 1 1 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -22,7 +22,10 @@
/* ── Entities ───────────────────────────────────────── */
#define MAX_ENTITIES 512
#define MAX_ENTITY_SPAWNS 64 /* max entity spawns per level */
#define MAX_ENTITY_SPAWNS 128 /* max entity spawns per level */
/* ── Level transitions ─────────────────────────────── */
#define MAX_EXIT_ZONES 8 /* max exit zones per level */
/* ── Rendering ──────────────────────────────────────── */
#define MAX_SPRITES 2048 /* max queued sprites per frame */

View File

@@ -26,6 +26,7 @@ typedef enum EntityType {
ENT_FORCE_FIELD,
ENT_POWERUP,
ENT_DRONE,
ENT_ASTEROID,
ENT_TYPE_COUNT
} EntityType;

View File

@@ -692,6 +692,198 @@ static void generate_deep_space_near(Parallax *p, SDL_Renderer *renderer) {
p->near_layer.owns_texture = true;
}
/* ── Themed: Moon surface ───────────────────────────── */
static void generate_moon_far(Parallax *p, SDL_Renderer *renderer) {
int w = SCREEN_WIDTH;
int h = SCREEN_HEIGHT;
SDL_Texture *tex = SDL_CreateTexture(renderer,
SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h);
if (!tex) return;
SDL_SetRenderTarget(renderer, tex);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer);
unsigned int saved_seed = (unsigned int)rand();
srand(77);
/* Dim, cold stars — no atmosphere, but sparse */
for (int i = 0; i < 100; i++) {
int x = (int)(randf() * w);
int y = (int)(randf() * h * 0.55f);
uint8_t brightness = (uint8_t)(80 + (int)(randf() * 120));
/* Cool-white tint for airless sky */
uint8_t r = clamp_u8(brightness - 5);
uint8_t g = clamp_u8(brightness);
uint8_t b = clamp_u8(brightness + 10);
uint8_t a = (uint8_t)(120 + (int)(randf() * 100));
SDL_SetRenderDrawColor(renderer, r, g, b, a);
SDL_Rect dot = {x, y, 1, 1};
SDL_RenderFillRect(renderer, &dot);
}
/* A few bright feature stars */
for (int i = 0; i < 12; i++) {
int x = (int)(randf() * w);
int y = (int)(randf() * h * 0.45f);
SDL_SetRenderDrawColor(renderer, 240, 240, 255, 220);
SDL_Rect dot = {x, y, 1, 1};
SDL_RenderFillRect(renderer, &dot);
/* Cross glow on some */
if (i < 4) {
SDL_SetRenderDrawColor(renderer, 200, 200, 230, 80);
SDL_Rect halo[] = {
{x - 1, y, 3, 1},
{x, y - 1, 1, 3},
};
SDL_RenderFillRect(renderer, &halo[0]);
SDL_RenderFillRect(renderer, &halo[1]);
}
}
/* Distant crater-pocked terrain silhouette */
int terrain_base = (int)(h * 0.65f);
for (int x = 0; x < w; x++) {
float t = (float)x / (float)w;
/* Gentle rolling hills with crater dips */
float h1 = sinf(t * 6.28f * 1.5f) * 12.0f;
float h2 = sinf(t * 6.28f * 3.7f + 0.8f) * 8.0f;
float h3 = sinf(t * 6.28f * 8.2f + 2.5f) * 4.0f;
/* Crater depressions — sharp V-shaped dips */
float crater = 0.0f;
float c_positions[] = {0.15f, 0.35f, 0.55f, 0.78f, 0.92f};
float c_widths[] = {0.06f, 0.08f, 0.05f, 0.10f, 0.04f};
float c_depths[] = {18.0f, 25.0f, 15.0f, 30.0f, 12.0f};
for (int c = 0; c < 5; c++) {
float dist = (t - c_positions[c]) / c_widths[c];
if (dist > -1.0f && dist < 1.0f) {
float d = 1.0f - dist * dist; /* parabolic bowl */
crater += c_depths[c] * d;
}
}
int peak = terrain_base - (int)(h1 + h2 + h3) + (int)(crater);
if (peak < terrain_base - 35) peak = terrain_base - 35;
if (peak > h - 5) peak = h - 5;
for (int y = peak; y < h; y++) {
int depth = y - peak;
/* Grey regolith tones — lighter at ridgeline, darker below */
uint8_t base = clamp_u8(30 - depth / 4);
uint8_t r = clamp_u8(base + 5);
uint8_t g = clamp_u8(base + 3);
uint8_t b = clamp_u8(base);
uint8_t a = (uint8_t)(depth < 3 ? 100 : 160);
SDL_SetRenderDrawColor(renderer, r, g, b, a);
SDL_Rect px = {x, y, 1, 1};
SDL_RenderFillRect(renderer, &px);
}
}
srand(saved_seed);
SDL_SetRenderTarget(renderer, NULL);
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
p->far_layer.texture = tex;
p->far_layer.tex_w = w;
p->far_layer.tex_h = h;
p->far_layer.scroll_x = 0.03f;
p->far_layer.scroll_y = 0.03f;
p->far_layer.active = true;
p->far_layer.owns_texture = true;
}
static void generate_moon_near(Parallax *p, SDL_Renderer *renderer) {
int w = SCREEN_WIDTH;
int h = SCREEN_HEIGHT;
SDL_Texture *tex = SDL_CreateTexture(renderer,
SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, w, h);
if (!tex) return;
SDL_SetRenderTarget(renderer, tex);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 0);
SDL_RenderClear(renderer);
unsigned int saved_seed = (unsigned int)rand();
srand(166);
/* Closer crater-rim terrain — taller, more detail */
int terrain_base = (int)(h * 0.72f);
for (int x = 0; x < w; x++) {
float t = (float)x / (float)w;
float h1 = sinf(t * 6.28f * 2.3f + 1.0f) * 18.0f;
float h2 = sinf(t * 6.28f * 5.1f + 3.5f) * 10.0f;
float h3 = sinf(t * 6.28f * 13.0f + 0.7f) * 3.0f;
/* Near craters — fewer but bolder */
float crater = 0.0f;
float c_positions[] = {0.25f, 0.60f, 0.85f};
float c_widths[] = {0.10f, 0.12f, 0.07f};
float c_depths[] = {22.0f, 35.0f, 18.0f};
for (int c = 0; c < 3; c++) {
float dist = (t - c_positions[c]) / c_widths[c];
if (dist > -1.0f && dist < 1.0f) {
float d = 1.0f - dist * dist;
crater += c_depths[c] * d;
}
}
int peak = terrain_base - (int)(h1 + h2 + h3) + (int)(crater);
if (peak < terrain_base - 45) peak = terrain_base - 45;
if (peak > h - 5) peak = h - 5;
for (int y = peak; y < h; y++) {
int depth = y - peak;
/* Slightly brighter grey than far layer */
uint8_t base = clamp_u8(40 - depth / 3);
uint8_t r = clamp_u8(base + 8);
uint8_t g = clamp_u8(base + 5);
uint8_t b = clamp_u8(base + 2);
uint8_t a = (uint8_t)(depth < 2 ? 80 : 140);
SDL_SetRenderDrawColor(renderer, r, g, b, a);
SDL_Rect px = {x, y, 1, 1};
SDL_RenderFillRect(renderer, &px);
}
/* Rim highlight — bright edge at the very top of terrain */
if (peak < h - 5) {
uint8_t rim_a = (uint8_t)(40 + (int)(randf() * 30));
SDL_SetRenderDrawColor(renderer, 80, 75, 65, rim_a);
SDL_Rect px = {x, peak, 1, 1};
SDL_RenderFillRect(renderer, &px);
}
}
/* Scattered regolith dust particles */
for (int i = 0; i < 30; i++) {
int x = (int)(randf() * w);
int y = (int)(h * 0.5f + randf() * h * 0.4f);
uint8_t grey = (uint8_t)(30 + (int)(randf() * 30));
SDL_SetRenderDrawColor(renderer, grey + 5, grey + 3, grey,
(uint8_t)(20 + (int)(randf() * 25)));
SDL_Rect dot = {x, y, 1, 1};
SDL_RenderFillRect(renderer, &dot);
}
srand(saved_seed);
SDL_SetRenderTarget(renderer, NULL);
SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
p->near_layer.texture = tex;
p->near_layer.tex_w = w;
p->near_layer.tex_h = h;
p->near_layer.scroll_x = 0.10f;
p->near_layer.scroll_y = 0.06f;
p->near_layer.active = true;
p->near_layer.owns_texture = true;
}
/* ── Themed parallax dispatcher ─────────────────────── */
void parallax_generate_themed(Parallax *p, SDL_Renderer *renderer, ParallaxStyle style) {
@@ -708,6 +900,10 @@ void parallax_generate_themed(Parallax *p, SDL_Renderer *renderer, ParallaxStyle
generate_deep_space_far(p, renderer);
generate_deep_space_near(p, renderer);
break;
case PARALLAX_STYLE_MOON:
generate_moon_far(p, renderer);
generate_moon_near(p, renderer);
break;
case PARALLAX_STYLE_DEFAULT:
default:
parallax_generate_stars(p, renderer);

View File

@@ -43,6 +43,7 @@ typedef enum ParallaxStyle {
PARALLAX_STYLE_ALIEN_SKY, /* alien planet surface: dusty, hazy */
PARALLAX_STYLE_INTERIOR, /* indoor base: panels, pipes, structural */
PARALLAX_STYLE_DEEP_SPACE, /* space station windows: vivid stars */
PARALLAX_STYLE_MOON, /* moon surface: craters, grey terrain */
} ParallaxStyle;
/* Generate both layers with a unified style */

View File

@@ -71,6 +71,34 @@ bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer) {
sscanf(line + 14, "%255s", map->parallax_near_path);
} else if (strncmp(line, "MUSIC ", 6) == 0) {
sscanf(line + 6, "%255s", map->music_path);
} else if (strncmp(line, "PARALLAX_STYLE ", 15) == 0) {
int style = 0;
if (sscanf(line + 15, "%d", &style) == 1) {
map->parallax_style = style;
}
} else if (strncmp(line, "PLAYER_UNARMED", 14) == 0) {
map->player_unarmed = true;
} else if (strncmp(line, "EXIT ", 5) == 0) {
if (map->exit_zone_count < MAX_EXIT_ZONES) {
ExitZone *ez = &map->exit_zones[map->exit_zone_count];
float tx, ty, tw, th;
char target[ASSET_PATH_MAX] = {0};
/* EXIT <tile_x> <tile_y> <tile_w> <tile_h> [target_path] */
int n = sscanf(line + 5, "%f %f %f %f %255s",
&tx, &ty, &tw, &th, target);
if (n >= 4) {
ez->x = tx * TILE_SIZE;
ez->y = ty * TILE_SIZE;
ez->w = tw * TILE_SIZE;
ez->h = th * TILE_SIZE;
if (n == 5) {
snprintf(ez->target, sizeof(ez->target), "%s", target);
} else {
ez->target[0] = '\0'; /* no target = victory */
}
map->exit_zone_count++;
}
}
} else if (strncmp(line, "ENTITY ", 7) == 0) {
if (map->entity_spawn_count < MAX_ENTITY_SPAWNS) {
EntitySpawn *es = &map->entity_spawns[map->entity_spawn_count];

View File

@@ -30,6 +30,14 @@ typedef struct EntitySpawn {
float x, y; /* world position (pixels) */
} EntitySpawn;
/* Exit zone — triggers level transition on player overlap */
typedef struct ExitZone {
float x, y, w, h; /* world-space bounding box */
char target[ASSET_PATH_MAX]; /* path to next level, or
* "generate" for procgen,
* or empty for "you win" */
} ExitZone;
typedef struct Tilemap {
int width, height; /* map size in tiles */
uint16_t *bg_layer; /* background tile IDs */
@@ -47,8 +55,11 @@ typedef struct Tilemap {
char parallax_far_path[ASSET_PATH_MAX]; /* far bg image path */
char parallax_near_path[ASSET_PATH_MAX]; /* near bg image path */
int parallax_style; /* procedural bg style (0=default) */
bool player_unarmed; /* if true, player starts without gun */
EntitySpawn entity_spawns[MAX_ENTITY_SPAWNS];
int entity_spawn_count;
ExitZone exit_zones[MAX_EXIT_ZONES];
int exit_zone_count;
} Tilemap;
bool tilemap_load(Tilemap *map, const char *path, SDL_Renderer *renderer);

View File

@@ -65,7 +65,7 @@ static void drone_update(Entity *self, float dt, const Tilemap *map) {
self->body.pos.y + self->body.size.y * 0.5f
);
particle_emit_spark(center, (SDL_Color){50, 200, 255, 255});
self->flags |= ENTITY_DEAD;
entity_destroy(s_em, self);
return;
}

View File

@@ -11,6 +11,99 @@
#include <string.h>
#include <math.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
/* ═══════════════════════════════════════════════════
* Browser file I/O helpers (Emscripten only)
*
* Save: write .lvl to virtual FS, then trigger
* browser download via Blob URL.
* Load: open an <input type="file"> dialog, read
* the file contents, write to virtual FS,
* and set a flag for the editor to pick up.
* ═══════════════════════════════════════════════════ */
/* Download a file from the Emscripten virtual FS to the user's machine */
EM_JS(void, browser_download_file, (const char *vfs_path, const char *download_name), {
var path = UTF8ToString(vfs_path);
var name = UTF8ToString(download_name);
try {
var data = FS.readFile(path);
var blob = new Blob([data], { type: 'text/plain' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
setTimeout(function() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
} catch (e) {
console.error('Download failed:', e);
}
});
/* Path where uploaded file will be written in virtual FS */
#define UPLOAD_VFS_PATH "/tmp/_upload.lvl"
/* Flags polled by editor_update — set by JS */
static volatile int s_upload_ready = 0; /* file upload completed */
static volatile int s_save_request = 0; /* Ctrl+S or Save button */
static volatile int s_load_request = 0; /* Ctrl+O or Load button */
/* Path to load when s_load_request is 2 (load a specific VFS file) */
static char s_load_vfs_path[256] = {0};
/* Open a browser file picker; on selection, write contents to virtual FS */
EM_JS(void, browser_open_file_picker, (), {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.lvl,.txt';
input.onchange = function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
var data = new Uint8Array(ev.target.result);
try { FS.unlink('/tmp/_upload.lvl'); } catch(x) {}
FS.writeFile('/tmp/_upload.lvl', data);
/* Set the C flag via direct heap access */
var ptr = _editor_upload_flag_ptr();
HEAP32[ptr >> 2] = 1;
};
reader.readAsArrayBuffer(file);
};
input.click();
});
/* Export addresses so JS can set flags and paths */
EMSCRIPTEN_KEEPALIVE
int *editor_upload_flag_ptr(void) {
return (int *)&s_upload_ready;
}
EMSCRIPTEN_KEEPALIVE
int *editor_save_flag_ptr(void) {
return (int *)&s_save_request;
}
EMSCRIPTEN_KEEPALIVE
int *editor_load_flag_ptr(void) {
return (int *)&s_load_request;
}
/* Called from JS to load an existing level file from the virtual FS */
EMSCRIPTEN_KEEPALIVE
void editor_load_vfs_file(const char *path) {
snprintf(s_load_vfs_path, sizeof(s_load_vfs_path), "%s", path);
s_load_request = 2; /* 2 = load from specific VFS path */
}
#endif /* __EMSCRIPTEN__ */
/* ═══════════════════════════════════════════════════
* Minimal 4x6 bitmap font
*
@@ -140,7 +233,7 @@ static int text_width(const char *text) {
#define ZOOM_STEP 0.25f
static const SDL_Color COL_BG = {30, 30, 46, 255};
static const SDL_Color COL_PANEL = {20, 20, 35, 240};
static const SDL_Color COL_PANEL = {20, 20, 35, 255};
static const SDL_Color COL_PANEL_LT = {40, 40, 60, 255};
static const SDL_Color COL_TEXT = {200, 200, 220, 255};
static const SDL_Color COL_TEXT_DIM = {120, 120, 140, 255};
@@ -150,9 +243,11 @@ static const SDL_Color COL_SPAWN = {60, 255, 60, 255};
static const SDL_Color COL_ENTITY = {255, 100, 100, 255};
static const SDL_Color COL_SELECT = {255, 255, 100, 255};
static const SDL_Color COL_EXIT = {50, 230, 180, 255};
/* Tool names */
static const char *s_tool_names[TOOL_COUNT] = {
"PENCIL", "ERASER", "FILL", "ENTITY", "SPAWN"
"PENCIL", "ERASER", "FILL", "ENTITY", "SPAWN", "EXIT"
};
/* Layer names */
@@ -249,6 +344,8 @@ static bool save_tilemap(const Tilemap *map, const char *path) {
fprintf(f, "PARALLAX_FAR %s\n", map->parallax_far_path);
if (map->parallax_near_path[0])
fprintf(f, "PARALLAX_NEAR %s\n", map->parallax_near_path);
if (map->player_unarmed)
fprintf(f, "PLAYER_UNARMED\n");
fprintf(f, "\n");
@@ -261,6 +358,21 @@ static bool save_tilemap(const Tilemap *map, const char *path) {
}
fprintf(f, "\n");
/* Exit zones */
for (int i = 0; i < map->exit_zone_count; i++) {
const ExitZone *ez = &map->exit_zones[i];
int tx = (int)(ez->x / TILE_SIZE);
int ty = (int)(ez->y / TILE_SIZE);
int tw = (int)(ez->w / TILE_SIZE);
int th = (int)(ez->h / TILE_SIZE);
if (ez->target[0]) {
fprintf(f, "EXIT %d %d %d %d %s\n", tx, ty, tw, th, ez->target);
} else {
fprintf(f, "EXIT %d %d %d %d\n", tx, ty, tw, th);
}
}
if (map->exit_zone_count > 0) fprintf(f, "\n");
/* Tile definitions */
for (int id = 1; id < map->tile_def_count; id++) {
const TileDef *td = &map->tile_defs[id];
@@ -449,6 +561,15 @@ bool editor_save(Editor *ed) {
}
if (save_tilemap(&ed->map, ed->file_path)) {
ed->dirty = false;
#ifdef __EMSCRIPTEN__
/* Trigger a browser download of the saved file */
{
/* Extract just the filename from the path for the download name */
const char *name = strrchr(ed->file_path, '/');
name = name ? name + 1 : ed->file_path;
browser_download_file(ed->file_path, name);
}
#endif
return true;
}
return false;
@@ -522,6 +643,42 @@ static void remove_entity_spawn(Tilemap *map, int index) {
map->entity_spawn_count--;
}
/* ── Exit zone helpers ─────────────────────────────── */
#define EXIT_DEFAULT_W 2 /* default exit zone width in tiles */
#define EXIT_DEFAULT_H 3 /* default exit zone height in tiles */
static int find_exit_at(const Tilemap *map, float wx, float wy) {
for (int i = 0; i < map->exit_zone_count; i++) {
const ExitZone *ez = &map->exit_zones[i];
if (wx >= ez->x && wx < ez->x + ez->w &&
wy >= ez->y && wy < ez->y + ez->h) {
return i;
}
}
return -1;
}
static void add_exit_zone(Tilemap *map, float wx, float wy) {
if (map->exit_zone_count >= MAX_EXIT_ZONES) return;
ExitZone *ez = &map->exit_zones[map->exit_zone_count];
/* Snap to tile grid */
ez->x = floorf(wx / TILE_SIZE) * TILE_SIZE;
ez->y = floorf(wy / TILE_SIZE) * TILE_SIZE;
ez->w = EXIT_DEFAULT_W * TILE_SIZE;
ez->h = EXIT_DEFAULT_H * TILE_SIZE;
ez->target[0] = '\0'; /* empty = victory / end of level */
map->exit_zone_count++;
}
static void remove_exit_zone(Tilemap *map, int index) {
if (index < 0 || index >= map->exit_zone_count) return;
for (int i = index; i < map->exit_zone_count - 1; i++) {
map->exit_zones[i] = map->exit_zones[i + 1];
}
map->exit_zone_count--;
}
/* ═══════════════════════════════════════════════════
* Internal state for test-play / quit requests
* ═══════════════════════════════════════════════════ */
@@ -559,6 +716,7 @@ void editor_update(Editor *ed, float dt) {
if (input_key_pressed(SDL_SCANCODE_3)) ed->tool = TOOL_FILL;
if (input_key_pressed(SDL_SCANCODE_4)) ed->tool = TOOL_ENTITY;
if (input_key_pressed(SDL_SCANCODE_5)) ed->tool = TOOL_SPAWN;
if (input_key_pressed(SDL_SCANCODE_6)) ed->tool = TOOL_EXIT;
/* Layer selection: Q/W/E */
if (input_key_pressed(SDL_SCANCODE_Q)) ed->active_layer = EDITOR_LAYER_COLLISION;
@@ -577,6 +735,55 @@ void editor_update(Editor *ed, float dt) {
editor_save(ed);
}
/* Open / Load: Ctrl+O */
if (input_key_pressed(SDL_SCANCODE_O) && input_key_held(SDL_SCANCODE_LCTRL)) {
#ifdef __EMSCRIPTEN__
browser_open_file_picker();
#else
/* Desktop: reload from current file (no native file dialog) */
if (ed->has_file) {
editor_load(ed, ed->file_path);
}
#endif
}
#ifdef __EMSCRIPTEN__
/* Poll for save request from JS (Ctrl+S or Save button) */
if (s_save_request) {
s_save_request = 0;
editor_save(ed);
}
/* Poll for load request from JS */
if (s_load_request == 1) {
/* 1 = open file picker (Ctrl+O or Load button) */
s_load_request = 0;
browser_open_file_picker();
} else if (s_load_request == 2) {
/* 2 = load a specific VFS file (level picker dropdown) */
s_load_request = 0;
if (s_load_vfs_path[0]) {
if (editor_load(ed, s_load_vfs_path)) {
printf("editor: loaded %s\n", s_load_vfs_path);
}
s_load_vfs_path[0] = '\0';
}
}
/* Poll for completed file upload from browser */
if (s_upload_ready) {
s_upload_ready = 0;
if (editor_load(ed, UPLOAD_VFS_PATH)) {
/* Clear the stored path so saves go to default,
* not the tmp upload path */
strncpy(ed->file_path, "assets/levels/edited.lvl",
sizeof(ed->file_path) - 1);
ed->has_file = true;
printf("editor: loaded from browser upload\n");
}
}
#endif
/* Test play: P */
if (input_key_pressed(SDL_SCANCODE_P)) {
s_wants_test_play = true;
@@ -642,9 +849,9 @@ void editor_update(Editor *ed, float dt) {
}
}
/* ── Zoom (scroll wheel) ── */
/* ── Zoom (scroll wheel) — only when mouse is over the canvas ── */
int scroll = input_mouse_scroll();
if (scroll != 0) {
if (scroll != 0 && in_canvas(mx, my)) {
/* Compute world pos under mouse at CURRENT zoom, before changing it */
Vec2 mouse_world = camera_screen_to_world(&ed->camera, vec2((float)mx, (float)my));
@@ -655,7 +862,7 @@ void editor_update(Editor *ed, float dt) {
/* Zoom toward mouse position: keep the world point under the
* mouse cursor in the same screen position after the zoom. */
if (ed->camera.zoom != old_zoom && in_canvas(mx, my)) {
if (ed->camera.zoom != old_zoom) {
float new_zoom = ed->camera.zoom;
ed->camera.pos.x = mouse_world.x - (float)mx / new_zoom;
ed->camera.pos.y = mouse_world.y - (float)my / new_zoom;
@@ -680,26 +887,37 @@ void editor_update(Editor *ed, float dt) {
if (max_y > 0 && ed->camera.pos.y > max_y + vp_h * 0.5f)
ed->camera.pos.y = max_y + vp_h * 0.5f;
/* ── Tile palette click (right panel) ── */
/* ── Right palette click ── */
if (mx >= SCREEN_WIDTH - EDITOR_PALETTE_W && my >= EDITOR_TOOLBAR_H &&
my < SCREEN_HEIGHT - EDITOR_STATUS_H) {
if (ed->tool != TOOL_ENTITY) {
int px = SCREEN_WIDTH - EDITOR_PALETTE_W;
int py = EDITOR_TOOLBAR_H;
int ph = SCREEN_HEIGHT - EDITOR_TOOLBAR_H - EDITOR_STATUS_H;
/* Split point: tiles take ~55%, entities take ~45% */
int tile_section_h = (ph * 55) / 100;
int ent_section_y = py + tile_section_h;
if (my < ent_section_y) {
/* Tile palette area */
if (input_mouse_pressed(MOUSE_LEFT)) {
int pal_x = mx - (SCREEN_WIDTH - EDITOR_PALETTE_W) - 2;
int pal_y = my - EDITOR_TOOLBAR_H - 2 + ed->tile_palette_scroll * TILE_SIZE;
int pal_x = mx - px - 2;
int pal_label_h = FONT_H + 6;
int pal_y = my - py - pal_label_h
+ ed->tile_palette_scroll * (TILE_SIZE + 1);
if (pal_x >= 0 && pal_y >= 0) {
int max_cols = (EDITOR_PALETTE_W - 4) / (TILE_SIZE + 1);
if (max_cols < 1) max_cols = 1;
int tile_col = pal_x / (TILE_SIZE + 1);
int tile_row = pal_y / (TILE_SIZE + 1);
if (ed->tileset_cols > 0) {
int tile_id = tile_row * ed->tileset_cols + tile_col + 1;
if (tile_id >= 1 && tile_id <= ed->tileset_total) {
ed->selected_tile = (uint16_t)tile_id;
}
int tile_id = tile_row * max_cols + tile_col + 1;
if (tile_id >= 1 && tile_id <= ed->tileset_total) {
ed->selected_tile = (uint16_t)tile_id;
/* Auto-switch to pencil tool when selecting a tile */
if (ed->tool == TOOL_ENTITY) ed->tool = TOOL_PENCIL;
}
}
}
/* Scroll palette */
if (scroll != 0) {
ed->tile_palette_scroll -= scroll;
if (ed->tile_palette_scroll < 0) ed->tile_palette_scroll = 0;
@@ -707,10 +925,14 @@ void editor_update(Editor *ed, float dt) {
} else {
/* Entity palette area */
if (input_mouse_pressed(MOUSE_LEFT)) {
int pal_y = my - EDITOR_TOOLBAR_H - 2 + ed->entity_palette_scroll * 12;
int pal_label_h = FONT_H + 6;
int pal_y = my - ent_section_y - pal_label_h
+ ed->entity_palette_scroll * 12;
int entry_idx = pal_y / 12;
if (entry_idx >= 0 && entry_idx < g_entity_registry.count) {
ed->selected_entity = entry_idx;
/* Auto-switch to entity tool when selecting an entity */
ed->tool = TOOL_ENTITY;
}
}
if (scroll != 0) {
@@ -724,13 +946,14 @@ void editor_update(Editor *ed, float dt) {
/* ── Toolbar click ── */
if (my < EDITOR_TOOLBAR_H) {
if (input_mouse_pressed(MOUSE_LEFT)) {
/* Tool buttons: each ~30px wide */
int btn = mx / 32;
/* Tool buttons: each 28px wide starting at x=2 */
int btn = (mx - 2) / 28;
if (btn >= 0 && btn < TOOL_COUNT) {
ed->tool = (EditorTool)btn;
}
/* Layer buttons at x=170+ */
int lx = mx - 170;
/* Layer buttons after separator */
int sep_x = TOOL_COUNT * 28 + 8;
int lx = mx - sep_x;
if (lx >= 0) {
int lbtn = lx / 25;
if (lbtn >= 0 && lbtn < EDITOR_LAYER_COUNT) {
@@ -835,6 +1058,25 @@ void editor_update(Editor *ed, float dt) {
}
break;
case TOOL_EXIT:
if (input_mouse_pressed(MOUSE_LEFT)) {
/* Place a new exit zone (if not clicking an existing one) */
int eidx = find_exit_at(&ed->map, world_pos.x, world_pos.y);
if (eidx < 0) {
add_exit_zone(&ed->map, world_pos.x, world_pos.y);
ed->dirty = true;
}
}
/* Right-click to delete exit zone */
if (input_mouse_pressed(MOUSE_RIGHT)) {
int eidx = find_exit_at(&ed->map, world_pos.x, world_pos.y);
if (eidx >= 0) {
remove_exit_zone(&ed->map, eidx);
ed->dirty = true;
}
}
break;
default:
break;
}
@@ -954,6 +1196,26 @@ void editor_render(Editor *ed, float interpolation) {
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
}
/* ── Exit zone markers ── */
for (int i = 0; i < ed->map.exit_zone_count; i++) {
const ExitZone *ez = &ed->map.exit_zones[i];
Vec2 sp = camera_world_to_screen(cam, vec2(ez->x, ez->y));
float zw = ez->w * cam->zoom;
float zh = ez->h * cam->zoom;
SDL_SetRenderDrawColor(r, COL_EXIT.r, COL_EXIT.g, COL_EXIT.b, 80);
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
SDL_Rect er = {(int)sp.x, (int)sp.y, (int)(zw + 0.5f), (int)(zh + 0.5f)};
SDL_RenderFillRect(r, &er);
SDL_SetRenderDrawColor(r, COL_EXIT.r, COL_EXIT.g, COL_EXIT.b, 220);
SDL_RenderDrawRect(r, &er);
draw_text(r, "EXIT", (int)sp.x + 1, (int)sp.y + 1, COL_EXIT);
if (ez->target[0]) {
draw_text(r, ez->target, (int)sp.x + 1, (int)sp.y + 8, COL_EXIT);
}
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
}
/* ── Cursor highlight ── */
{
int mx_c, my_c;
@@ -965,9 +1227,21 @@ void editor_render(Editor *ed, float interpolation) {
Vec2 cpos = camera_world_to_screen(cam,
vec2(tile_to_world(tx), tile_to_world(ty)));
float zs = TILE_SIZE * cam->zoom;
SDL_SetRenderDrawColor(r, COL_SELECT.r, COL_SELECT.g, COL_SELECT.b, 120);
/* Exit tool: show 2x3 preview */
int cw = 1, ch = 1;
if (ed->tool == TOOL_EXIT) {
cw = EXIT_DEFAULT_W;
ch = EXIT_DEFAULT_H;
}
SDL_SetRenderDrawColor(r,
ed->tool == TOOL_EXIT ? COL_EXIT.r : COL_SELECT.r,
ed->tool == TOOL_EXIT ? COL_EXIT.g : COL_SELECT.g,
ed->tool == TOOL_EXIT ? COL_EXIT.b : COL_SELECT.b, 120);
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
SDL_Rect cr = {(int)cpos.x, (int)cpos.y, (int)(zs + 0.5f), (int)(zs + 0.5f)};
SDL_Rect cr = {(int)cpos.x, (int)cpos.y,
(int)(zs * cw + 0.5f), (int)(zs * ch + 0.5f)};
SDL_RenderDrawRect(r, &cr);
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
}
@@ -984,22 +1258,32 @@ void editor_render(Editor *ed, float interpolation) {
SDL_Rect tb = {0, 0, SCREEN_WIDTH, EDITOR_TOOLBAR_H};
SDL_RenderFillRect(r, &tb);
/* Vertically center text: (EDITOR_TOOLBAR_H - FONT_H) / 2 */
int text_y = (EDITOR_TOOLBAR_H - FONT_H) / 2;
/* Tool buttons */
for (int i = 0; i < TOOL_COUNT; i++) {
int bx = i * 32 + 2;
int bx = i * 28 + 2;
SDL_Color tc = (i == (int)ed->tool) ? COL_HIGHLIGHT : COL_TEXT_DIM;
draw_text(r, s_tool_names[i], bx, 4, tc);
draw_text(r, s_tool_names[i], bx, text_y, tc);
}
/* Separator */
int sep_x = TOOL_COUNT * 28 + 4;
SDL_SetRenderDrawColor(r, COL_PANEL_LT.r, COL_PANEL_LT.g, COL_PANEL_LT.b, 255);
SDL_RenderDrawLine(r, sep_x, 3, sep_x, EDITOR_TOOLBAR_H - 3);
/* Layer buttons */
int layer_start = sep_x + 4;
for (int i = 0; i < EDITOR_LAYER_COUNT; i++) {
int bx = 170 + i * 25;
int bx = layer_start + i * 25;
SDL_Color lc = (i == (int)ed->active_layer) ? COL_HIGHLIGHT : COL_TEXT_DIM;
draw_text(r, s_layer_names[i], bx, 4, lc);
draw_text(r, s_layer_names[i], bx, text_y, lc);
}
/* Grid & Layers indicators */
draw_text(r, ed->show_grid ? "[G]RID" : "[G]rid", 260, 4,
int grid_x = layer_start + EDITOR_LAYER_COUNT * 25 + 4;
draw_text(r, ed->show_grid ? "[G]RID" : "[G]rid", grid_x, text_y,
ed->show_grid ? COL_TEXT : COL_TEXT_DIM);
}
@@ -1013,21 +1297,26 @@ void editor_render(Editor *ed, float interpolation) {
SDL_Rect panel = {px, py, EDITOR_PALETTE_W, ph};
SDL_RenderFillRect(r, &panel);
/* Separator line */
/* Left separator line */
SDL_SetRenderDrawColor(r, COL_PANEL_LT.r, COL_PANEL_LT.g, COL_PANEL_LT.b, 255);
SDL_RenderDrawLine(r, px, py, px, py + ph);
if (ed->tool != TOOL_ENTITY) {
/* ── Tile palette ── */
draw_text(r, "TILES", px + 2, py + 2, COL_TEXT);
/* Split: tiles top ~55%, entities bottom ~45% */
int tile_section_h = (ph * 55) / 100;
int ent_section_y = py + tile_section_h;
int pal_y_start = py + 10;
/* ── Tile palette (top section) ── */
{
int label_h = FONT_H + 6; /* label area: font + padding */
draw_text(r, "TILES", px + 2, py + (label_h - FONT_H) / 2, COL_TEXT);
int pal_y_start = py + label_h;
int max_cols = (EDITOR_PALETTE_W - 4) / (TILE_SIZE + 1);
if (max_cols < 1) max_cols = 1;
if (ed->map.tileset && ed->tileset_total > 0) {
/* Set clip rect to palette area */
SDL_Rect clip = {px, pal_y_start, EDITOR_PALETTE_W, ph - 10};
SDL_Rect clip = {px, pal_y_start, EDITOR_PALETTE_W,
tile_section_h - label_h};
SDL_RenderSetClipRect(r, &clip);
for (int id = 1; id <= ed->tileset_total; id++) {
@@ -1038,11 +1327,9 @@ void editor_render(Editor *ed, float interpolation) {
int draw_y = pal_y_start + row_idx * (TILE_SIZE + 1)
- ed->tile_palette_scroll * (TILE_SIZE + 1);
/* Skip if off-screen */
if (draw_y + TILE_SIZE < pal_y_start || draw_y > py + ph)
continue;
if (draw_y + TILE_SIZE < pal_y_start ||
draw_y > ent_section_y) continue;
/* Source rect from tileset */
int src_col = (id - 1) % ed->tileset_cols;
int src_row = (id - 1) / ed->tileset_cols;
SDL_Rect src = {
@@ -1064,12 +1351,20 @@ void editor_render(Editor *ed, float interpolation) {
SDL_RenderSetClipRect(r, NULL);
}
} else {
/* ── Entity palette ── */
draw_text(r, "ENTITIES", px + 2, py + 2, COL_TEXT);
}
int pal_y_start = py + 12;
SDL_Rect clip = {px, pal_y_start, EDITOR_PALETTE_W, ph - 12};
/* ── Divider line between tiles and entities ── */
SDL_SetRenderDrawColor(r, COL_PANEL_LT.r, COL_PANEL_LT.g, COL_PANEL_LT.b, 255);
SDL_RenderDrawLine(r, px + 2, ent_section_y, px + EDITOR_PALETTE_W - 2, ent_section_y);
/* ── Entity palette (bottom section) ── */
{
int label_h = FONT_H + 6;
draw_text(r, "ENTITIES", px + 2, ent_section_y + (label_h - FONT_H) / 2, COL_TEXT);
int pal_y_start = ent_section_y + label_h;
int ent_area_h = py + ph - pal_y_start;
SDL_Rect clip = {px, pal_y_start, EDITOR_PALETTE_W, ent_area_h};
SDL_RenderSetClipRect(r, &clip);
for (int i = 0; i < g_entity_registry.count; i++) {
@@ -1113,6 +1408,6 @@ void editor_render(Editor *ed, float interpolation) {
ed->camera.zoom * 100.0f,
ed->has_file ? ed->file_path : "new level",
ed->dirty ? " *" : "");
draw_text(r, status, 2, sy + 2, COL_TEXT);
draw_text(r, status, 2, sy + (EDITOR_STATUS_H - FONT_H) / 2, COL_TEXT);
}
}

View File

@@ -26,6 +26,7 @@ typedef enum EditorTool {
TOOL_FILL, /* flood fill area */
TOOL_ENTITY, /* place/select entities */
TOOL_SPAWN, /* set player spawn point */
TOOL_EXIT, /* place/delete exit zones */
TOOL_COUNT
} EditorTool;
@@ -37,9 +38,9 @@ typedef enum EditorLayer {
} EditorLayer;
/* UI panel regions */
#define EDITOR_TOOLBAR_H 14 /* top bar height */
#define EDITOR_TOOLBAR_H 18 /* top bar height */
#define EDITOR_PALETTE_W 80 /* right panel width */
#define EDITOR_STATUS_H 10 /* bottom status bar height */
#define EDITOR_STATUS_H 16 /* bottom status bar height */
typedef struct Editor {
/* ── Map data ──────────────────────────── */

View File

@@ -31,6 +31,10 @@ static Entity *spawn_powerup_drone(EntityManager *em, Vec2 pos) {
return powerup_spawn_drone(em, pos);
}
static Entity *spawn_powerup_gun(EntityManager *em, Vec2 pos) {
return powerup_spawn_gun(em, pos);
}
/* ── Registry population ─────────────────────────── */
static void reg_add(const char *name, const char *display,
@@ -62,6 +66,7 @@ void entity_registry_init(EntityManager *em) {
force_field_register(em);
powerup_register(em);
drone_register(em);
asteroid_register(em);
/* ════════════════════════════════════════════
* REGISTRY TABLE
@@ -85,6 +90,8 @@ void entity_registry_init(EntityManager *em) {
reg_add("powerup_hp", "Health Pickup", spawn_powerup_health, (SDL_Color){255, 80, 80, 255}, 12, 12);
reg_add("powerup_jet", "Jetpack Refill", spawn_powerup_jetpack,(SDL_Color){255, 200, 50, 255}, 12, 12);
reg_add("powerup_drone", "Drone Pickup", spawn_powerup_drone, (SDL_Color){80, 200, 255, 255}, 12, 12);
reg_add("powerup_gun", "Gun Pickup", spawn_powerup_gun, (SDL_Color){200, 200, 220, 255}, 12, 12);
reg_add("asteroid", "Asteroid", asteroid_spawn, (SDL_Color){140, 110, 80, 255}, ASTEROID_WIDTH, ASTEROID_HEIGHT);
printf("Entity registry: %d types registered\n", g_entity_registry.count);
}

View File

@@ -1,9 +1,11 @@
#include "game/hazards.h"
#include "game/player.h"
#include "game/sprites.h"
#include "game/projectile.h"
#include "engine/physics.h"
#include "engine/renderer.h"
#include "engine/particle.h"
#include "engine/audio.h"
#include <stdlib.h>
#include <math.h>
@@ -597,3 +599,203 @@ Entity *force_field_spawn(EntityManager *em, Vec2 pos) {
return e;
}
/* ════════════════════════════════════════════════════
* ASTEROID — Falling space rock
* ════════════════════════════════════════════════════ */
static EntityManager *s_asteroid_em = NULL;
static Sound s_sfx_asteroid_impact;
static bool s_asteroid_sfx_loaded = false;
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static void asteroid_update(Entity *self, float dt, const Tilemap *map) {
AsteroidData *ad = (AsteroidData *)self->data;
if (!ad) return;
/* Initial delay before first fall */
if (ad->start_delay > 0) {
ad->start_delay -= dt;
self->body.pos.y = -20.0f; /* hide off-screen */
return;
}
if (!ad->falling) {
/* Waiting to respawn */
ad->respawn_timer -= dt;
if (ad->respawn_timer <= 0) {
/* Reset to spawn position and start falling */
self->body.pos = ad->spawn_pos;
ad->falling = true;
ad->trail_timer = 0;
ad->fall_speed = ASTEROID_FALL_SPEED;
}
return;
}
/* Accelerate while falling (gravity-like) */
ad->fall_speed += 200.0f * dt;
self->body.pos.y += ad->fall_speed * dt;
/* Tumble animation */
animation_update(&self->anim, dt);
/* Smoke trail particles */
ad->trail_timer -= dt;
if (ad->trail_timer <= 0) {
ad->trail_timer = 0.04f;
Vec2 center = vec2(
self->body.pos.x + self->body.size.x * 0.5f,
self->body.pos.y
);
ParticleBurst trail = {
.origin = center,
.count = 2,
.speed_min = 5.0f,
.speed_max = 20.0f,
.life_min = 0.2f,
.life_max = 0.5f,
.size_min = 1.0f,
.size_max = 2.0f,
.spread = 0.5f,
.direction = -(float)M_PI / 2.0f, /* upward */
.drag = 2.0f,
.gravity_scale = 0.0f,
.color = {140, 110, 80, 180},
.color_vary = true,
};
particle_emit(&trail);
}
/* Check player collision */
Entity *player = find_player(s_asteroid_em);
if (player && !(player->flags & ENTITY_INVINCIBLE) &&
!(player->flags & ENTITY_DEAD)) {
if (physics_overlap(&self->body, &player->body)) {
player->health -= ASTEROID_DAMAGE;
if (player->health <= 0) {
player->health = 0;
player->flags |= ENTITY_DEAD;
} else {
/* Grant invincibility frames */
PlayerData *ppd = (PlayerData *)player->data;
if (ppd) {
ppd->inv_timer = PLAYER_INV_TIME;
player->flags |= ENTITY_INVINCIBLE;
}
/* Knockback downward and away */
float knock_dir = (player->body.pos.x < self->body.pos.x)
? -1.0f : 1.0f;
player->body.vel.x = knock_dir * 120.0f;
player->body.vel.y = 100.0f;
}
Vec2 hit_pos = vec2(
player->body.pos.x + player->body.size.x * 0.5f,
player->body.pos.y + player->body.size.y * 0.5f
);
particle_emit_spark(hit_pos, (SDL_Color){180, 140, 80, 255});
if (s_asteroid_sfx_loaded) {
audio_play_sound(s_sfx_asteroid_impact, 80);
}
}
}
/* Check ground collision */
int tx = world_to_tile(self->body.pos.x + self->body.size.x * 0.5f);
int ty = world_to_tile(self->body.pos.y + self->body.size.y);
bool hit_ground = tilemap_is_solid(map, tx, ty);
/* Also despawn if far below level */
float level_bottom = (float)(map->height * TILE_SIZE) + 32.0f;
if (hit_ground || self->body.pos.y > level_bottom) {
/* Impact effect */
Vec2 impact_pos = vec2(
self->body.pos.x + self->body.size.x * 0.5f,
self->body.pos.y + self->body.size.y
);
particle_emit_death_puff(impact_pos, (SDL_Color){140, 110, 80, 255});
if (s_asteroid_sfx_loaded) {
audio_play_sound(s_sfx_asteroid_impact, 60);
}
/* Hide and start respawn timer */
ad->falling = false;
ad->respawn_timer = ASTEROID_RESPAWN;
self->body.pos.y = -100.0f; /* hide off-screen */
}
}
static void asteroid_render(Entity *self, const Camera *cam) {
(void)cam;
AsteroidData *ad = (AsteroidData *)self->data;
if (!ad || !ad->falling) return;
if (!g_spritesheet || !self->anim.def) return;
SDL_Rect src = animation_current_rect(&self->anim);
Body *body = &self->body;
float draw_x = body->pos.x + body->size.x * 0.5f - SPRITE_CELL * 0.5f;
float draw_y = body->pos.y + body->size.y * 0.5f - SPRITE_CELL * 0.5f;
Sprite spr = {
.texture = g_spritesheet,
.src = src,
.pos = vec2(draw_x, draw_y),
.size = vec2(SPRITE_CELL, SPRITE_CELL),
.flip_x = false,
.flip_y = false,
.layer = LAYER_ENTITIES,
.alpha = 255,
};
renderer_submit(&spr);
}
static void asteroid_destroy(Entity *self) {
free(self->data);
self->data = NULL;
}
void asteroid_register(EntityManager *em) {
entity_register(em, ENT_ASTEROID, asteroid_update, asteroid_render, asteroid_destroy);
s_asteroid_em = em;
if (!s_asteroid_sfx_loaded) {
s_sfx_asteroid_impact = audio_load_sound("assets/sounds/hitHurt.wav");
s_asteroid_sfx_loaded = true;
}
}
Entity *asteroid_spawn(EntityManager *em, Vec2 pos) {
Entity *e = entity_spawn(em, ENT_ASTEROID, pos);
if (!e) return NULL;
e->body.size = vec2(ASTEROID_WIDTH, ASTEROID_HEIGHT);
e->body.gravity_scale = 0.0f; /* we handle movement manually */
e->health = 9999;
e->max_health = 9999;
e->flags |= ENTITY_INVINCIBLE;
e->damage = ASTEROID_DAMAGE;
AsteroidData *ad = calloc(1, sizeof(AsteroidData));
ad->spawn_pos = pos;
ad->falling = true;
ad->fall_speed = ASTEROID_FALL_SPEED;
ad->trail_timer = 0;
ad->respawn_timer = 0;
/* Stagger start times based on spawn position to avoid all falling at once */
ad->start_delay = (pos.x * 0.013f + pos.y * 0.007f);
ad->start_delay = ad->start_delay - (float)(int)ad->start_delay; /* frac part */
ad->start_delay *= 3.0f; /* 0-3s stagger */
e->data = ad;
animation_set(&e->anim, &anim_asteroid);
return e;
}

View File

@@ -87,4 +87,28 @@ typedef struct ForceFieldData {
void force_field_register(EntityManager *em);
Entity *force_field_spawn(EntityManager *em, Vec2 pos);
/* ═══════════════════════════════════════════════════
* ASTEROID — Falls from the sky, damages the player
* on contact, explodes on hitting the ground, then
* respawns at its original position after a delay.
* ═══════════════════════════════════════════════════ */
#define ASTEROID_WIDTH 14
#define ASTEROID_HEIGHT 14
#define ASTEROID_FALL_SPEED 120.0f /* base fall speed (px/s) */
#define ASTEROID_DAMAGE 1
#define ASTEROID_RESPAWN 3.0f /* seconds before respawning */
typedef struct AsteroidData {
Vec2 spawn_pos; /* original position (for reset)*/
float fall_speed; /* current fall velocity */
float respawn_timer; /* countdown while inactive */
bool falling; /* true = falling, false = wait */
float trail_timer; /* particle trail interval */
float start_delay; /* initial delay before first fall */
} AsteroidData;
void asteroid_register(EntityManager *em);
Entity *asteroid_spawn(EntityManager *em, Vec2 pos);
#endif /* JNR_HAZARDS_H */

View File

@@ -3,6 +3,8 @@
#include "game/enemy.h"
#include "game/projectile.h"
#include "game/hazards.h"
#include "game/powerup.h"
#include "game/drone.h"
#include "game/sprites.h"
#include "game/entity_registry.h"
#include "engine/core.h"
@@ -15,10 +17,12 @@
#include "engine/assets.h"
#include <stdio.h>
#include <string.h>
#include <math.h>
/* ── Sound effects ───────────────────────────────── */
static Sound s_sfx_hit;
static Sound s_sfx_enemy_death;
static Sound s_sfx_pickup;
static bool s_sfx_loaded = false;
/* ── Shared level setup (after tilemap is ready) ─── */
@@ -32,6 +36,7 @@ static bool level_setup(Level *level) {
if (!s_sfx_loaded) {
s_sfx_hit = audio_load_sound("assets/sounds/hitHurt.wav");
s_sfx_enemy_death = audio_load_sound("assets/sounds/teleport.wav");
s_sfx_pickup = audio_load_sound("assets/sounds/teleport.wav");
s_sfx_loaded = true;
}
@@ -94,6 +99,12 @@ static bool level_setup(Level *level) {
return false;
}
/* Disarm player if level requests it */
if (level->map.player_unarmed) {
PlayerData *ppd = (PlayerData *)player->data;
if (ppd) ppd->has_gun = false;
}
/* Spawn entities from level data (via registry) */
for (int i = 0; i < level->map.entity_spawn_count; i++) {
EntitySpawn *es = &level->map.entity_spawns[i];
@@ -276,10 +287,111 @@ static void handle_collisions(EntityManager *em) {
}
}
}
/* ── Powerup pickup ────────────────────── */
if (player && a->type == ENT_POWERUP && a->active &&
!(a->flags & ENTITY_DEAD)) {
if (physics_overlap(&a->body, &player->body)) {
PowerupData *pd = (PowerupData *)a->data;
bool picked_up = false;
if (pd) {
switch (pd->kind) {
case POWERUP_HEALTH:
if (player->health < player->max_health) {
player->health++;
picked_up = true;
}
break;
case POWERUP_JETPACK: {
PlayerData *ppd = (PlayerData *)player->data;
if (ppd) {
ppd->dash_charges = ppd->dash_max_charges;
ppd->dash_recharge_timer = 0.0f;
ppd->jetpack_boost_timer = PLAYER_JETPACK_BOOST_DURATION;
picked_up = true;
}
break;
}
case POWERUP_DRONE:
drone_spawn(em, vec2(
player->body.pos.x + player->body.size.x * 0.5f,
player->body.pos.y
));
picked_up = true;
break;
case POWERUP_GUN:
if (!player_has_gun(player)) {
player_give_gun(player);
picked_up = true;
}
break;
default:
break;
}
}
if (picked_up) {
/* Pickup particles */
Vec2 center = vec2(
a->body.pos.x + a->body.size.x * 0.5f,
a->body.pos.y + a->body.size.y * 0.5f
);
particle_emit_spark(center, (SDL_Color){255, 255, 100, 255});
audio_play_sound(s_sfx_pickup, 80);
/* Destroy the powerup */
a->flags |= ENTITY_DEAD;
}
}
}
}
}
/* ── Exit zone checking ──────────────────────────── */
static void check_exit_zones(Level *level) {
if (level->exit_triggered) return;
if (level->map.exit_zone_count == 0) return;
/* Find the player */
Entity *player = NULL;
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
if (e->active && e->type == ENT_PLAYER && !(e->flags & ENTITY_DEAD)) {
player = e;
break;
}
}
if (!player) return;
for (int i = 0; i < level->map.exit_zone_count; i++) {
const ExitZone *ez = &level->map.exit_zones[i];
if (physics_aabb_overlap(
player->body.pos, player->body.size,
vec2(ez->x, ez->y), vec2(ez->w, ez->h))) {
level->exit_triggered = true;
snprintf(level->exit_target, sizeof(level->exit_target),
"%s", ez->target);
printf("Exit zone triggered -> %s\n",
ez->target[0] ? ez->target : "(victory)");
return;
}
}
}
bool level_exit_triggered(const Level *level) {
return level->exit_triggered;
}
void level_update(Level *level, float dt) {
/* Don't update if exit already triggered (transition pending) */
if (level->exit_triggered) return;
/* Start music on first update (deferred so browser audio context
* is unlocked by the first user interaction / keypress) */
if (!level->music_started && level->music.music) {
@@ -297,6 +409,9 @@ void level_update(Level *level, float dt) {
/* Handle collisions */
handle_collisions(&level->entities);
/* Check exit zones */
check_exit_zones(level);
/* Check for player respawn */
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
@@ -347,6 +462,39 @@ void level_render(Level *level, float interpolation) {
tilemap_render_layer(&level->map, level->map.collision_layer,
cam, g_engine.renderer);
/* Render exit zones (pulsing glow on ground layer) */
if (level->map.exit_zone_count > 0) {
/* Pulse alpha between 40 and 100 using a sine wave */
static float s_exit_pulse = 0.0f;
s_exit_pulse += 3.0f * DT; /* ~3 Hz pulse */
float pulse = 0.5f + 0.5f * sinf(s_exit_pulse);
uint8_t alpha = (uint8_t)(40.0f + pulse * 60.0f);
SDL_Renderer *r = g_engine.renderer;
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
for (int i = 0; i < level->map.exit_zone_count; i++) {
const ExitZone *ez = &level->map.exit_zones[i];
Vec2 screen_pos = camera_world_to_screen(cam, vec2(ez->x, ez->y));
float zoom = cam->zoom > 0.0f ? cam->zoom : 1.0f;
SDL_Rect rect = {
(int)screen_pos.x,
(int)screen_pos.y,
(int)(ez->w * zoom + 0.5f),
(int)(ez->h * zoom + 0.5f)
};
/* Green/cyan fill */
SDL_SetRenderDrawColor(r, 50, 230, 180, alpha);
SDL_RenderFillRect(r, &rect);
/* Brighter border */
SDL_SetRenderDrawColor(r, 80, 255, 200, (uint8_t)(alpha + 40));
SDL_RenderDrawRect(r, &rect);
}
}
/* Render entities */
entity_render_all(&level->entities, cam);
@@ -384,7 +532,17 @@ void level_render(Level *level, float interpolation) {
/* Draw jetpack charge indicators */
int charges, max_charges;
float recharge_pct;
if (player_get_dash_charges(player, &charges, &max_charges, &recharge_pct)) {
bool boosted = false;
if (player_get_dash_charges(player, &charges, &max_charges,
&recharge_pct, &boosted)) {
/* Blue when boosted, orange normally */
SDL_Color full_color = boosted
? (SDL_Color){50, 150, 255, 255}
: (SDL_Color){255, 180, 50, 255};
SDL_Color partial_color = boosted
? (SDL_Color){40, 120, 200, 180}
: (SDL_Color){200, 140, 40, 180};
for (int i = 0; i < max_charges; i++) {
float bx = 8.0f + i * 10.0f;
float by = 22.0f;
@@ -396,15 +554,15 @@ void level_render(Level *level, float interpolation) {
(SDL_Color){50, 50, 60, 255}, LAYER_HUD, cam);
if (i < charges) {
/* Full charge — bright orange */
/* Full charge */
renderer_draw_rect(vec2(bx, by), vec2(bw, bh),
(SDL_Color){255, 180, 50, 255}, LAYER_HUD, cam);
full_color, LAYER_HUD, cam);
} else if (i == charges) {
/* Currently recharging — partial fill */
float fill = recharge_pct * bw;
if (fill > 0.5f) {
renderer_draw_rect(vec2(bx, by), vec2(fill, bh),
(SDL_Color){200, 140, 40, 180}, LAYER_HUD, cam);
partial_color, LAYER_HUD, cam);
}
}
}

View File

@@ -14,6 +14,10 @@ typedef struct Level {
Parallax parallax;
Music music;
bool music_started;
/* ── Exit / transition state ─────────── */
bool exit_triggered; /* player entered an exit zone */
char exit_target[ASSET_PATH_MAX]; /* target level path */
} Level;
bool level_load(Level *level, const char *path);
@@ -22,4 +26,8 @@ void level_update(Level *level, float dt);
void level_render(Level *level, float interpolation);
void level_free(Level *level);
/* Returns true if the player has triggered a level exit.
* The target path is stored in level->exit_target. */
bool level_exit_triggered(const Level *level);
#endif /* JNR_LEVEL_H */

View File

@@ -251,6 +251,14 @@ static void gen_platforms(Tilemap *map, int x0, int w, float difficulty, LevelTh
add_entity(map, "flyer", x0 + rng_range(2, w - 3), rng_range(5, 10));
}
}
/* Jetpack refill on a high platform — reward for climbing */
if (rng_float() < 0.3f) {
int top_py = GROUND_ROW - 3 - (num_plats - 1) * 3;
if (top_py >= 3) {
add_entity(map, "powerup_jet", x0 + rng_range(2, w - 3), top_py - 1);
}
}
}
/* SEG_CORRIDOR: walled section with ceiling */
@@ -304,6 +312,11 @@ static void gen_corridor(Tilemap *map, int x0, int w, float difficulty, LevelThe
if (rng_float() < 0.5f + difficulty * 0.3f) {
add_entity(map, "grunt", x0 + rng_range(2, w - 3), GROUND_ROW - 1);
}
/* Health pickup near the exit — reward for surviving the corridor */
if (difficulty > 0.3f && rng_float() < 0.35f) {
add_entity(map, "powerup_hp", x0 + w - 3, GROUND_ROW - 1);
}
}
/* SEG_ARENA: wide open area, multiple enemies */
@@ -362,6 +375,15 @@ static void gen_arena(Tilemap *map, int x0, int w, float difficulty, LevelTheme
add_entity(map, "turret", tx, plat_h - 1);
}
}
/* Powerup reward on the central platform (earned by clearing the arena) */
if (rng_float() < 0.5f) {
if (difficulty > 0.6f && rng_float() < 0.25f) {
add_entity(map, "powerup_drone", cp_x + 2, cp_y - 1);
} else {
add_entity(map, "powerup_hp", cp_x + 2, cp_y - 1);
}
}
}
/* SEG_SHAFT: vertical shaft with platforms to climb */
@@ -428,6 +450,11 @@ static void gen_shaft(Tilemap *map, int x0, int w, float difficulty, LevelTheme
add_entity(map, "flyer", x0 + w / 2, rng_range(6, 12));
}
}
/* Jetpack refill near the top — reward for climbing */
if (rng_float() < 0.4f) {
add_entity(map, "powerup_jet", x0 + w / 2, 6);
}
}
/* ═══════════════════════════════════════════════════
@@ -791,8 +818,30 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
map->has_bg_color = true;
map->parallax_style = (int)parallax_style_for_theme(primary_theme);
/* Exit zone at the far-right end of the level.
* Placed just inside the right border wall, 2 tiles wide, 3 tiles tall
* sitting on the ground row. Target "generate" chains to another
* procedurally generated level. */
if (map->exit_zone_count < MAX_EXIT_ZONES) {
ExitZone *ez = &map->exit_zones[map->exit_zone_count++];
int exit_x = map->width - 5; /* a few tiles from the right wall */
int exit_y = GROUND_ROW - 3; /* 3 tiles above ground */
ez->x = (float)(exit_x * TILE_SIZE);
ez->y = (float)(exit_y * TILE_SIZE);
ez->w = 2.0f * TILE_SIZE;
ez->h = 3.0f * TILE_SIZE;
snprintf(ez->target, sizeof(ez->target), "generate");
/* Clear any solid tiles in the exit zone area so the player can walk into it */
for (int y = exit_y; y < exit_y + 3 && y < map->height; y++) {
for (int x = exit_x; x < exit_x + 2 && x < map->width; x++) {
map->collision_layer[y * map->width + x] = 0;
}
}
}
/* Music */
strncpy(map->music_path, "assets/sounds/algardalgar.ogg", ASSET_PATH_MAX - 1);
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg");
/* Tileset */
/* NOTE: tileset texture will be loaded by level_load_generated */
@@ -812,6 +861,470 @@ bool levelgen_generate(Tilemap *map, const LevelGenConfig *config) {
return true;
}
/* ═══════════════════════════════════════════════════
* Space Station Generator
*
* A dedicated generator for long, narrow, low-gravity
* space station levels. Uses the standard 23-tile height
* but fills ceiling and floor to create a narrower
* playable corridor (roughly 10 tiles of vertical space).
*
* Segment types are biased toward horizontal layouts:
* corridors, flat sections, arenas. Shafts are replaced
* by wider horizontal variants.
* ═══════════════════════════════════════════════════ */
/* Station layout constants */
#define STATION_CEIL_ROW 4 /* ceiling bottom edge (rows 0-4 solid) */
#define STATION_FLOOR_ROW 17 /* floor top edge (rows 17-22 solid) */
#define STATION_PLAY_H 12 /* playable rows: 5-16 inclusive */
static void station_fill_envelope(uint16_t *col, int mw, int x0, int x1) {
/* Ceiling: rows 0 through STATION_CEIL_ROW */
fill_rect(col, mw, x0, 0, x1, STATION_CEIL_ROW, TILE_SOLID_1);
/* Floor: rows STATION_FLOOR_ROW through bottom */
fill_rect(col, mw, x0, STATION_FLOOR_ROW, x1, SEG_HEIGHT - 1, TILE_SOLID_1);
}
/* ── Station segment: long flat corridor ── */
static void gen_station_corridor(Tilemap *map, int x0, int w, float difficulty) {
uint16_t *col = map->collision_layer;
int mw = map->width;
station_fill_envelope(col, mw, x0, x0 + w - 1);
/* Random platforms floating in the corridor */
int num_plats = rng_range(1, 3);
for (int i = 0; i < num_plats; i++) {
int px = x0 + rng_range(2, w - 5);
int py = rng_range(STATION_CEIL_ROW + 3, STATION_FLOOR_ROW - 3);
int pw = rng_range(2, 4);
for (int j = 0; j < pw && px + j < x0 + w; j++) {
set_tile(col, mw, px + j, py, TILE_PLAT);
}
}
/* Ground patrols — always at least 1, scales with difficulty */
int num_grunts = 1 + (int)(difficulty * 2);
for (int i = 0; i < num_grunts; i++) {
add_entity(map, "grunt", x0 + rng_range(3, w - 4), STATION_FLOOR_ROW - 1);
}
/* Flyers in the corridor */
int num_flyers = (int)(difficulty * 2);
for (int i = 0; i < num_flyers; i++) {
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 3));
}
/* Turret at high difficulty */
if (difficulty > 0.6f && rng_float() < 0.5f) {
add_entity(map, "turret", x0 + rng_range(3, w - 4), STATION_CEIL_ROW + 1);
}
}
/* ── Station segment: bulkhead (walls with doorway) ── */
static void gen_station_bulkhead(Tilemap *map, int x0, int w, float difficulty) {
uint16_t *col = map->collision_layer;
int mw = map->width;
station_fill_envelope(col, mw, x0, x0 + w - 1);
/* Central bulkhead wall with a doorway */
int wall_x = x0 + w / 2;
fill_rect(col, mw, wall_x, STATION_CEIL_ROW + 1, wall_x, STATION_FLOOR_ROW - 1, TILE_SOLID_2);
/* Doorway opening (3 tiles tall) */
int door_y = rng_range(STATION_CEIL_ROW + 3, STATION_FLOOR_ROW - 4);
for (int y = door_y; y < door_y + 3; y++) {
set_tile(col, mw, wall_x, y, TILE_EMPTY);
}
/* Turret guarding the doorway — always present */
add_entity(map, "turret", wall_x - 2, door_y - 1);
/* Second turret on the other side at higher difficulty */
if (difficulty > 0.6f) {
add_entity(map, "turret", wall_x + 2, door_y - 1);
}
/* Force field in the doorway */
if (difficulty > 0.4f && rng_float() < 0.6f) {
add_entity(map, "force_field", wall_x, door_y + 1);
}
/* Grunts on both sides of the bulkhead */
add_entity(map, "grunt", x0 + rng_range(2, w / 2 - 2), STATION_FLOOR_ROW - 1);
if (rng_float() < 0.5f + difficulty * 0.5f) {
add_entity(map, "grunt", x0 + rng_range(w / 2 + 2, w - 3), STATION_FLOOR_ROW - 1);
}
/* Flyer patrol */
if (difficulty > 0.4f) {
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 3));
}
}
/* ── Station segment: platform gauntlet ── */
static void gen_station_platforms(Tilemap *map, int x0, int w, float difficulty) {
uint16_t *col = map->collision_layer;
int mw = map->width;
station_fill_envelope(col, mw, x0, x0 + w - 1);
/* Remove some floor sections to create pits (lethal in a station) */
int pit_start = x0 + rng_range(3, 6);
int pit_end = x0 + w - rng_range(3, 6);
for (int x = pit_start; x < pit_end && x < x0 + w; x++) {
for (int y = STATION_FLOOR_ROW; y < STATION_FLOOR_ROW + 2; y++) {
set_tile(col, mw, x, y, TILE_EMPTY);
}
}
/* Floating platforms across the gap */
int num_plats = rng_range(3, 5);
int spacing = (pit_end - pit_start) / (num_plats + 1);
if (spacing < 2) spacing = 2;
for (int i = 0; i < num_plats; i++) {
int px = pit_start + (i + 1) * spacing - 1;
int py = rng_range(STATION_CEIL_ROW + 4, STATION_FLOOR_ROW - 2);
int pw = rng_range(2, 3);
for (int j = 0; j < pw && px + j < x0 + w; j++) {
set_tile(col, mw, px + j, py, TILE_PLAT);
}
}
/* Moving platform */
if (rng_float() < 0.7f) {
bool vertical = rng_float() < 0.4f;
int mx = pit_start + (pit_end - pit_start) / 2;
int my = rng_range(STATION_CEIL_ROW + 4, STATION_FLOOR_ROW - 3);
add_entity(map, vertical ? "platform_v" : "platform", mx, my);
}
/* Flyers guarding the gap — always at least 1 */
int num_flyers = 1 + (int)(difficulty * 2);
for (int i = 0; i < num_flyers; i++) {
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 4));
}
/* Turret overlooking the pit */
if (difficulty > 0.5f) {
add_entity(map, "turret", x0 + rng_range(2, 4), STATION_CEIL_ROW + 1);
}
}
/* ── Station segment: combat bay (wider arena) ── */
static void gen_station_bay(Tilemap *map, int x0, int w, float difficulty) {
uint16_t *col = map->collision_layer;
int mw = map->width;
station_fill_envelope(col, mw, x0, x0 + w - 1);
/* Open up a taller space by raising the ceiling locally */
int bay_x0 = x0 + 2;
int bay_x1 = x0 + w - 3;
for (int x = bay_x0; x <= bay_x1; x++) {
set_tile(col, mw, x, STATION_CEIL_ROW, TILE_EMPTY);
set_tile(col, mw, x, STATION_CEIL_ROW - 1, TILE_EMPTY);
}
/* Central floating platform */
int cp_x = x0 + w / 2 - 2;
int cp_y = rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 5);
for (int j = 0; j < 4; j++) {
set_tile(col, mw, cp_x + j, cp_y, TILE_PLAT);
}
/* Ledges on the sides */
fill_rect(col, mw, x0, STATION_FLOOR_ROW - 3, x0 + 1, STATION_FLOOR_ROW - 1, TILE_SOLID_1);
fill_rect(col, mw, x0 + w - 2, STATION_FLOOR_ROW - 3, x0 + w - 1, STATION_FLOOR_ROW - 1, TILE_SOLID_1);
/* Swarm of enemies — bays are the big combat encounters */
int num_enemies = 3 + (int)(difficulty * 4);
for (int i = 0; i < num_enemies; i++) {
float r = rng_float();
if (r < 0.30f) {
add_entity(map, "grunt", x0 + rng_range(3, w - 4), STATION_FLOOR_ROW - 1);
} else {
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
rng_range(STATION_CEIL_ROW + 1, STATION_FLOOR_ROW - 4));
}
}
/* Turrets on both side ledges */
add_entity(map, "turret", x0 + 1, STATION_FLOOR_ROW - 4);
if (difficulty > 0.5f) {
add_entity(map, "turret", x0 + w - 2, STATION_FLOOR_ROW - 4);
}
/* Powerup on the central platform — reward for surviving */
if (rng_float() < 0.6f) {
if (difficulty > 0.5f && rng_float() < 0.3f) {
add_entity(map, "powerup_drone", cp_x + 2, cp_y - 1);
} else {
add_entity(map, "powerup_hp", cp_x + 2, cp_y - 1);
}
}
}
/* ── Station segment: vent / crawlspace ── */
static void gen_station_vent(Tilemap *map, int x0, int w, float difficulty) {
uint16_t *col = map->collision_layer;
int mw = map->width;
station_fill_envelope(col, mw, x0, x0 + w - 1);
/* Lower the ceiling even more for a tight crawlspace */
int vent_ceil = STATION_CEIL_ROW + 3;
fill_rect(col, mw, x0, STATION_CEIL_ROW + 1, x0 + w - 1, vent_ceil, TILE_SOLID_2);
/* Opening at left */
for (int y = vent_ceil - 1; y <= vent_ceil + 2 && y < STATION_FLOOR_ROW; y++) {
set_tile(col, mw, x0, y, TILE_EMPTY);
}
/* Opening at right */
for (int y = vent_ceil - 1; y <= vent_ceil + 2 && y < STATION_FLOOR_ROW; y++) {
set_tile(col, mw, x0 + w - 1, y, TILE_EMPTY);
}
/* Flame vents along the floor — always present, more at higher difficulty */
int num_vents = 1 + (int)(difficulty * 2);
for (int i = 0; i < num_vents; i++) {
add_entity(map, "flame_vent", x0 + rng_range(2, w - 3), STATION_FLOOR_ROW - 1);
}
/* Force field obstacle */
if (difficulty > 0.3f && rng_float() < 0.6f) {
add_entity(map, "force_field", x0 + w / 2, vent_ceil + 2);
}
/* Grunt lurking in the vent */
if (rng_float() < 0.4f + difficulty * 0.4f) {
add_entity(map, "grunt", x0 + rng_range(2, w - 3), STATION_FLOOR_ROW - 1);
}
/* Jetpack refill reward (useful in low gravity) */
if (rng_float() < 0.35f) {
add_entity(map, "powerup_jet", x0 + rng_range(2, w - 3), vent_ceil + 1);
}
}
/* ── Station segment: airlock entry (first segment) ── */
static void gen_station_entry(Tilemap *map, int x0, int w, float difficulty) {
uint16_t *col = map->collision_layer;
int mw = map->width;
station_fill_envelope(col, mw, x0, x0 + w - 1);
/* Health pickup to start — always present */
add_entity(map, "powerup_hp", x0 + w / 2, STATION_FLOOR_ROW - 1);
/* At higher difficulty, even the entry isn't safe */
if (difficulty > 0.6f) {
add_entity(map, "grunt", x0 + rng_range(w / 2 + 2, w - 3), STATION_FLOOR_ROW - 1);
}
if (difficulty > 0.8f) {
add_entity(map, "flyer", x0 + rng_range(3, w - 4),
rng_range(STATION_CEIL_ROW + 2, STATION_FLOOR_ROW - 3));
}
}
/* Segment type selection for station */
typedef enum StationSegType {
SSEG_ENTRY,
SSEG_CORRIDOR,
SSEG_BULKHEAD,
SSEG_PLATFORMS,
SSEG_BAY,
SSEG_VENT,
SSEG_TYPE_COUNT
} StationSegType;
static StationSegType pick_station_segment(int index, int total) {
if (index == 0) return SSEG_ENTRY;
if (index == total - 1 && rng_float() < 0.6f) return SSEG_BAY;
float r = rng_float();
if (r < 0.25f) return SSEG_CORRIDOR;
if (r < 0.45f) return SSEG_BULKHEAD;
if (r < 0.65f) return SSEG_PLATFORMS;
if (r < 0.80f) return SSEG_BAY;
return SSEG_VENT;
}
static int station_segment_width(StationSegType type) {
switch (type) {
case SSEG_ENTRY: return rng_range(12, 16);
case SSEG_CORRIDOR: return rng_range(18, 28);
case SSEG_BULKHEAD: return rng_range(16, 22);
case SSEG_PLATFORMS: return rng_range(20, 30);
case SSEG_BAY: return rng_range(24, 34);
case SSEG_VENT: return rng_range(14, 20);
default: return 18;
}
}
LevelGenConfig levelgen_station_config(uint32_t seed, int depth) {
if (depth < 0) depth = 0;
/* Segments grow with depth: 10 -> 11 -> 12 -> 13 -> 14 (capped) */
int segments = 10 + depth;
if (segments > 14) segments = 14;
/* Difficulty ramps up: 0.5 -> 0.65 -> 0.8 -> 0.9 -> 1.0 (capped) */
float diff = 0.5f + depth * 0.15f;
if (diff > 1.0f) diff = 1.0f;
LevelGenConfig config = {
.seed = seed,
.num_segments = segments,
.difficulty = diff,
.gravity = 150.0f, /* near-zero: floaty space station */
.theme_count = 1,
};
config.themes[0] = THEME_SPACE_STATION;
return config;
}
bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config) {
if (!map || !config) return false;
rng_seed(config->seed);
int num_segs = config->num_segments;
if (num_segs < 3) num_segs = 3;
if (num_segs > 14) num_segs = 14;
/* ── Phase 1: decide segment types and widths ── */
StationSegType seg_types[20];
int seg_widths[20];
int total_width = 0;
for (int i = 0; i < num_segs && i < 20; i++) {
seg_types[i] = pick_station_segment(i, num_segs);
seg_widths[i] = station_segment_width(seg_types[i]);
total_width += seg_widths[i];
}
/* Add 2-tile buffer on each side */
total_width += 4;
/* ── Phase 2: allocate tilemap ── */
memset(map, 0, sizeof(Tilemap));
map->width = total_width;
map->height = SEG_HEIGHT;
int total_tiles = map->width * map->height;
map->collision_layer = calloc(total_tiles, sizeof(uint16_t));
map->bg_layer = calloc(total_tiles, sizeof(uint16_t));
map->fg_layer = calloc(total_tiles, sizeof(uint16_t));
if (!map->collision_layer || !map->bg_layer || !map->fg_layer) {
fprintf(stderr, "levelgen_station: failed to allocate layers\n");
return false;
}
/* ── Phase 3: tile definitions ── */
map->tile_defs[1] = (TileDef){0, 0, TILE_SOLID};
map->tile_defs[2] = (TileDef){1, 0, TILE_SOLID};
map->tile_defs[3] = (TileDef){2, 0, TILE_SOLID};
map->tile_defs[4] = (TileDef){0, 1, TILE_PLATFORM};
map->tile_def_count = 5;
/* ── Phase 4: generate segments ── */
int cursor = 2;
/* Left border wall */
fill_rect(map->collision_layer, map->width, 0, 0, 1, SEG_HEIGHT - 1, TILE_SOLID_1);
static const char *sseg_names[] = {
"entry", "corr", "bulk", "plat", "bay", "vent"
};
for (int i = 0; i < num_segs; i++) {
int w = seg_widths[i];
float diff = config->difficulty;
switch (seg_types[i]) {
case SSEG_ENTRY: gen_station_entry(map, cursor, w, diff); break;
case SSEG_CORRIDOR: gen_station_corridor(map, cursor, w, diff); break;
case SSEG_BULKHEAD: gen_station_bulkhead(map, cursor, w, diff); break;
case SSEG_PLATFORMS: gen_station_platforms(map, cursor, w, diff); break;
case SSEG_BAY: gen_station_bay(map, cursor, w, diff); break;
case SSEG_VENT: gen_station_vent(map, cursor, w, diff); break;
default: gen_station_corridor(map, cursor, w, diff); break;
}
cursor += w;
}
/* Right border wall */
fill_rect(map->collision_layer, map->width,
map->width - 2, 0, map->width - 1, SEG_HEIGHT - 1, TILE_SOLID_1);
/* ── Phase 5: visual variety ── */
for (int y = 0; y < map->height; y++) {
for (int x = 0; x < map->width; x++) {
int idx = y * map->width + x;
if (map->collision_layer[idx] == TILE_SOLID_1) {
bool has_air_neighbor = false;
if (y > 0 && map->collision_layer[(y-1)*map->width+x] == 0)
has_air_neighbor = true;
if (!has_air_neighbor) {
map->collision_layer[idx] = random_solid();
}
}
}
}
/* ── Phase 6: background decoration ── */
gen_bg_decoration(map);
/* ── Phase 7: metadata ── */
map->player_spawn = vec2(4.0f * TILE_SIZE,
(STATION_FLOOR_ROW - 2) * TILE_SIZE);
map->gravity = config->gravity > 0 ? config->gravity : 150.0f;
map->bg_color = (SDL_Color){3, 3, 14, 255}; /* very dark blue-black */
map->has_bg_color = true;
map->parallax_style = (int)PARALLAX_STYLE_DEEP_SPACE;
/* Exit zone at far right */
if (map->exit_zone_count < MAX_EXIT_ZONES) {
ExitZone *ez = &map->exit_zones[map->exit_zone_count++];
int exit_x = map->width - 5;
int exit_y = STATION_FLOOR_ROW - 3;
ez->x = (float)(exit_x * TILE_SIZE);
ez->y = (float)(exit_y * TILE_SIZE);
ez->w = 2.0f * TILE_SIZE;
ez->h = 3.0f * TILE_SIZE;
snprintf(ez->target, sizeof(ez->target), "generate:station");
/* Clear exit zone area */
for (int y = exit_y; y < exit_y + 3 && y < map->height; y++) {
for (int x = exit_x; x < exit_x + 2 && x < map->width; x++) {
map->collision_layer[y * map->width + x] = 0;
}
}
}
/* Music */
snprintf(map->music_path, sizeof(map->music_path), "assets/sounds/algardalgar.ogg");
printf("levelgen_station: generated %dx%d level (%d segments, seed=%u, gravity=%.0f)\n",
map->width, map->height, num_segs, s_rng_state, map->gravity);
printf(" segments:");
for (int i = 0; i < num_segs; i++) {
printf(" %s", sseg_names[seg_types[i]]);
}
printf("\n");
return true;
}
/* ═══════════════════════════════════════════════════
* Dump to .lvl file format
* ═══════════════════════════════════════════════════ */
@@ -849,6 +1362,10 @@ bool levelgen_dump_lvl(const Tilemap *map, const char *path) {
fprintf(f, "MUSIC %s\n", map->music_path);
}
if (map->player_unarmed) {
fprintf(f, "PLAYER_UNARMED\n");
}
fprintf(f, "\n");
/* Entity spawns */
@@ -861,6 +1378,21 @@ bool levelgen_dump_lvl(const Tilemap *map, const char *path) {
fprintf(f, "\n");
/* Exit zones */
for (int i = 0; i < map->exit_zone_count; i++) {
const ExitZone *ez = &map->exit_zones[i];
int tx = (int)(ez->x / TILE_SIZE);
int ty = (int)(ez->y / TILE_SIZE);
int tw = (int)(ez->w / TILE_SIZE);
int th = (int)(ez->h / TILE_SIZE);
if (ez->target[0]) {
fprintf(f, "EXIT %d %d %d %d %s\n", tx, ty, tw, th, ez->target);
} else {
fprintf(f, "EXIT %d %d %d %d\n", tx, ty, tw, th);
}
}
if (map->exit_zone_count > 0) fprintf(f, "\n");
/* Tile definitions */
for (int id = 1; id < map->tile_def_count; id++) {
const TileDef *td = &map->tile_defs[id];

View File

@@ -46,6 +46,13 @@ typedef struct LevelGenConfig {
/* Default config for quick generation */
LevelGenConfig levelgen_default_config(void);
/* Config preset for a space station level:
* very long horizontally, narrow playable area,
* near-zero gravity, all THEME_SPACE_STATION.
* depth (0-based) escalates difficulty and length
* with each subsequent station level. */
LevelGenConfig levelgen_station_config(uint32_t seed, int depth);
/* Generate a level directly into a Tilemap struct.
* The Tilemap will have layers allocated, tile defs set,
* entity spawns populated, and metadata configured.
@@ -53,6 +60,12 @@ LevelGenConfig levelgen_default_config(void);
* Returns true on success. */
bool levelgen_generate(Tilemap *map, const LevelGenConfig *config);
/* Generate a space-station level: very long, narrow,
* near-zero gravity. Uses its own segment pool tuned
* for horizontal layouts in tight corridors.
* Returns true on success. */
bool levelgen_generate_station(Tilemap *map, const LevelGenConfig *config);
/* Dump a generated (or any) Tilemap to a .lvl file.
* Useful for inspecting/editing procedural output.
* Returns true on success. */

View File

@@ -178,13 +178,39 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
return;
}
/* Fall off bottom of level = instant death */
float level_bottom = (float)(map->height * TILE_SIZE) + 64.0f;
if (self->body.pos.y > level_bottom) {
self->health = 0;
self->flags |= ENTITY_DEAD;
pd->respawn_timer = 0.3f; /* shorter delay for pit death */
return;
/* Fall off bottom of level — lose 1 HP and auto-dash upward */
float level_bottom = (float)(map->height * TILE_SIZE);
if (self->body.pos.y + self->body.size.y > level_bottom &&
!(self->flags & ENTITY_INVINCIBLE)) {
self->health--;
if (self->health <= 0) {
/* Out of HP — die as before */
self->health = 0;
self->flags |= ENTITY_DEAD;
pd->respawn_timer = 0.3f;
return;
}
/* Drain all jetpack charges */
pd->dash_charges = 0;
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
/* Auto-trigger upward dash (ignores charge requirement) */
pd->dash_timer = PLAYER_DASH_DURATION;
pd->dash_dir = vec2(0.0f, -1.0f);
self->body.vel.y = 0;
/* Grant invincibility so this doesn't re-trigger immediately */
pd->inv_timer = PLAYER_INV_TIME;
self->flags |= ENTITY_INVINCIBLE;
/* Effects */
Vec2 burst_pos = vec2(
self->body.pos.x + self->body.size.x * 0.5f,
self->body.pos.y + self->body.size.y
);
particle_emit_jetpack_burst(burst_pos, vec2(0.0f, -1.0f));
audio_play_sound(s_sfx_dash, 96);
}
/* Update invincibility */
@@ -225,14 +251,24 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
/* ── Dash / Jetpack ─────────────────────── */
/* Count down jetpack boost timer */
if (pd->jetpack_boost_timer > 0) {
pd->jetpack_boost_timer -= dt;
if (pd->jetpack_boost_timer < 0)
pd->jetpack_boost_timer = 0;
}
/* Recharge jetpack charges over time */
float recharge_rate = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE
: PLAYER_DASH_RECHARGE;
if (pd->dash_charges < pd->dash_max_charges) {
pd->dash_recharge_timer -= dt;
if (pd->dash_recharge_timer <= 0) {
pd->dash_charges++;
/* Reset timer for next charge (if still not full) */
if (pd->dash_charges < pd->dash_max_charges) {
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
pd->dash_recharge_timer = recharge_rate;
} else {
pd->dash_recharge_timer = 0;
}
@@ -260,7 +296,8 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) {
pd->dash_charges--;
pd->dash_recharge_timer = PLAYER_DASH_RECHARGE;
pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
pd->dash_timer = PLAYER_DASH_DURATION;
/* Determine dash direction from input */
@@ -362,7 +399,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
/* ── Shooting ────────────────────────────── */
pd->shoot_cooldown -= dt;
if (input_pressed(ACTION_SHOOT) && pd->shoot_cooldown <= 0 && s_em) {
if (pd->has_gun && input_pressed(ACTION_SHOOT) && pd->shoot_cooldown <= 0 && s_em) {
pd->shoot_cooldown = PLAYER_SHOOT_COOLDOWN;
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
@@ -486,7 +523,7 @@ void player_render(Entity *self, const Camera *cam) {
renderer_submit(&spr);
/* ── Weapon overlay ─────────────────── */
if (s_weapon_tex && !(self->flags & ENTITY_DEAD) && pd) {
if (s_weapon_tex && !(self->flags & ENTITY_DEAD) && pd && pd->has_gun) {
bool facing_left = (self->flags & ENTITY_FACING_LEFT) != 0;
/* Anchor gun to the player sprite position (not body pos)
@@ -574,6 +611,7 @@ Entity *player_spawn(EntityManager *em, Vec2 pos) {
e->max_health = 3;
PlayerData *pd = calloc(1, sizeof(PlayerData));
pd->has_gun = true; /* armed by default; moon level overrides */
pd->dash_charges = PLAYER_DASH_MAX_CHARGES;
pd->dash_max_charges = PLAYER_DASH_MAX_CHARGES;
pd->respawn_timer = RESPAWN_DELAY;
@@ -595,21 +633,36 @@ float player_get_look_up_offset(const Entity *self) {
}
bool player_get_dash_charges(const Entity *self, int *charges, int *max_charges,
float *recharge_pct) {
float *recharge_pct, bool *boosted) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
const PlayerData *pd = (const PlayerData *)self->data;
if (charges) *charges = pd->dash_charges;
if (max_charges) *max_charges = pd->dash_max_charges;
if (boosted) *boosted = pd->jetpack_boost_timer > 0;
if (recharge_pct) {
float rate = (pd->jetpack_boost_timer > 0)
? PLAYER_JETPACK_BOOST_RECHARGE : PLAYER_DASH_RECHARGE;
if (pd->dash_charges >= pd->dash_max_charges) {
*recharge_pct = 1.0f;
} else {
*recharge_pct = 1.0f - (pd->dash_recharge_timer / PLAYER_DASH_RECHARGE);
*recharge_pct = 1.0f - (pd->dash_recharge_timer / rate);
}
}
return true;
}
void player_give_gun(Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return;
PlayerData *pd = (PlayerData *)self->data;
pd->has_gun = true;
}
bool player_has_gun(const Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
const PlayerData *pd = (const PlayerData *)self->data;
return pd->has_gun;
}
bool player_wants_respawn(const Entity *self) {
if (!self || !self->data || self->type != ENT_PLAYER) return false;
if (!(self->flags & ENTITY_DEAD)) return false;
@@ -631,7 +684,7 @@ void player_respawn(Entity *self, Vec2 pos) {
pd->inv_timer = PLAYER_INV_TIME;
self->flags |= ENTITY_INVINCIBLE;
/* Reset player-specific state */
/* Reset player-specific state (preserve has_gun across respawn) */
pd->coyote_timer = 0;
pd->jump_buffer_timer = 0;
pd->jumping = false;
@@ -640,6 +693,7 @@ void player_respawn(Entity *self, Vec2 pos) {
pd->dash_timer = 0;
pd->dash_charges = pd->dash_max_charges;
pd->dash_recharge_timer = 0;
pd->jetpack_boost_timer = 0;
pd->aim_dir = AIM_FORWARD;
pd->looking_up = false;
pd->look_up_timer = 0;

View File

@@ -33,6 +33,10 @@
#define PLAYER_DASH_MAX_CHARGES 3 /* max jetpack charges */
#define PLAYER_DASH_RECHARGE 3.0f /* seconds to recharge one charge*/
/* Jetpack boost (from powerup) */
#define PLAYER_JETPACK_BOOST_DURATION 15.0f /* seconds the boost lasts */
#define PLAYER_JETPACK_BOOST_RECHARGE 0.5f /* boosted recharge rate (s) */
/* Invincibility after taking damage */
#define PLAYER_INV_TIME 1.5f /* seconds of invincibility */
@@ -56,6 +60,9 @@ typedef struct PlayerData {
int dash_max_charges; /* max charges (for HUD) */
float dash_recharge_timer; /* time until next charge restored*/
Vec2 dash_dir; /* direction of current dash */
float jetpack_boost_timer; /* remaining boost time (0=off) */
/* Weapon */
bool has_gun; /* false until gun powerup picked up */
/* Aiming */
AimDir aim_dir; /* current aim direction */
bool looking_up; /* holding up without moving */
@@ -84,7 +91,13 @@ float player_get_look_up_offset(const Entity *self);
/* Get jetpack dash charge info for HUD (returns false if entity is not player) */
bool player_get_dash_charges(const Entity *self, int *charges, int *max_charges,
float *recharge_pct);
float *recharge_pct, bool *boosted);
/* Arm the player (give them the gun). Safe to call multiple times. */
void player_give_gun(Entity *self);
/* Check if the player has a gun */
bool player_has_gun(const Entity *self);
/* Check if the player is requesting a respawn (death anim finished + timer expired).
* Returns true when respawn should occur. */

View File

@@ -24,6 +24,12 @@ static void powerup_update(Entity *self, float dt, const Tilemap *map) {
PowerupData *pd = (PowerupData *)self->data;
if (!pd) return;
/* Destroy when picked up */
if (self->flags & ENTITY_DEAD) {
entity_destroy(s_em, self);
return;
}
/* Sine bob animation */
pd->bob_timer += dt;
float bob = sinf(pd->bob_timer * BOB_SPEED * 2.0f * (float)M_PI) * BOB_HEIGHT;
@@ -36,6 +42,7 @@ static void powerup_update(Entity *self, float dt, const Tilemap *map) {
case POWERUP_HEALTH: color = (SDL_Color){220, 50, 50, 200}; break;
case POWERUP_JETPACK: color = (SDL_Color){255, 180, 50, 200}; break;
case POWERUP_DRONE: color = (SDL_Color){50, 200, 255, 200}; break;
case POWERUP_GUN: color = (SDL_Color){200, 200, 220, 200}; break;
default: color = (SDL_Color){255, 255, 255, 200}; break;
}
Vec2 center = vec2(
@@ -123,6 +130,7 @@ Entity *powerup_spawn(EntityManager *em, Vec2 pos, PowerupKind kind) {
case POWERUP_HEALTH: animation_set(&e->anim, &anim_powerup_health); break;
case POWERUP_JETPACK: animation_set(&e->anim, &anim_powerup_jetpack); break;
case POWERUP_DRONE: animation_set(&e->anim, &anim_powerup_drone); break;
case POWERUP_GUN: animation_set(&e->anim, &anim_powerup_gun); break;
default: animation_set(&e->anim, &anim_powerup_health); break;
}
@@ -140,3 +148,7 @@ Entity *powerup_spawn_jetpack(EntityManager *em, Vec2 pos) {
Entity *powerup_spawn_drone(EntityManager *em, Vec2 pos) {
return powerup_spawn(em, pos, POWERUP_DRONE);
}
Entity *powerup_spawn_gun(EntityManager *em, Vec2 pos) {
return powerup_spawn(em, pos, POWERUP_GUN);
}

View File

@@ -17,6 +17,7 @@ typedef enum PowerupKind {
POWERUP_HEALTH,
POWERUP_JETPACK,
POWERUP_DRONE,
POWERUP_GUN,
POWERUP_KIND_COUNT
} PowerupKind;
@@ -36,5 +37,6 @@ Entity *powerup_spawn(EntityManager *em, Vec2 pos, PowerupKind kind);
Entity *powerup_spawn_health(EntityManager *em, Vec2 pos);
Entity *powerup_spawn_jetpack(EntityManager *em, Vec2 pos);
Entity *powerup_spawn_drone(EntityManager *em, Vec2 pos);
Entity *powerup_spawn_gun(EntityManager *em, Vec2 pos);
#endif /* JNR_POWERUP_H */

View File

@@ -860,6 +860,90 @@ static const uint32_t drone_frame2[16*16] = {
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
};
/* ── Gun powerup sprite ────────────────────────────── */
/* Gun powerup — metallic gun icon (grey/cyan) */
static const uint32_t powerup_gun1[16*16] = {
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, GYD, GYD, GYD, GYD, GYD, GYD, GYD, T, T, T,
T, T, T, T, GYD, GYD, GRY, GRY, GYL, GYL, GYL, GRY, GRY, GYD, T, T,
T, T, T, GYD, GRY, GRY, GYL, WHT, WHT, WHT, GYL, GRY, GRY, GRY, GYD, T,
T, T, T, GYD, GRY, GYL, WHT, WHT, WHT, WHT, GYL, GRY, GRY, GRY, GYD, T,
T, T, T, T, GYD, GRY, GRY, GYL, GYL, GRY, GRY, GYD, GYD, GYD, T, T,
T, T, T, T, T, T, GYD, GRY, GRY, GYD, T, T, T, T, T, T,
T, T, T, T, T, T, T, GYD, GYD, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
};
/* Gun powerup frame 2 — brighter glow */
static const uint32_t powerup_gun2[16*16] = {
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, GYD, GYL, GYL, GYL, GYL, GYL, GYL, GYD, T, T, T,
T, T, T, GYD, GYD, GYL, WHT, WHT, WHT, WHT, WHT, GYL, GYL, GYD, T, T,
T, T, T, GYD, GYL, WHT, WHT, WHT, WHT, WHT, WHT, GYL, GYL, GYL, GYD, T,
T, T, T, GYD, GYL, WHT, WHT, WHT, WHT, WHT, WHT, GYL, GYL, GYL, GYD, T,
T, T, T, T, GYD, GYL, GYL, WHT, WHT, GYL, GYL, GYD, GYD, GYD, T, T,
T, T, T, T, T, T, GYD, GYL, GYL, GYD, T, T, T, T, T, T,
T, T, T, T, T, T, T, GYD, GYD, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
};
/* ── Asteroid sprite ────────────────────────────────── */
/* Asteroid frame 1 — jagged rocky boulder */
static const uint32_t asteroid1[16*16] = {
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, BRN, BRN, BRN, T, T, T, T, T, T, T,
T, T, T, T, T, BRN, BRD, BRN, BRD, BRN, T, T, T, T, T, T,
T, T, T, T, BRN, BRD, GYD, BRD, GYD, BRD, BRN, T, T, T, T, T,
T, T, T, BRN, BRD, GYD, GRY, GYD, GRY, GYD, BRD, BRN, T, T, T, T,
T, T, BRN, BRD, GYD, GRY, GYL, GRY, GRY, GYD, GYD, BRD, BRN, T, T, T,
T, T, BRN, BRD, GYD, GRY, GRY, GYD, GRY, GYL, GYD, BRD, BRN, T, T, T,
T, T, BRD, BRD, GYD, GRY, GYD, BRD, GYD, GRY, GYD, BRD, BRD, T, T, T,
T, T, BRN, BRD, GYD, GYD, GRY, GYD, GRY, GYD, GYD, BRD, BRN, T, T, T,
T, T, BRN, BRD, GYD, GRY, GYL, GRY, GYD, GRY, GYD, BRD, BRN, T, T, T,
T, T, T, BRN, BRD, GYD, GRY, GYD, GRY, GYD, BRD, BRN, T, T, T, T,
T, T, T, T, BRN, BRD, GYD, BRD, GYD, BRD, BRN, T, T, T, T, T,
T, T, T, T, T, BRN, BRD, BRN, BRD, BRN, T, T, T, T, T, T,
T, T, T, T, T, T, BRN, BRN, BRN, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
};
/* Asteroid frame 2 — rotated highlights */
static const uint32_t asteroid2[16*16] = {
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, BRN, BRN, BRN, T, T, T, T, T, T, T,
T, T, T, T, T, BRN, BRD, BRN, BRD, BRN, T, T, T, T, T, T,
T, T, T, T, BRN, BRD, GYD, BRD, GYD, BRD, BRN, T, T, T, T, T,
T, T, T, BRN, BRD, GYD, GRY, GYD, GYL, GYD, BRD, BRN, T, T, T, T,
T, T, BRN, BRD, GYD, GRY, GYD, GRY, GRY, GYL, GYD, BRD, BRN, T, T, T,
T, T, BRN, BRD, GYD, GYD, GRY, GYD, GYL, GRY, GYD, BRD, BRN, T, T, T,
T, T, BRD, BRD, GYD, GRY, GYD, BRD, GYD, GYD, GYD, BRD, BRD, T, T, T,
T, T, BRN, BRD, GYD, GYL, GRY, GYD, GRY, GYD, GYD, BRD, BRN, T, T, T,
T, T, BRN, BRD, GYD, GRY, GRY, GYD, GYD, GRY, GYD, BRD, BRN, T, T, T,
T, T, T, BRN, BRD, GYD, GYL, GYD, GRY, GYD, BRD, BRN, T, T, T, T,
T, T, T, T, BRN, BRD, GYD, BRD, GYD, BRD, BRN, T, T, T, T, T,
T, T, T, T, T, BRN, BRD, BRN, BRD, BRN, T, T, T, T, T, T,
T, T, T, T, T, T, BRN, BRN, BRN, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
T, T, T, T, T, T, T, T, T, T, T, T, T, T, T, T,
};
/* ── Spritesheet generation ────────────────────────── */
/* All sprite definitions for the sheet - row, column, pixel data */
@@ -926,9 +1010,13 @@ static const SpriteDef s_sprite_defs[] = {
{5, 5, powerup_drone1},
{5, 6, powerup_drone2},
/* Row 6: Drone companion */
/* Row 6: Drone companion + Gun powerup + Asteroid */
{6, 0, drone_frame1},
{6, 1, drone_frame2},
{6, 2, powerup_gun1},
{6, 3, powerup_gun2},
{6, 4, asteroid1},
{6, 5, asteroid2},
};
#define SHEET_COLS 8
@@ -1115,6 +1203,18 @@ static AnimFrame s_powerup_drone_frames[] = {
FRAME(6, 5, 0.3f),
};
/* Gun powerup */
static AnimFrame s_powerup_gun_frames[] = {
FRAME(2, 6, 0.4f),
FRAME(3, 6, 0.4f),
};
/* Asteroid */
static AnimFrame s_asteroid_frames[] = {
FRAME(4, 6, 0.15f),
FRAME(5, 6, 0.15f),
};
/* Drone companion */
static AnimFrame s_drone_frames[] = {
FRAME(0, 6, 0.2f),
@@ -1150,6 +1250,8 @@ AnimDef anim_force_field_off;
AnimDef anim_powerup_health;
AnimDef anim_powerup_jetpack;
AnimDef anim_powerup_drone;
AnimDef anim_powerup_gun;
AnimDef anim_asteroid;
AnimDef anim_drone;
@@ -1182,6 +1284,8 @@ void sprites_init_anims(void) {
anim_powerup_health = (AnimDef){s_powerup_health_frames, 2, true, NULL};
anim_powerup_jetpack = (AnimDef){s_powerup_jetpack_frames, 2, true, NULL};
anim_powerup_drone = (AnimDef){s_powerup_drone_frames, 2, true, NULL};
anim_powerup_gun = (AnimDef){s_powerup_gun_frames, 2, true, NULL};
anim_asteroid = (AnimDef){s_asteroid_frames, 2, true, NULL};
anim_drone = (AnimDef){s_drone_frames, 2, true, NULL};
}

View File

@@ -53,6 +53,10 @@ extern AnimDef anim_force_field_off;
extern AnimDef anim_powerup_health;
extern AnimDef anim_powerup_jetpack;
extern AnimDef anim_powerup_drone;
extern AnimDef anim_powerup_gun;
/* ── Asteroid animation ─────────────────────────── */
extern AnimDef anim_asteroid;
/* ── Drone animation ───────────────────────────── */
extern AnimDef anim_drone;

View File

@@ -8,6 +8,10 @@
#include <time.h>
#include <SDL2/SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
/* ═══════════════════════════════════════════════════
* Game modes
* ═══════════════════════════════════════════════════ */
@@ -29,6 +33,10 @@ static char s_edit_path[256] = {0};
/* Track whether we came from the editor (for returning after test play) */
static bool s_testing_from_editor = false;
/* Station depth: increments each time we enter a new station level.
* Drives escalating difficulty and length. */
static int s_station_depth = 0;
static const char *theme_name(LevelTheme t) {
switch (t) {
case THEME_PLANET_SURFACE: return "Planet Surface";
@@ -110,6 +118,30 @@ static void load_generated_level(void) {
}
}
static void load_station_level(void) {
LevelGenConfig config = levelgen_station_config(s_gen_seed, s_station_depth);
s_station_depth++;
printf("Generating space station level (depth=%d, gravity=%.0f, segments=%d, difficulty=%.2f)\n",
s_station_depth, config.gravity, config.num_segments, config.difficulty);
Tilemap gen_map;
if (!levelgen_generate_station(&gen_map, &config)) {
fprintf(stderr, "Failed to generate station level!\n");
g_engine.running = false;
return;
}
if (s_dump_lvl) {
levelgen_dump_lvl(&gen_map, "assets/levels/generated_station.lvl");
}
if (!level_load_generated(&s_level, &gen_map)) {
fprintf(stderr, "Failed to load station level!\n");
g_engine.running = false;
}
}
/* ── Switch to editor mode ── */
static void enter_editor(void) {
if (s_mode == MODE_PLAY) {
@@ -168,7 +200,7 @@ static void game_init(void) {
} else if (s_use_procgen) {
load_generated_level();
} else {
if (!level_load(&s_level, "assets/levels/level01.lvl")) {
if (!level_load(&s_level, "assets/levels/moon01.lvl")) {
fprintf(stderr, "Failed to load level!\n");
g_engine.running = false;
}
@@ -221,6 +253,46 @@ static void game_update(float dt) {
r_was_pressed = r_pressed;
level_update(&s_level, dt);
/* Check for level exit transition */
if (level_exit_triggered(&s_level)) {
const char *target = s_level.exit_target;
if (target[0] == '\0') {
/* Empty target = victory / end of game */
printf("Level complete! (no next level)\n");
/* Loop back to the beginning */
level_free(&s_level);
if (!level_load(&s_level, "assets/levels/moon01.lvl")) {
g_engine.running = false;
}
} else if (strcmp(target, "generate") == 0) {
/* Procedurally generated next level */
printf("Transitioning to generated level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_generated_level();
} else if (strcmp(target, "generate:station") == 0) {
/* Procedurally generated space station level */
printf("Transitioning to space station level\n");
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
load_station_level();
} else {
/* Load a specific level file */
printf("Transitioning to: %s\n", target);
char path[ASSET_PATH_MAX];
snprintf(path, sizeof(path), "%s", target);
level_free(&s_level);
if (!level_load(&s_level, path)) {
fprintf(stderr, "Failed to load next level: %s\n", path);
/* Fallback to moon01 */
if (!level_load(&s_level, "assets/levels/moon01.lvl")) {
g_engine.running = false;
}
}
}
}
}
static void game_render(float interpolation) {
@@ -275,7 +347,7 @@ int main(int argc, char *argv[]) {
printf(" E Open level editor\n");
printf(" ESC Quit (or return to editor from test play)\n");
printf("\nEditor:\n");
printf(" 1-5 Select tool (Pencil/Eraser/Fill/Entity/Spawn)\n");
printf(" 1-6 Select tool (Pencil/Eraser/Fill/Entity/Spawn/Exit)\n");
printf(" Q/W/E Select layer (Collision/BG/FG)\n");
printf(" G Toggle grid\n");
printf(" V Toggle all-layer visibility\n");
@@ -285,6 +357,7 @@ int main(int argc, char *argv[]) {
printf(" Left click Paint/place (canvas) or select (palette)\n");
printf(" Right click Pick tile / delete entity\n");
printf(" Ctrl+S Save level\n");
printf(" Ctrl+O Open/load level\n");
printf(" Ctrl++/- Resize level width\n");
printf(" P Test play level\n");
printf(" ESC Quit editor\n");
@@ -294,6 +367,29 @@ int main(int argc, char *argv[]) {
srand((unsigned)time(NULL));
#ifdef __EMSCRIPTEN__
/* Check URL query string for ?edit or ?edit=filename */
{
const char *qs = emscripten_run_script_string(
"window.location.search || ''");
if (qs && strstr(qs, "edit")) {
s_use_editor = true;
/* Check for ?edit=filename */
const char *eq = strstr(qs, "edit=");
if (eq) {
eq += 5; /* skip "edit=" */
/* Copy until & or end of string */
int len = 0;
while (eq[len] && eq[len] != '&' && len < (int)sizeof(s_edit_path) - 1) {
s_edit_path[len] = eq[len];
len++;
}
s_edit_path[len] = '\0';
}
}
}
#endif
if (!engine_init()) {
return 1;
}

View File

@@ -23,6 +23,8 @@
}
canvas.emscripten {
display: block;
width: 1280px;
height: 720px;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
@@ -46,6 +48,51 @@
transition: width 0.2s;
}
.hidden { display: none !important; }
#controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 12px;
flex-wrap: wrap;
justify-content: center;
max-width: 1300px;
}
#hint { color: #666; }
.ctrl-btn {
color: #4ecdc4;
background: transparent;
border: 1px solid #4ecdc4;
padding: 2px 8px;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.ctrl-btn:hover {
background: #4ecdc4;
color: #1a1a2e;
}
#level-select {
background: #1a1a2e;
color: #4ecdc4;
border: 1px solid #4ecdc4;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
cursor: pointer;
}
#level-select option {
background: #1a1a2e;
color: #e0e0e0;
}
.ctrl-sep {
color: #333;
user-select: none;
}
</style>
</head>
<body>
@@ -53,6 +100,18 @@
<canvas class="emscripten" id="canvas" tabindex="1"
width="640" height="360"></canvas>
</div>
<div id="controls">
<a class="ctrl-btn" id="editor-link" href="?edit">Editor</a>
<span class="ctrl-sep">|</span>
<button class="ctrl-btn" id="btn-save" title="Save level (download .lvl)">Save</button>
<button class="ctrl-btn" id="btn-load" title="Load .lvl from disk">Load</button>
<span class="ctrl-sep">|</span>
<select id="level-select" title="Open a built-in level in the editor">
<option value="">-- Open level --</option>
</select>
<span class="ctrl-sep">|</span>
<span id="hint">E=editor P=test play 1-6=tools</span>
</div>
<div id="status">Loading...</div>
<div id="progress-bar"><div id="progress-bar-inner"></div></div>
@@ -63,6 +122,9 @@
var Module = {
canvas: document.getElementById('canvas'),
/* Force 1:1 pixel mapping so SDL_RenderSetLogicalSize doesn't
compute a viewport offset on HiDPI / fractional-scale displays */
devicePixelRatio: 1,
print: function(text) { console.log(text); },
printErr: function(text) { console.error(text); },
setStatus: function(text) {
@@ -92,6 +154,92 @@
window.onerror = function() {
Module.setStatus('Error - check the browser console');
};
/* ── Keyboard shortcuts: Ctrl+S / Ctrl+O ────────── */
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
e.stopPropagation();
if (typeof _editor_save_flag_ptr === 'function') {
HEAP32[_editor_save_flag_ptr() >> 2] = 1;
}
}
if ((e.ctrlKey || e.metaKey) && e.key === 'o') {
e.preventDefault();
e.stopPropagation();
if (typeof _editor_load_flag_ptr === 'function') {
HEAP32[_editor_load_flag_ptr() >> 2] = 1;
}
}
}, true); /* useCapture=true to intercept before SDL */
/* ── Button handlers ────────────────────────────── */
document.getElementById('btn-save').addEventListener('click', function() {
if (typeof _editor_save_flag_ptr === 'function') {
HEAP32[_editor_save_flag_ptr() >> 2] = 1;
}
/* Return focus to canvas so keys keep working */
document.getElementById('canvas').focus();
});
document.getElementById('btn-load').addEventListener('click', function() {
if (typeof _editor_load_flag_ptr === 'function') {
HEAP32[_editor_load_flag_ptr() >> 2] = 1;
}
document.getElementById('canvas').focus();
});
/* ── Level picker dropdown ──────────────────────── */
var levelSelect = document.getElementById('level-select');
/* Populate the dropdown after the module is ready (FS available) */
Module.postRun = Module.postRun || [];
Module.postRun.push(function() {
/* Scan for .lvl files in the virtual FS */
var levels = [];
try {
var entries = FS.readdir('assets/levels');
for (var i = 0; i < entries.length; i++) {
if (entries[i].endsWith('.lvl') && entries[i][0] !== '_') {
levels.push('assets/levels/' + entries[i]);
}
}
levels.sort();
} catch(e) {
console.error('Could not read levels dir:', e);
}
for (var j = 0; j < levels.length; j++) {
var opt = document.createElement('option');
opt.value = levels[j];
/* Show just the filename without path */
opt.textContent = levels[j].replace('assets/levels/', '');
levelSelect.appendChild(opt);
}
});
levelSelect.addEventListener('change', function() {
var path = this.value;
if (!path) return;
if (typeof _editor_load_vfs_file === 'function') {
/* Pass the path string to C */
var len = lengthBytesUTF8(path) + 1;
var buf = _malloc(len);
stringToUTF8(path, buf, len);
_editor_load_vfs_file(buf);
_free(buf);
}
/* Reset dropdown to placeholder */
this.selectedIndex = 0;
document.getElementById('canvas').focus();
});
/* Update title if in editor mode */
if (window.location.search.indexOf('edit') !== -1) {
document.title = 'Jump \'n Run - Level Editor';
}
</script>
{{{ SCRIPT }}}
</body>