From 5a16f798ac6c5851f7d28979835d1c434f9ee1c2 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 19 Jan 2026 15:00:01 -0500 Subject: [PATCH] Sound has been updated. As a result snares can be collected from a bit further away as it is now harder to tell when you're about to step on it. A few string updates. Mountain terrain added. May require rope for traversal. --- draugnorak.nvgt | 107 +++++++++---- sounds/actions/climb_rope.ogg | 3 + src/audio_utils.nvgt | 61 +++++++- src/combat.nvgt | 6 +- src/constants.nvgt | 32 +++- src/crafting.nvgt | 79 +++++----- src/environment.nvgt | 200 ++++++++++++++++++------ src/notify.nvgt | 4 +- src/player.nvgt | 10 ++ src/save_system.nvgt | 79 ++++++++++ src/time_system.nvgt | 83 ++++++++-- src/world_state.nvgt | 282 ++++++++++++++++++++++++++++++---- 12 files changed, 774 insertions(+), 172 deletions(-) create mode 100644 sounds/actions/climb_rope.ogg diff --git a/draugnorak.nvgt b/draugnorak.nvgt index 4661571..3ce235b 100644 --- a/draugnorak.nvgt +++ b/draugnorak.nvgt @@ -1,4 +1,3 @@ -#include "bgt_compat.nvgt" #include "sound_pool.nvgt" #include "virtual_dialogs.nvgt" @@ -57,6 +56,10 @@ int run_main_menu() { void main() { + // Configure sound pool for better spatial audio + p.volume_step = AUDIO_VOLUME_STEP / float(AUDIO_TILE_SCALE); // Default falloff in audio units + p.pan_step = AUDIO_PAN_STEP; // Panning strength for scaled tile distances + show_window("Draugnorak"); bool game_started = false; @@ -154,7 +157,12 @@ void main() // Coordinates Key if (key_pressed(KEY_X)) { string direction_label = (facing == 1) ? "east" : "west"; - screen_reader_speak(direction_label + ", x " + x + ", y " + y, true); + string terrain_info = ""; + MountainRange@ mountain = get_mountain_at(x); + if (mountain !is null) { + terrain_info = ", elevation " + y + ", " + mountain.get_terrain_at(x); + } + screen_reader_speak(direction_label + ", x " + x + ", y " + y + terrain_info, true); } // Base Info Key (base only) @@ -165,19 +173,41 @@ void main() // Climbing and Falling Updates update_climbing(); update_falling(); + update_rope_climbing(); + check_rope_climb_fall(); + update_mountains(); - // Down arrow to climb down from tree + // Down arrow to climb down from tree or start rope climb down if (key_pressed(KEY_DOWN)) { - Tree@ tree = get_tree_at(x); - if (tree != null && !tree.is_chopped && y > 0 && !jumping && !climbing && !falling) { - climb_down_tree(); + // Check for pending rope climb (going down) + if (pending_rope_climb_x != -1 && !rope_climbing && !climbing && !falling && !jumping) { + int elevation_change = pending_rope_climb_elevation - y; + if (elevation_change < 0) { + start_rope_climb(false, pending_rope_climb_x, pending_rope_climb_elevation); + pending_rope_climb_x = -1; + } + } + // Tree climbing down + else { + Tree@ tree = get_tree_at(x); + if (tree != null && !tree.is_chopped && y > 0 && !jumping && !climbing && !falling && !rope_climbing) { + climb_down_tree(); + } } } // Jumping Logic if(key_pressed(KEY_UP)) { - if(!jumping && !climbing && !falling) + // Check for pending rope climb (going up) + if (pending_rope_climb_x != -1 && !rope_climbing && !climbing && !falling && !jumping) { + int elevation_change = pending_rope_climb_elevation - y; + if (elevation_change > 0) { + start_rope_climb(true, pending_rope_climb_x, pending_rope_climb_elevation); + pending_rope_climb_x = -1; + } + } + else if(!jumping && !climbing && !falling && !rope_climbing) { // Check if on tree tile Tree@ tree = get_tree_at(x); @@ -210,46 +240,67 @@ void main() movetime = jumping ? jump_speed : walk_speed; // Movement Logic - if (key_pressed(KEY_LEFT) && facing != 0 && !climbing && !falling) { + if (key_pressed(KEY_LEFT) && facing != 0 && !climbing && !falling && !rope_climbing) { facing = 0; screen_reader_speak("west", true); walktimer.restart(); + // Cancel pending rope climb when changing direction + pending_rope_climb_x = -1; } - if (key_pressed(KEY_RIGHT) && facing != 1 && !climbing && !falling) { + if (key_pressed(KEY_RIGHT) && facing != 1 && !climbing && !falling && !rope_climbing) { facing = 1; screen_reader_speak("east", true); walktimer.restart(); + // Cancel pending rope climb when changing direction + pending_rope_climb_x = -1; } if(walktimer.elapsed > movetime) { int old_x = x; - // Check if trying to move left/right while in tree - if((key_down(KEY_LEFT) || key_down(KEY_RIGHT)) && y > 0 && !jumping && !falling) { + // Check if trying to move left/right while in tree (not in mountain) + MountainRange@ current_mountain = get_mountain_at(x); + if((key_down(KEY_LEFT) || key_down(KEY_RIGHT)) && y > 0 && !jumping && !falling && !rope_climbing && current_mountain is null) { // Fall out of tree climbing = false; start_falling(); } - if(key_down(KEY_LEFT) && x > 0 && !climbing && !falling) + if(key_down(KEY_LEFT) && x > 0 && !climbing && !falling && !rope_climbing) { facing = 0; - x--; - walktimer.restart(); - if(!jumping) { - play_footstep(x, BASE_END, GRASS_END); - check_snare_collision(x); // Check when moving onto a tile + // Check mountain movement + if (can_move_mountain(x, x - 1)) { + x--; + // Update elevation if in mountain + int new_elevation = get_mountain_elevation_at(x); + if (new_elevation != y && get_mountain_at(x) !is null) { + y = new_elevation; + } + walktimer.restart(); + if(!jumping) { + play_footstep(x, BASE_END, GRASS_END); + check_snare_collision(x); + } } } - else if(key_down(KEY_RIGHT) && x < MAP_SIZE - 1 && !climbing && !falling) + else if(key_down(KEY_RIGHT) && x < MAP_SIZE - 1 && !climbing && !falling && !rope_climbing) { facing = 1; - x++; - walktimer.restart(); - if(!jumping) { - play_footstep(x, BASE_END, GRASS_END); - check_snare_collision(x); // Check when moving onto a tile + // Check mountain movement + if (can_move_mountain(x, x + 1)) { + x++; + // Update elevation if in mountain + int new_elevation = get_mountain_elevation_at(x); + if (new_elevation != y && get_mountain_at(x) !is null) { + y = new_elevation; + } + walktimer.restart(); + if(!jumping) { + play_footstep(x, BASE_END, GRASS_END); + check_snare_collision(x); + } } } } @@ -285,7 +336,7 @@ void main() } // Sling charge detection - if (sling_equipped && (key_down(KEY_LCONTROL) || key_down(KEY_RCONTROL)) && !sling_charging) { + if (sling_equipped && (key_down(KEY_LCTRL) || key_down(KEY_RCTRL)) && !sling_charging) { if (inv_stones > 0) { sling_charging = true; sling_charge_timer.restart(); @@ -297,12 +348,12 @@ void main() } // Update sling charge state while holding - if (sling_charging && (key_down(KEY_LCONTROL) || key_down(KEY_RCONTROL))) { + if (sling_charging && (key_down(KEY_LCTRL) || key_down(KEY_RCTRL))) { update_sling_charge(); } // Sling release detection - if (sling_charging && (!key_down(KEY_LCONTROL) && !key_down(KEY_RCONTROL))) { + if (sling_charging && (!key_down(KEY_LCTRL) && !key_down(KEY_RCTRL))) { release_sling_attack(x); sling_charging = false; if (sling_sound_handle != -1) { @@ -317,7 +368,7 @@ void main() if (spear_equipped) attack_cooldown = 800; if (axe_equipped) attack_cooldown = 1600; - if((key_down(KEY_LCONTROL) || key_down(KEY_RCONTROL)) && attack_timer.elapsed > attack_cooldown) + if((key_down(KEY_LCTRL) || key_down(KEY_RCTRL)) && attack_timer.elapsed > attack_cooldown) { attack_timer.restart(); perform_attack(x); @@ -325,6 +376,6 @@ void main() } // Audio Listener Update - p.update_listener_1d(x); + update_listener_tile(x); } } diff --git a/sounds/actions/climb_rope.ogg b/sounds/actions/climb_rope.ogg new file mode 100644 index 0000000..e71a4f0 --- /dev/null +++ b/sounds/actions/climb_rope.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c37ca7c8af13bd36bd8b95ba8d3de19a2208204de9216c99ece1f9d9ca2a2e4 +size 37212 diff --git a/src/audio_utils.nvgt b/src/audio_utils.nvgt index 040edcf..ece530d 100644 --- a/src/audio_utils.nvgt +++ b/src/audio_utils.nvgt @@ -1,7 +1,7 @@ string get_footstep_sound(int current_x, int base_end, int grass_end) { - // Check if in water first (overrides all other terrain) - if (is_position_in_water(current_x)) { + // Check if in water first (regular streams or mountain streams) + if (is_position_in_water(current_x) || is_mountain_stream_at(current_x)) { return "sounds/terrain/shallow_water.ogg"; } @@ -27,17 +27,36 @@ string get_footstep_sound(int current_x, int base_end, int grass_end) } else if (expanded_area_start != -1 && current_x >= expanded_area_start && current_x <= expanded_area_end) { - // Expanded area - check terrain type + // Check for mountain terrain first + MountainRange@ mountain = get_mountain_at(current_x); + if (mountain !is null) { + string terrain = mountain.get_terrain_at(current_x); + if (terrain == "stone") { + return "sounds/terrain/stone.ogg"; + } else if (terrain == "gravel") { + return "sounds/terrain/gravel.ogg"; + } else if (terrain == "snow") { + return "sounds/terrain/snow.ogg"; + } + } + + // Regular expanded area - check terrain type int index = current_x - expanded_area_start; - if (index >= 0 && index < expanded_terrain_types.length()) + if (index >= 0 && index < int(expanded_terrain_types.length())) { string terrain = expanded_terrain_types[index]; + // Handle "mountain:terrain" format from older saves + if (terrain.find("mountain:") == 0) { + terrain = terrain.substr(9); + } if (terrain == "stone") { return "sounds/terrain/stone.ogg"; } else if (terrain == "grass") { return "sounds/terrain/grass.ogg"; } else if (terrain == "snow") { return "sounds/terrain/snow.ogg"; + } else if (terrain == "gravel") { + return "sounds/terrain/gravel.ogg"; } } } @@ -55,11 +74,41 @@ void play_footstep(int current_x, int base_end, int grass_end) } } +int to_audio_position(int tile_x) +{ + return tile_x * AUDIO_TILE_SCALE; +} + +float to_audio_volume_step(float volume_step) +{ + return volume_step / float(AUDIO_TILE_SCALE); +} + +int play_1d_tile(string sound_file, int listener_x, int sound_x, bool looping, bool persistent = false) +{ + return p.play_1d(sound_file, to_audio_position(listener_x), to_audio_position(sound_x), looping, persistent); +} + +bool update_sound_1d_tile(int slot, int sound_x) +{ + return p.update_sound_1d(slot, to_audio_position(sound_x)); +} + +void update_listener_tile(int listener_x) +{ + p.update_listener_1d(to_audio_position(listener_x)); +} + +void update_sound_range_1d_tile(int slot, int range_tiles) +{ + p.update_sound_range_1d(slot, range_tiles * AUDIO_TILE_SCALE, range_tiles * AUDIO_TILE_SCALE); +} + int play_1d_with_volume_step(string sound_file, int listener_x, int sound_x, bool looping, float volume_step) { - int slot = p.play_1d(sound_file, listener_x, sound_x, looping); + int slot = p.play_1d(sound_file, to_audio_position(listener_x), to_audio_position(sound_x), looping); if (slot != -1) { - p.update_sound_positioning_values(slot, -1.0, volume_step, true); + p.update_sound_positioning_values(slot, -1.0, to_audio_volume_step(volume_step), true); } return slot; } diff --git a/src/combat.nvgt b/src/combat.nvgt index 5c01814..a3d0441 100644 --- a/src/combat.nvgt +++ b/src/combat.nvgt @@ -161,7 +161,7 @@ void release_sling_attack(int player_x) { Tree@ tree = get_tree_at(check_x); if (tree != null && !tree.is_chopped) { // Stone hits tree but doesn't damage it - p.play_1d("sounds/weapons/sling_hit.ogg", player_x, check_x, false); + play_1d_tile("sounds/weapons/sling_hit.ogg", player_x, check_x, false); screen_reader_speak("Stone hit tree at " + check_x + ".", true); return; } @@ -179,11 +179,11 @@ void release_sling_attack(int player_x) { // Damage the correct enemy type if (hit_bandit) { damage_bandit_at(target_x, damage); - p.play_1d("sounds/weapons/sling_hit.ogg", player_x, target_x, false); + play_1d_tile("sounds/weapons/sling_hit.ogg", player_x, target_x, false); play_1d_with_volume_step("sounds/enemies/zombie_hit.ogg", player_x, target_x, false, BANDIT_SOUND_VOLUME_STEP); } else { damage_zombie_at(target_x, damage); - p.play_1d("sounds/weapons/sling_hit.ogg", player_x, target_x, false); + play_1d_tile("sounds/weapons/sling_hit.ogg", player_x, target_x, false); play_1d_with_volume_step("sounds/enemies/zombie_hit.ogg", player_x, target_x, false, ZOMBIE_SOUND_VOLUME_STEP); } } diff --git a/src/constants.nvgt b/src/constants.nvgt index 18bc0e8..d6ea3f2 100644 --- a/src/constants.nvgt +++ b/src/constants.nvgt @@ -87,9 +87,35 @@ const int BANDIT_DETECTION_RADIUS = 5; const int BANDIT_WANDER_DIRECTION_CHANGE_MIN = 3000; const int BANDIT_WANDER_DIRECTION_CHANGE_MAX = 8000; -// Stream audio -const int STREAM_SOUND_RANGE = 5; -const float STREAM_SOUND_VOLUME_STEP = 0.6; +// Audio ranges and volume falloff +// Formula: final_volume = start_volume - (volume_step × distance) +// Using 30 dB fade over range for gradual but noticeable falloff +const int AUDIO_TILE_SCALE = 10; +const float AUDIO_PAN_STEP = 2.0; +const float AUDIO_VOLUME_STEP = 3.0; +const int SNARE_SOUND_RANGE = 5; +const float SNARE_SOUND_VOLUME_STEP = 4.0; // More audible for locating snares +const float SNARE_SOUND_PAN_STEP = 4.0; // Stronger pan for direction +const int SNARE_COLLECT_RANGE = 2; + +const int FIRE_SOUND_RANGE = 6; +const float FIRE_SOUND_VOLUME_STEP = 5.0; // 30 dB over 6 tiles + +const int FIREPIT_SOUND_RANGE = 5; +const float FIREPIT_SOUND_VOLUME_STEP = 6.0; // 30 dB over 5 tiles + +const int STREAM_SOUND_RANGE = 7; +const float STREAM_SOUND_VOLUME_STEP = 4.3; // 30 dB over 7 tiles + +// Mountain configuration +const int MOUNTAIN_SIZE = 60; +const int MOUNTAIN_MIN_ELEVATION = 0; +const int MOUNTAIN_MAX_ELEVATION = 40; +const int MOUNTAIN_STEEP_THRESHOLD = 6; +const int MOUNTAIN_MAX_SLOPE = 20; +const int ROPE_CLIMB_SPEED = 1000; +const int MOUNTAIN_STREAM_SOUND_RANGE = 7; +const float MOUNTAIN_STREAM_VOLUME_STEP = 4.3; // 30 dB over 7 tiles const int QUEST_MAX_ACTIVE = 4; const int QUEST_CHANCE_PER_FAVOR = 10; const int QUEST_MIN_CHANCE = 5; diff --git a/src/crafting.nvgt b/src/crafting.nvgt index f082ca8..755a722 100644 --- a/src/crafting.nvgt +++ b/src/crafting.nvgt @@ -294,20 +294,21 @@ void run_barricade_menu() { } } -void simulate_crafting() { +void simulate_crafting(int item_count) { screen_reader_speak("Crafting...", true); - timer t; - int duration = 4000; - int next_sound = 0; - - while(t.elapsed < duration) { - if(t.elapsed > next_sound) { - float pitch = random(85, 115); - p.play_stationary_extended("sounds/crafting.ogg", false, 0, 0, 0, pitch); - next_sound = t.elapsed + 800; + // Nothing should take less than 4. + if(item_count < 4) { + item_count = 4; + } + for(int i = 0; i < item_count; i++) { + float pitch = random(85, 115); + p.play_stationary_extended("sounds/crafting.ogg", false, 0, 0, 0, pitch); + + timer t; + while(t.elapsed < 800) { + wait(5); + menu_background_tick(); } - wait(5); - menu_background_tick(); } p.play_stationary("sounds/crafting_complete.ogg", false); } @@ -321,7 +322,7 @@ void craft_knife() { screen_reader_speak("You can't carry any more stone knives.", true); return; } - simulate_crafting(); + simulate_crafting(2); inv_stones -= 2; inv_knives++; screen_reader_speak("Crafted a Stone Knife.", true); @@ -342,7 +343,7 @@ void craft_spear() { screen_reader_speak("You can't carry any more spears.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_sticks--; inv_vines--; inv_stones--; @@ -363,7 +364,7 @@ void craft_sling() { screen_reader_speak("You can't carry any more slings.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_skins--; inv_vines -= 2; inv_slings++; @@ -383,7 +384,7 @@ void craft_skin_hat() { screen_reader_speak("You can't carry any more skin hats.", true); return; } - simulate_crafting(); + simulate_crafting(2); inv_skins--; inv_vines--; inv_skin_hats++; @@ -403,7 +404,7 @@ void craft_skin_gloves() { screen_reader_speak("You can't carry any more skin gloves.", true); return; } - simulate_crafting(); + simulate_crafting(2); inv_skins--; inv_vines--; inv_skin_gloves++; @@ -423,7 +424,7 @@ void craft_skin_pants() { screen_reader_speak("You can't carry any more skin pants.", true); return; } - simulate_crafting(); + simulate_crafting(9); inv_skins -= 6; inv_vines -= 3; inv_skin_pants++; @@ -443,7 +444,7 @@ void craft_skin_tunic() { screen_reader_speak("You can't carry any more skin tunics.", true); return; } - simulate_crafting(); + simulate_crafting(6); inv_skins -= 4; inv_vines -= 2; inv_skin_tunics++; @@ -463,7 +464,7 @@ void craft_moccasins() { screen_reader_speak("You can't carry any more moccasins.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_skins -= 2; inv_vines--; inv_moccasins++; @@ -483,7 +484,7 @@ void craft_skin_pouch() { screen_reader_speak("You can't carry any more skin pouches.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_skins -= 2; inv_vines--; inv_skin_pouches++; @@ -503,7 +504,7 @@ void craft_snare() { screen_reader_speak("You can't carry any more snares.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_sticks--; inv_vines -= 2; inv_snares++; @@ -525,7 +526,7 @@ void craft_axe() { screen_reader_speak("You can't carry any more stone axes.", true); return; } - simulate_crafting(); + simulate_crafting(4); inv_sticks--; inv_vines--; inv_stones -= 2; @@ -547,7 +548,7 @@ void craft_firepit() { if (inv_stones < 9) missing += "9 stones "; if (missing == "") { - simulate_crafting(); + simulate_crafting(9); inv_stones -= 9; add_world_firepit(x); screen_reader_speak("Firepit built here.", true); @@ -569,7 +570,7 @@ void craft_campfire() { if (inv_sticks < 2) missing += "2 sticks "; if (missing == "") { - simulate_crafting(); + simulate_crafting(3); inv_logs--; inv_sticks -= 2; // Build the fire at the firepit location, not player location @@ -599,7 +600,7 @@ void craft_herb_garden() { if (inv_logs < 2) missing += "2 logs "; if (missing == "") { - simulate_crafting(); + simulate_crafting(14); inv_stones -= 9; inv_vines -= 3; inv_logs -= 2; @@ -625,7 +626,7 @@ void craft_storage() { if (inv_vines < STORAGE_VINE_COST) missing += STORAGE_VINE_COST + " vines "; if (missing == "") { - simulate_crafting(); + simulate_crafting(23); inv_logs -= STORAGE_LOG_COST; inv_stones -= STORAGE_STONE_COST; inv_vines -= STORAGE_VINE_COST; @@ -650,7 +651,7 @@ void craft_pasture() { if (inv_vines < PASTURE_VINE_COST) missing += PASTURE_VINE_COST + " vines "; if (missing == "") { - simulate_crafting(); + simulate_crafting(28); inv_logs -= PASTURE_LOG_COST; inv_vines -= PASTURE_VINE_COST; add_world_pasture(x); @@ -675,7 +676,7 @@ void craft_stable() { if (inv_vines < STABLE_VINE_COST) missing += STABLE_VINE_COST + " vines "; if (missing == "") { - simulate_crafting(); + simulate_crafting(35); inv_logs -= STABLE_LOG_COST; inv_stones -= STABLE_STONE_COST; inv_vines -= STABLE_VINE_COST; @@ -700,7 +701,7 @@ void craft_altar() { if (inv_sticks < ALTAR_STICK_COST) missing += ALTAR_STICK_COST + " sticks "; if (missing == "") { - simulate_crafting(); + simulate_crafting(12); inv_stones -= ALTAR_STONE_COST; inv_sticks -= ALTAR_STICK_COST; add_world_altar(x); @@ -720,7 +721,7 @@ void reinforce_barricade_with_sticks() { return; } - simulate_crafting(); + simulate_crafting(BARRICADE_STICK_COST); inv_sticks -= BARRICADE_STICK_COST; int gained = add_barricade_health(BARRICADE_STICK_HEALTH); screen_reader_speak("Reinforced barricade with sticks. +" + gained + " health. Now " + barricade_health + " of " + BARRICADE_MAX_HEALTH + ".", true); @@ -736,7 +737,7 @@ void reinforce_barricade_with_vines() { return; } - simulate_crafting(); + simulate_crafting(BARRICADE_VINE_COST); inv_vines -= BARRICADE_VINE_COST; int gained = add_barricade_health(BARRICADE_VINE_HEALTH); screen_reader_speak("Reinforced barricade with vines. +" + gained + " health. Now " + barricade_health + " of " + BARRICADE_MAX_HEALTH + ".", true); @@ -752,7 +753,7 @@ void reinforce_barricade_with_log() { return; } - simulate_crafting(); + simulate_crafting(BARRICADE_LOG_COST); inv_logs -= BARRICADE_LOG_COST; int gained = add_barricade_health(BARRICADE_LOG_HEALTH); screen_reader_speak("Reinforced barricade with log. +" + gained + " health. Now " + barricade_health + " of " + BARRICADE_MAX_HEALTH + ".", true); @@ -768,7 +769,7 @@ void reinforce_barricade_with_stones() { return; } - simulate_crafting(); + simulate_crafting(BARRICADE_STONE_COST); inv_stones -= BARRICADE_STONE_COST; int gained = add_barricade_health(BARRICADE_STONE_HEALTH); screen_reader_speak("Reinforced barricade with stones. +" + gained + " health. Now " + barricade_health + " of " + BARRICADE_MAX_HEALTH + ".", true); @@ -784,7 +785,7 @@ void craft_fishing_pole() { screen_reader_speak("You can't carry any more fishing poles.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_sticks--; inv_vines -= 2; inv_fishing_poles++; @@ -803,7 +804,7 @@ void craft_rope() { screen_reader_speak("You can't carry any more rope.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_vines -= 3; inv_ropes++; screen_reader_speak("Crafted rope.", true); @@ -821,7 +822,7 @@ void craft_reed_basket() { screen_reader_speak("You can't carry any more reed baskets.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_reeds -= 3; inv_reed_baskets++; screen_reader_speak("Crafted a reed basket.", true); @@ -846,7 +847,7 @@ void craft_clay_pot() { screen_reader_speak("You can't carry any more clay pots.", true); return; } - simulate_crafting(); + simulate_crafting(3); inv_clay -= 3; inv_clay_pots++; screen_reader_speak("Crafted a clay pot.", true); @@ -880,7 +881,7 @@ void butcher_small_game() { screen_reader_speak("You can't carry any more skins.", true); return; } - simulate_crafting(); + simulate_crafting(1); // Get the type of game we're butchering (first in the list) string game_type = inv_small_game_types[0]; diff --git a/src/environment.nvgt b/src/environment.nvgt index 155b515..3e428b3 100644 --- a/src/environment.nvgt +++ b/src/environment.nvgt @@ -40,18 +40,14 @@ class Tree { } void update() { - // Only play tree sound if not chopped and within 3 tiles distance (2 tiles on either side) + // Keep tree sound active so distance-based fade can work. if (!is_chopped) { - if (abs(x - position) <= 3) { - if (sound_handle == -1 || !p.sound_is_active(sound_handle)) { - sound_handle = p.play_1d("sounds/environment/tree.ogg", x, position, true); - } - } else { - if (sound_handle != -1) { - p.destroy_sound(sound_handle); - sound_handle = -1; - } + if (sound_handle == -1 || !p.sound_is_active(sound_handle)) { + sound_handle = play_1d_tile("sounds/environment/tree.ogg", x, position, true); } + } else if (sound_handle != -1) { + p.destroy_sound(sound_handle); + sound_handle = -1; } } @@ -199,7 +195,7 @@ void damage_tree(int target_x, int damage) { } // Play the falling sound at the tree's position - p.play_1d("sounds/items/tree.ogg", x, target.position, false); + play_1d_tile("sounds/items/tree.ogg", x, target.position, false); int sticks_dropped = random(1, 3); int vines_dropped = random(1, 2); @@ -228,10 +224,8 @@ void damage_tree(int target_x, int damage) { void perform_search(int current_x) { - // Check for Snares nearby (Current or Adjacent) - // "Shift beside the snare will collect the snare" -> adjacent - // We check current and +/- 1 - for (int check_x = current_x - 1; check_x <= current_x + 1; check_x++) { + // Check for snares nearby (adjacent within range) + for (int check_x = current_x - SNARE_COLLECT_RANGE; check_x <= current_x + SNARE_COLLECT_RANGE; check_x++) { // Skip current x? User said "beside". If on top, it breaks. // But if I stand adjacent and shift... if (check_x == current_x) continue; // Safety against collecting own snare you stand on? (Collision happens on move) @@ -496,11 +490,14 @@ void start_falling() { void update_falling() { if (!falling) return; + // Get ground level (mountain elevation or 0) + int ground_level = get_mountain_elevation_at(x); + // Fall faster than climbing - 1 foot per 100ms if (fall_timer.elapsed > 100) { fall_timer.restart(); - if (y > 0) { + if (y > ground_level) { y--; // Restart falling sound with decreasing pitch each foot @@ -509,38 +506,155 @@ void update_falling() { } // Pitch ranges from 100 (high up) to 50 (near ground) - // Calculate based on current y position - float pitch_percent = 50.0 + (50.0 * (y / 30.0)); + float height_above_ground = float(y - ground_level); + float pitch_percent = 50.0 + (50.0 * (height_above_ground / 30.0)); if (pitch_percent < 50.0) pitch_percent = 50.0; if (pitch_percent > 100.0) pitch_percent = 100.0; fall_sound_handle = p.play_stationary_extended("sounds/actions/falling.ogg", true, 0, 0, 0, pitch_percent); + + // Check if we've reached ground level + if (y <= ground_level) { + land_on_ground(ground_level); + } } else { - // Hit the ground - falling = false; - - // Stop falling sound - if (fall_sound_handle != -1) { - p.destroy_sound(fall_sound_handle); - fall_sound_handle = -1; - } - - p.play_stationary("sounds/actions/hit_ground.ogg", false); - - // Calculate fall damage - int fall_height = fall_start_y; - if (fall_height > 10) { - int damage = 0; - for (int i = 10; i < fall_height; i++) { - damage += random(1, 3); - } - player_health -= damage; - screen_reader_speak("Fell " + fall_height + " feet! Took " + damage + " damage. " + player_health + " health remaining.", true); - } else { - screen_reader_speak("Landed safely.", true); - } - - fall_start_y = 0; + land_on_ground(ground_level); } } } + +void land_on_ground(int ground_level) { + falling = false; + + // Stop falling sound + if (fall_sound_handle != -1) { + p.destroy_sound(fall_sound_handle); + fall_sound_handle = -1; + } + + p.play_stationary("sounds/actions/hit_ground.ogg", false); + + // Calculate fall damage + int fall_height = fall_start_y - ground_level; + y = ground_level; + + if (fall_height > 10) { + int damage = 0; + for (int i = 10; i < fall_height; i++) { + damage += random(1, 3); + } + player_health -= damage; + screen_reader_speak("Fell " + fall_height + " feet! Took " + damage + " damage. " + player_health + " health remaining.", true); + } else { + screen_reader_speak("Landed safely.", true); + } + + fall_start_y = 0; +} + +// Mountain movement check +bool can_move_mountain(int from_x, int to_x) { + MountainRange@ mountain = get_mountain_at(to_x); + if (mountain is null) { + // Not entering a mountain + return true; + } + + // Check if from_x is also in same mountain + if (!mountain.contains_position(from_x)) { + // Entering mountain from edge - always allowed + return true; + } + + // Check elevation change + if (mountain.is_steep_section(from_x, to_x)) { + // Need rope + if (inv_ropes < 1) { + screen_reader_speak("You'll need a rope to climb there.", true); + return false; + } + + // Prompt for rope climb + int elevation_change = mountain.get_elevation_change(from_x, to_x); + if (elevation_change > 0) { + screen_reader_speak("Press up to climb up.", true); + } else { + screen_reader_speak("Press down to climb down.", true); + } + + // Store pending rope climb info + pending_rope_climb_x = to_x; + pending_rope_climb_elevation = mountain.get_elevation_at(to_x); + return false; + } + + return true; +} + +// Rope climbing functions +void start_rope_climb(bool climbing_up, int target_x, int target_elevation) { + rope_climbing = true; + rope_climb_up = climbing_up; + rope_climb_target_x = target_x; + rope_climb_target_y = target_elevation; + rope_climb_start_y = y; + rope_climb_timer.restart(); + + int distance = rope_climb_target_y - y; + if (distance < 0) distance = -distance; + + string direction = climbing_up ? "up" : "down"; + screen_reader_speak("Climbing " + direction + ". " + distance + " feet.", true); +} + +void update_rope_climbing() { + if (!rope_climbing) return; + + // Climb at ROPE_CLIMB_SPEED ms per foot + if (rope_climb_timer.elapsed > ROPE_CLIMB_SPEED) { + rope_climb_timer.restart(); + + if (rope_climb_up) { + // Climbing up + if (y < rope_climb_target_y) { + y++; + p.play_stationary("sounds/actions/climb_rope.ogg", false); + + if (y >= rope_climb_target_y) { + complete_rope_climb(); + } + } + } else { + // Climbing down + if (y > rope_climb_target_y) { + y--; + p.play_stationary("sounds/actions/climb_rope.ogg", false); + + if (y <= rope_climb_target_y) { + complete_rope_climb(); + } + } + } + } +} + +void complete_rope_climb() { + rope_climbing = false; + x = rope_climb_target_x; + y = rope_climb_target_y; + + // Play footstep for new terrain + play_footstep(x, BASE_END, GRASS_END); + + screen_reader_speak("Reached elevation " + y + ".", true); +} + +void check_rope_climb_fall() { + if (!rope_climbing) return; + + if (key_down(KEY_LEFT) || key_down(KEY_RIGHT)) { + // Fall from rope! + rope_climbing = false; + start_falling(); + } +} diff --git a/src/notify.nvgt b/src/notify.nvgt index 327e8cf..484aeff 100644 --- a/src/notify.nvgt +++ b/src/notify.nvgt @@ -40,7 +40,7 @@ void update_notifications() { void check_notification_keys() { // [ for previous notification (older) with position - if (key_pressed(KEY_LBRACKET)) { + if (key_pressed(KEY_LEFTBRACKET)) { if (notification_history.length() == 0) { screen_reader_speak("No notifications.", true); return; @@ -58,7 +58,7 @@ void check_notification_keys() { } // ] for next notification (newer) with position - if (key_pressed(KEY_RBRACKET)) { + if (key_pressed(KEY_RIGHTBRACKET)) { if (notification_history.length() == 0) { screen_reader_speak("No notifications.", true); return; diff --git a/src/player.nvgt b/src/player.nvgt index 3fe7007..73310ce 100644 --- a/src/player.nvgt +++ b/src/player.nvgt @@ -11,6 +11,16 @@ int fall_sound_handle = -1; // Handle for looping fall sound timer fall_timer; // For fall sound pitch timer climb_timer; // For climb speed +// Rope climbing state +bool rope_climbing = false; +bool rope_climb_up = true; +int rope_climb_target_x = 0; +int rope_climb_target_y = 0; +int rope_climb_start_y = 0; +timer rope_climb_timer; +int pending_rope_climb_x = -1; +int pending_rope_climb_elevation = 0; + // Health System int player_health = 10; int base_max_health = 10; diff --git a/src/save_system.nvgt b/src/save_system.nvgt index 597b2c7..55c4a99 100644 --- a/src/save_system.nvgt +++ b/src/save_system.nvgt @@ -145,6 +145,7 @@ void clear_world_objects() { clear_zombies(); clear_bandits(); + clear_mountains(); } void reset_game_state() { @@ -161,6 +162,13 @@ void reset_game_state() { fall_start_y = 0; sling_charging = false; searching = false; + rope_climbing = false; + rope_climb_up = true; + rope_climb_target_x = 0; + rope_climb_target_y = 0; + rope_climb_start_y = 0; + pending_rope_climb_x = -1; + pending_rope_climb_elevation = 0; player_health = 10; base_max_health = 10; @@ -302,6 +310,32 @@ string serialize_bandit(Bandit@ bandit) { return bandit.position + "|" + bandit.health + "|" + bandit.weapon_type + "|" + bandit.behavior_state + "|" + bandit.wander_direction + "|" + bandit.move_interval; } +string serialize_mountain(MountainRange@ mountain) { + string result = mountain.start_position + "|" + mountain.end_position + "|"; + + // Serialize elevations + for (int i = 0; i < int(mountain.elevations.length()); i++) { + if (i > 0) result += ","; + result += mountain.elevations[i]; + } + result += "|"; + + // Serialize terrain types + for (int i = 0; i < int(mountain.terrain_types.length()); i++) { + if (i > 0) result += ","; + result += mountain.terrain_types[i]; + } + result += "|"; + + // Serialize stream positions + for (uint i = 0; i < mountain.stream_positions.length(); i++) { + if (i > 0) result += ","; + result += mountain.stream_positions[i]; + } + + return result; +} + string join_string_array(const string[]@ arr) { if (@arr == null || arr.length() == 0) return ""; string result = arr[0]; @@ -645,6 +679,12 @@ bool save_game_state() { } saveData.set("bandits_data", join_string_array(banditData)); + string[] mountainData; + for (uint i = 0; i < world_mountains.length(); i++) { + mountainData.insert_last(serialize_mountain(world_mountains[i])); + } + saveData.set("mountains_data", join_string_array(mountainData)); + string rawData = saveData.serialize(); string encryptedData = encrypt_save_data(rawData); return save_data(SAVE_FILE_PATH, encryptedData); @@ -982,6 +1022,45 @@ bool load_game_state() { bandits.insert_last(b); } + string[] mountainData = get_string_list_or_split(saveData, "mountains_data"); + for (uint i = 0; i < mountainData.length(); i++) { + string[]@ parts = mountainData[i].split("|"); + if (parts.length() < 5) continue; + + int start_pos = parse_int(parts[0]); + int end_pos = parse_int(parts[1]); + int size = end_pos - start_pos + 1; + + // Create mountain with minimal init (we'll override everything) + MountainRange@ mountain = MountainRange(start_pos, 1); + mountain.start_position = start_pos; + mountain.end_position = end_pos; + + // Parse elevations + string[]@ elev_parts = parts[2].split(","); + mountain.elevations.resize(elev_parts.length()); + for (uint j = 0; j < elev_parts.length(); j++) { + mountain.elevations[j] = parse_int(elev_parts[j]); + } + + // Parse terrain types + string[]@ terrain_parts = parts[3].split(","); + mountain.terrain_types.resize(terrain_parts.length()); + for (uint j = 0; j < terrain_parts.length(); j++) { + mountain.terrain_types[j] = terrain_parts[j]; + } + + // Parse stream positions + if (parts[4].length() > 0) { + string[]@ stream_parts = parts[4].split(","); + for (uint j = 0; j < stream_parts.length(); j++) { + mountain.stream_positions.insert_last(parse_int(stream_parts[j])); + } + } + + world_mountains.insert_last(mountain); + } + update_ambience(true); return true; } diff --git a/src/time_system.nvgt b/src/time_system.nvgt index a035a6f..2365e1b 100644 --- a/src/time_system.nvgt +++ b/src/time_system.nvgt @@ -17,7 +17,7 @@ bool crossfade_active = false; bool crossfade_to_night = false; // true = fading to night, false = fading to day timer crossfade_timer; const int CROSSFADE_DURATION = 60000; // 1 minute (1 game hour) -const float CROSSFADE_MIN_VOLUME = -40.0; // dB, effectively silent but not extreme +const float CROSSFADE_MIN_VOLUME = -25.0; // dB, keep overlap audible during crossfade const float CROSSFADE_MAX_VOLUME = 0.0; // dB, full volume // Expansion and invasion tracking @@ -53,6 +53,16 @@ void expand_area() { // Play invasion sound p.play_stationary("sounds/enemies/invasion.ogg", false); + // 25% chance for mountain, 75% for regular expansion + int type_roll = random(0, 3); + if (type_roll == 0) { + expand_mountain(); + } else { + expand_regular_area(); + } +} + +void expand_regular_area() { // Calculate new area int new_start = MAP_SIZE; int new_end = MAP_SIZE + EXPANSION_SIZE - 1; @@ -103,6 +113,30 @@ void expand_area() { notify("The area has expanded! New territory discovered to the east."); } +void expand_mountain() { + int new_start = MAP_SIZE; + int size = MOUNTAIN_SIZE; + int new_end = new_start + size - 1; + + if (expanded_area_start == -1) { + expanded_area_start = new_start; + } + expanded_area_end = new_end; + MAP_SIZE += size; + + // Generate mountain range + MountainRange@ mountain = MountainRange(new_start, size); + world_mountains.insert_last(mountain); + + // Fill terrain types array for compatibility with save system + for (int i = 0; i < size; i++) { + expanded_terrain_types.insert_last("mountain:" + mountain.terrain_types[i]); + } + + area_expanded_today = true; + notify("A mountain range has been discovered to the east!"); +} + void start_invasion() { expand_area(); invasion_active = true; @@ -139,26 +173,34 @@ void schedule_invasion() { } void check_scheduled_invasion() { - if (invasion_active || invasion_triggered_today) return; - if (invasion_scheduled_hour == -1) return; - if (current_hour == invasion_scheduled_hour) { - invasion_scheduled_hour = -1; - invasion_triggered_today = true; - start_invasion(); - } else if (current_hour > 11) { - invasion_scheduled_hour = -1; + if (invasion_active) return; + + // Check scheduled invasion regardless of triggered flag (fixes bug where flag was set early in old saves) + if (invasion_scheduled_hour != -1) { + if (current_hour == invasion_scheduled_hour) { + invasion_scheduled_hour = -1; + invasion_triggered_today = true; + start_invasion(); + } else if (current_hour > 11) { + invasion_scheduled_hour = -1; + } + return; } + + if (invasion_triggered_today) return; } void attempt_daily_invasion() { if (current_day < 2) return; if (invasion_triggered_today || invasion_active) return; + if (invasion_roll_done_today) return; if (current_hour < 6 || current_hour > 12) return; + invasion_roll_done_today = true; + int roll = random(1, 100); if (roll > invasion_chance) return; - invasion_triggered_today = true; schedule_invasion(); check_scheduled_invasion(); } @@ -250,24 +292,30 @@ void attempt_blessing() { favor -= 1.0; if (favor < 0) favor = 0; + string[] god_names = { + "Odin's", "Thor's", "Freyja's", "Loki's", "Tyr's", "Baldur's", + "Frigg's", "Heimdall's", "Hel's", "Fenrir's", "Freyr's", "The gods'" + }; + string god_name = god_names[random(0, god_names.length() - 1)]; + if (choice == 0) { int before = player_health; player_health += BLESSING_HEAL_AMOUNT; if (player_health > max_health) player_health = max_health; int healed = player_health - before; string bonus = (healed > 0) ? "You feel restored. +" + healed + " health." : "You feel restored."; - notify("The gods' favor shines upon you. " + bonus); + notify(god_name + " favor shines upon you. " + bonus); } else if (choice == 1) { blessing_speed_active = true; blessing_speed_timer.restart(); update_max_health_from_equipment(); - notify("The gods' favor shines upon you. You feel swift for a while."); + notify(god_name + " favor shines upon you. You feel swift for a while."); } else if (choice == 2) { int gained = add_barricade_health(BLESSING_BARRICADE_REPAIR); string bonus = (gained > 0) ? "A divine force repairs the barricade. +" + gained + " health." : "A divine force surrounds the barricade."; - notify("The gods' favor shines upon you. " + bonus); + notify(god_name + " favor shines upon you. " + bonus); } } @@ -393,10 +441,11 @@ void update_crossfade() { float progress = float(crossfade_timer.elapsed) / float(CROSSFADE_DURATION); if (progress > 1.0) progress = 1.0; - // Volume interpolation: fade out goes 0 -> -40, fade in goes -40 -> 0 - float volume_range = CROSSFADE_MAX_VOLUME - CROSSFADE_MIN_VOLUME; // 40 dB range - float fade_out_vol = CROSSFADE_MAX_VOLUME - (volume_range * progress); // 0 -> -40 - float fade_in_vol = CROSSFADE_MIN_VOLUME + (volume_range * progress); // -40 -> 0 + // Volume interpolation: use a slow-start curve to make fade-outs more gradual + float volume_range = CROSSFADE_MAX_VOLUME - CROSSFADE_MIN_VOLUME; // dB range + float eased_progress = progress * progress; + float fade_out_vol = CROSSFADE_MAX_VOLUME - (volume_range * eased_progress); // 0 -> min + float fade_in_vol = CROSSFADE_MIN_VOLUME + (volume_range * eased_progress); // min -> 0 if (crossfade_to_night) { // Fading day out, night in diff --git a/src/world_state.nvgt b/src/world_state.nvgt index 492ba4d..c1e0930 100644 --- a/src/world_state.nvgt +++ b/src/world_state.nvgt @@ -114,15 +114,11 @@ class WorldSnare { minute_timer.restart(); } - // Limit snare sound to 2 tiles distance - if (abs(x - position) <= 2) { - if (sound_handle == -1 || !p.sound_is_active(sound_handle)) { - sound_handle = p.play_1d("sounds/actions/set_snare.ogg", x, position, true); - } - } else { + // Keep snare sound active so distance-based fade can work. + if (sound_handle == -1 || !p.sound_is_active(sound_handle)) { + sound_handle = play_1d_tile("sounds/actions/set_snare.ogg", x, position, true); if (sound_handle != -1) { - p.destroy_sound(sound_handle); - sound_handle = -1; + p.update_sound_positioning_values(sound_handle, SNARE_SOUND_PAN_STEP, to_audio_volume_step(SNARE_SOUND_VOLUME_STEP), true); } } @@ -200,13 +196,13 @@ class WorldFire { // Warn when fuel is low (30 seconds remaining) if (!low_fuel_warned && fuel_remaining <= 30000 && fuel_remaining > 0) { low_fuel_warned = true; - notify("Fire at " + position + " is getting low!"); + notify("Fire at x " + position + " y " + y + " is getting low!"); } // Fire went out if (fuel_remaining <= 0) { fuel_remaining = 0; - notify("Fire at " + position + " has gone out."); + notify("Fire at x " + position + " y " + y + " has gone out."); if (sound_handle != -1) { p.destroy_sound(sound_handle); sound_handle = -1; @@ -215,16 +211,12 @@ class WorldFire { } } - // Limit fire sound to 2 tiles distance (only if burning) + // Keep fire sound active while burning so distance-based fade can work. if (is_burning()) { - if (abs(x - position) <= 2) { - if (sound_handle == -1 || !p.sound_is_active(sound_handle)) { - sound_handle = p.play_1d("sounds/items/fire.ogg", x, position, true); - } - } else { + if (sound_handle == -1 || !p.sound_is_active(sound_handle)) { + sound_handle = play_1d_tile("sounds/items/fire.ogg", x, position, true); if (sound_handle != -1) { - p.destroy_sound(sound_handle); - sound_handle = -1; + p.update_sound_positioning_values(sound_handle, -1.0, to_audio_volume_step(FIRE_SOUND_VOLUME_STEP), true); } } } @@ -297,11 +289,13 @@ class WorldStream { int start_position; int end_position; int sound_handle; + int sound_position; WorldStream(int start_pos, int width) { start_position = start_pos; end_position = start_pos + width - 1; sound_handle = -1; + sound_position = -1; } bool contains_position(int pos) { @@ -317,22 +311,25 @@ class WorldStream { } void update() { - int center = get_center_position(); - - // Play stream sound within range of center - if (abs(x - center) <= STREAM_SOUND_RANGE) { - if (sound_handle == -1 || !p.sound_is_active(sound_handle)) { - sound_handle = p.play_1d("sounds/terrain/stream.ogg", x, center, true); - if (sound_handle != -1) { - p.update_sound_positioning_values(sound_handle, -1.0, STREAM_SOUND_VOLUME_STEP, true); - p.update_sound_range_1d(sound_handle, STREAM_SOUND_RANGE, STREAM_SOUND_RANGE); - } - } + int sound_pos = 0; + if (x < start_position) { + sound_pos = start_position; + } else if (x > end_position) { + sound_pos = end_position; } else { + sound_pos = x; + } + + // Keep stream sound active so distance-based fade can work. + if (sound_handle == -1 || !p.sound_is_active(sound_handle)) { + sound_handle = play_1d_tile("sounds/terrain/stream.ogg", x, sound_pos, true); + sound_position = sound_pos; if (sound_handle != -1) { - p.destroy_sound(sound_handle); - sound_handle = -1; + p.update_sound_positioning_values(sound_handle, -1.0, to_audio_volume_step(STREAM_SOUND_VOLUME_STEP), true); } + } else if (sound_position != sound_pos) { + update_sound_1d_tile(sound_handle, sound_pos); + sound_position = sound_pos; } } @@ -341,6 +338,7 @@ class WorldStream { p.destroy_sound(sound_handle); sound_handle = -1; } + sound_position = -1; } } WorldStream@[] world_streams; @@ -1021,3 +1019,225 @@ WorldStream@ get_stream_at(int pos) { bool is_position_in_water(int pos) { return get_stream_at(pos) != null; } + +// Mountain Range Class +class MountainRange { + int start_position; + int end_position; + int[] elevations; + string[] terrain_types; + int[] stream_positions; + int stream_sound_handle; + int stream_sound_position; + + MountainRange(int start_pos, int size) { + start_position = start_pos; + end_position = start_pos + size - 1; + elevations.resize(size); + terrain_types.resize(size); + stream_sound_handle = -1; + stream_sound_position = -1; + generate_terrain(); + } + + void generate_terrain() { + int size = int(elevations.length()); + + // Initialize endpoints at moderate elevations + elevations[0] = random(5, 15); + elevations[size - 1] = random(5, 15); + + // Use midpoint displacement for natural terrain + midpoint_displace(0, size - 1, 25); + + // Clamp values to valid range + for (int i = 0; i < size; i++) { + if (elevations[i] < MOUNTAIN_MIN_ELEVATION) elevations[i] = MOUNTAIN_MIN_ELEVATION; + if (elevations[i] > MOUNTAIN_MAX_ELEVATION) elevations[i] = MOUNTAIN_MAX_ELEVATION; + } + + // Smooth to enforce max slope constraint + smooth_slopes(); + + // Assign terrain types based on elevation + for (int i = 0; i < size; i++) { + if (elevations[i] > 30) { + terrain_types[i] = "snow"; + } else if (elevations[i] > 15) { + terrain_types[i] = "stone"; + } else { + terrain_types[i] = "gravel"; + } + } + + // Place streams in valley areas + place_streams(); + } + + void midpoint_displace(int left, int right, int roughness) { + if (right - left <= 1) return; + + int mid = (left + right) / 2; + int avg = (elevations[left] + elevations[right]) / 2; + int displacement = random(-roughness, roughness); + elevations[mid] = avg + displacement; + + int new_roughness = roughness * 7 / 10; + if (new_roughness < 1) new_roughness = 1; + + midpoint_displace(left, mid, new_roughness); + midpoint_displace(mid, right, new_roughness); + } + + void smooth_slopes() { + bool changed = true; + int iterations = 0; + while (changed && iterations < 100) { + changed = false; + iterations++; + + for (int i = 1; i < int(elevations.length()); i++) { + int diff = elevations[i] - elevations[i-1]; + if (diff > MOUNTAIN_MAX_SLOPE) { + elevations[i] = elevations[i-1] + MOUNTAIN_MAX_SLOPE; + changed = true; + } else if (diff < -MOUNTAIN_MAX_SLOPE) { + elevations[i] = elevations[i-1] - MOUNTAIN_MAX_SLOPE; + changed = true; + } + } + } + } + + void place_streams() { + // Find valley bottoms (local minima) + int[] valleys; + for (int i = 2; i < int(elevations.length()) - 2; i++) { + if (elevations[i] < elevations[i-1] && elevations[i] < elevations[i+1] && + elevations[i] < elevations[i-2] && elevations[i] < elevations[i+2]) { + valleys.insert_last(i); + } + } + + // Place 1-3 streams in valleys + int num_streams = random(1, 3); + if (num_streams > int(valleys.length())) num_streams = int(valleys.length()); + + for (int i = 0; i < num_streams && valleys.length() > 0; i++) { + int idx = random(0, int(valleys.length()) - 1); + stream_positions.insert_last(valleys[idx]); + valleys.remove_at(idx); + } + } + + int get_elevation_at(int world_x) { + if (world_x < start_position || world_x > end_position) return 0; + int index = world_x - start_position; + return elevations[index]; + } + + string get_terrain_at(int world_x) { + if (world_x < start_position || world_x > end_position) return "stone"; + int index = world_x - start_position; + return terrain_types[index]; + } + + int get_elevation_change(int from_x, int to_x) { + int from_elev = get_elevation_at(from_x); + int to_elev = get_elevation_at(to_x); + return to_elev - from_elev; + } + + bool is_steep_section(int from_x, int to_x) { + int change = get_elevation_change(from_x, to_x); + if (change < 0) change = -change; + return change >= MOUNTAIN_STEEP_THRESHOLD; + } + + bool contains_position(int world_x) { + return world_x >= start_position && world_x <= end_position; + } + + bool is_stream_at(int world_x) { + if (!contains_position(world_x)) return false; + int index = world_x - start_position; + for (uint i = 0; i < stream_positions.length(); i++) { + if (stream_positions[i] == index) return true; + } + return false; + } + + void update() { + if (stream_positions.length() == 0) return; + + // Find nearest stream to player + int nearest_stream = -1; + int nearest_distance = 999; + + for (uint i = 0; i < stream_positions.length(); i++) { + int stream_world_x = start_position + stream_positions[i]; + int distance = abs(x - stream_world_x); + if (distance < nearest_distance) { + nearest_distance = distance; + nearest_stream = stream_world_x; + } + } + + // Keep nearest stream sound active so distance-based fade can work. + if (nearest_stream != -1) { + if (stream_sound_handle == -1 || !p.sound_is_active(stream_sound_handle)) { + stream_sound_handle = play_1d_tile("sounds/terrain/stream.ogg", x, nearest_stream, true); + stream_sound_position = nearest_stream; + if (stream_sound_handle != -1) { + p.update_sound_positioning_values(stream_sound_handle, -1.0, to_audio_volume_step(MOUNTAIN_STREAM_VOLUME_STEP), true); + } + } else if (stream_sound_position != nearest_stream) { + update_sound_1d_tile(stream_sound_handle, nearest_stream); + stream_sound_position = nearest_stream; + } + } + } + + void destroy() { + if (stream_sound_handle != -1) { + p.destroy_sound(stream_sound_handle); + stream_sound_handle = -1; + } + stream_sound_position = -1; + } +} +MountainRange@[] world_mountains; + +MountainRange@ get_mountain_at(int world_x) { + for (uint i = 0; i < world_mountains.length(); i++) { + if (world_mountains[i].contains_position(world_x)) { + return @world_mountains[i]; + } + } + return null; +} + +int get_mountain_elevation_at(int world_x) { + MountainRange@ mountain = get_mountain_at(world_x); + if (mountain is null) return 0; + return mountain.get_elevation_at(world_x); +} + +bool is_mountain_stream_at(int world_x) { + MountainRange@ mountain = get_mountain_at(world_x); + if (mountain is null) return false; + return mountain.is_stream_at(world_x); +} + +void update_mountains() { + for (uint i = 0; i < world_mountains.length(); i++) { + world_mountains[i].update(); + } +} + +void clear_mountains() { + for (uint i = 0; i < world_mountains.length(); i++) { + world_mountains[i].destroy(); + } + world_mountains.resize(0); +}