From fc4b8d244baae63d380e44afcf0213cbdbe7bb52 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 31 Jan 2026 12:56:36 -0500 Subject: [PATCH] Multiple characters allowed. Starting base health lowered. Fylgjr system implemented. --- .gitignore | 2 +- AGENTS.md | 4 + draugnorak.nvgt | 63 ++-- sounds/enemies/zombie_hits_player.ogg | 3 - sounds/environment/tree.ogg | 4 +- sounds/player_female_damage.ogg | 3 + sounds/player_male_damage.ogg | 3 + src/audio_utils.nvgt | 14 + src/base_system.nvgt | 4 +- src/bosses/adventure_combat.nvgt | 144 +++++++++ src/bosses/adventure_system.nvgt | 1 + src/bosses/unicorn/unicorn_boss.nvgt | 270 +++++++++++++--- src/combat.nvgt | 19 +- src/constants.nvgt | 7 +- src/enemies/bandit.nvgt | 6 +- src/enemies/ground_game.nvgt | 4 +- src/enemies/undead.nvgt | 4 +- src/environment.nvgt | 3 + src/fylgja_system.nvgt | 308 +++++++++++++++++++ src/inventory_items.nvgt | 28 +- src/menus/character_info.nvgt | 4 + src/menus/equipment_menu.nvgt | 3 + src/player.nvgt | 4 +- src/save_system.nvgt | 424 +++++++++++++++++++++++++- src/time_system.nvgt | 23 +- src/ui.nvgt | 2 +- 26 files changed, 1248 insertions(+), 106 deletions(-) delete mode 100644 sounds/enemies/zombie_hits_player.ogg create mode 100644 sounds/player_female_damage.ogg create mode 100644 sounds/player_male_damage.ogg create mode 100644 src/bosses/adventure_combat.nvgt create mode 100644 src/fylgja_system.nvgt diff --git a/.gitignore b/.gitignore index 9b1bd3e..236e223 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ lib_mac/ lib_windows/ stub/ include/ -save.dat +*.dat *.bak *.wav *.opus diff --git a/AGENTS.md b/AGENTS.md index f2a3a13..49632a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # Draugnorak Project Guidelines +## Engine Reference + +- If unsure about NVGT behavior or API details, consult the engine source in `nvgt-git/`. + ## Asset Usage **CRITICAL**: The `bloodshed/` directory is for reference only. DO NOT use any assets from the bloodshed directory in this game. It exists solely to help with coding examples and understanding patterns, but none of its files should be included or referenced in the actual game code. diff --git a/draugnorak.nvgt b/draugnorak.nvgt index b08866d..8ae8b1e 100755 --- a/draugnorak.nvgt +++ b/draugnorak.nvgt @@ -27,6 +27,7 @@ sound_pool p(300); #include "src/quest_system.nvgt" #include "src/environment.nvgt" #include "src/combat.nvgt" +#include "src/fylgja_system.nvgt" #include "src/save_system.nvgt" #include "src/base_system.nvgt" #include "src/time_system.nvgt" @@ -43,7 +44,7 @@ int run_main_menu() { speak_with_history("Draugnorak. Main menu.", true); int selection = 0; - string load_label = has_save_game() ? "Load Game" : "Load Game (no save found)"; + string load_label = has_save_game() ? "Load Game" : "Load Game (no saves found)"; string[] options = {"New Game", load_label, "Exit"}; speak_with_history(options[selection], true); @@ -75,7 +76,7 @@ int run_main_menu() { return 2; } -void main() +void run_game() { // Configure sound pool for better spatial audio p.volume_step = AUDIO_VOLUME_STEP / float(AUDIO_TILE_SCALE); // Default falloff in audio units @@ -91,28 +92,28 @@ void main() int selection = run_main_menu(); if (selection == 0) { - // Check if save file exists and confirm before overwriting - if (has_save_game()) { - int confirm = ui_question("", "Save found. Are you sure you want to start a new game?"); - if (confirm != 1) { - continue; // Return to main menu - } + if (!setup_new_character()) { + continue; } start_new_game(); speak_with_history("New game started.", true); game_started = true; } else if (selection == 1) { - if (load_game_state()) { + if (!has_save_game()) { + ui_info_box("Draugnorak", "Load Game", "No saves found."); + continue; + } + string selectedFile; + if (!select_save_file(selectedFile)) { + continue; + } + if (load_game_state_from_file(selectedFile)) { speak_with_history("Game loaded.", true); game_started = true; } else { - if (has_save_game()) { - string message = last_save_error; - if (message == "") message = "Unable to load save."; - ui_info_box("Draugnorak", "Load Game", message); - } else { - ui_info_box("Draugnorak", "Load Game", "No save found."); - } + string message = last_save_error; + if (message == "") message = "Unable to load save."; + ui_info_box("Draugnorak", "Load Game", message); } } else { exit(); @@ -198,6 +199,11 @@ void main() } } + if (fylgjaCharging) { + update_fylgja_charge(); + continue; + } + // Inventory & Actions check_inventory_keys(x); check_action_menu(x); @@ -206,6 +212,7 @@ void main() check_altar_menu(x); check_equipment_menu(); check_quest_menu(); + check_fylgja_menu(); check_quick_slot_keys(); check_time_input(); check_notification_keys(); @@ -304,6 +311,9 @@ void main() movetime = jumping ? jump_speed : walk_speed; + bool left_active = key_down(KEY_LEFT); + bool right_active = key_down(KEY_RIGHT); + // Movement Logic if (key_pressed(KEY_LEFT) && facing != 0 && !climbing && !falling && !rope_climbing) { facing = 0; @@ -328,13 +338,13 @@ void main() MountainRange@ current_mountain = get_mountain_at(x); Tree@ current_tree = get_tree_at(x); int ground_elevation = get_mountain_elevation_at(x); - if((key_down(KEY_LEFT) || key_down(KEY_RIGHT)) && y > ground_elevation && !jumping && !falling && !rope_climbing && current_mountain is null && current_tree !is null) { + if((left_active || right_active) && y > ground_elevation && !jumping && !falling && !rope_climbing && current_mountain is null && current_tree !is null) { // Fall out of tree climbing = false; start_falling(); } - if(key_down(KEY_LEFT) && x > 0 && !climbing && !falling && !rope_climbing) + if(left_active && x > 0 && !climbing && !falling && !rope_climbing) { facing = 0; int target_x = x - 1; @@ -353,7 +363,7 @@ void main() } } } - else if(key_down(KEY_RIGHT) && x < MAP_SIZE - 1 && !climbing && !falling && !rope_climbing) + else if(right_active && x < MAP_SIZE - 1 && !climbing && !falling && !rope_climbing) { facing = 1; int target_x = x + 1; @@ -384,10 +394,7 @@ void main() // Searching Logic bool shift_down = (key_down(KEY_LSHIFT) || key_down(KEY_RSHIFT)); - if (!shift_down) { - if (searching) { - searching = false; - } + if (!shift_down && !searching) { search_timer.restart(); } // Apply rune gathering bonus to search time @@ -475,3 +482,13 @@ void main() p.update_listener_1d(x); } } + +void main() +{ + try { + run_game(); + } catch { + log_unhandled_exception("main"); + ui_info_box("Draugnorak", "Unhandled exception", "A crash log was written to crash.log."); + } +} diff --git a/sounds/enemies/zombie_hits_player.ogg b/sounds/enemies/zombie_hits_player.ogg deleted file mode 100644 index 7a5d56d..0000000 --- a/sounds/enemies/zombie_hits_player.ogg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5688157b3e6ca6d9b59efac68fe4c0a4999e59ed368b6e382233afccc700372b -size 5959 diff --git a/sounds/environment/tree.ogg b/sounds/environment/tree.ogg index 5531465..2c7f348 100644 --- a/sounds/environment/tree.ogg +++ b/sounds/environment/tree.ogg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be868d7d00f75739ae0dcde9fea50fb3b1aa012230ff49d6be9485a45b11a01c -size 17496 +oid sha256:5bf53c7165b18c36d339099f1358b1d44daa107db9f37c81e5df3513a3065a83 +size 13208 diff --git a/sounds/player_female_damage.ogg b/sounds/player_female_damage.ogg new file mode 100644 index 0000000..44b2954 --- /dev/null +++ b/sounds/player_female_damage.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fbe783ef1cec332517c070cae4d139909fe5aff891d3d92a484d92a442c47d4 +size 10341 diff --git a/sounds/player_male_damage.ogg b/sounds/player_male_damage.ogg new file mode 100644 index 0000000..9782ff2 --- /dev/null +++ b/sounds/player_male_damage.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2869ab9063bc43414e2703f27ff1ddbeeba085e8c8f696102bcfe734b1b69009 +size 10420 diff --git a/src/audio_utils.nvgt b/src/audio_utils.nvgt index 9e51838..c088b9d 100644 --- a/src/audio_utils.nvgt +++ b/src/audio_utils.nvgt @@ -181,6 +181,20 @@ void play_item_collect_sound(string itemName) } } +string get_player_damage_sound() { + if (player_sex == SEX_FEMALE) { + return "sounds/player_female_damage.ogg"; + } + return "sounds/player_male_damage.ogg"; +} + +void play_player_damage_sound() { + string soundFile = get_player_damage_sound(); + if (file_exists(soundFile)) { + p.play_stationary(soundFile, false); + } +} + // Safe sound handle cleanup - checks if handle is valid and sound is active before destroying void safe_destroy_sound(int &inout handle) { diff --git a/src/base_system.nvgt b/src/base_system.nvgt index 7ac1f2d..66cd885 100644 --- a/src/base_system.nvgt +++ b/src/base_system.nvgt @@ -473,7 +473,7 @@ void attempt_resident_snare_retrieval() { } } -// Resident butchering - processes up to two games per day when blessed +// Resident butchering - processes up to residents_count games per day (doubled when blessed) void attempt_resident_butchering() { // Need residents if (residents_count <= 0) return; @@ -490,7 +490,7 @@ void attempt_resident_butchering() { // Need a fire in base if (!has_burning_fire_in_base()) return; - int attempts = get_resident_effect_multiplier(); + int attempts = residents_count * get_resident_effect_multiplier(); int break_chance = get_resident_break_chance(RESIDENT_TOOL_BREAK_CHANCE); for (int attempt = 0; attempt < attempts; attempt++) { // Need game in storage diff --git a/src/bosses/adventure_combat.nvgt b/src/bosses/adventure_combat.nvgt new file mode 100644 index 0000000..d7ba40f --- /dev/null +++ b/src/bosses/adventure_combat.nvgt @@ -0,0 +1,144 @@ +// Shared combat helpers for adventures + +const int ADVENTURE_WEAPON_BOW = 1; +const int ADVENTURE_WEAPON_SLING = 2; +const int ADVENTURE_WEAPON_SPEAR = 3; +const int ADVENTURE_WEAPON_AXE = 4; + +funcdef int AdventureRangedReleaseCallback(int player_x, int direction, int range, int weapon_type, int damage); + +bool adventure_arrow_recover_pending = false; + +void reset_adventure_combat_state() { + bow_drawing = false; + sling_charging = false; + adventure_arrow_recover_pending = false; + stop_bow_shot_audio(); + safe_destroy_sound(sling_sound_handle); + last_sling_stage = -1; +} + +void update_weapon_range_audio_with_listener(int listener_x, int creature_pos, bool &inout was_in_range) { + int range = get_current_weapon_range(); + bool in_range = (range >= 0) && (abs(creature_pos - listener_x) <= range); + if (in_range && !was_in_range) { + play_weapon_range_sound("sounds/enemies/enter_range.ogg", creature_pos); + } else if (!in_range && was_in_range) { + play_weapon_range_sound("sounds/enemies/exit_range.ogg", creature_pos); + } + was_in_range = in_range; +} + +void adventure_start_bow_shot_audio(int listener_x, int start_x, int end_x, int hit_x, int duration_ms) { + stop_bow_shot_audio(); + bow_shot_active = true; + bow_shot_timer.restart(); + bow_shot_start_x = start_x; + bow_shot_end_x = end_x; + bow_shot_hit_x = hit_x; + bow_shot_duration_ms = duration_ms; + if (bow_shot_duration_ms < 1) bow_shot_duration_ms = 1; + + bow_shot_sound_handle = play_1d_with_volume_step( + "sounds/weapons/arrow_flies.ogg", + listener_x, + bow_shot_start_x, + false, + PLAYER_WEAPON_SOUND_VOLUME_STEP + ); +} + +void adventure_update_bow_shot(int listener_x) { + if (!bow_shot_active) return; + if (bow_shot_duration_ms < 1) bow_shot_duration_ms = 1; + + int elapsed = bow_shot_timer.elapsed; + float progress = float(elapsed) / float(bow_shot_duration_ms); + if (progress > 1.0f) progress = 1.0f; + + int travel = int(float(bow_shot_end_x - bow_shot_start_x) * progress); + int current_pos = bow_shot_start_x + travel; + if (bow_shot_sound_handle != -1) { + p.update_sound_1d(bow_shot_sound_handle, current_pos); + } + + if (elapsed >= bow_shot_duration_ms) { + int hit_x = bow_shot_hit_x; + bool recover_pending = adventure_arrow_recover_pending; + stop_bow_shot_audio(); + adventure_arrow_recover_pending = false; + if (hit_x >= 0) { + play_1d_with_volume_step( + "sounds/weapons/arrow_hit.ogg", + listener_x, + hit_x, + false, + PLAYER_WEAPON_SOUND_VOLUME_STEP + ); + } + if (recover_pending) { + add_personal_count(ITEM_ARROWS, 1); + } + } +} + +void adventure_release_bow_attack(int player_x, int player_facing, AdventureRangedReleaseCallback@ ranged_callback) { + if (get_personal_count(ITEM_ARROWS) <= 0) { + speak_ammo_blocked("No arrows."); + return; + } + + int damage = get_bow_draw_damage(bow_draw_timer.elapsed); + add_personal_count(ITEM_ARROWS, -1); + p.play_stationary("sounds/weapons/bow_fire.ogg", false); + + int search_direction = (player_facing == 1) ? 1 : -1; + int target_x = -1; + if (@ranged_callback !is null) { + target_x = ranged_callback(player_x, search_direction, BOW_RANGE, ADVENTURE_WEAPON_BOW, damage); + } + + int end_x = (target_x != -1) + ? target_x + : (player_x + (search_direction * (BOW_RANGE + BOW_MISS_EXTRA_TILES))); + + int duration_ms = ARROW_FLIES_DURATION_MS; + if (target_x != -1) { + int distance = abs(target_x - player_x); + if (distance < 1) distance = 1; + duration_ms = int(float(ARROW_FLIES_DURATION_MS) * (float(distance) / float(BOW_RANGE))); + if (duration_ms < 1) duration_ms = 1; + } + + adventure_arrow_recover_pending = (random(1, 100) <= 25); + adventure_start_bow_shot_audio(player_x, player_x, end_x, target_x, duration_ms); +} + +void adventure_release_sling_attack(int player_x, int player_facing, AdventureRangedReleaseCallback@ ranged_callback) { + add_personal_count(ITEM_STONES, -1); + + int elapsed = sling_charge_timer.elapsed; + int cycle_time = 1500; + int time_in_cycle = elapsed % cycle_time; + int stage = time_in_cycle / 500; // 0=low, 1=in-range, 2=high + + if (stage != 1) { + speak_with_history("Stone missed.", true); + return; + } + + int search_direction = (player_facing == 1) ? 1 : -1; + int target_x = -1; + int damage = random(SLING_DAMAGE_MIN, SLING_DAMAGE_MAX); + + if (@ranged_callback !is null) { + target_x = ranged_callback(player_x, search_direction, SLING_RANGE, ADVENTURE_WEAPON_SLING, damage); + } + + if (target_x == -1) { + speak_with_history("Stone missed.", true); + return; + } + + play_1d_with_volume_step("sounds/weapons/sling_hit.ogg", player_x, target_x, false, PLAYER_WEAPON_SOUND_VOLUME_STEP); +} diff --git a/src/bosses/adventure_system.nvgt b/src/bosses/adventure_system.nvgt index 1d91b67..c557292 100644 --- a/src/bosses/adventure_system.nvgt +++ b/src/bosses/adventure_system.nvgt @@ -1,6 +1,7 @@ // Adventure System // Handles triggering and managing terrain-specific adventures +#include "src/bosses/adventure_combat.nvgt" #include "src/bosses/unicorn/unicorn_boss.nvgt" void check_adventure_menu(int player_x) { diff --git a/src/bosses/unicorn/unicorn_boss.nvgt b/src/bosses/unicorn/unicorn_boss.nvgt index 9bdcc5c..0833eb6 100644 --- a/src/bosses/unicorn/unicorn_boss.nvgt +++ b/src/bosses/unicorn/unicorn_boss.nvgt @@ -16,7 +16,7 @@ class UnicornBoss { } void reset() { - health = 10000; + health = 450; speed = UNICORN_SPEED; facing = 0; // Start facing west (toward player) x = 0; @@ -33,6 +33,8 @@ const int BRIDGE_END = 54; const int BRIDGE_SUPPORT_MAX_HEALTH = 100; const float UNICORN_SOUND_VOLUME_STEP = 2.5; // Lower = audible from further away const int UNICORN_SPEED = 80; // ms per tile, 100 tiles * 80ms = 8 seconds per charge +const bool UNICORN_BOW_CAN_DAMAGE_SUPPORTS = false; +const bool UNICORN_SLING_CAN_DAMAGE_SUPPORTS = false; // State UnicornBoss unicorn; @@ -46,16 +48,23 @@ int player_arena_facing = 1; // 0 = west, 1 = east int[] bridge_supports_health; // 2 supports: Left (start) and Right (end) bool bridge_collapsed = false; string current_unicorn_sound = ""; +bool unicorn_defeated = false; +bool unicorn_defeated_by_fall = false; +bool unicorn_in_weapon_range = false; void init_unicorn_adventure() { unicorn.reset(); unicorn.x = UNICORN_ARENA_SIZE - 1; // Start at east end + reset_adventure_combat_state(); player_arena_x = 0; // Start player at west end player_arena_y = 0; player_arena_jumping = false; bridge_collapsed = false; current_unicorn_sound = ""; + unicorn_defeated = false; + unicorn_defeated_by_fall = false; + unicorn_in_weapon_range = false; // Initialize supports bridge_supports_health.resize(2); @@ -65,6 +74,7 @@ void init_unicorn_adventure() { void cleanup_unicorn_adventure() { p.destroy_all(); + reset_adventure_combat_state(); if (unicorn.sound_handle != -1) { p.destroy_sound(unicorn.sound_handle); unicorn.sound_handle = -1; @@ -90,6 +100,7 @@ void run_unicorn_adventure() { intro.insert_last("Strategy:"); intro.insert_last(" - Use your axe to destroy a bridge support"); intro.insert_last(" - Lure the Unicorn onto the bridge"); + intro.insert_last(" - Or fight the Unicorn directly (it has massive health)"); intro.insert_last(" - Jump (UP arrow) to avoid being trampled"); intro.insert_last(" - When the bridge collapses with the Unicorn on it, you win!"); intro.insert_last(""); @@ -130,10 +141,24 @@ void run_unicorn_adventure() { // Updates update_player_jump(); update_unicorn(); + adventure_update_bow_shot(player_arena_x); + update_unicorn_weapon_range_audio(); // Check Conditions - unicorn falls when on collapsed bridge - if (bridge_collapsed && unicorn.x >= BRIDGE_START && unicorn.x <= BRIDGE_END) { - play_unicorn_death_sequence(); + if (!unicorn_defeated && bridge_collapsed && unicorn.x >= BRIDGE_START && unicorn.x <= BRIDGE_END) { + mark_unicorn_defeated(true); + } + + if (!unicorn_defeated && unicorn.health <= 0) { + mark_unicorn_defeated(false); + } + + if (unicorn_defeated) { + if (unicorn_defeated_by_fall) { + play_unicorn_death_sequence(); + } else { + play_unicorn_ground_death_sequence(); + } cleanup_unicorn_adventure(); give_unicorn_rewards(); return; @@ -247,55 +272,207 @@ void handle_player_actions() { // Can't attack while jumping if (player_arena_jumping) return; - // Attack cooldown like main game - int attack_cooldown = 1000; - if (spear_equipped) attack_cooldown = 800; - if (axe_equipped) attack_cooldown = 1600; + bool ctrl_down = (key_down(KEY_LCTRL) || key_down(KEY_RCTRL)); - if ((key_down(KEY_LCTRL) || key_down(KEY_RCTRL)) && arena_attack_timer.elapsed > attack_cooldown) { - arena_attack_timer.restart(); - - // Check for bridge supports - int target_support = -1; - - // Check Left Support (at BRIDGE_START) - if (abs(player_arena_x - BRIDGE_START) <= 1) { - target_support = 0; - } - // Check Right Support (at BRIDGE_END) - else if (abs(player_arena_x - BRIDGE_END) <= 1) { - target_support = 1; - } - - if (target_support != -1) { - if (bridge_supports_health[target_support] > 0) { - // Only axe can damage supports (like chopping trees) - if (axe_equipped) { - bridge_supports_health[target_support] -= AXE_DAMAGE; - p.play_stationary("sounds/weapons/axe_hit.ogg", false); - - if (bridge_supports_health[target_support] <= 0) { - check_bridge_collapse(); - } - } else if (spear_equipped) { - // Spear just makes sound, no damage (like hitting trees) - p.play_stationary("sounds/weapons/spear_hit.ogg", false); - } else { - // No weapon or sling - swing sound, no effect - p.play_stationary("sounds/weapons/axe_swing.ogg", false); - } - } - } else { - // Normal attack (useless vs unicorn but gives feedback) - if (abs(player_arena_x - unicorn.x) <= 1) { - p.play_stationary("sounds/weapons/axe_hit.ogg", false); + // Bow draw detection + if (bow_equipped) { + if (ctrl_down && !bow_drawing) { + if (get_personal_count(ITEM_ARROWS) > 0) { + bow_drawing = true; + bow_draw_timer.restart(); + p.play_stationary("sounds/weapons/bow_draw.ogg", false); } else { - p.play_stationary("sounds/weapons/axe_swing.ogg", false); + speak_ammo_blocked("No arrows."); + } + } + + if (bow_drawing && !ctrl_down) { + adventure_release_bow_attack(player_arena_x, player_arena_facing, @unicorn_ranged_attack); + bow_drawing = false; + } + } + if (!bow_equipped && bow_drawing) { + bow_drawing = false; + } + + // Sling charge detection + if (!bow_equipped && sling_equipped && ctrl_down && !sling_charging) { + if (get_personal_count(ITEM_STONES) > 0) { + sling_charging = true; + sling_charge_timer.restart(); + sling_sound_handle = p.play_stationary("sounds/weapons/sling_swing.ogg", true); + last_sling_stage = -1; + } else { + speak_ammo_blocked("No stones."); + } + } + + // Update sling charge state while holding + if (sling_charging && ctrl_down) { + update_sling_charge(); + } + + // Sling release detection + if (sling_charging && !ctrl_down) { + adventure_release_sling_attack(player_arena_x, player_arena_facing, @unicorn_ranged_attack); + sling_charging = false; + safe_destroy_sound(sling_sound_handle); + } + + // Non-sling weapon attacks (existing pattern) + if (!bow_equipped && !bow_drawing && !sling_equipped && !sling_charging) { + if (fishing_pole_equipped) return; + + int weapon_type = get_unicorn_melee_weapon_type(); + if (weapon_type == -1) return; + + int attack_cooldown = 1000; + if (weapon_type == ADVENTURE_WEAPON_SPEAR) attack_cooldown = 800; + if (weapon_type == ADVENTURE_WEAPON_AXE) attack_cooldown = 1600; + + if (ctrl_down && arena_attack_timer.elapsed > attack_cooldown) { + arena_attack_timer.restart(); + play_unicorn_melee_swing(weapon_type); + if (unicorn_melee_hit(weapon_type)) { + play_unicorn_melee_hit(weapon_type); } } } } +int get_unicorn_melee_weapon_type() { + if (spear_equipped) return ADVENTURE_WEAPON_SPEAR; + if (axe_equipped) return ADVENTURE_WEAPON_AXE; + return -1; +} + +void play_unicorn_melee_swing(int weapon_type) { + if (weapon_type == ADVENTURE_WEAPON_SPEAR) { + p.play_stationary("sounds/weapons/spear_swing.ogg", false); + } else if (weapon_type == ADVENTURE_WEAPON_AXE) { + p.play_stationary("sounds/weapons/axe_swing.ogg", false); + } +} + +void play_unicorn_melee_hit(int weapon_type) { + if (weapon_type == ADVENTURE_WEAPON_SPEAR) { + p.play_stationary("sounds/weapons/spear_hit.ogg", false); + } else if (weapon_type == ADVENTURE_WEAPON_AXE) { + p.play_stationary("sounds/weapons/axe_hit.ogg", false); + } +} + +bool unicorn_melee_hit(int weapon_type) { + int target_support = -1; + if (abs(player_arena_x - BRIDGE_START) <= 1) { + target_support = 0; + } else if (abs(player_arena_x - BRIDGE_END) <= 1) { + target_support = 1; + } + + if (target_support != -1 && bridge_supports_health[target_support] > 0) { + if (weapon_type == ADVENTURE_WEAPON_AXE) { + bridge_supports_health[target_support] -= AXE_DAMAGE; + if (bridge_supports_health[target_support] <= 0) { + check_bridge_collapse(); + } + } + return true; + } + + if (abs(player_arena_x - unicorn.x) <= 1) { + int damage = (weapon_type == ADVENTURE_WEAPON_SPEAR) ? SPEAR_DAMAGE : AXE_DAMAGE; + apply_unicorn_damage(damage); + return true; + } + + return false; +} + +int find_unicorn_ranged_target(int player_x, int direction, int range) { + int unicorn_distance = (unicorn.x - player_x) * direction; + if (unicorn_distance > 0 && unicorn_distance <= range) { + return unicorn.x; + } + + for (int dist = 1; dist <= range; dist++) { + int check_x = player_x + (dist * direction); + if (check_x < 0 || check_x >= UNICORN_ARENA_SIZE) break; + + if (bridge_supports_health[0] > 0 && check_x == BRIDGE_START) { + return check_x; + } + + if (bridge_supports_health[1] > 0 && check_x == BRIDGE_END) { + return check_x; + } + } + + return -1; +} + +int unicorn_ranged_attack(int player_x, int direction, int range, int weapon_type, int damage) { + int target_x = find_unicorn_ranged_target(player_x, direction, range); + if (target_x == -1) return -1; + + if (target_x == unicorn.x) { + apply_unicorn_damage(damage); + return target_x; + } + + if (target_x == BRIDGE_START || target_x == BRIDGE_END) { + int support_index = (target_x == BRIDGE_START) ? 0 : 1; + bool can_damage = false; + if (weapon_type == ADVENTURE_WEAPON_BOW) { + can_damage = UNICORN_BOW_CAN_DAMAGE_SUPPORTS; + } else if (weapon_type == ADVENTURE_WEAPON_SLING) { + can_damage = UNICORN_SLING_CAN_DAMAGE_SUPPORTS; + } + + if (can_damage && bridge_supports_health[support_index] > 0) { + bridge_supports_health[support_index] -= damage; + if (bridge_supports_health[support_index] <= 0) { + check_bridge_collapse(); + } + } + } + + return target_x; +} + +void mark_unicorn_defeated(bool by_fall) { + if (unicorn_defeated) return; + unicorn_defeated = true; + unicorn_defeated_by_fall = by_fall; + if (by_fall) { + unicorn.health = 0; + } +} + +void apply_unicorn_damage(int damage) { + if (damage <= 0) return; + if (unicorn.health <= 0) return; + + unicorn.health -= damage; + if (unicorn.health <= 0) { + unicorn.health = 0; + mark_unicorn_defeated(false); + } +} + +void play_unicorn_ground_death_sequence() { + if (unicorn.sound_handle != -1) { + p.destroy_sound(unicorn.sound_handle); + unicorn.sound_handle = -1; + } + p.play_stationary("sounds/actions/hit_ground.ogg", false); + wait(800); +} + +void update_unicorn_weapon_range_audio() { + update_weapon_range_audio_with_listener(player_arena_x, unicorn.x, unicorn_in_weapon_range); +} + void check_bridge_collapse() { // Bridge collapses when any support is destroyed if (bridge_supports_health[0] <= 0 || bridge_supports_health[1] <= 0) { @@ -339,6 +516,7 @@ void update_unicorn() { if (unicorn.x == player_arena_x && player_arena_y == 0) { player_health -= 10; p.play_stationary("sounds/actions/hit_ground.ogg", false); + play_player_damage_sound(); } } } @@ -440,6 +618,8 @@ void give_unicorn_rewards() { rewards.insert_last("You have already mastered the Rune of Swiftness."); } + append_adventure_completion_rewards(ADVENTURE_UNICORN, rewards); + // Display rewards in text reader text_reader_lines(rewards, "Unicorn Victory", true); } diff --git a/src/combat.nvgt b/src/combat.nvgt index 1c0769f..5586c8c 100644 --- a/src/combat.nvgt +++ b/src/combat.nvgt @@ -281,9 +281,23 @@ void release_bow_attack(int player_x) { bool hit_flying_creature = false; bool hit_boar = false; int target_x = find_ranged_enemy(player_x, BOW_RANGE, search_direction, true, hit_bandit, hit_boar, hit_flying_creature); + bool hit_tree = false; + + if (target_x == -1) { + for (int dist = 1; dist <= BOW_RANGE; dist++) { + int check_x = player_x + (dist * search_direction); + if (check_x < 0 || check_x >= MAP_SIZE) break; + Tree@ tree = get_tree_at(check_x); + if (tree != null && !tree.is_chopped) { + target_x = check_x; + hit_tree = true; + break; + } + } + } int hit_type = BOW_HIT_NONE; - if (target_x != -1) { + if (target_x != -1 && !hit_tree) { if (hit_bandit) { damage_bandit_at(target_x, damage); hit_type = BOW_HIT_BANDIT; @@ -297,6 +311,8 @@ void release_bow_attack(int player_x) { damage_zombie_at(target_x, damage); hit_type = BOW_HIT_ZOMBIE; } + } else if (hit_tree) { + hit_type = BOW_HIT_NONE; } int end_x = (target_x != -1) @@ -351,7 +367,6 @@ void release_sling_attack(int player_x) { if (tree != null && !tree.is_chopped) { // Stone hits tree but doesn't damage it play_1d_with_volume_step("sounds/weapons/sling_hit.ogg", player_x, check_x, false, PLAYER_WEAPON_SOUND_VOLUME_STEP); - speak_with_history("Stone hit tree at " + check_x + ".", true); return; } } diff --git a/src/constants.nvgt b/src/constants.nvgt index 72aeeb2..f3a99b0 100644 --- a/src/constants.nvgt +++ b/src/constants.nvgt @@ -28,6 +28,9 @@ const int BLESSING_TRIGGER_CHANCE = 10; const int BLESSING_WALK_SPEED = 320; const int FISH_WEIGHT_MIN = 1; const int FISH_WEIGHT_MAX = 30; +// Player sex constants +const int SEX_MALE = 0; +const int SEX_FEMALE = 1; // Weapon damage const int SPEAR_DAMAGE = 3; @@ -77,7 +80,7 @@ const int BOAR_CHARGE_SPEED = 500; // ms per tile when charging const int BOAR_SPAWN_CHANCE_PER_HOUR = 30; // Barricade configuration -const int BARRICADE_BASE_HEALTH = 100; +const int BARRICADE_BASE_HEALTH = 40; const int BARRICADE_MAX_HEALTH = 500; const int BARRICADE_STICK_COST = 3; const int BARRICADE_STICK_HEALTH = 10; @@ -247,7 +250,7 @@ const int FALL_DAMAGE_MAX = 4; // Base Automation const int RESIDENT_SLING_COOLDOWN = 4000; // 4 seconds between shots const int RESIDENT_COLLECTION_CHANCE = 10; // 10% chance per basket per hour -const int RESIDENT_FORAGING_CHANCE = 50; // 50% chance per resident per attempt (twice daily) +const int RESIDENT_FORAGING_CHANCE = 50; // 50% chance per resident per attempt (daily) // Utility functions int abs(int value) { diff --git a/src/enemies/bandit.nvgt b/src/enemies/bandit.nvgt index bbeb5d2..6dfda39 100644 --- a/src/enemies/bandit.nvgt +++ b/src/enemies/bandit.nvgt @@ -184,6 +184,7 @@ bool try_attack_player_bandit(Bandit@ bandit) { } else if (bandit.weapon_type == "axe") { play_creature_attack_sound("sounds/weapons/axe_hit.ogg", x, bandit.position, BANDIT_SOUND_VOLUME_STEP); } + play_player_damage_sound(); return true; } @@ -199,14 +200,13 @@ void try_attack_barricade_bandit(Bandit@ bandit) { barricade_health -= damage; if (barricade_health < 0) barricade_health = 0; - // Play weapon swing sound + // Play weapon swing sound (barricade hits share a common impact sound) if (bandit.weapon_type == "spear") { play_creature_attack_sound("sounds/weapons/spear_swing.ogg", x, bandit.position, BANDIT_SOUND_VOLUME_STEP); - play_creature_attack_sound("sounds/weapons/spear_hit.ogg", x, bandit.position, BANDIT_SOUND_VOLUME_STEP); } else if (bandit.weapon_type == "axe") { play_creature_attack_sound("sounds/weapons/axe_swing.ogg", x, bandit.position, BANDIT_SOUND_VOLUME_STEP); - play_creature_attack_sound("sounds/weapons/axe_hit.ogg", x, bandit.position, BANDIT_SOUND_VOLUME_STEP); } + play_creature_attack_sound("sounds/weapons/axe_hit.ogg", x, bandit.position, BANDIT_SOUND_VOLUME_STEP); // Resident defense counter-attack if (can_residents_defend()) { diff --git a/src/enemies/ground_game.nvgt b/src/enemies/ground_game.nvgt index c8bde25..10c7845 100644 --- a/src/enemies/ground_game.nvgt +++ b/src/enemies/ground_game.nvgt @@ -119,12 +119,10 @@ bool try_attack_player_ground_game(GroundGame@ game) { game.attack_timer.restart(); // Attack! - // TODO: Add specific boar attack sound? For now re-use zombie hit as generic impact - play_creature_attack_sound("sounds/enemies/zombie_hits_player.ogg", x, game.position, BOAR_SOUND_VOLUME_STEP); - int damage = random(BOAR_DAMAGE_MIN, BOAR_DAMAGE_MAX); player_health -= damage; if (player_health < 0) player_health = 0; + play_player_damage_sound(); return true; } diff --git a/src/enemies/undead.nvgt b/src/enemies/undead.nvgt index 91593d1..b5e67f2 100644 --- a/src/enemies/undead.nvgt +++ b/src/enemies/undead.nvgt @@ -94,7 +94,7 @@ void try_attack_barricade_undead(Undead@ undead) { barricade_health -= damage; if (barricade_health < 0) barricade_health = 0; - play_creature_attack_sound("sounds/enemies/zombie_hits_player.ogg", x, undead.position, ZOMBIE_SOUND_VOLUME_STEP); + play_creature_attack_sound("sounds/weapons/axe_hit.ogg", x, undead.position, ZOMBIE_SOUND_VOLUME_STEP); // Resident defense counter-attack if (can_residents_defend()) { @@ -144,7 +144,7 @@ bool try_attack_player_undead(Undead@ undead) { player_health = 0; } - play_creature_attack_sound("sounds/enemies/zombie_hits_player.ogg", x, undead.position, ZOMBIE_SOUND_VOLUME_STEP); + play_player_damage_sound(); return true; } diff --git a/src/environment.nvgt b/src/environment.nvgt index 1c9975f..6de4993 100644 --- a/src/environment.nvgt +++ b/src/environment.nvgt @@ -21,6 +21,9 @@ void apply_falling_damage(int fall_height) { // Apply damage player_health -= damage; if (player_health < 0) player_health = 0; + if (damage > 0) { + play_player_damage_sound(); + } // Feedback speak_with_history("Fell " + fall_height + " feet! Took " + damage + " damage. " + player_health + " health remaining.", true); diff --git a/src/fylgja_system.nvgt b/src/fylgja_system.nvgt new file mode 100644 index 0000000..bcdf095 --- /dev/null +++ b/src/fylgja_system.nvgt @@ -0,0 +1,308 @@ +// Fylgja system +// Tracks adventure completion stages, unlocks, and activation + +const int ADVENTURE_UNICORN = 1; +const int FYLGJA_STAGE_COUNT = 9; +const int FYLGJA_UNICORN = 0; +const int UNICORN_TRAMPLE_DAMAGE = 20; + +string[] fylgjaStageNames = { + "tenuous", + "faint", + "stirring", + "budding", + "kindled", + "bound", + "sworn", + "ascendant", + "ultimate" +}; + +int[] adventureIds = {ADVENTURE_UNICORN}; +string[] adventureStageTargets = {"unicorn"}; +int[] adventureCompletionCounts = {0}; + +int[] fylgjaAdventureIds = {ADVENTURE_UNICORN}; +string[] fylgjaNames = {"Unicorn"}; + +int lastFylgjaDay = -1; +bool fylgjaCharging = false; +int fylgjaChargeDirection = 1; +timer fylgjaChargeTimer; +int fylgjaSoundHandle = -1; +string currentFylgjaSound = ""; + +void reset_fylgja_state() { + lastFylgjaDay = -1; + fylgjaCharging = false; + fylgjaChargeDirection = 1; + currentFylgjaSound = ""; + if (fylgjaSoundHandle != -1) { + p.destroy_sound(fylgjaSoundHandle); + fylgjaSoundHandle = -1; + } + + for (uint i = 0; i < adventureCompletionCounts.length(); i++) { + adventureCompletionCounts[i] = 0; + } +} + +int get_adventure_index(int adventureId) { + for (uint i = 0; i < adventureIds.length(); i++) { + if (adventureIds[i] == adventureId) return int(i); + } + return -1; +} + +int get_fylgja_index_for_adventure(int adventureId) { + for (uint i = 0; i < fylgjaAdventureIds.length(); i++) { + if (fylgjaAdventureIds[i] == adventureId) return int(i); + } + return -1; +} + +bool is_fylgja_unlocked(int fylgjaIndex) { + if (fylgjaIndex < 0 || fylgjaIndex >= int(fylgjaAdventureIds.length())) return false; + int adventureIndex = get_adventure_index(fylgjaAdventureIds[fylgjaIndex]); + if (adventureIndex < 0) return false; + return adventureCompletionCounts[adventureIndex] >= FYLGJA_STAGE_COUNT; +} + +int get_unlocked_fylgja_count() { + int unlockedCount = 0; + for (uint i = 0; i < fylgjaNames.length(); i++) { + if (is_fylgja_unlocked(int(i))) unlockedCount++; + } + return unlockedCount; +} + +void append_adventure_completion_rewards(int adventureId, string[]@ rewards) { + int adventureIndex = get_adventure_index(adventureId); + if (adventureIndex < 0 || @rewards == null) return; + + adventureCompletionCounts[adventureIndex]++; + int completionCount = adventureCompletionCounts[adventureIndex]; + + int stageIndex = completionCount - 1; + if (stageIndex < 0) stageIndex = 0; + if (stageIndex >= int(fylgjaStageNames.length())) stageIndex = int(fylgjaStageNames.length()) - 1; + + string stageName = fylgjaStageNames[stageIndex]; + string targetName = adventureStageTargets[adventureIndex]; + rewards.insert_last("You have a " + stageName + " connection with the " + targetName + "."); + + int fylgjaIndex = get_fylgja_index_for_adventure(adventureId); + if (fylgjaIndex == -1) return; + + if (completionCount >= FYLGJA_STAGE_COUNT) { + if (completionCount == FYLGJA_STAGE_COUNT) { + rewards.insert_last("You have unlocked the " + fylgjaNames[fylgjaIndex] + " Fylgja!"); + } else { + rewards.insert_last("You have already unlocked the " + fylgjaNames[fylgjaIndex] + " Fylgja."); + } + } +} + +void check_fylgja_menu() { + if (!key_pressed(KEY_F)) return; + if (fylgjaCharging) return; + if (get_unlocked_fylgja_count() == 0) return; + + if (lastFylgjaDay == current_day) { + speak_with_history("You have already used your Fylgja today.", true); + return; + } + + run_fylgja_menu(); +} + +void run_fylgja_menu() { + string[] options; + int[] fylgjaIndices; + + for (uint i = 0; i < fylgjaNames.length(); i++) { + if (is_fylgja_unlocked(int(i))) { + options.insert_last(fylgjaNames[i] + " Fylgja"); + fylgjaIndices.insert_last(int(i)); + } + } + + if (options.length() == 0) return; + + speak_with_history("Fylgja menu.", true); + + int selection = 0; + speak_with_history(options[selection], true); + + while (true) { + wait(5); + menu_background_tick(); + + if (key_pressed(KEY_ESCAPE)) { + return; + } + + if (key_pressed(KEY_DOWN)) { + selection++; + if (selection >= int(options.length())) selection = 0; + speak_with_history(options[selection], true); + } + + if (key_pressed(KEY_UP)) { + selection--; + if (selection < 0) selection = int(options.length()) - 1; + speak_with_history(options[selection], true); + } + + if (key_pressed(KEY_RETURN)) { + int chosenIndex = fylgjaIndices[selection]; + if (activate_fylgja(chosenIndex)) { + lastFylgjaDay = current_day; + } + return; + } + } +} + +bool activate_fylgja(int fylgjaIndex) { + if (fylgjaIndex == FYLGJA_UNICORN) { + start_unicorn_fylgja_charge(); + return true; + } + return false; +} + +void start_unicorn_fylgja_charge() { + reset_fishing_session(); + + if (sling_charging) { + sling_charging = false; + if (sling_sound_handle != -1) { + p.destroy_sound(sling_sound_handle); + sling_sound_handle = -1; + } + } + + if (bow_drawing) { + bow_drawing = false; + } + + searching = false; + search_timer.restart(); + search_delay_timer.restart(); + + jumping = false; + climbing = false; + falling = false; + rope_climbing = false; + pending_rope_climb_x = -1; + pending_rope_climb_elevation = 0; + y = get_mountain_elevation_at(x); + + fylgjaCharging = true; + fylgjaChargeDirection = facing; + fylgjaChargeTimer.restart(); + currentFylgjaSound = ""; + update_fylgja_charge_audio(); +} + +bool should_stop_charge_for_climb_up(int fromX, int toX) { + MountainRange@ mountain = get_mountain_at(toX); + if (mountain is null) return false; + + int elevationChange = mountain.get_elevation_change(fromX, toX); + return elevationChange >= MOUNTAIN_STEEP_THRESHOLD; +} + +void apply_fylgja_trample_damage(int posX) { + damage_undead_at(posX, UNICORN_TRAMPLE_DAMAGE); + damage_bandit_at(posX, UNICORN_TRAMPLE_DAMAGE); + damage_boar_at(posX, UNICORN_TRAMPLE_DAMAGE); +} + +string get_unicorn_charge_sound(int posX) { + string terrain = get_terrain_at_position(posX); + if (terrain == "stone" || terrain == "gravel") { + return "sounds/bosses/unicorn/unicorn_on_bridge.ogg"; + } + return "sounds/bosses/unicorn/unicorn_galloping.ogg"; +} + +void update_fylgja_charge_audio() { + string soundFile = get_unicorn_charge_sound(x); + bool needNewSound = (fylgjaSoundHandle == -1 || !p.sound_is_active(fylgjaSoundHandle) || currentFylgjaSound != soundFile); + + if (needNewSound) { + if (fylgjaSoundHandle != -1) { + p.destroy_sound(fylgjaSoundHandle); + fylgjaSoundHandle = -1; + } + fylgjaSoundHandle = play_1d_with_volume_step(soundFile, x, x, true, UNICORN_SOUND_VOLUME_STEP); + currentFylgjaSound = soundFile; + } else { + p.update_sound_1d(fylgjaSoundHandle, x); + } +} + +void stop_fylgja_charge() { + fylgjaCharging = false; + currentFylgjaSound = ""; + if (fylgjaSoundHandle != -1) { + p.destroy_sound(fylgjaSoundHandle); + fylgjaSoundHandle = -1; + } +} + +void update_fylgja_charge() { + if (!fylgjaCharging) return; + + if (key_down(KEY_LSHIFT) || key_down(KEY_RSHIFT)) { + stop_fylgja_charge(); + return; + } + + if (x <= BASE_END || x <= 0 || x >= MAP_SIZE - 1) { + stop_fylgja_charge(); + return; + } + + if (fylgjaChargeTimer.elapsed >= UNICORN_SPEED) { + fylgjaChargeTimer.restart(); + int step = (fylgjaChargeDirection == 1) ? 1 : -1; + int targetX = x + step; + + if (targetX < 0 || targetX >= MAP_SIZE) { + stop_fylgja_charge(); + return; + } + + if (should_stop_charge_for_climb_up(x, targetX)) { + stop_fylgja_charge(); + return; + } + + int previousY = y; + x = targetX; + int targetElevation = get_mountain_elevation_at(x); + if (targetElevation != y) { + y = targetElevation; + if (targetElevation < previousY) { + int fallHeight = previousY - targetElevation; + apply_falling_damage(fallHeight); + } + } + + check_snare_collision(x); + apply_fylgja_trample_damage(x); + + if (x <= BASE_END || x <= 0 || x >= MAP_SIZE - 1) { + stop_fylgja_charge(); + return; + } + } + + update_fylgja_charge_audio(); + update_fishing(); + update_bow_shot(); + p.update_listener_1d(x); +} diff --git a/src/inventory_items.nvgt b/src/inventory_items.nvgt index 0d04afb..deceacf 100644 --- a/src/inventory_items.nvgt +++ b/src/inventory_items.nvgt @@ -39,6 +39,7 @@ int equipped_feet = EQUIP_NONE; // Quick slots int[] quick_slots; +int[] quick_slot_runes; int[] item_count_slots; void reset_quick_slots() { @@ -47,6 +48,11 @@ void reset_quick_slots() { quick_slots[i] = -1; } + quick_slot_runes.resize(10); + for (uint i = 0; i < quick_slot_runes.length(); i++) { + quick_slot_runes[i] = RUNE_NONE; + } + item_count_slots.resize(10); for (uint i = 0; i < item_count_slots.length(); i++) { item_count_slots[i] = -1; @@ -272,6 +278,10 @@ void activate_quick_slot(int slot_index) { } int equip_type = quick_slots[slot_index]; + int rune_type = RUNE_NONE; + if (slot_index >= 0 && slot_index < int(quick_slot_runes.length())) { + rune_type = quick_slot_runes[slot_index]; + } if (equip_type < 0) { int item_type = -1; if (slot_index >= 0 && slot_index < int(item_count_slots.length())) { @@ -287,6 +297,16 @@ void activate_quick_slot(int slot_index) { } if (equipment_is_equipped(equip_type)) { + if (get_equipped_rune_for_slot(equip_type) != rune_type) { + if (rune_type != RUNE_NONE && get_runed_item_count(equip_type, rune_type) <= 0) { + speak_with_history("Item not available.", true); + return; + } + set_equipped_rune_for_slot(equip_type, rune_type); + update_max_health_from_equipment(); + speak_with_history(get_equipment_name(equip_type) + " equipped.", true); + return; + } unequip_equipment_type(equip_type); clear_equipped_rune_for_slot(equip_type); update_max_health_from_equipment(); @@ -294,12 +314,18 @@ void activate_quick_slot(int slot_index) { return; } - if (!equipment_available(equip_type)) { + if (rune_type != RUNE_NONE) { + if (get_runed_item_count(equip_type, rune_type) <= 0) { + speak_with_history("Item not available.", true); + return; + } + } else if (!equipment_available(equip_type)) { speak_with_history("Item not available.", true); return; } equip_equipment_type(equip_type); + set_equipped_rune_for_slot(equip_type, rune_type); update_max_health_from_equipment(); speak_with_history(get_equipment_name(equip_type) + " equipped.", true); } diff --git a/src/menus/character_info.nvgt b/src/menus/character_info.nvgt index 2657ba3..f7da924 100644 --- a/src/menus/character_info.nvgt +++ b/src/menus/character_info.nvgt @@ -20,6 +20,10 @@ void show_character_info() { else missing_slots.insert_last("feet"); string info = "Character info. "; + if (player_name != "") { + string sex_label = (player_sex == SEX_FEMALE) ? "Female" : "Male"; + info += "Name " + player_name + ". Sex " + sex_label + ". "; + } info += "Health " + player_health + " of " + max_health + ". "; info += "Weapon " + get_equipped_weapon_name() + ". "; if (equipped_clothing.length() > 0) { diff --git a/src/menus/equipment_menu.nvgt b/src/menus/equipment_menu.nvgt index 434edfb..b97f5e3 100644 --- a/src/menus/equipment_menu.nvgt +++ b/src/menus/equipment_menu.nvgt @@ -192,6 +192,9 @@ void run_equipment_menu() { int equip_type = equipment_types[filtered_indices[selection]]; int rune_type = rune_types[filtered_indices[selection]]; quick_slots[slot_index] = equip_type; + if (slot_index >= 0 && slot_index < int(quick_slot_runes.length())) { + quick_slot_runes[slot_index] = rune_type; + } string name = get_full_equipment_name(equip_type, rune_type); speak_with_history(name + " set to slot " + slot_index + ".", true); } diff --git a/src/player.nvgt b/src/player.nvgt index e8dcefb..7e7ff85 100644 --- a/src/player.nvgt +++ b/src/player.nvgt @@ -2,6 +2,8 @@ int x = 0; int y = 0; int facing = 1; // 0: left, 1: right +string player_name = ""; +int player_sex = SEX_MALE; bool jumping = false; bool climbing = false; bool falling = false; @@ -85,7 +87,6 @@ timer jumptimer; timer search_timer; timer search_delay_timer; timer attack_timer; - // Search state bool searching = false; @@ -104,6 +105,7 @@ void restart_all_timers() { sling_charge_timer.restart(); bow_draw_timer.restart(); bow_shot_timer.restart(); + fylgjaChargeTimer.restart(); // Fire fuel timers for (uint i = 0; i < world_fires.length(); i++) { diff --git a/src/save_system.nvgt b/src/save_system.nvgt index 8042ec6..e01a6a5 100644 --- a/src/save_system.nvgt +++ b/src/save_system.nvgt @@ -1,12 +1,35 @@ // Save system -const string SAVE_FILE_PATH = "save.dat"; +const string SAVE_EXTENSION = ".dat"; const string SAVE_ENCRYPTION_KEY = "draugnorak_save_v1"; -const int SAVE_VERSION = 2; +const int SAVE_VERSION = 3; string last_save_error = ""; +string current_save_file = ""; + +string[] get_save_files() { + string[] result; + string[]@ items = glob("*" + SAVE_EXTENSION); + if (@items == null) return result; + + for (uint i = 0; i < items.length(); i++) { + string item = items[i]; + if (item.length() >= SAVE_EXTENSION.length() && + item.substr(item.length() - SAVE_EXTENSION.length()) == SAVE_EXTENSION) { + result.insert_last(item); + } + } + if (result.length() > 1) { + result.sort(sort_string_case_insensitive); + } + return result; +} + +bool sort_string_case_insensitive(const string &in a, const string &in b) { + return a.lower() < b.lower(); +} bool has_save_game() { - return file_exists(SAVE_FILE_PATH); + return get_save_files().length() > 0; } string encrypt_save_data(const string&in rawData) { @@ -93,6 +116,345 @@ string[] get_string_list(dictionary@ data, const string&in key) { return result; } +string flatten_exception_text(const string&in text) { + string result = ""; + bool lastWasSpace = false; + for (uint i = 0; i < text.length(); i++) { + string ch = text.substr(i, 1); + if (ch == "\r" || ch == "\n") { + if (!lastWasSpace) { + result += " | "; + lastWasSpace = true; + } + continue; + } + result += ch; + lastWasSpace = false; + } + return result; +} + +string format_log_timestamp() { + datetime dt; + string stamp = dt.format(DATE_TIME_FORMAT_RFC1123); + return "[" + stamp + "]"; +} + +void log_unhandled_exception(const string&in context) { + string info = get_exception_info(); + string filePath = get_exception_file(); + int line = get_exception_line(); + string func = get_exception_function(); + string stack = flatten_exception_text(last_exception_call_stack); + + string message = "Unhandled exception"; + if (context != "") message += " (" + context + ")"; + if (info != "") message += ": " + info; + if (filePath != "") message += " at " + filePath; + if (line > 0) message += ":" + line; + if (func != "") message += " in " + func; + if (stack != "") message += " | stack: " + stack; + message += " " + format_log_timestamp(); + + file logFile; + if (logFile.open("crash.log", "ab")) { + logFile.write(message + "\r\n"); + logFile.close(); + } +} + +string normalize_player_name(string name) { + string result = ""; + bool lastWasSpace = true; + for (uint i = 0; i < name.length(); i++) { + string ch = name.substr(i, 1); + bool isSpace = (ch == " " || ch == "\t" || ch == "\r" || ch == "\n"); + if (isSpace) { + if (!lastWasSpace) { + result += " "; + lastWasSpace = true; + } + continue; + } + result += ch; + lastWasSpace = false; + } + if (result.length() > 0 && result.substr(result.length() - 1) == " ") { + result = result.substr(0, result.length() - 1); + } + return result; +} + +bool is_windows_reserved_name(const string&in upperName) { + if (upperName == "CON" || upperName == "PRN" || upperName == "AUX" || upperName == "NUL") return true; + if (upperName.length() == 4 && upperName.substr(0, 3) == "COM") { + int num = parse_int(upperName.substr(3)); + if (num >= 1 && num <= 9) return true; + } + if (upperName.length() == 4 && upperName.substr(0, 3) == "LPT") { + int num = parse_int(upperName.substr(3)); + if (num >= 1 && num <= 9) return true; + } + return false; +} + +string sanitize_save_filename_base(string name) { + string normalized = normalize_player_name(name); + string result = ""; + bool lastSeparator = false; + + for (uint i = 0; i < normalized.length(); i++) { + string ch = normalized.substr(i, 1); + bool isUpper = (ch >= "A" && ch <= "Z"); + bool isLower = (ch >= "a" && ch <= "z"); + bool isDigit = (ch >= "0" && ch <= "9"); + if (isUpper || isLower || isDigit) { + result += ch; + lastSeparator = false; + continue; + } + if (ch == " " || ch == "_" || ch == "-") { + if (!lastSeparator && result.length() > 0) { + result += "_"; + lastSeparator = true; + } + } + } + + while (result.length() > 0 && result.substr(result.length() - 1) == "_") { + result = result.substr(0, result.length() - 1); + } + if (result.length() == 0) { + result = "character"; + } + if (result.length() > 40) { + result = result.substr(0, 40); + } + while (result.length() > 0 && result.substr(result.length() - 1) == "_") { + result = result.substr(0, result.length() - 1); + } + string upperName = result.upper(); + if (upperName == "." || upperName == "..") { + result = "character"; + upperName = result.upper(); + } + if (is_windows_reserved_name(upperName)) { + result = "save_" + result; + } + return result; +} + +string get_save_filename_for_name(const string&in name) { + return sanitize_save_filename_base(name) + SAVE_EXTENSION; +} + +string strip_save_extension(const string&in filename) { + if (filename.length() >= SAVE_EXTENSION.length() && + filename.substr(filename.length() - SAVE_EXTENSION.length()) == SAVE_EXTENSION) { + return filename.substr(0, filename.length() - SAVE_EXTENSION.length()); + } + return filename; +} + +bool read_save_metadata(const string&in filename, string &out displayName, int &out sex, int &out day) { + string encryptedData; + if (!read_file_string(filename, encryptedData)) { + return false; + } + + string rawData = decrypt_save_data(encryptedData); + dictionary@ saveData = deserialize(rawData); + if (@saveData == null || !dictionary_has_keys(saveData)) { + saveData = deserialize(encryptedData); + } + if (@saveData == null || !dictionary_has_keys(saveData)) { + return false; + } + + displayName = ""; + if (!saveData.get("player_name", displayName)) { + displayName = ""; + } + sex = int(get_number(saveData, "player_sex", SEX_MALE)); + if (sex != SEX_FEMALE) sex = SEX_MALE; + day = int(get_number(saveData, "time_current_day", 1)); + if (day < 1) day = 1; + if (displayName == "") { + displayName = strip_save_extension(filename); + } + return true; +} + +bool is_name_used(const string&in name, const string[]@ usedNames) { + string target = name.lower(); + if (@usedNames != null) { + for (uint i = 0; i < usedNames.length(); i++) { + if (usedNames[i].lower() == target) return true; + } + } + if (file_exists(get_save_filename_for_name(name))) return true; + return false; +} + +string pick_random_name(const string[]@ pool, const string[]@ usedNames) { + string[] available; + if (@pool != null) { + for (uint i = 0; i < pool.length(); i++) { + if (!is_name_used(pool[i], usedNames)) { + available.insert_last(pool[i]); + } + } + } + const string[]@ pickFrom = @available; + if (available.length() == 0) { + @pickFrom = pool; + } + if (@pickFrom == null || pickFrom.length() == 0) return "character"; + int index = random(0, int(pickFrom.length()) - 1); + return pickFrom[index]; +} + +string[] get_existing_character_names() { + string[] names; + string[] files = get_save_files(); + for (uint i = 0; i < files.length(); i++) { + string displayName; + int sex = SEX_MALE; + int day = 1; + if (read_save_metadata(files[i], displayName, sex, day)) { + if (displayName.length() > 0) { + names.insert_last(displayName); + } + } + } + return names; +} + +string[] male_name_pool = { + "Arne", "Asbjorn", "Aegir", "Bjorn", "Brand", "Egil", "Einar", "Eirik", "Erik", "Gunnar", + "Gudmund", "Hakon", "Halfdan", "Hallvard", "Harald", "Hjalmar", "Hrafn", "Hrolf", "Ivar", "Ketil", + "Knut", "Leif", "Magnus", "Njord", "Odd", "Olaf", "Orm", "Ragnar", "Roald", "Rolf", + "Sigurd", "Sten", "Stig", "Sven", "Svend", "Thor", "Toke", "Torbjorn", "Torstein", "Trygve", + "Ulf", "Ulrik", "Valdemar", "Vidar", "Yngvar", "Haldor", "Skjold", "Eystein", "Gorm", "Havard" +}; + +string[] female_name_pool = { + "Astrid", "Asta", "Birgit", "Brynhild", "Dagny", "Eira", "Freya", "Frida", "Gerda", "Gudrun", + "Gunhild", "Halla", "Helga", "Hild", "Hilda", "Inga", "Ingrid", "Kari", "Lagertha", "Liv", + "Ragna", "Ragnhild", "Randi", "Runa", "Sif", "Signy", "Sigrid", "Solveig", "Sunniva", "Thora", + "Thyra", "Tora", "Tove", "Tyrna", "Ulla", "Yrsa", "Ylva", "Aud", "Eydis", "Herdis", + "Ingunn", "Jorunn", "Ragnheid", "Sigrun", "Torhild", "Ase", "Alfhild", "Gudlaug", "Katra", "Rikissa" +}; + +string pick_random_name_for_sex(int sex, const string[]@ usedNames) { + if (sex == SEX_FEMALE) { + return pick_random_name(female_name_pool, usedNames); + } + return pick_random_name(male_name_pool, usedNames); +} + +bool select_player_sex(int &out sex) { + string[] options = {"Male", "Female"}; + int selection = 0; + string prompt = "Choose your character's sex."; + speak_with_history(prompt + " " + options[selection], true); + + while (true) { + wait(5); + if (key_pressed(KEY_DOWN)) { + selection++; + if (selection >= options.length()) selection = 0; + speak_with_history(prompt + " " + options[selection], true); + } + if (key_pressed(KEY_UP)) { + selection--; + if (selection < 0) selection = options.length() - 1; + speak_with_history(prompt + " " + options[selection], true); + } + if (key_pressed(KEY_RETURN)) { + sex = (selection == 1) ? SEX_FEMALE : SEX_MALE; + return true; + } + if (key_pressed(KEY_ESCAPE)) { + return false; + } + } + return false; +} + +bool setup_new_character() { + int selectedSex = SEX_MALE; + if (!select_player_sex(selectedSex)) { + return false; + } + + string[] existingNames = get_existing_character_names(); + while (true) { + string entered = ui_input_box("Draugnorak", "Enter your name or press Enter for random.", ""); + string normalized = normalize_player_name(entered); + if (normalized.length() == 0) { + normalized = pick_random_name_for_sex(selectedSex, existingNames); + } + string saveFile = get_save_filename_for_name(normalized); + if (file_exists(saveFile)) { + int confirm = ui_question("", "Save found for " + normalized + ". Overwrite?"); + if (confirm != 1) { + continue; + } + } + player_name = normalized; + player_sex = selectedSex; + current_save_file = saveFile; + return true; + } + return false; +} + +bool select_save_file(string &out filename) { + string[] files = get_save_files(); + if (files.length() == 0) return false; + + string[] options; + for (uint i = 0; i < files.length(); i++) { + string displayName; + int sex = SEX_MALE; + int day = 1; + if (!read_save_metadata(files[i], displayName, sex, day)) { + displayName = strip_save_extension(files[i]); + options.insert_last(displayName); + } else { + string sex_label = (sex == SEX_FEMALE) ? "female" : "male"; + options.insert_last(displayName + ", " + sex_label + ", day " + day); + } + } + + int selection = 0; + speak_with_history("Load game. Select character.", true); + speak_with_history(options[selection], true); + + while (true) { + wait(5); + if (key_pressed(KEY_DOWN)) { + selection++; + if (selection >= options.length()) selection = 0; + speak_with_history(options[selection], true); + } + if (key_pressed(KEY_UP)) { + selection--; + if (selection < 0) selection = int(options.length()) - 1; + speak_with_history(options[selection], true); + } + if (key_pressed(KEY_RETURN)) { + filename = files[selection]; + return true; + } + if (key_pressed(KEY_ESCAPE)) { + return false; + } + } + return false; +} + void stop_active_sounds() { if (day_sound_handle != -1) { p.destroy_sound(day_sound_handle); @@ -217,6 +579,7 @@ void reset_game_state() { incense_burning = false; blessing_speed_active = false; blessing_resident_active = false; + reset_fylgja_state(); // Reset inventory using the registry system reset_inventory(); @@ -283,6 +646,12 @@ void start_new_game() { init_barricade(); init_time(); init_weather(); + if (player_name.length() == 0) { + player_name = "Character"; + } + if (current_save_file == "") { + current_save_file = get_save_filename_for_name(player_name); + } save_game_state(); } @@ -398,8 +767,11 @@ bool save_game_state() { saveData.set("player_health", player_health); saveData.set("player_base_health", base_max_health); saveData.set("player_max_health", max_health); + saveData.set("player_name", player_name); + saveData.set("player_sex", player_sex); saveData.set("player_favor", favor); saveData.set("player_last_adventure_day", last_adventure_day); + saveData.set("adventure_completion_counts", serialize_inventory_array(adventureCompletionCounts)); saveData.set("incense_hours_remaining", incense_hours_remaining); saveData.set("incense_burning", incense_burning); @@ -435,6 +807,11 @@ bool save_game_state() { quickSlotData.insert_last("" + quick_slots[i]); } saveData.set("equipment_quick_slots", join_string_array(quickSlotData)); + string[] quickSlotRuneData; + for (uint i = 0; i < quick_slot_runes.length(); i++) { + quickSlotRuneData.insert_last("" + quick_slot_runes[i]); + } + saveData.set("equipment_quick_slot_runes", join_string_array(quickSlotRuneData)); string[] itemCountSlotData; for (uint i = 0; i < item_count_slots.length(); i++) { @@ -585,20 +962,23 @@ bool save_game_state() { } saveData.set("drops_data", join_string_array(dropData)); + if (current_save_file == "") { + current_save_file = get_save_filename_for_name(player_name); + } string rawData = saveData.serialize(); string encryptedData = encrypt_save_data(rawData); - return save_data(SAVE_FILE_PATH, encryptedData); + return save_data(current_save_file, encryptedData); } -bool load_game_state() { +bool load_game_state_from_file(const string&in filename) { last_save_error = ""; - if (!file_exists(SAVE_FILE_PATH)) { + if (!file_exists(filename)) { last_save_error = "No save file found."; return false; } string encryptedData; - if (!read_file_string(SAVE_FILE_PATH, encryptedData)) { + if (!read_file_string(filename, encryptedData)) { last_save_error = "Unable to read save file."; return false; } @@ -623,6 +1003,7 @@ bool load_game_state() { } reset_game_state(); + current_save_file = filename; MAP_SIZE = int(get_number(saveData, "world_map_size", 35)); expanded_area_start = int(get_number(saveData, "world_expanded_area_start", -1)); @@ -651,8 +1032,20 @@ bool load_game_state() { player_health = int(get_number(saveData, "player_health", 10)); max_health = int(get_number(saveData, "player_max_health", 10)); base_max_health = int(get_number(saveData, "player_base_health", max_health)); + string loadedName; + if (saveData.get("player_name", loadedName)) { + player_name = loadedName; + } else { + player_name = strip_save_extension(filename); + } + player_sex = int(get_number(saveData, "player_sex", SEX_MALE)); + if (player_sex != SEX_FEMALE) player_sex = SEX_MALE; favor = get_number(saveData, "player_favor", 0.0); last_adventure_day = int(get_number(saveData, "player_last_adventure_day", -1)); + string adventureCountsStr; + if (saveData.get("adventure_completion_counts", adventureCountsStr)) { + deserialize_inventory_array(adventureCountsStr, adventureCompletionCounts, int(adventureIds.length())); + } incense_hours_remaining = int(get_number(saveData, "incense_hours_remaining", 0)); incense_burning = get_bool(saveData, "incense_burning", false); if (incense_hours_remaining > 0) incense_burning = true; @@ -757,6 +1150,15 @@ bool load_game_state() { quick_slots[i] = slot_value; } } + string[] loadedQuickSlotRunes = get_string_list_or_split(saveData, "equipment_quick_slot_runes"); + uint rune_slot_count = loadedQuickSlotRunes.length(); + if (rune_slot_count > quick_slot_runes.length()) rune_slot_count = quick_slot_runes.length(); + for (uint i = 0; i < rune_slot_count; i++) { + int slot_value = parse_int(loadedQuickSlotRunes[i]); + if (slot_value >= RUNE_NONE) { + quick_slot_runes[i] = slot_value; + } + } string[] loadedItemCountSlots = get_string_list_or_split(saveData, "item_count_slots"); uint count_slot_count = loadedItemCountSlots.length(); @@ -1085,3 +1487,11 @@ bool load_game_state() { update_ambience(true); return true; } + +bool load_game_state() { + if (current_save_file == "") { + last_save_error = "No save selected."; + return false; + } + return load_game_state_from_file(current_save_file); +} diff --git a/src/time_system.nvgt b/src/time_system.nvgt index f286bb0..4907811 100644 --- a/src/time_system.nvgt +++ b/src/time_system.nvgt @@ -454,11 +454,21 @@ void update_time() { } } - if (is_daytime && residents_count > 0 && barricade_health < BARRICADE_MAX_HEALTH && current_hour % 4 == 0) { - if (has_any_storage_food()) { - int gained = add_barricade_health(residents_count * get_resident_effect_multiplier()); - if (gained > 0 && x <= BASE_END) { - speak_with_history("Residents repaired the barricade. +" + gained + " health.", true); + if (is_daytime && residents_count > 0 && barricade_health < BARRICADE_MAX_HEALTH) { + const int day_start_hour = 6; + const int day_end_hour = 18; // Exclusive for repair scheduling (12-hour window) + if (current_hour >= day_start_hour && current_hour < day_end_hour) { + int repair_window_hours = day_end_hour - day_start_hour; + int interval = repair_window_hours / residents_count; + if (interval < 1) interval = 1; + int day_hour = current_hour - day_start_hour; + if (day_hour % interval == 0) { + if (has_any_storage_food()) { + int gained = add_barricade_health(residents_count * get_resident_effect_multiplier()); + if (gained > 0 && x <= BASE_END) { + speak_with_history("Residents repaired the barricade. +" + gained + " health.", true); + } + } } } } @@ -469,9 +479,6 @@ void update_time() { attempt_resident_butchering(); attempt_resident_foraging(); } - if (current_hour == 12) { - attempt_resident_foraging(); - } attempt_daily_invasion(); keep_base_fires_fed(); update_incense_burning(); diff --git a/src/ui.nvgt b/src/ui.nvgt index 6123762..9906f67 100644 --- a/src/ui.nvgt +++ b/src/ui.nvgt @@ -35,7 +35,7 @@ string get_terrain_at_position(int pos_x) { } string ui_input_box(const string title, const string prompt, const string default_value) { - string result = virtual_input_box(title, prompt, default_value); + string result = virtual_input_box(prompt, prompt, default_value); show_window("Draugnorak"); return result; }