From 8cd2e1d5a9c79b882920d0c01e62b92a6ba8030d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 6 Feb 2026 18:51:36 -0500 Subject: [PATCH] New flying game turkey added. Zombie swarms added. Enemies from areas can now be encountered in those areas they do not leave the area unless invading. --- sounds/game/turkey.ogg | 3 + src/base_system.nvgt | 3 + src/constants.nvgt | 28 ++++++ src/crafting/craft_materials.nvgt | 9 ++ src/enemies/bandit.nvgt | 107 +++++++++++++++++--- src/enemies/flying_creatures.nvgt | 123 +++++++++++++++++++---- src/enemies/undead.nvgt | 71 ++++++++++--- src/environment.nvgt | 45 +++++++-- src/save_system.nvgt | 52 +++++++++- src/time_system.nvgt | 162 ++++++++++++++++++++++++++++++ 10 files changed, 546 insertions(+), 57 deletions(-) create mode 100644 sounds/game/turkey.ogg diff --git a/sounds/game/turkey.ogg b/sounds/game/turkey.ogg new file mode 100644 index 0000000..ccfbb9b --- /dev/null +++ b/sounds/game/turkey.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c697ae6f8afe268bd0034d29f75f5fcd1f2686d37dc2878b96748168b817d44 +size 16742 diff --git a/src/base_system.nvgt b/src/base_system.nvgt index 2d859bc..f213cf7 100644 --- a/src/base_system.nvgt +++ b/src/base_system.nvgt @@ -739,6 +739,9 @@ void attempt_resident_butchering() { meat_yield = 1; feathers_yield = random(3, 6); down_yield = random(1, 3); + } else if (game_type == "turkey") { + meat_yield = 1; + feathers_yield = random(1, 4); } else if (is_boar) { meat_yield = random(2, 3); skins_yield = 3; diff --git a/src/constants.nvgt b/src/constants.nvgt index ede0966..60eae42 100644 --- a/src/constants.nvgt +++ b/src/constants.nvgt @@ -53,6 +53,15 @@ const int ARROW_CAPACITY_PER_QUIVER = 12; const int ZOMBIE_HEALTH = 12; const int ZOMBIE_MAX_COUNT = 5; const int ZOMBIE_MAX_COUNT_CAP = 12; +const int ZOMBIE_SWARM_START_DAY = 4; +const int ZOMBIE_SWARM_INTERVAL_DAYS = 9; +const int ZOMBIE_SWARM_CHANCE_INTERVAL_DAYS = 4; +const int ZOMBIE_SWARM_BASE_DURATION_HOURS = 1; +const int ZOMBIE_SWARM_DURATION_STEP_HOURS = 1; +const int ZOMBIE_SWARM_ZOMBIE_MAX_BONUS = 3; +const int ZOMBIE_SWARM_CHANCE_START = 15; +const int ZOMBIE_SWARM_CHANCE_STEP = 5; +const int ZOMBIE_SWARM_CHANCE_CAP = 95; const int ZOMBIE_MOVE_INTERVAL = 1000; const int ZOMBIE_ATTACK_INTERVAL = 1600; const int ZOMBIE_DAMAGE_MIN = 4; @@ -156,6 +165,8 @@ const int BANDIT_DETECTION_RADIUS = 5; const int BANDIT_WANDER_DIRECTION_CHANGE_MIN = 3000; const int BANDIT_WANDER_DIRECTION_CHANGE_MAX = 8000; const int INVADER_SOUND_VARIANTS_MAX = 5; +const int EXPANSION_ROAMER_SPAWN_INTERVAL_HOURS = 2; +const int EXPANSION_ROAMER_MAX_PER_AREA = 3; // Audio ranges and volume falloff // Formula: final_volume = start_volume - (volume_step × distance) @@ -181,6 +192,7 @@ const float TREE_SOUND_VOLUME_STEP = 4.0; // Similar to snares for good audibil const int TREE_SOUND_RANGE = 4; const int TREE_MIN_DISTANCE = 10; const int TREE_MAX_PER_AREA = 2; +const int TREE_AVOID_STEEP_CLIMB_RANGE = 3; const float RESIDENT_DEFENSE_VOLUME_STEP = 3.0; // Default volume for resident counter-attacks const float PLAYER_WEAPON_SOUND_VOLUME_STEP = 3.0; @@ -237,6 +249,22 @@ const int GOOSE_MAX_COUNT = 3; const int GOOSE_HOURLY_SPAWN_CHANCE = 40; // Percent chance per hour to spawn a goose const int GOOSE_SIGHT_RANGE = 0; +// Turkey settings +const int TURKEY_HEALTH = 1; +const int TURKEY_MOVE_INTERVAL_MIN = 800; +const int TURKEY_MOVE_INTERVAL_MAX = 2000; +const int TURKEY_FLYING_HEIGHT_MIN = 10; +const int TURKEY_FLYING_HEIGHT_MAX = 30; +const float TURKEY_SOUND_VOLUME_STEP = 3.0; +const int TURKEY_FLIGHT_SOUND_DELAY_MIN = 2000; +const int TURKEY_FLIGHT_SOUND_DELAY_MAX = 5000; +const int TURKEY_FALL_SPEED = 100; // ms per foot +const int TURKEY_FLY_AWAY_CHANCE = 0; // Chance out of 1000 per tick to fly away +const int TURKEY_MAX_DIST_FROM_FOREST = 0; // How far they can wander from forest +const int TURKEY_MAX_COUNT = 3; +const int TURKEY_HOURLY_SPAWN_CHANCE = 40; // Percent chance per hour to spawn a turkey +const int TURKEY_SIGHT_RANGE = 0; + // Weather settings const int WEATHER_FADE_DURATION = 8000; // 8 seconds for smooth audio transitions const float WEATHER_MIN_VOLUME = -30.0; diff --git a/src/crafting/craft_materials.nvgt b/src/crafting/craft_materials.nvgt index a4cd227..11e8b11 100644 --- a/src/crafting/craft_materials.nvgt +++ b/src/crafting/craft_materials.nvgt @@ -368,6 +368,10 @@ void butcher_small_game() { add_personal_count(ITEM_FEATHERS, random(3, 6)); add_personal_count(ITEM_DOWN, random(1, 3)); speak_with_history("Butchered goose. Got 1 meat, feathers, and down.", true); + } else if (game_type == "turkey") { + add_personal_count(ITEM_MEAT, 1); + add_personal_count(ITEM_FEATHERS, random(1, 4)); + speak_with_history("Butchered turkey. Got 1 meat and feathers.", true); } else if (game_type == "boar carcass") { add_personal_count(ITEM_MEAT, random(2, 3)); add_personal_count(ITEM_SKINS, 3); @@ -446,6 +450,7 @@ void butcher_small_game_max() { int total_down = 0; int total_sinew = 0; int geese_count = 0; + int turkey_count = 0; int boars_count = 0; for (int i = 0; i < max_craft; i++) { @@ -459,12 +464,16 @@ void butcher_small_game_max() { personal_small_game_types.remove_at(0); add_personal_count(ITEM_SMALL_GAME, -1); if (game_type == "goose") geese_count++; + if (game_type == "turkey") turkey_count++; } if (game_type == "goose") { total_meat++; total_feathers += random(3, 6); total_down += random(1, 3); + } else if (game_type == "turkey") { + total_meat++; + total_feathers += random(1, 4); } else if (game_type == "boar carcass") { total_meat += random(2, 3); total_skins += 3; diff --git a/src/enemies/bandit.nvgt b/src/enemies/bandit.nvgt index 6dfda39..3b9dd22 100644 --- a/src/enemies/bandit.nvgt +++ b/src/enemies/bandit.nvgt @@ -44,6 +44,8 @@ class Bandit { timer move_timer; timer attack_timer; int move_interval; + int home_start; + int home_end; // Wandering behavior properties string behavior_state; // "aggressive" or "wandering" @@ -52,11 +54,25 @@ class Bandit { int wander_direction_change_interval; Bandit(int pos, int expansion_start, int expansion_end, string invader = "bandit") { + int range_start = expansion_start; + int range_end = expansion_end; + if (range_start > range_end) { + int temp = range_start; + range_start = range_end; + range_end = temp; + } + // Spawn somewhere in the expanded area - position = random(expansion_start, expansion_end); + if (pos < range_start || pos > range_end) { + position = random(range_start, range_end); + } else { + position = pos; + } health = BANDIT_HEALTH; sound_handle = -1; invader_type = invader; + home_start = range_start; + home_end = range_end; // Choose random alert sound alert_sound = pick_invader_alert_sound(invader_type); @@ -114,20 +130,68 @@ Bandit@ get_bandit_at(int pos) { return null; } -void spawn_bandit(int expansion_start, int expansion_end, const string&in invader_type = "bandit") { - int spawn_x = -1; - for (int attempts = 0; attempts < 20; attempts++) { - int candidate = random(expansion_start, expansion_end); - if (get_bandit_at(candidate) == null) { - spawn_x = candidate; - break; - } - } - if (spawn_x == -1) { - spawn_x = random(expansion_start, expansion_end); +int pick_bandit_spawn_position(int range_start, int range_end) { + int start = range_start; + int end = range_end; + if (start > end) { + int temp = start; + start = end; + end = temp; } - Bandit@ b = Bandit(spawn_x, expansion_start, expansion_end, invader_type); + for (int attempts = 0; attempts < 20; attempts++) { + int candidate = random(start, end); + if (candidate == x) continue; + if (get_bandit_at(candidate) != null) continue; + return candidate; + } + + for (int candidate = start; candidate <= end; candidate++) { + if (candidate == x) continue; + if (get_bandit_at(candidate) != null) continue; + return candidate; + } + + return -1; +} + +int count_bandits_in_range(int range_start, int range_end) { + int start = range_start; + int end = range_end; + if (start > end) { + int temp = start; + start = end; + end = temp; + } + + int count = 0; + for (uint i = 0; i < bandits.length(); i++) { + if (bandits[i].position >= start && bandits[i].position <= end) { + count++; + } + } + return count; +} + +void spawn_bandit(int expansion_start, int expansion_end, const string&in invader_type = "bandit") { + int spawn_x = pick_bandit_spawn_position(expansion_start, expansion_end); + if (spawn_x == -1) return; + + int home_start = expansion_start; + int home_end = expansion_end; + if (expanded_area_start != -1 && spawn_x >= expanded_area_start) { + int area_start = -1; + int area_end = -1; + if (get_audio_area_bounds_for_position(spawn_x, area_start, area_end)) { + home_start = area_start; + home_end = area_end; + } + } + + Bandit@ b = Bandit(spawn_x, home_start, home_end, invader_type); + if (!invasion_active) { + b.behavior_state = "wandering"; + } bandits.insert_last(b); // Play looping sound that follows the bandit int[] areaStarts; @@ -226,6 +290,15 @@ void try_attack_barricade_bandit(Bandit@ bandit) { } void update_bandit(Bandit@ bandit, bool audio_active) { + bool enforce_home = (!invasion_active && bandit.home_start <= bandit.home_end); + if (enforce_home) { + if (bandit.position < bandit.home_start) { + bandit.position = bandit.home_start; + } else if (bandit.position > bandit.home_end) { + bandit.position = bandit.home_end; + } + } + // Update looping sound position if (!audio_active) { if (bandit.sound_handle != -1) { @@ -277,6 +350,10 @@ void update_bandit(Bandit@ bandit, bool audio_active) { // Check bounds if (target_x >= 0 && target_x < MAP_SIZE) { + if (enforce_home && (target_x < bandit.home_start || target_x > bandit.home_end)) { + bandit.wander_direction = -bandit.wander_direction; + return; + } // Don't wander into base if barricade is up if (target_x <= BASE_END && barricade_health > 0) { // Change direction instead @@ -321,6 +398,10 @@ void update_bandit(Bandit@ bandit, bool audio_active) { int target_x = bandit.position + direction; if (target_x < 0 || target_x >= MAP_SIZE) return; + if (enforce_home && (target_x < bandit.home_start || target_x > bandit.home_end)) { + return; + } + // Don't enter base if barricade is up if (target_x <= BASE_END && barricade_health > 0) { try_attack_barricade_bandit(bandit); diff --git a/src/enemies/flying_creatures.nvgt b/src/enemies/flying_creatures.nvgt index e979cf3..d7d8478 100644 --- a/src/enemies/flying_creatures.nvgt +++ b/src/enemies/flying_creatures.nvgt @@ -2,10 +2,12 @@ // Config-driven system for spawning and managing flying creatures near water string[] goose_sounds = {"sounds/game/goose.ogg"}; +string[] turkey_sounds = {"sounds/game/turkey.ogg"}; class FlyingCreatureConfig { string id; string drop_type; + string spawn_mode; // "water" or "forest" string[] sounds; string fall_sound; string impact_sound; @@ -95,6 +97,7 @@ void init_flying_creature_configs() { FlyingCreatureConfig@ goose_cfg = FlyingCreatureConfig(); goose_cfg.id = "goose"; goose_cfg.drop_type = "goose"; + goose_cfg.spawn_mode = "water"; goose_cfg.sounds = goose_sounds; goose_cfg.fall_sound = "sounds/actions/falling.ogg"; goose_cfg.impact_sound = "sounds/game/game_falls.ogg"; @@ -114,6 +117,30 @@ void init_flying_creature_configs() { goose_cfg.sight_range = GOOSE_SIGHT_RANGE; goose_cfg.flee_on_sight = false; flying_creature_configs.insert_last(goose_cfg); + + FlyingCreatureConfig@ turkey_cfg = FlyingCreatureConfig(); + turkey_cfg.id = "turkey"; + turkey_cfg.drop_type = "turkey"; + turkey_cfg.spawn_mode = "forest"; + turkey_cfg.sounds = turkey_sounds; + turkey_cfg.fall_sound = "sounds/actions/falling.ogg"; + turkey_cfg.impact_sound = "sounds/game/game_falls.ogg"; + turkey_cfg.health = TURKEY_HEALTH; + turkey_cfg.move_interval_min = TURKEY_MOVE_INTERVAL_MIN; + turkey_cfg.move_interval_max = TURKEY_MOVE_INTERVAL_MAX; + turkey_cfg.min_height = TURKEY_FLYING_HEIGHT_MIN; + turkey_cfg.max_height = TURKEY_FLYING_HEIGHT_MAX; + turkey_cfg.sound_volume_step = TURKEY_SOUND_VOLUME_STEP; + turkey_cfg.sound_delay_min = TURKEY_FLIGHT_SOUND_DELAY_MIN; + turkey_cfg.sound_delay_max = TURKEY_FLIGHT_SOUND_DELAY_MAX; + turkey_cfg.fall_speed = TURKEY_FALL_SPEED; + turkey_cfg.fly_away_chance = TURKEY_FLY_AWAY_CHANCE; + turkey_cfg.max_dist_from_water = TURKEY_MAX_DIST_FROM_FOREST; + turkey_cfg.hourly_spawn_chance = TURKEY_HOURLY_SPAWN_CHANCE; + turkey_cfg.max_count = TURKEY_MAX_COUNT; + turkey_cfg.sight_range = TURKEY_SIGHT_RANGE; + turkey_cfg.flee_on_sight = false; + flying_creature_configs.insert_last(turkey_cfg); } FlyingCreatureConfig@ get_flying_creature_config(string creature_type) { @@ -169,30 +196,34 @@ int get_flying_creature_count(string creature_type) { } bool get_random_flying_creature_area(FlyingCreatureConfig@ cfg, int &out area_start, int &out area_end) { - int stream_count = int(world_streams.length()); - int mountain_stream_count = 0; - for (uint i = 0; i < world_mountains.length(); i++) { - mountain_stream_count += int(world_mountains[i].stream_positions.length()); - } - - int total_areas = stream_count + mountain_stream_count; - if (total_areas <= 0) return false; - - int pick = random(0, total_areas - 1); - if (pick < stream_count) { - area_start = world_streams[pick].start_position; - area_end = world_streams[pick].end_position; + if (cfg.spawn_mode == "forest") { + if (!get_random_forest_area(area_start, area_end)) return false; } else { - pick -= stream_count; + int stream_count = int(world_streams.length()); + int mountain_stream_count = 0; for (uint i = 0; i < world_mountains.length(); i++) { - int local_count = int(world_mountains[i].stream_positions.length()); - if (pick < local_count) { - int stream_pos = world_mountains[i].start_position + world_mountains[i].stream_positions[pick]; - area_start = stream_pos; - area_end = stream_pos; - break; + mountain_stream_count += int(world_mountains[i].stream_positions.length()); + } + + int total_areas = stream_count + mountain_stream_count; + if (total_areas <= 0) return false; + + int pick = random(0, total_areas - 1); + if (pick < stream_count) { + area_start = world_streams[pick].start_position; + area_end = world_streams[pick].end_position; + } else { + pick -= stream_count; + for (uint i = 0; i < world_mountains.length(); i++) { + int local_count = int(world_mountains[i].stream_positions.length()); + if (pick < local_count) { + int stream_pos = world_mountains[i].start_position + world_mountains[i].stream_positions[pick]; + area_start = stream_pos; + area_end = stream_pos; + break; + } + pick -= local_count; } - pick -= local_count; } } @@ -203,6 +234,56 @@ bool get_random_flying_creature_area(FlyingCreatureConfig@ cfg, int &out area_st return true; } +bool get_random_forest_area(int &out area_start, int &out area_end) { + if (expanded_area_start == -1) return false; + int total = int(expanded_terrain_types.length()); + if (total <= 0) return false; + + int[] segment_starts; + int[] segment_ends; + int total_tiles = 0; + + int index = 0; + while (index < total) { + string terrain = expanded_terrain_types[index]; + if (terrain.find("mountain:") == 0) { + terrain = terrain.substr(9); + } + + if (terrain == "forest" || terrain == "deep_forest") { + int segment_start = index; + while (index + 1 < total) { + string nextTerrain = expanded_terrain_types[index + 1]; + if (nextTerrain.find("mountain:") == 0) { + nextTerrain = nextTerrain.substr(9); + } + if (nextTerrain != terrain) break; + index++; + } + int segment_end = index; + segment_starts.insert_last(expanded_area_start + segment_start); + segment_ends.insert_last(expanded_area_start + segment_end); + total_tiles += (segment_end - segment_start + 1); + } + index++; + } + + if (total_tiles <= 0) return false; + + int pick = random(0, total_tiles - 1); + for (uint i = 0; i < segment_starts.length(); i++) { + int segment_len = segment_ends[i] - segment_starts[i] + 1; + if (pick < segment_len) { + area_start = segment_starts[i]; + area_end = segment_ends[i]; + return true; + } + pick -= segment_len; + } + + return false; +} + bool find_flying_creature_spawn(FlyingCreatureConfig@ cfg, int &out spawn_x, int &out area_start, int &out area_end) { if (!get_random_flying_creature_area(cfg, area_start, area_end)) return false; diff --git a/src/enemies/undead.nvgt b/src/enemies/undead.nvgt index 26da7cf..0b35805 100644 --- a/src/enemies/undead.nvgt +++ b/src/enemies/undead.nvgt @@ -157,19 +157,35 @@ Undead@ get_undead_at(int pos) { return null; } -void spawn_undead(const string &in undead_type = "zombie") { - int spawn_x = -1; +int pick_undead_spawn_position(int range_start, int range_end) { + int start = range_start; + int end = range_end; + if (start > end) { + int temp = start; + start = end; + end = temp; + } + for (int attempts = 0; attempts < 20; attempts++) { - int candidate = random(BASE_END + 1, MAP_SIZE - 1); - if (get_undead_at(candidate) == null) { - spawn_x = candidate; - break; - } + int candidate = random(start, end); + if (candidate == x) continue; + if (get_undead_at(candidate) != null) continue; + return candidate; } - if (spawn_x == -1) { - spawn_x = random(BASE_END + 1, MAP_SIZE - 1); + + for (int candidate = start; candidate <= end; candidate++) { + if (candidate == x) continue; + if (get_undead_at(candidate) != null) continue; + return candidate; } + return -1; +} + +void spawn_undead(const string &in undead_type = "zombie") { + int spawn_x = pick_undead_spawn_position(BASE_END + 1, MAP_SIZE - 1); + if (spawn_x == -1) return; + Undead@ undead = Undead(spawn_x, undead_type); undeads.insert_last(undead); // Play looping sound that follows the undead @@ -354,8 +370,16 @@ void update_undead(Undead@ undead, bool audio_active) { return; } } else { - direction = random(-1, 1); - if (direction == 0) return; + if (zombie_swarm_active && undead.undead_type == "zombie") { + if (undead.position > BASE_END + 1) { + direction = -1; + } else { + return; + } + } else { + direction = random(-1, 1); + if (direction == 0) return; + } } int target_x = undead.position + direction; @@ -374,16 +398,31 @@ void update_undead(Undead@ undead, bool audio_active) { void update_undeads() { ensure_undead_range_audio_registration(); - if (is_daytime) { + if (is_daytime && !zombie_swarm_active) { clear_undeads(); return; } + if (is_daytime && zombie_swarm_active) { + for (int i = int(undeads.length()) - 1; i >= 0; i--) { + if (undeads[i].undead_type != "zombie") { + if (undeads[i].sound_handle != -1) { + p.destroy_sound(undeads[i].sound_handle); + undeads[i].sound_handle = -1; + } + undeads.remove_at(i); + } + } + } + int extra = 0; if (MAP_SIZE > 35) { extra = (MAP_SIZE - 35) / 15; } int maxCount = ZOMBIE_MAX_COUNT + extra; + if (zombie_swarm_active) { + maxCount += ZOMBIE_SWARM_ZOMBIE_MAX_BONUS; + } if (maxCount > ZOMBIE_MAX_COUNT_CAP) maxCount = ZOMBIE_MAX_COUNT_CAP; int zombie_count = 0; @@ -401,9 +440,11 @@ void update_undeads() { zombie_count++; } - while (undead_resident_count < undead_residents_count) { - spawn_undead("undead_resident"); - undead_resident_count++; + if (!is_daytime) { + while (undead_resident_count < undead_residents_count) { + spawn_undead("undead_resident"); + undead_resident_count++; + } } int[] areaStarts; diff --git a/src/environment.nvgt b/src/environment.nvgt index a0218c2..23a2bec 100644 --- a/src/environment.nvgt +++ b/src/environment.nvgt @@ -316,12 +316,37 @@ int count_trees_in_area(int areaStart, int areaEnd, Tree@ ignoreTree) { return count; } +bool is_near_required_climb(int pos, int radius) { + MountainRange@ mountain = get_mountain_at(pos); + if (mountain is null) return false; + + int startPos = mountain.start_position; + int endPos = mountain.end_position; + int edgeStart = pos - radius - 1; + int edgeEnd = pos + radius; + + if (edgeStart < startPos) edgeStart = startPos; + if (edgeEnd > endPos - 1) edgeEnd = endPos - 1; + + for (int xPos = edgeStart; xPos <= edgeEnd; xPos++) { + if (mountain.is_steep_section(xPos, xPos + 1)) { + return true; + } + } + + return false; +} + bool tree_too_close_in_area(int pos, int areaStart, int areaEnd, Tree@ ignoreTree) { // Keep trees away from the base edge if (pos < BASE_END + 5) { return true; } + if (is_near_required_climb(pos, TREE_AVOID_STEEP_CLIMB_RANGE)) { + return true; + } + for (uint i = 0; i < trees.length(); i++) { if (@trees[i] is ignoreTree) continue; if (trees[i].position < areaStart || trees[i].position > areaEnd) continue; @@ -883,14 +908,19 @@ void start_climbing_tree(int target_x) { return; } + int ground_elevation = get_mountain_elevation_at(target_x); climbing = true; - climb_target_y = tree.height; + climb_target_y = ground_elevation + tree.height; climb_timer.restart(); speak_with_history("Started climbing tree. Height is " + tree.height + " feet.", true); } void update_climbing() { if (!climbing) return; + if (y == climb_target_y) { + climbing = false; + return; + } // Climb at 1 foot per 500ms if (climb_timer.elapsed > 500) { @@ -903,7 +933,9 @@ void update_climbing() { if (y >= climb_target_y) { climbing = false; - speak_with_history("Reached the top at " + y + " feet.", true); + int ground_elevation = get_mountain_elevation_at(x); + int height_above_ground = y - ground_elevation; + speak_with_history("Reached the top at " + height_above_ground + " feet.", true); } } // Climbing down @@ -911,9 +943,9 @@ void update_climbing() { y--; p.play_stationary("sounds/actions/climb_tree.ogg", false); - if (y <= 0) { + if (y <= climb_target_y) { climbing = false; - y = 0; + y = climb_target_y; speak_with_history("Safely reached the ground.", true); } } @@ -921,10 +953,11 @@ void update_climbing() { } void climb_down_tree() { - if (y == 0 || climbing) return; + int ground_elevation = get_mountain_elevation_at(x); + if (y == ground_elevation || climbing) return; climbing = true; - climb_target_y = 0; + climb_target_y = ground_elevation; climb_timer.restart(); speak_with_history("Climbing down.", true); } diff --git a/src/save_system.nvgt b/src/save_system.nvgt index 77901c0..1df9555 100644 --- a/src/save_system.nvgt +++ b/src/save_system.nvgt @@ -625,6 +625,13 @@ void reset_game_state() { invasion_triggered_today = false; invasion_roll_done_today = false; invasion_scheduled_hour = -1; + invasion_started_once = false; + zombie_swarm_active = false; + zombie_swarm_start_hour = -1; + zombie_swarm_scheduled_hour = -1; + zombie_swarm_triggered_today = false; + zombie_swarm_roll_done_today = false; + zombie_swarm_duration_hours = 0; quest_roll_done_today = false; quest_queue.resize(0); playerItemBreakChance = PLAYER_ITEM_BREAK_CHANCE_MIN; @@ -687,7 +694,7 @@ string serialize_stream(WorldStream@ stream) { } string serialize_bandit(Bandit@ bandit) { - return bandit.position + "|" + bandit.health + "|" + bandit.weapon_type + "|" + bandit.behavior_state + "|" + bandit.wander_direction + "|" + bandit.move_interval + "|" + bandit.invader_type; + return bandit.position + "|" + bandit.health + "|" + bandit.weapon_type + "|" + bandit.behavior_state + "|" + bandit.wander_direction + "|" + bandit.move_interval + "|" + bandit.invader_type + "|" + bandit.home_start + "|" + bandit.home_end; } string serialize_mountain(MountainRange@ mountain) { @@ -879,6 +886,13 @@ bool save_game_state() { saveData.set("time_invasion_roll_done_today", invasion_roll_done_today); saveData.set("time_invasion_scheduled_hour", invasion_scheduled_hour); saveData.set("time_invasion_enemy_type", invasion_enemy_type); + saveData.set("time_invasion_started_once", invasion_started_once); + saveData.set("time_zombie_swarm_active", zombie_swarm_active); + saveData.set("time_zombie_swarm_start_hour", zombie_swarm_start_hour); + saveData.set("time_zombie_swarm_scheduled_hour", zombie_swarm_scheduled_hour); + saveData.set("time_zombie_swarm_triggered_today", zombie_swarm_triggered_today); + saveData.set("time_zombie_swarm_roll_done_today", zombie_swarm_roll_done_today); + saveData.set("time_zombie_swarm_duration_hours", zombie_swarm_duration_hours); saveData.set("player_item_break_chance", playerItemBreakChance); saveData.set("player_item_breaks_today", playerItemBreaksToday); saveData.set("player_item_break_pending", playerItemBreakPending); @@ -1306,10 +1320,29 @@ bool load_game_state_from_file(const string&in filename) { if (invasion_enemy_type == "") { invasion_enemy_type = "bandit"; } + bool loaded_invasion_started = false; + if (saveData.get("time_invasion_started_once", loaded_invasion_started)) { + invasion_started_once = loaded_invasion_started; + } else { + invasion_started_once = (expanded_area_start != -1); + } + zombie_swarm_active = get_bool(saveData, "time_zombie_swarm_active", false); + zombie_swarm_start_hour = int(get_number(saveData, "time_zombie_swarm_start_hour", -1)); + zombie_swarm_scheduled_hour = int(get_number(saveData, "time_zombie_swarm_scheduled_hour", -1)); + zombie_swarm_triggered_today = get_bool(saveData, "time_zombie_swarm_triggered_today", false); + zombie_swarm_roll_done_today = get_bool(saveData, "time_zombie_swarm_roll_done_today", false); + zombie_swarm_duration_hours = int(get_number(saveData, "time_zombie_swarm_duration_hours", 0)); if (invasion_chance < 0) invasion_chance = 0; if (invasion_chance > 100) invasion_chance = 100; if (invasion_scheduled_hour < -1) invasion_scheduled_hour = -1; if (invasion_scheduled_hour > 23) invasion_scheduled_hour = -1; + if (zombie_swarm_start_hour < -1 || zombie_swarm_start_hour > 23) zombie_swarm_start_hour = -1; + if (zombie_swarm_scheduled_hour < -1 || zombie_swarm_scheduled_hour > 23) zombie_swarm_scheduled_hour = -1; + if (zombie_swarm_duration_hours < 0) zombie_swarm_duration_hours = 0; + if (!zombie_swarm_active) { + zombie_swarm_start_hour = -1; + zombie_swarm_duration_hours = 0; + } playerItemBreakChance = float(get_number(saveData, "player_item_break_chance", PLAYER_ITEM_BREAK_CHANCE_MIN)); playerItemBreaksToday = int(get_number(saveData, "player_item_breaks_today", 0)); playerItemBreakPending = get_bool(saveData, "player_item_break_pending", false); @@ -1484,13 +1517,28 @@ bool load_game_state_from_file(const string&in filename) { int wander_dir = parse_int(parts[4]); int move_int = parse_int(parts[5]); string invader_type = "bandit"; + int home_start = pos; + int home_end = pos; if (parts.length() >= 7) { invader_type = parts[6]; if (invader_type == "") invader_type = "bandit"; } + if (parts.length() >= 9) { + home_start = parse_int(parts[7]); + home_end = parse_int(parts[8]); + } else { + if (expanded_area_start != -1 && pos >= expanded_area_start) { + int area_start = -1; + int area_end = -1; + if (get_audio_area_bounds_for_position(pos, area_start, area_end)) { + home_start = area_start; + home_end = area_end; + } + } + } // Create bandit with dummy expansion area (position will be overridden) - Bandit@ b = Bandit(pos, pos, pos, invader_type); + Bandit@ b = Bandit(pos, home_start, home_end, invader_type); b.position = pos; b.health = health; b.weapon_type = weapon; diff --git a/src/time_system.nvgt b/src/time_system.nvgt index 11bf873..2825c86 100644 --- a/src/time_system.nvgt +++ b/src/time_system.nvgt @@ -38,6 +38,15 @@ bool invasion_triggered_today = false; bool invasion_roll_done_today = false; int invasion_scheduled_hour = -1; string invasion_enemy_type = "bandit"; +bool invasion_started_once = false; + +// Zombie swarm tracking +bool zombie_swarm_active = false; +int zombie_swarm_start_hour = -1; +int zombie_swarm_scheduled_hour = -1; +bool zombie_swarm_triggered_today = false; +bool zombie_swarm_roll_done_today = false; +int zombie_swarm_duration_hours = 0; // Invasion mapping: "terrain=enemy" (defaults to bandits when no match) // Terrain keys: "mountain" for mountain ranges, or regular types like "grass", "snow", "forest", "deep_forest", "stone". @@ -61,6 +70,13 @@ void init_time() { invasion_roll_done_today = false; invasion_scheduled_hour = -1; invasion_enemy_type = "bandit"; + invasion_started_once = false; + zombie_swarm_active = false; + zombie_swarm_start_hour = -1; + zombie_swarm_scheduled_hour = -1; + zombie_swarm_triggered_today = false; + zombie_swarm_roll_done_today = false; + zombie_swarm_duration_hours = 0; reset_player_item_break_state(); update_ambience(true); // Force start } @@ -311,6 +327,7 @@ void start_invasion() { invasion_active = true; invasion_start_hour = current_hour; invasion_enemy_type = get_invasion_enemy_type_for_terrain(expansion_terrain); + invasion_started_once = true; string source = (expansion_terrain == "mountain") ? "the mountains" : "the new area"; string enemy_plural = get_invasion_enemy_plural(invasion_enemy_type); notify(enemy_plural + " are invading from " + source + "!"); @@ -377,6 +394,144 @@ void attempt_daily_invasion() { check_scheduled_invasion(); } +bool can_roll_zombie_swarm_today() { + return current_day >= ZOMBIE_SWARM_START_DAY; +} + +int get_zombie_swarm_duration_hours() { + int offset = current_day - ZOMBIE_SWARM_START_DAY; + if (offset < 0) offset = 0; + int cycles = offset / ZOMBIE_SWARM_INTERVAL_DAYS; + int duration = ZOMBIE_SWARM_BASE_DURATION_HOURS + (cycles * ZOMBIE_SWARM_DURATION_STEP_HOURS); + if (duration < 1) duration = 1; + return duration; +} + +int get_zombie_swarm_chance_for_day() { + int offset = current_day - ZOMBIE_SWARM_START_DAY; + if (offset < 0) offset = 0; + int cycles = offset / ZOMBIE_SWARM_CHANCE_INTERVAL_DAYS; + int chance = ZOMBIE_SWARM_CHANCE_START + (cycles * ZOMBIE_SWARM_CHANCE_STEP); + if (chance > ZOMBIE_SWARM_CHANCE_CAP) chance = ZOMBIE_SWARM_CHANCE_CAP; + if (chance < 0) chance = 0; + return chance; +} + +void start_zombie_swarm() { + zombie_swarm_active = true; + zombie_swarm_start_hour = current_hour; + zombie_swarm_duration_hours = get_zombie_swarm_duration_hours(); + speak_with_history("A swarm of zombies has been spotted.", true); +} + +void end_zombie_swarm() { + zombie_swarm_active = false; + zombie_swarm_start_hour = -1; + zombie_swarm_duration_hours = 0; +} + +void check_zombie_swarm_status() { + if (!zombie_swarm_active) return; + int hours_elapsed = current_hour - zombie_swarm_start_hour; + if (hours_elapsed < 0) { + hours_elapsed += 24; + } + if (hours_elapsed >= zombie_swarm_duration_hours) { + end_zombie_swarm(); + } +} + +void schedule_zombie_swarm() { + if (zombie_swarm_scheduled_hour != -1) return; + int hour = get_random_invasion_hour(current_hour); + if (hour == -1) return; + zombie_swarm_scheduled_hour = hour; +} + +void check_scheduled_zombie_swarm() { + if (zombie_swarm_active) return; + if (zombie_swarm_scheduled_hour != -1) { + if (current_hour == zombie_swarm_scheduled_hour) { + zombie_swarm_scheduled_hour = -1; + zombie_swarm_triggered_today = true; + start_zombie_swarm(); + } else if (current_hour > 11) { + zombie_swarm_scheduled_hour = -1; + } + return; + } + if (zombie_swarm_triggered_today) return; +} + +void attempt_daily_zombie_swarm() { + if (!can_roll_zombie_swarm_today()) return; + if (zombie_swarm_roll_done_today || zombie_swarm_triggered_today || zombie_swarm_active) return; + if (current_hour < 6) return; + + zombie_swarm_roll_done_today = true; + int chance = get_zombie_swarm_chance_for_day(); + int roll = random(1, 100); + if (roll > chance) { + return; + } + + if (current_hour > 11) { + zombie_swarm_triggered_today = true; + start_zombie_swarm(); + return; + } + + schedule_zombie_swarm(); + check_scheduled_zombie_swarm(); +} + +void get_expanded_area_segments(int[]@ areaStarts, int[]@ areaEnds, string[]@ areaTypes) { + areaStarts.resize(0); + areaEnds.resize(0); + areaTypes.resize(0); + if (expanded_area_start == -1) return; + int total = int(expanded_terrain_types.length()); + if (total <= 0) return; + + string current_type = get_expanded_area_type(0); + int segment_start = 0; + for (int i = 1; i < total; i++) { + string segment_type = get_expanded_area_type(i); + if (segment_type != current_type) { + areaStarts.insert_last(expanded_area_start + segment_start); + areaEnds.insert_last(expanded_area_start + i - 1); + areaTypes.insert_last(current_type); + segment_start = i; + current_type = segment_type; + } + } + + areaStarts.insert_last(expanded_area_start + segment_start); + areaEnds.insert_last(expanded_area_start + total - 1); + areaTypes.insert_last(current_type); +} + +void attempt_expansion_roamer_spawn() { + if (!is_daytime) return; + if (invasion_active) return; + if (!invasion_started_once) return; + if (expanded_area_start == -1) return; + if (current_hour % EXPANSION_ROAMER_SPAWN_INTERVAL_HOURS != 0) return; + + int[] areaStarts; + int[] areaEnds; + string[] areaTypes; + get_expanded_area_segments(areaStarts, areaEnds, areaTypes); + if (areaStarts.length() == 0) return; + + for (uint i = 0; i < areaStarts.length(); i++) { + int count = count_bandits_in_range(areaStarts[i], areaEnds[i]); + if (count >= EXPANSION_ROAMER_MAX_PER_AREA) continue; + string invader_type = get_invasion_enemy_type_for_terrain(areaTypes[i]); + spawn_bandit(areaStarts[i], areaEnds[i], invader_type); + } +} + void attempt_resident_recruitment() { if (barricade_health <= 0) { return; @@ -561,6 +716,9 @@ void update_time() { invasion_triggered_today = false; invasion_roll_done_today = false; invasion_scheduled_hour = -1; + zombie_swarm_triggered_today = false; + zombie_swarm_scheduled_hour = -1; + zombie_swarm_roll_done_today = false; quest_roll_done_today = false; playerItemBreaksToday = 0; } @@ -585,6 +743,7 @@ void update_time() { // Check invasion status check_invasion_status(); + check_zombie_swarm_status(); check_ambience_transition(); // Safety: if crossfade failed or was skipped, align day/night with the current hour. @@ -629,6 +788,7 @@ void update_time() { attempt_resident_foraging(); } attempt_daily_invasion(); + attempt_daily_zombie_swarm(); keep_base_fires_fed(); attempt_player_item_break_check(); update_incense_burning(); @@ -637,6 +797,8 @@ void update_time() { attempt_hourly_wight_spawn(); attempt_hourly_vampyr_spawn(); check_scheduled_invasion(); + check_scheduled_zombie_swarm(); + attempt_expansion_roamer_spawn(); attempt_blessing(); check_weather_transition(); attempt_resident_collection();