diff --git a/DESIGN.md b/DESIGN.md index 4106ae2..f9ab3ca 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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) diff --git a/Makefile b/Makefile index 32e0468..e8b5f82 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/assets/levels/level01.lvl b/assets/levels/level01.lvl index 2e7d1be..f57efdb 100644 --- a/assets/levels/level01.lvl +++ b/assets/levels/level01.lvl @@ -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 [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 diff --git a/assets/levels/level02.lvl b/assets/levels/level02.lvl new file mode 100644 index 0000000..31ddc15 --- /dev/null +++ b/assets/levels/level02.lvl @@ -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 diff --git a/assets/levels/moon01.lvl b/assets/levels/moon01.lvl new file mode 100644 index 0000000..9071890 --- /dev/null +++ b/assets/levels/moon01.lvl @@ -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 diff --git a/assets/tiles/moon_tileset.png b/assets/tiles/moon_tileset.png new file mode 100644 index 0000000..445c170 Binary files /dev/null and b/assets/tiles/moon_tileset.png differ diff --git a/include/config.h b/include/config.h index ff68a7a..b655189 100644 --- a/include/config.h +++ b/include/config.h @@ -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 */ diff --git a/src/engine/entity.h b/src/engine/entity.h index 9ab1e8e..0b58bea 100644 --- a/src/engine/entity.h +++ b/src/engine/entity.h @@ -26,6 +26,7 @@ typedef enum EntityType { ENT_FORCE_FIELD, ENT_POWERUP, ENT_DRONE, + ENT_ASTEROID, ENT_TYPE_COUNT } EntityType; diff --git a/src/engine/parallax.c b/src/engine/parallax.c index d507ebb..0e048e5 100644 --- a/src/engine/parallax.c +++ b/src/engine/parallax.c @@ -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); diff --git a/src/engine/parallax.h b/src/engine/parallax.h index df61d69..11c3de2 100644 --- a/src/engine/parallax.h +++ b/src/engine/parallax.h @@ -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 */ diff --git a/src/engine/tilemap.c b/src/engine/tilemap.c index 244737e..6a2b770 100644 --- a/src/engine/tilemap.c +++ b/src/engine/tilemap.c @@ -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 [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]; diff --git a/src/engine/tilemap.h b/src/engine/tilemap.h index f131e9e..75b9889 100644 --- a/src/engine/tilemap.h +++ b/src/engine/tilemap.h @@ -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); diff --git a/src/game/drone.c b/src/game/drone.c index c5a82bd..3e15751 100644 --- a/src/game/drone.c +++ b/src/game/drone.c @@ -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; } diff --git a/src/game/editor.c b/src/game/editor.c index dd49e69..9294c66 100644 --- a/src/game/editor.c +++ b/src/game/editor.c @@ -11,6 +11,99 @@ #include #include +#ifdef __EMSCRIPTEN__ +#include + +/* ═══════════════════════════════════════════════════ + * Browser file I/O helpers (Emscripten only) + * + * Save: write .lvl to virtual FS, then trigger + * browser download via Blob URL. + * Load: open an 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); } } diff --git a/src/game/editor.h b/src/game/editor.h index a01f125..0237faa 100644 --- a/src/game/editor.h +++ b/src/game/editor.h @@ -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 ──────────────────────────── */ diff --git a/src/game/entity_registry.c b/src/game/entity_registry.c index 3e6239d..f1aecb3 100644 --- a/src/game/entity_registry.c +++ b/src/game/entity_registry.c @@ -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); } diff --git a/src/game/hazards.c b/src/game/hazards.c index 3074950..cea1999 100644 --- a/src/game/hazards.c +++ b/src/game/hazards.c @@ -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 #include @@ -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; +} diff --git a/src/game/hazards.h b/src/game/hazards.h index 9d6fe64..14ae80b 100644 --- a/src/game/hazards.h +++ b/src/game/hazards.h @@ -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 */ diff --git a/src/game/level.c b/src/game/level.c index 35668bc..ed5f784 100644 --- a/src/game/level.c +++ b/src/game/level.c @@ -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 #include +#include /* ── 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); } } } diff --git a/src/game/level.h b/src/game/level.h index 43b3e6b..0e4c070 100644 --- a/src/game/level.h +++ b/src/game/level.h @@ -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 */ diff --git a/src/game/levelgen.c b/src/game/levelgen.c index 56ee42d..4b1be39 100644 --- a/src/game/levelgen.c +++ b/src/game/levelgen.c @@ -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]; diff --git a/src/game/levelgen.h b/src/game/levelgen.h index 043cf4c..6b47a46 100644 --- a/src/game/levelgen.h +++ b/src/game/levelgen.h @@ -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. */ diff --git a/src/game/player.c b/src/game/player.c index 46cbbfe..355d424 100644 --- a/src/game/player.c +++ b/src/game/player.c @@ -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; diff --git a/src/game/player.h b/src/game/player.h index 4519816..4a8fd8f 100644 --- a/src/game/player.h +++ b/src/game/player.h @@ -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. */ diff --git a/src/game/powerup.c b/src/game/powerup.c index b9b8e62..90c0046 100644 --- a/src/game/powerup.c +++ b/src/game/powerup.c @@ -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); +} diff --git a/src/game/powerup.h b/src/game/powerup.h index 4d55183..36f5e17 100644 --- a/src/game/powerup.h +++ b/src/game/powerup.h @@ -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 */ diff --git a/src/game/sprites.c b/src/game/sprites.c index 3041851..ca4d20a 100644 --- a/src/game/sprites.c +++ b/src/game/sprites.c @@ -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}; } diff --git a/src/game/sprites.h b/src/game/sprites.h index ea16642..0e24e02 100644 --- a/src/game/sprites.h +++ b/src/game/sprites.h @@ -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; diff --git a/src/main.c b/src/main.c index c8c9109..d292c04 100644 --- a/src/main.c +++ b/src/main.c @@ -8,6 +8,10 @@ #include #include +#ifdef __EMSCRIPTEN__ +#include +#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; } diff --git a/web/shell.html b/web/shell.html index ebb9f40..6b2c415 100644 --- a/web/shell.html +++ b/web/shell.html @@ -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; + } @@ -53,6 +100,18 @@ +
+ Editor + | + + + | + + | + E=editor P=test play 1-6=tools +
Loading...
@@ -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 }}}