diff --git a/README.md b/README.md index f2daa8a..6f6aebe 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Draugnorak -A survival audio game built with NVGT. Explore, gather, craft, and defend your base as day and night cycle on. +A survival audio game built with NVGT. Explore, gather, craft, and defend your base as day and night cycle. ## Installation - Unzip the file for your operating system. -- On Linux and Mac make sure the dragnorak executable is marked executable +- On Linux and Mac, make sure the draugnorak executable is marked executable. ``` bash chmod +x draugnorak ``` @@ -16,14 +16,12 @@ chmod +x draugnorak - Set snares and build fires to survive. Some of the first things you will want are a stone knife, spear, stone axe, and firepit. - - ## Controls - **Left/Right**: Move. - **Up**: Jump, climb trees, start rope climbs when prompted. - **Down**: Climb down trees or descend rope climbs when prompted. - **Shift (hold)**: Search area (1-second hold, 1-second delay). -- **Control (hold/release)**: Attack with equipped weapon. Sling uses a charge window on release. +- **Control (hold/release)**: Attack with equipped weapon. Sling uses a charge window. - **A**: Action menu (place snare, feed fire, burn incense). - **C**: Crafting menu (base only). - **I**: Inventory. @@ -66,9 +64,9 @@ Some of the first things you will want are a stone knife, spear, stone axe, and Crafting is only available in the base (C). ### Categories -- **Weapons**: Spear, Sling +- **Weapons**: Spear, Sling, Bow - **Tools**: Knife, Snare, Stone Axe, Fishing Pole, Rope, Quiver, Canoe, Reed Basket, Clay Pot -- **Materials**: Arrows, Butcher Game, Incense +- **Materials**: Butcher Game, Smoke Fish, Bowstring, Arrows, Incense - **Clothing**: Skin gear, Moccasins, Pouch, Backpack - **Buildings**: Firepit, Fire, Herb Garden, Storage, Pasture, Stable, Altar - **Barricade**: Reinforcement options @@ -79,8 +77,10 @@ Crafting is only available in the base (C). - **Spear**: 1-tile range. - **Axe**: Melee; also chops trees. - **Sling**: Ranged (uses stones). Hold Control to charge; release on the “in-range” audio cue. +- **Bow**: Ranged (uses arrows). Hold Control to draw; release to fire. Damage scales with draw time (0–10). - Enemies include zombies (night), bandits (invasions), and boars (daytime). - - Bow combat is planned; arrows are stockpiled for future bow use. +- **25% chance** to recover a fired arrow as a world drop. +- If your base has an altar, each undead kill grants **+0.2 favor**. ## Base and Survival - **Passive healing** in the base when below max health. @@ -94,7 +94,7 @@ Crafting is only available in the base (C). Residents can automate survival tasks, defend your base, and process resources. To get the most out of them, ensure you have the necessary tools and facilities in your base storage. ### Recruitment and Care -- **Recruitment**: Residents join automatically after invasions. If you have **Storage** built the likelihood for survivors to become residents increases. You can have a maximum of 4 residents. +- **Recruitment**: Residents join automatically after invasions. If you have **Storage** built, the likelihood for survivors to become residents increases. **1–2 survivors** can join at a time, up to a maximum of **4 residents**. - **Food**: Each resident consumes **1 unit of food every 8 hours** (3 per day). - Priority: Meat > Smoked Fish > Basket of Fruits and Nuts. - If no food is available, residents will go hungry and stop working. @@ -103,10 +103,11 @@ Residents can automate survival tasks, defend your base, and process resources. ### Activities and Requirements Residents automatically perform these tasks if the requirements are met in your base storage. -| Activity | Requires (in Storage) | details | +| Activity | Requires (in Storage) | Details | | :--- | :--- | :--- | | **Defense (Melee)** | **Spear** | Deals damage to attackers. Spears may break. | -| **Defense (Ranged)** | **Sling** + **Stones** | Attacks enemies from a distance. Consumes stones. Slings have a chance to break. | +| **Defense (Ranged - Bow)** | **Bow** + **Arrows** | Preferred ranged defense. Consumes arrows. Bows have a chance to break. | +| **Defense (Ranged - Sling)** | **Sling** + **Stones** | Attacks enemies from a distance. Consumes stones. Slings have a chance to break. | | **Repairs** | N/A | Repairs the **Barricade** during the day. | | **Gathering** | **Reed Basket** | Collects Sticks, Vines, Stones, and Logs. | | **Foraging** | **Reed Basket** | Occurs at 6 AM and 12 PM. Produces **Basket of Fruits and Nuts**. | @@ -115,7 +116,7 @@ Residents automatically perform these tasks if the requirements are met in your | **Butchering** | **Knife** + **Game** | Requires a **burning fire**. Processes Small Game or Boar Carcasses into Meat, Skins, etc. | | **Snare Check** | **Placed Snares** | Checks active snares, retrieves **Small Game**, and resets the snare. | -> **Note**: Tools (Spears, Slings, Baskets, Knives, Fishing Poles) have a chance to break when used by residents. Keep a stock of spares! +> **Note**: Tools (Spears, Slings, Bows, Baskets, Knives, Fishing Poles) have a chance to break when used by residents. Keep a stock of spares! ## Time, Weather, and Events - **1 real minute = 1 in-game hour**. @@ -141,6 +142,7 @@ Residents automatically perform these tasks if the requirements are met in your - Personal inventory stacks are capped at 9. - Skin pouches add +2 stack capacity, backpacks add +9. - Arrows require quivers. Each quiver adds capacity for 12 arrows. +- Bowstrings are crafted from **sinew at a fire** and used to craft bows. - Storage is separate and accessed in the base (Inventory menu when storage exists). ## Saving and Loading diff --git a/draugnorak.nvgt b/draugnorak.nvgt index cda8bf3..b08866d 100755 --- a/draugnorak.nvgt +++ b/draugnorak.nvgt @@ -407,9 +407,33 @@ void main() } update_fishing(); + update_bow_shot(); + bool ctrl_down = (key_down(KEY_LCTRL) || key_down(KEY_RCTRL)); + + // 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 { + speak_ammo_blocked("No arrows."); + } + } + + if (bow_drawing && !ctrl_down) { + release_bow_attack(x); + bow_drawing = false; + } + } + if (!bow_equipped && bow_drawing) { + bow_drawing = false; + } + // Sling charge detection - if (sling_equipped && (key_down(KEY_LCTRL) || key_down(KEY_RCTRL)) && !sling_charging) { + if (!bow_equipped && sling_equipped && ctrl_down && !sling_charging) { if (get_personal_count(ITEM_STONES) > 0) { sling_charging = true; sling_charge_timer.restart(); @@ -421,12 +445,12 @@ void main() } // Update sling charge state while holding - if (sling_charging && (key_down(KEY_LCTRL) || key_down(KEY_RCTRL))) { + if (sling_charging && ctrl_down) { update_sling_charge(); } // Sling release detection - if (sling_charging && (!key_down(KEY_LCTRL) && !key_down(KEY_RCTRL))) { + if (sling_charging && !ctrl_down) { release_sling_attack(x); sling_charging = false; if (sling_sound_handle != -1) { @@ -436,20 +460,12 @@ void main() } // Non-sling weapon attacks (existing pattern) - if (!sling_equipped && !sling_charging) { + if (!bow_equipped && !bow_drawing && !sling_equipped && !sling_charging) { 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 (bow_equipped && ctrl_down && attack_timer.elapsed > attack_cooldown) { - if (get_personal_count(ITEM_ARROWS) <= 0) { - speak_ammo_blocked("No arrows."); - } else { - attack_timer.restart(); - perform_attack(x); - } - } else if (!bow_equipped && !fishing_pole_equipped && ctrl_down && attack_timer.elapsed > attack_cooldown) { + if (!fishing_pole_equipped && ctrl_down && attack_timer.elapsed > attack_cooldown) { attack_timer.restart(); perform_attack(x); } diff --git a/sounds/weapons/arrow_flies.ogg b/sounds/weapons/arrow_flies.ogg new file mode 100644 index 0000000..a82b1c5 --- /dev/null +++ b/sounds/weapons/arrow_flies.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e985e65e857462aa6ac17199268dd7c571c0694d7c879815a568f97f6a5c4203 +size 5683 diff --git a/sounds/weapons/arrow_hit.ogg b/sounds/weapons/arrow_hit.ogg new file mode 100644 index 0000000..e6cbd06 --- /dev/null +++ b/sounds/weapons/arrow_hit.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46c9f34ea54ca3ab9c2fdb2124b35635f2bdc8215e6e4269eb671f07fd642c4e +size 17742 diff --git a/sounds/weapons/bow_draw.ogg b/sounds/weapons/bow_draw.ogg new file mode 100644 index 0000000..a9720a5 --- /dev/null +++ b/sounds/weapons/bow_draw.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10f3bd61c12a17ca1483e6a4ffb4217d658e483ad7d97add4fc06ec15a89047d +size 13370 diff --git a/sounds/weapons/bow_fire.ogg b/sounds/weapons/bow_fire.ogg new file mode 100644 index 0000000..143de06 --- /dev/null +++ b/sounds/weapons/bow_fire.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58a71d0580c3ce278519f239265d04d3e196afc6cb1f054c58a47e7b4db38f46 +size 5533 diff --git a/src/base_system.nvgt b/src/base_system.nvgt index eae6fea..7ac1f2d 100644 --- a/src/base_system.nvgt +++ b/src/base_system.nvgt @@ -198,12 +198,20 @@ void keep_base_fires_fed() { } // Resident defense functions +const int RESIDENT_WEAPON_SPEAR = 0; +const int RESIDENT_WEAPON_SLING = 1; +const int RESIDENT_WEAPON_BOW = 2; + int get_available_defense_weapons() { int count = get_storage_count(ITEM_SPEARS); // Slings only count if stones are available if (get_storage_count(ITEM_SLINGS) > 0 && get_storage_count(ITEM_STONES) > 0) { count += get_storage_count(ITEM_SLINGS); } + // Bows only count if arrows are available + if (get_storage_count(ITEM_BOWS) > 0 && get_storage_count(ITEM_ARROWS) > 0) { + count += get_storage_count(ITEM_BOWS); + } return count; } @@ -212,33 +220,42 @@ bool can_residents_defend() { return get_available_defense_weapons() > 0; } -bool choose_defense_weapon() { - // Returns true for spear, false for sling +int choose_defense_weapon_type() { + // Prefer bows if available + int bowCount = (get_storage_count(ITEM_BOWS) > 0 && get_storage_count(ITEM_ARROWS) > 0) + ? get_storage_count(ITEM_BOWS) + : 0; + if (bowCount > 0) return RESIDENT_WEAPON_BOW; + int spearCount = get_storage_count(ITEM_SPEARS); int slingCount = (get_storage_count(ITEM_SLINGS) > 0 && get_storage_count(ITEM_STONES) > 0) ? get_storage_count(ITEM_SLINGS) : 0; int total = spearCount + slingCount; - if (total == 0) return true; - if (slingCount == 0) return true; - if (spearCount == 0) return false; + if (total == 0) return RESIDENT_WEAPON_SPEAR; + if (slingCount == 0) return RESIDENT_WEAPON_SPEAR; + if (spearCount == 0) return RESIDENT_WEAPON_SLING; int roll = random(1, total); - return roll <= spearCount; + return (roll <= spearCount) ? RESIDENT_WEAPON_SPEAR : RESIDENT_WEAPON_SLING; } int perform_resident_defense() { if (!can_residents_defend()) return 0; - // Choose weapon type randomly weighted by availability - bool useSpear = choose_defense_weapon(); + // Choose weapon type (bows preferred, otherwise weighted by availability) + int weapon_type = choose_defense_weapon_type(); int damage = 0; - if (useSpear && get_storage_count(ITEM_SPEARS) > 0) { + if (weapon_type == RESIDENT_WEAPON_BOW && get_storage_count(ITEM_BOWS) > 0 && get_storage_count(ITEM_ARROWS) > 0) { + damage = apply_resident_damage_bonus(random(RESIDENT_SLING_DAMAGE_MIN, RESIDENT_SLING_DAMAGE_MAX)); + add_storage_count(ITEM_ARROWS, -1); + play_1d_with_volume_step("sounds/weapons/bow_fire.ogg", x, BASE_END + 1, false, RESIDENT_DEFENSE_VOLUME_STEP); + } else if (weapon_type == RESIDENT_WEAPON_SPEAR && get_storage_count(ITEM_SPEARS) > 0) { damage = apply_resident_damage_bonus(RESIDENT_SPEAR_DAMAGE); // Weapons don't get consumed on use - they break via daily breakage check // Just play the sound play_1d_with_volume_step("sounds/weapons/spear_swing.ogg", x, BASE_END + 1, false, RESIDENT_DEFENSE_VOLUME_STEP); - } else if (get_storage_count(ITEM_SLINGS) > 0 && get_storage_count(ITEM_STONES) > 0) { + } else if (weapon_type == RESIDENT_WEAPON_SLING && get_storage_count(ITEM_SLINGS) > 0 && get_storage_count(ITEM_STONES) > 0) { damage = apply_resident_damage_bonus(random(RESIDENT_SLING_DAMAGE_MIN, RESIDENT_SLING_DAMAGE_MAX)); // Slings use stones as ammo, so consume a stone add_storage_count(ITEM_STONES, -1); @@ -248,28 +265,31 @@ int perform_resident_defense() { return damage; } -// Proactive resident sling defense -timer resident_sling_timer; +// Proactive resident ranged defense +timer resident_ranged_timer; -void attempt_resident_sling_defense() { - // Only if residents exist and have slings with stones +void attempt_resident_ranged_defense() { + // Only if residents exist and have ranged weapons if (residents_count <= 0) return; - if (get_storage_count(ITEM_SLINGS) <= 0 || get_storage_count(ITEM_STONES) <= 0) return; + bool has_bow = (get_storage_count(ITEM_BOWS) > 0 && get_storage_count(ITEM_ARROWS) > 0); + bool has_sling = (get_storage_count(ITEM_SLINGS) > 0 && get_storage_count(ITEM_STONES) > 0); + if (!has_bow && !has_sling) return; // Cooldown between shots - if (resident_sling_timer.elapsed < get_resident_cooldown(RESIDENT_SLING_COOLDOWN)) return; + if (resident_ranged_timer.elapsed < get_resident_cooldown(RESIDENT_SLING_COOLDOWN)) return; - // Find nearest enemy within sling range - int nearestDistance = SLING_RANGE + 1; + int range = has_bow ? BOW_RANGE : SLING_RANGE; + // Find nearest enemy within range + int nearestDistance = range + 1; int targetPos = -1; bool targetIsBandit = false; - int sling_origin = BASE_END; + int ranged_origin = BASE_END; // Check zombies for (uint i = 0; i < zombies.length(); i++) { - int dist = abs(zombies[i].position - sling_origin); - if (dist > 0 && dist <= SLING_RANGE && dist < nearestDistance) { + int dist = abs(zombies[i].position - ranged_origin); + if (dist > 0 && dist <= range && dist < nearestDistance) { nearestDistance = dist; targetPos = zombies[i].position; targetIsBandit = false; @@ -278,8 +298,8 @@ void attempt_resident_sling_defense() { // Check bandits for (uint i = 0; i < bandits.length(); i++) { - int dist = abs(bandits[i].position - sling_origin); - if (dist > 0 && dist <= SLING_RANGE && dist < nearestDistance) { + int dist = abs(bandits[i].position - ranged_origin); + if (dist > 0 && dist <= range && dist < nearestDistance) { nearestDistance = dist; targetPos = bandits[i].position; targetIsBandit = true; @@ -290,11 +310,16 @@ void attempt_resident_sling_defense() { if (targetPos == -1) return; // Shoot! - resident_sling_timer.restart(); - add_storage_count(ITEM_STONES, -1); - + resident_ranged_timer.restart(); int damage = apply_resident_damage_bonus(random(RESIDENT_SLING_DAMAGE_MIN, RESIDENT_SLING_DAMAGE_MAX)); - play_1d_with_volume_step("sounds/weapons/sling_hit.ogg", x, targetPos, false, RESIDENT_DEFENSE_VOLUME_STEP); + if (has_bow) { + add_storage_count(ITEM_ARROWS, -1); + play_1d_with_volume_step("sounds/weapons/bow_fire.ogg", x, BASE_END + 1, false, RESIDENT_DEFENSE_VOLUME_STEP); + play_1d_with_volume_step("sounds/weapons/arrow_hit.ogg", x, targetPos, false, RESIDENT_DEFENSE_VOLUME_STEP); + } else { + add_storage_count(ITEM_STONES, -1); + play_1d_with_volume_step("sounds/weapons/sling_hit.ogg", x, targetPos, false, RESIDENT_DEFENSE_VOLUME_STEP); + } if (targetIsBandit) { damage_bandit_at(targetPos, damage); @@ -313,7 +338,7 @@ void attempt_resident_sling_defense() { void process_daily_weapon_breakage() { if (residents_count <= 0) return; - int totalWeapons = get_storage_count(ITEM_SPEARS) + get_storage_count(ITEM_SLINGS); + int totalWeapons = get_storage_count(ITEM_SPEARS) + get_storage_count(ITEM_SLINGS) + get_storage_count(ITEM_BOWS); if (totalWeapons == 0) return; // Number of breakage checks = min(residents, weapons) @@ -322,17 +347,21 @@ void process_daily_weapon_breakage() { // Distribute checks among available weapons int spearChecks = 0; int slingChecks = 0; + int bowChecks = 0; for (int i = 0; i < checksToPerform; i++) { int remainingSpears = get_storage_count(ITEM_SPEARS) - spearChecks; int remainingSlings = get_storage_count(ITEM_SLINGS) - slingChecks; - int remaining = remainingSpears + remainingSlings; + int remainingBows = get_storage_count(ITEM_BOWS) - bowChecks; + int remaining = remainingSpears + remainingSlings + remainingBows; if (remaining <= 0) break; int roll = random(1, remaining); if (roll <= remainingSpears && remainingSpears > 0) { spearChecks++; + } else if (roll <= remainingSpears + remainingBows && remainingBows > 0) { + bowChecks++; } else if (remainingSlings > 0) { slingChecks++; } @@ -341,6 +370,7 @@ void process_daily_weapon_breakage() { // Perform breakage checks int spearsBroken = 0; int slingsBroken = 0; + int bowsBroken = 0; int break_chance = get_resident_break_chance(RESIDENT_WEAPON_BREAK_CHANCE); for (int i = 0; i < spearChecks; i++) { @@ -355,6 +385,12 @@ void process_daily_weapon_breakage() { } } + for (int i = 0; i < bowChecks; i++) { + if (random(1, 100) <= break_chance) { + bowsBroken++; + } + } + // Apply breakage if (spearsBroken > 0) { add_storage_count(ITEM_SPEARS, -spearsBroken); @@ -373,6 +409,15 @@ void process_daily_weapon_breakage() { : slingsBroken + " slings broke from wear."; notify(msg); } + + if (bowsBroken > 0) { + add_storage_count(ITEM_BOWS, -bowsBroken); + if (get_storage_count(ITEM_BOWS) < 0) set_storage_count(ITEM_BOWS, 0); + string msg = (bowsBroken == 1) + ? "A resident's bow broke from wear." + : bowsBroken + " bows broke from wear."; + notify(msg); + } } // Resident snare retrieval diff --git a/src/combat.nvgt b/src/combat.nvgt index 2d9d60e..1c0769f 100644 --- a/src/combat.nvgt +++ b/src/combat.nvgt @@ -14,6 +14,11 @@ void perform_attack(int current_x) { timer ammoBlockTimer; string lastAmmoBlockMessage = ""; const int AMMO_BLOCK_COOLDOWN_MS = 3000; +const int BOW_HIT_NONE = 0; +const int BOW_HIT_BANDIT = 1; +const int BOW_HIT_BOAR = 2; +const int BOW_HIT_ZOMBIE = 3; +const int BOW_HIT_FLYING = 4; void speak_ammo_blocked(string message) { if (message == lastAmmoBlockMessage && ammoBlockTimer.elapsed < AMMO_BLOCK_COOLDOWN_MS) { @@ -25,6 +30,44 @@ void speak_ammo_blocked(string message) { speak_with_history(message, true); } +int find_ranged_enemy(int player_x, int range, int direction, bool allow_flying, bool &out hit_bandit, bool &out hit_boar, bool &out hit_flying_creature) { + hit_bandit = false; + hit_boar = false; + hit_flying_creature = false; + + for (int dist = 1; dist <= range; dist++) { + int check_x = player_x + (dist * direction); + if (check_x < 0 || check_x >= MAP_SIZE) break; + + Bandit@ bandit = get_bandit_at(check_x); + if (bandit != null) { + hit_bandit = true; + return check_x; + } + + GroundGame@ boar = get_boar_at(check_x); + if (boar != null) { + hit_boar = true; + return check_x; + } + + Undead@ undead = get_zombie_at(check_x); + if (undead != null) { + return check_x; + } + + if (allow_flying) { + FlyingCreature@ creature = get_flying_creature_at(check_x); + if (creature != null && creature.state == "flying") { + hit_flying_creature = true; + return check_x; + } + } + } + + return -1; +} + int attack_enemy_ranged(int start_x, int end_x, int damage) { for (int check_x = start_x; check_x <= end_x; check_x++) { // Check for bandits first (priority during daytime) @@ -137,6 +180,145 @@ void update_sling_charge() { } } +int get_bow_draw_damage(int elapsed_ms) { + int clamped = elapsed_ms; + if (clamped < 0) clamped = 0; + if (clamped > BOW_DRAW_TIME_MS) clamped = BOW_DRAW_TIME_MS; + + float ratio = float(clamped) / float(BOW_DRAW_TIME_MS); + int damage = int(ratio * BOW_DAMAGE_MAX); + if (damage < BOW_DAMAGE_MIN) damage = BOW_DAMAGE_MIN; + if (damage > BOW_DAMAGE_MAX) damage = BOW_DAMAGE_MAX; + return damage; +} + +void stop_bow_shot_audio() { + safe_destroy_sound(bow_shot_sound_handle); + bow_shot_active = false; + bow_shot_duration_ms = 0; + bow_shot_hit_x = -1; + bow_shot_hit_type = BOW_HIT_NONE; +} + +void start_bow_shot_audio(int start_x, int end_x, int hit_x, int hit_type, 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_hit_type = hit_type; + bow_shot_duration_ms = duration_ms; + bow_shot_drop_pending = false; + bow_shot_drop_pos = -1; + 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", + x, + bow_shot_start_x, + false, + PLAYER_WEAPON_SOUND_VOLUME_STEP + ); +} + +void update_bow_shot() { + 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; + int hit_type = bow_shot_hit_type; + bool drop_pending = bow_shot_drop_pending; + int drop_pos = bow_shot_drop_pos; + stop_bow_shot_audio(); + if (hit_x >= 0) { + play_1d_with_volume_step( + "sounds/weapons/arrow_hit.ogg", + x, + hit_x, + false, + PLAYER_WEAPON_SOUND_VOLUME_STEP + ); + if (hit_type == BOW_HIT_BANDIT) { + play_creature_hit_sound("sounds/enemies/zombie_hit.ogg", x, hit_x, BANDIT_SOUND_VOLUME_STEP); + } else if (hit_type == BOW_HIT_BOAR) { + play_creature_hit_sound("sounds/enemies/zombie_hit.ogg", x, hit_x, BOAR_SOUND_VOLUME_STEP); + } else if (hit_type == BOW_HIT_ZOMBIE) { + play_creature_hit_sound("sounds/enemies/zombie_hit.ogg", x, hit_x, ZOMBIE_SOUND_VOLUME_STEP); + } + } + if (drop_pending && drop_pos >= 0) { + if (get_drop_at(drop_pos) == null) { + add_world_drop(drop_pos, "arrow"); + } + } + } +} + +void release_bow_attack(int player_x) { + 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 = (facing == 1) ? 1 : -1; + bool hit_bandit = false; + 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); + + int hit_type = BOW_HIT_NONE; + if (target_x != -1) { + if (hit_bandit) { + damage_bandit_at(target_x, damage); + hit_type = BOW_HIT_BANDIT; + } else if (hit_boar) { + damage_boar_at(target_x, damage); + hit_type = BOW_HIT_BOAR; + } else if (hit_flying_creature) { + damage_flying_creature_at(target_x, damage); + hit_type = BOW_HIT_FLYING; + } else { + damage_zombie_at(target_x, damage); + hit_type = BOW_HIT_ZOMBIE; + } + } + + 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; + } + + int hit_x = (target_x != -1) ? target_x : -1; + start_bow_shot_audio(player_x, end_x, hit_x, hit_type, duration_ms); + if (random(1, 100) <= 25) { + bow_shot_drop_pending = true; + bow_shot_drop_pos = (target_x != -1) ? target_x : end_x; + } +} + void release_sling_attack(int player_x) { // Consume stone add_personal_count(ITEM_STONES, -1); @@ -158,44 +340,7 @@ void release_sling_attack(int player_x) { bool hit_bandit = false; bool hit_flying_creature = false; bool hit_boar = false; - - // Priority: Find nearest enemy (bandit or zombie) first - for (int dist = 1; dist <= SLING_RANGE; dist++) { - int check_x = player_x + (dist * search_direction); - if (check_x < 0 || check_x >= MAP_SIZE) break; - - // Check for bandit first - Bandit@ bandit = get_bandit_at(check_x); - if (bandit != null) { - target_x = check_x; - hit_bandit = true; - break; - } - - // Then check for boar - GroundGame@ boar = get_boar_at(check_x); - if (boar != null) { - target_x = check_x; - hit_boar = true; - break; - } - - // Then check for undead - Undead@ undead = get_zombie_at(check_x); - if (undead != null) { - target_x = check_x; - hit_bandit = false; - break; - } - - // Then check for flying creature (only if flying) - FlyingCreature@ creature = get_flying_creature_at(check_x); - if (creature != null && creature.state == "flying") { - target_x = check_x; - hit_flying_creature = true; - break; - } - } + target_x = find_ranged_enemy(player_x, SLING_RANGE, search_direction, true, hit_bandit, hit_boar, hit_flying_creature); // If no enemy found, check for trees (but don't damage them) if (target_x == -1) { diff --git a/src/constants.nvgt b/src/constants.nvgt index 4a5694f..72aeeb2 100644 --- a/src/constants.nvgt +++ b/src/constants.nvgt @@ -37,9 +37,12 @@ const int SLING_DAMAGE_MAX = 8; const int SLING_RANGE = 8; // Bow settings -const int BOW_DAMAGE_MIN = 6; -const int BOW_DAMAGE_MAX = 9; +const int BOW_DAMAGE_MIN = 0; +const int BOW_DAMAGE_MAX = 10; const int BOW_RANGE = 12; +const int BOW_DRAW_TIME_MS = 1000; +const int ARROW_FLIES_DURATION_MS = 230; +const int BOW_MISS_EXTRA_TILES = 3; const int ARROWS_PER_CRAFT = 12; const int ARROW_CAPACITY_PER_QUIVER = 12; diff --git a/src/crafting/craft_materials.nvgt b/src/crafting/craft_materials.nvgt index 8f35009..a4cd227 100644 --- a/src/crafting/craft_materials.nvgt +++ b/src/crafting/craft_materials.nvgt @@ -7,6 +7,7 @@ void run_materials_menu() { "Butcher Game [Requires Game, Knife, Fire nearby]", "Smoke Fish (1 Fish, 1 Stick) [Requires Fire nearby]", "Arrows (2 Sticks, 4 Feathers, 2 Stones) [Requires Quiver]", + "Bowstring (3 Sinew) [Requires Fire nearby]", "Incense (6 Sticks, 2 Vines, 1 Reed) [Requires Altar]" }; @@ -34,7 +35,8 @@ void run_materials_menu() { if (selection == 0) butcher_small_game(); else if (selection == 1) craft_smoke_fish(); else if (selection == 2) craft_arrows(); - else if (selection == 3) craft_incense(); + else if (selection == 3) craft_bowstring(); + else if (selection == 4) craft_incense(); break; } @@ -42,7 +44,8 @@ void run_materials_menu() { if (selection == 0) butcher_small_game_max(); else if (selection == 1) craft_smoke_fish_max(); else if (selection == 2) craft_arrows_max(); - else if (selection == 3) craft_incense_max(); + else if (selection == 3) craft_bowstring_max(); + else if (selection == 4) craft_incense_max(); break; } } @@ -119,6 +122,61 @@ void craft_arrows_max() { speak_with_history("Crafted " + (ARROWS_PER_CRAFT * maxCraft) + " arrows.", true); } +void craft_bowstring() { + WorldFire@ fire = get_fire_within_range(x, 3); + if (fire == null) { + speak_with_history("You need a fire within 3 tiles to make bowstring.", true); + return; + } + + string missing = ""; + if (get_personal_count(ITEM_SINEW) < 3) missing += "3 sinew "; + + if (missing == "") { + if (get_personal_count(ITEM_BOWSTRINGS) >= get_personal_stack_limit()) { + speak_with_history("You can't carry any more bowstrings.", true); + return; + } + simulate_crafting(3); + add_personal_count(ITEM_SINEW, -3); + add_personal_count(ITEM_BOWSTRINGS, 1); + speak_with_history("Crafted a bowstring.", true); + } else { + speak_with_history("Missing: " + missing, true); + } +} + +void craft_bowstring_max() { + WorldFire@ fire = get_fire_within_range(x, 3); + if (fire == null) { + speak_with_history("You need a fire within 3 tiles to make bowstring.", true); + return; + } + + if (get_personal_count(ITEM_BOWSTRINGS) >= get_personal_stack_limit()) { + speak_with_history("You can't carry any more bowstrings.", true); + return; + } + + int max_by_sinew = get_personal_count(ITEM_SINEW) / 3; + int max_craft = max_by_sinew; + + int space = get_personal_stack_limit() - get_personal_count(ITEM_BOWSTRINGS); + if (max_craft > space) max_craft = space; + + if (max_craft <= 0) { + speak_with_history("Missing: 3 sinew", true); + return; + } + + int total_cost = max_craft * 3; // 3 sinew per bowstring + int craft_time = (total_cost < max_craft * 4) ? max_craft * 4 : total_cost; + simulate_crafting(craft_time); + add_personal_count(ITEM_SINEW, -(max_craft * 3)); + add_personal_count(ITEM_BOWSTRINGS, max_craft); + speak_with_history("Crafted " + max_craft + " Bowstrings.", true); +} + void craft_incense() { if (world_altars.length() == 0) { speak_with_history("You need an altar to craft incense.", true); diff --git a/src/crafting/craft_weapons.nvgt b/src/crafting/craft_weapons.nvgt index 08594d0..9fd7401 100644 --- a/src/crafting/craft_weapons.nvgt +++ b/src/crafting/craft_weapons.nvgt @@ -5,7 +5,8 @@ void run_weapons_menu() { int selection = 0; string[] options = { "Spear (1 Stick, 1 Vine, 1 Stone) [Requires Knife]", - "Sling (1 Skin, 2 Vines)" + "Sling (1 Skin, 2 Vines)", + "Bow (1 Stick, 1 Bowstring)" }; while(true) { @@ -31,12 +32,14 @@ void run_weapons_menu() { if (key_pressed(KEY_RETURN)) { if (selection == 0) craft_spear(); else if (selection == 1) craft_sling(); + else if (selection == 2) craft_bow(); break; } if (key_pressed(KEY_TAB)) { if (selection == 0) craft_spear_max(); else if (selection == 1) craft_sling_max(); + else if (selection == 2) craft_bow_max(); break; } } @@ -155,6 +158,57 @@ void craft_sling_max() { speak_with_history("Crafted " + max_craft + " Slings.", true); } +void craft_bow() { + string missing = ""; + if (get_personal_count(ITEM_STICKS) < 1) missing += "1 stick "; + if (get_personal_count(ITEM_BOWSTRINGS) < 1) missing += "1 bowstring "; + + if (missing == "") { + if (get_personal_count(ITEM_BOWS) >= get_personal_stack_limit()) { + speak_with_history("You can't carry any more bows.", true); + return; + } + simulate_crafting(3); + add_personal_count(ITEM_STICKS, -1); + add_personal_count(ITEM_BOWSTRINGS, -1); + add_personal_count(ITEM_BOWS, 1); + speak_with_history("Crafted a Bow.", true); + } else { + speak_with_history("Missing: " + missing, true); + } +} + +void craft_bow_max() { + if (get_personal_count(ITEM_BOWS) >= get_personal_stack_limit()) { + speak_with_history("You can't carry any more bows.", true); + return; + } + + int max_by_sticks = get_personal_count(ITEM_STICKS); + int max_by_bowstrings = get_personal_count(ITEM_BOWSTRINGS); + int max_craft = max_by_sticks; + if (max_by_bowstrings < max_craft) max_craft = max_by_bowstrings; + + int space = get_personal_stack_limit() - get_personal_count(ITEM_BOWS); + if (max_craft > space) max_craft = space; + + if (max_craft <= 0) { + string missing = ""; + if (get_personal_count(ITEM_STICKS) < 1) missing += "1 stick "; + if (get_personal_count(ITEM_BOWSTRINGS) < 1) missing += "1 bowstring "; + speak_with_history("Missing: " + missing, true); + return; + } + + int total_cost = max_craft * 2; // 1 stick + 1 bowstring per bow + int craft_time = (total_cost < max_craft * 4) ? max_craft * 4 : total_cost; + simulate_crafting(craft_time); + add_personal_count(ITEM_STICKS, -max_craft); + add_personal_count(ITEM_BOWSTRINGS, -max_craft); + add_personal_count(ITEM_BOWS, max_craft); + speak_with_history("Crafted " + max_craft + " Bows.", true); +} + void craft_axe() { string missing = ""; if (get_personal_count(ITEM_KNIVES) < 1) missing += "Stone Knife "; diff --git a/src/enemies/undead.nvgt b/src/enemies/undead.nvgt index 05ccf14..91593d1 100644 --- a/src/enemies/undead.nvgt +++ b/src/enemies/undead.nvgt @@ -239,6 +239,9 @@ bool damage_undead_at(int pos, int damage) { if (undeads[i].position == pos) { undeads[i].health -= damage; if (undeads[i].health <= 0) { + if (world_altars.length() > 0) { + favor += 0.2; + } if (undeads[i].sound_handle != -1) { p.destroy_sound(undeads[i].sound_handle); undeads[i].sound_handle = -1; diff --git a/src/player.nvgt b/src/player.nvgt index 0a82f64..e8dcefb 100644 --- a/src/player.nvgt +++ b/src/player.nvgt @@ -35,6 +35,20 @@ timer sling_charge_timer; int sling_sound_handle = -1; int last_sling_stage = -1; // Track which stage we're in to avoid duplicate sounds +// Bow state +bool bow_drawing = false; +timer bow_draw_timer; +bool bow_shot_active = false; +timer bow_shot_timer; +int bow_shot_start_x = 0; +int bow_shot_end_x = 0; +int bow_shot_hit_x = -1; +int bow_shot_hit_type = 0; +int bow_shot_sound_handle = -1; +int bow_shot_duration_ms = 0; +bool bow_shot_drop_pending = false; +int bow_shot_drop_pos = -1; + // Fishing state bool is_casting = false; // Holding control, cast_strength playing bool line_in_water = false; // Cast successful, waiting for fish @@ -88,6 +102,8 @@ void restart_all_timers() { fire_damage_timer.restart(); healing_timer.restart(); sling_charge_timer.restart(); + bow_draw_timer.restart(); + bow_shot_timer.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 df44e32..8042ec6 100644 --- a/src/save_system.nvgt +++ b/src/save_system.nvgt @@ -110,6 +110,10 @@ void stop_active_sounds() { p.destroy_sound(sling_sound_handle); sling_sound_handle = -1; } + if (bow_shot_sound_handle != -1) { + p.destroy_sound(bow_shot_sound_handle); + bow_shot_sound_handle = -1; + } stop_all_weather_sounds(); } @@ -164,6 +168,16 @@ void reset_game_state() { climb_target_y = 0; fall_start_y = 0; sling_charging = false; + bow_drawing = false; + bow_shot_active = false; + bow_shot_start_x = 0; + bow_shot_end_x = 0; + bow_shot_hit_x = -1; + bow_shot_hit_type = 0; + bow_shot_duration_ms = 0; + bow_shot_sound_handle = -1; + bow_shot_drop_pending = false; + bow_shot_drop_pos = -1; searching = false; rope_climbing = false; rope_climb_up = true; @@ -256,6 +270,8 @@ void reset_game_state() { fire_damage_timer.restart(); healing_timer.restart(); sling_charge_timer.restart(); + bow_draw_timer.restart(); + bow_shot_timer.restart(); fall_timer.restart(); climb_timer.restart(); } diff --git a/src/time_system.nvgt b/src/time_system.nvgt index afbc022..f64e426 100644 --- a/src/time_system.nvgt +++ b/src/time_system.nvgt @@ -284,7 +284,7 @@ void attempt_resident_recruitment() { return; } - int added = random(1, 3); + int added = random(1, 2); // Don't exceed cap if (residents_count + added > MAX_RESIDENTS) { added = MAX_RESIDENTS - residents_count; @@ -483,8 +483,8 @@ void update_time() { ensure_ambience_running(); - // Proactive resident defense with slings - attempt_resident_sling_defense(); + // Proactive resident ranged defense + attempt_resident_ranged_defense(); // Manage invasion enemies during active invasion manage_bandits_during_invasion(); diff --git a/src/world/world_drops.nvgt b/src/world/world_drops.nvgt index 0588e3a..0ddb206 100644 --- a/src/world/world_drops.nvgt +++ b/src/world/world_drops.nvgt @@ -104,6 +104,20 @@ bool try_pickup_world_drop(WorldDrop@ drop) { if (get_flying_creature_config_by_drop_type(drop.type) !is null) { return try_pickup_small_game(drop.type); } + if (drop.type == "arrow") { + int max_arrows = get_arrow_limit(); + if (max_arrows <= 0) { + speak_with_history("You need a quiver to carry arrows.", true); + return false; + } + if (get_personal_count(ITEM_ARROWS) >= max_arrows) { + speak_with_history("You can't carry any more arrows.", true); + return false; + } + add_personal_count(ITEM_ARROWS, 1); + speak_with_history("Picked up arrow.", true); + return true; + } if (drop.type == "boar carcass") { if (get_personal_count(ITEM_BOAR_CARCASSES) >= get_personal_stack_limit()) { speak_with_history("You can't carry any more boar carcasses.", true);