From 09b327fab7bb936d037269aa2568a166012d13c6 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Mon, 6 Apr 2026 22:46:16 -0500 Subject: [PATCH 01/11] new mine weapon type --- code/ai/ai_flags.h | 1 + code/ai/ai_profiles.cpp | 2 + code/ai/aicode.cpp | 107 +++++++++++++ code/ai/aiturret.cpp | 6 + code/hud/hudtarget.cpp | 50 +++---- code/radar/radarsetup.cpp | 38 ++++- code/scripting/global_hooks.cpp | 8 + code/scripting/global_hooks.h | 1 + code/scripting/hook_conditions.cpp | 5 + code/scripting/hook_conditions.h | 6 + code/ship/ship.cpp | 4 +- code/weapon/weapon.h | 10 ++ code/weapon/weapon_flags.h | 1 + code/weapon/weapons.cpp | 233 ++++++++++++++++++++++++++++- 14 files changed, 438 insertions(+), 34 deletions(-) diff --git a/code/ai/ai_flags.h b/code/ai/ai_flags.h index 5e8362a463c..0e9fa4d0d5e 100644 --- a/code/ai/ai_flags.h +++ b/code/ai/ai_flags.h @@ -132,6 +132,7 @@ namespace AI { Smart_shield_management, Smart_subsystem_targeting_for_turrets, Strict_turret_tagged_only_targeting, + Strikecraft_intercept_mines, // strikecraft autonomously engage hostile mines within mine_targetable_range of themselves Support_dont_add_primaries, //Prevents support ship from equipping new primary as requested in https://scp.indiegames.us/mantis/view.php?id=3198 Turrets_ignore_target_radius, Use_actual_primary_range, diff --git a/code/ai/ai_profiles.cpp b/code/ai/ai_profiles.cpp index 57303161423..84a8ed49b81 100644 --- a/code/ai/ai_profiles.cpp +++ b/code/ai/ai_profiles.cpp @@ -744,6 +744,8 @@ void parse_ai_profiles_tbl(const char *filename) set_flag(profile, "$cancel future waves of any wing launched from an exited ship:", AI::Profile_Flags::Cancel_future_waves_of_any_wing_launched_from_an_exited_ship); + set_flag(profile, "$strikecraft intercept mines:", AI::Profile_Flags::Strikecraft_intercept_mines); + // end of options ---------------------------------------- diff --git a/code/ai/aicode.cpp b/code/ai/aicode.cpp index ad556704659..e9e1edc00ce 100644 --- a/code/ai/aicode.cpp +++ b/code/ai/aicode.cpp @@ -6484,6 +6484,11 @@ bool ai_select_secondary_weapon(object *objp, ship_weapon *swp, flagsetobjnum >= 0 && mo->objnum < MAX_OBJECTS); + object *mine_objp = &Objects[mo->objnum]; + if (mine_objp->flags[Object::Object_Flags::Should_be_dead]) + continue; + + weapon *wp = &Weapons[mine_objp->instance]; + weapon_info *wip = &Weapon_info[wp->weapon_info_index]; + + if (!wip->is_mine()) + continue; + + // Only engage destructible mines + if (!wip->wi_flags[Weapon::Info_Flags::Fighter_Interceptable]) + continue; + + // Only engage mines hostile to this ship + if (!iff_x_attacks_y(wp->team, obj_team(ai_objp))) + continue; + + // Mine must be within its own targetable range of this ship + float dist = vm_vec_dist_quick(&mine_objp->pos, &ai_objp->pos); + if (wip->mine_targetable_range >= 0.0f && dist > wip->mine_targetable_range) + continue; + + if (dist < closest_dist) { + closest_dist = dist; + closest_mine = mine_objp; + } + } + + return closest_mine; +} + int ai_guard_find_nearby_bomb(object *guarding_objp, object *guarded_objp) { missile_obj *mo; @@ -10544,6 +10594,48 @@ int ai_guard_find_nearby_bomb(object *guarding_objp, object *guarded_objp) return 1; } + // No homing threat found... check for mines within targeting range of the guarded ship. + // Mines are stationary so they won't home; handle them as a lower priority threat. + object *closest_mine_objp = nullptr; + float closest_mine_dist_to_guarding = 999999.0f; + + for ( mo = GET_NEXT(&Missile_obj_list); mo != END_OF_LIST(&Missile_obj_list); mo = GET_NEXT(mo) ) { + Assert(mo->objnum >= 0 && mo->objnum < MAX_OBJECTS); + bomb_objp = &Objects[mo->objnum]; + if (bomb_objp->flags[Object::Object_Flags::Should_be_dead]) + continue; + + wp = &Weapons[bomb_objp->instance]; + wip = &Weapon_info[wp->weapon_info_index]; + + if (!wip->is_mine()) + continue; + + // Only engage destructible mines + if (!wip->wi_flags[Weapon::Info_Flags::Fighter_Interceptable]) + continue; + + // Only engage mines that threaten the guarded ship's team + if (!iff_x_attacks_y(wp->team, obj_team(guarded_objp))) + continue; + + // Mine must be within its targetable range of the guarded ship + float dist_to_guarded = vm_vec_dist_quick(&bomb_objp->pos, &guarded_objp->pos); + if (wip->mine_targetable_range >= 0.0f && dist_to_guarded > wip->mine_targetable_range) + continue; + + float dist_to_guarding = vm_vec_dist_quick(&bomb_objp->pos, &guarding_objp->pos); + if (dist_to_guarding < closest_mine_dist_to_guarding) { + closest_mine_dist_to_guarding = dist_to_guarding; + closest_mine_objp = bomb_objp; + } + } + + if (closest_mine_objp) { + guard_object_was_hit(guarding_objp, closest_mine_objp); + return 1; + } + return 0; } @@ -10654,6 +10746,13 @@ void ai_guard_find_nearby_object() if ( (aip->target_objnum == -1) && asteroid_count() ) { ai_guard_find_nearby_asteroid(Pl_objp, guardobjp); } + + // if still not attacking anything and flag is set, engage mines near the guarding ship itself + if (aip->target_objnum == -1 && The_mission.ai_profile->flags[AI::Profile_Flags::Strikecraft_intercept_mines]) { + object *mine_objp = ai_find_nearby_mine_threat(Pl_objp); + if (mine_objp) + guard_object_was_hit(Pl_objp, mine_objp); + } } } @@ -15302,6 +15401,14 @@ void ai_frame(int objnum) { En_objp = &Objects[target_objnum]; } + } else if (aip->mode != AIM_GUARD && The_mission.ai_profile->flags[AI::Profile_Flags::Strikecraft_intercept_mines]) { + // No enemy found so check for nearby hostile mines within their targetable range + object *mine_objp = ai_find_nearby_mine_threat(Pl_objp); + if (mine_objp) { + target_objnum = set_target_objnum(aip, OBJ_INDEX(mine_objp)); + if (target_objnum >= 0) + En_objp = &Objects[target_objnum]; + } } } } diff --git a/code/ai/aiturret.cpp b/code/ai/aiturret.cpp index 1cf25f6a502..09bd6991829 100644 --- a/code/ai/aiturret.cpp +++ b/code/ai/aiturret.cpp @@ -479,6 +479,12 @@ int valid_turret_enemy(object *objp, object *turret_parent) return 0; } + // Mines are only targetable within their configured detection range + if (wip->is_mine() && wip->mine_targetable_range >= 0.0f) { + if (vm_vec_dist_quick(&objp->pos, &turret_parent->pos) > wip->mine_targetable_range) + return 0; + } + return 1; } diff --git a/code/hud/hudtarget.cpp b/code/hud/hudtarget.cpp index 4dce52fbbec..ec28e7cf3bc 100644 --- a/code/hud/hudtarget.cpp +++ b/code/hud/hudtarget.cpp @@ -478,6 +478,18 @@ int hud_target_invalid_awacs(object *objp) return 0; } +// Returns true if the weapon object can currently be targeted by the player. +// For mines, uses range-based detection (mine_targetable_range). For other weapons, uses flags. +static bool weapon_is_player_targetable(object *objp) +{ + weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; + if (wip->is_mine()) { + if (wip->mine_targetable_range < 0.0f) return true; // infinite range + return vm_vec_dist(&Player_obj->pos, &objp->pos) <= wip->mine_targetable_range; + } + return wip->wi_flags[Weapon::Info_Flags::Can_be_targeted] || wip->wi_flags[Weapon::Info_Flags::Bomb]; +} + ship_subsys *advance_subsys(ship_subsys *cur, int next_flag) { if (next_flag) { @@ -1187,9 +1199,8 @@ void hud_target_common(int team_mask, int next_flag) continue; if (A->type == OBJ_WEAPON) { - if ( !(Weapon_info[Weapons[A->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Can_be_targeted]) ) - if ( !(Weapon_info[Weapons[A->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Bomb]) ) - continue; + if (!weapon_is_player_targetable(A)) + continue; if (Weapons[A->instance].lssm_stage == 3) continue; @@ -1503,10 +1514,9 @@ void hud_target_hostile_bomb_or_bomber(object* source_obj, int next_flag, bool t continue; weapon* wp = &Weapons[A->instance]; - weapon_info* wip = &Weapon_info[wp->weapon_info_index]; - // only allow targeting of bombs - if (!(wip->wi_flags[Weapon::Info_Flags::Can_be_targeted]) && !(wip->wi_flags[Weapon::Info_Flags::Bomb])) + // only allow targeting of bombs/targetable weapons/mines in range + if (!weapon_is_player_targetable(A)) continue; if (wp->lssm_stage == 3) @@ -2498,11 +2508,8 @@ void hud_target_in_reticle_new() } if ( A->type == OBJ_WEAPON ) { - if ( !(Weapon_info[Weapons[A->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Can_be_targeted]) ) { - if ( !(Weapon_info[Weapons[A->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Bomb]) ){ - continue; - } - } + if (!weapon_is_player_targetable(A)) + continue; if (Weapons[A->instance].lssm_stage==3){ continue; } @@ -2607,11 +2614,8 @@ void hud_target_in_reticle_old() } if ( A->type == OBJ_WEAPON ) { - if ( !(Weapon_info[Weapons[A->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Can_be_targeted]) ){ - if ( !(Weapon_info[Weapons[A->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Bomb]) ){ - continue; - } - } + if (!weapon_is_player_targetable(A)) + continue; if (Weapons[A->instance].lssm_stage==3){ continue; @@ -4188,11 +4192,8 @@ void HudGaugeLeadIndicator::renderLeadCurrentTarget(bool config) // only allow bombs to have lead indicator displayed if ( targetp->type == OBJ_WEAPON ) { - if ( !(Weapon_info[Weapons[targetp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Can_be_targeted]) ) { - if ( !(Weapon_info[Weapons[targetp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Bomb]) ) { - return; - } - } + if (!weapon_is_player_targetable(targetp)) + return; } // If the target is out of range, then draw the correct frame for the lead indicator @@ -4363,11 +4364,8 @@ void HudGaugeLeadIndicator::renderLeadQuick(vec3d *target_world_pos, object *tar // only allow bombs to have lead indicator displayed if ( targetp->type == OBJ_WEAPON ) { - if ( !(Weapon_info[Weapons[targetp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Can_be_targeted]) ) { - if ( !(Weapon_info[Weapons[targetp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Bomb]) ) { - return; - } - } + if (!weapon_is_player_targetable(targetp)) + return; } // If the target is out of range, then draw the correct frame for the lead indicator diff --git a/code/radar/radarsetup.cpp b/code/radar/radarsetup.cpp index 1f48e0a4a70..437651d35bf 100644 --- a/code/radar/radarsetup.cpp +++ b/code/radar/radarsetup.cpp @@ -236,17 +236,24 @@ void radar_plot_object( object *objp ) case OBJ_WEAPON: { + weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; + + if (wip->wi_flags[Weapon::Info_Flags::Mine]) { + // Mine range-based detection... visibility determined after distance is calculated below + break; + } + // if not a bomb, return - if ( !(Weapon_info[Weapons[objp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Shown_on_radar]) ) - if ( !(Weapon_info[Weapons[objp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Bomb]) ) + if ( !(wip->wi_flags[Weapon::Info_Flags::Shown_on_radar]) ) + if ( !(wip->wi_flags[Weapon::Info_Flags::Bomb]) ) return; // if explicitly hidden, return - if (Weapon_info[Weapons[objp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Dont_show_on_radar]) + if (wip->wi_flags[Weapon::Info_Flags::Dont_show_on_radar]) return; // if we don't attack the bomb, return - if ( (!(Weapon_info[Weapons[objp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Show_friendly])) && (!iff_x_attacks_y(Player_ship->team, obj_team(objp)))) + if ( !wip->wi_flags[Weapon::Info_Flags::Show_friendly] && !iff_x_attacks_y(Player_ship->team, obj_team(objp)) ) return; // if a local ssm is in subspace, return @@ -254,7 +261,7 @@ void radar_plot_object( object *objp ) return; // if corkscrew missile use last frame pos for pos - if ( (Weapon_info[Weapons[objp->instance].weapon_info_index].wi_flags[Weapon::Info_Flags::Corkscrew]) ) + if (wip->wi_flags[Weapon::Info_Flags::Corkscrew]) world_pos = objp->last_pos; break; @@ -281,6 +288,17 @@ void radar_plot_object( object *objp ) return; } + // Mine range-based visibility: determine state from mine-specific ranges + if (objp->type == OBJ_WEAPON) { + weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; + if (wip->wi_flags[Weapon::Info_Flags::Mine]) { + bool in_targetable = (wip->mine_targetable_range < 0.0f || dist <= wip->mine_targetable_range); + bool in_sensors = (wip->mine_sensors_range < 0.0f || dist <= wip->mine_sensors_range); + if (!in_targetable && !in_sensors) + return; // beyond all detection ranges + } + } + // determine the range within which the radar blip is bright if (timestamp_elapsed(Radar_calc_bright_dist_timer)) { @@ -337,6 +355,16 @@ void radar_plot_object( object *objp ) // see if blip should be drawn distorted // also determine if alternate image was defined for this ship + if (objp->type == OBJ_WEAPON) { + weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; + if (wip->wi_flags[Weapon::Info_Flags::Mine]) { + // Distorted if beyond targetable range but within sensors range + bool in_targetable = (wip->mine_targetable_range < 0.0f || dist <= wip->mine_targetable_range); + if (!in_targetable) + b->flags |= BLIP_DRAW_DISTORTED; + } + } + if (objp->type == OBJ_SHIP) { // ships specifically hidden from sensors diff --git a/code/scripting/global_hooks.cpp b/code/scripting/global_hooks.cpp index 0d82cb0d4c7..8f415bfd5f9 100644 --- a/code/scripting/global_hooks.cpp +++ b/code/scripting/global_hooks.cpp @@ -297,6 +297,14 @@ const std::shared_ptr> OnMissileDeath = Hook> OnMineDetonated = Hook::Factory( + "On Mine Detonated", "Called when a mine detonates via proximity trigger.", + { + {"Mine", "weapon", "The mine weapon that detonated."}, + {"Ship", "object", "The ship whose proximity triggered the detonation."}, + {"Position", "vector", "The world coordinates of the mine at the time of detonation."}, + }); + const std::shared_ptr> OnBeamDeath = Hook<>::Factory( "On Beam Death", "Called when a beam has been removed from the mission (whether by finishing firing, destruction of turret, etc.).", { diff --git a/code/scripting/global_hooks.h b/code/scripting/global_hooks.h index 8112c6bf537..f4e64e0826e 100644 --- a/code/scripting/global_hooks.h +++ b/code/scripting/global_hooks.h @@ -55,6 +55,7 @@ extern const std::shared_ptr> OnShipDeathStarted; extern const std::shared_ptr> OnShipDeath; extern const std::shared_ptr> OnMissileDeathStarted; extern const std::shared_ptr> OnMissileDeath; +extern const std::shared_ptr> OnMineDetonated; extern const std::shared_ptr> OnBeamDeath; extern const std::shared_ptr> OnAsteroidDeath; extern const std::shared_ptr> OnDebrisDeath; diff --git a/code/scripting/hook_conditions.cpp b/code/scripting/hook_conditions.cpp index 1b62727430b..94812a4d4b4 100644 --- a/code/scripting/hook_conditions.cpp +++ b/code/scripting/hook_conditions.cpp @@ -244,6 +244,11 @@ HOOK_CONDITIONS_START(WeaponDeathConditions) HOOK_CONDITION(WeaponDeathConditions, "Weapon class", "Specifies the class of the weapon that died.", dying_wep, conditionParseWeaponClass, conditionCompareWeaponClass); HOOK_CONDITIONS_END +HOOK_CONDITIONS_START(MineDetonatedConditions) + HOOK_CONDITION(MineDetonatedConditions, "Mine class", "Specifies the class of the mine that detonated.", mine_wep, conditionParseWeaponClass, conditionCompareWeaponClass); + HOOK_CONDITION_SHIPP(MineDetonatedConditions, "", "that triggered the mine.", trigger_shipp); +HOOK_CONDITIONS_END + HOOK_CONDITIONS_START(ObjectDeathConditions) HOOK_CONDITION_SHIP_OBJP(ObjectDeathConditions, "", "that died.", dying_objp); HOOK_CONDITION(ObjectDeathConditions, "Weapon class", "Specifies the class of the weapon that died.", dying_objp, conditionParseWeaponClass, [](const object* objp, const int& weaponclass) -> bool { diff --git a/code/scripting/hook_conditions.h b/code/scripting/hook_conditions.h index 6f4e71dbc39..e74487599a4 100644 --- a/code/scripting/hook_conditions.h +++ b/code/scripting/hook_conditions.h @@ -79,6 +79,12 @@ struct WeaponDeathConditions { const weapon* dying_wep; }; +struct MineDetonatedConditions { + HOOK_DEFINE_CONDITIONS; + const weapon* mine_wep; + const ship* trigger_shipp; +}; + struct ObjectDeathConditions { HOOK_DEFINE_CONDITIONS; const object* dying_objp; diff --git a/code/ship/ship.cpp b/code/ship/ship.cpp index fb5f6107e62..a03bf2beafc 100644 --- a/code/ship/ship.cpp +++ b/code/ship/ship.cpp @@ -6777,8 +6777,10 @@ void ship_init() // We shouldn't already have any subsystem pointers at this point. Assertion(Ship_subsystems.empty(), "Some pre-allocated subsystems didn't get cleared out: " SIZE_T_ARG " batches present during ship_init(); get a coder!\n", Ship_subsystems.size()); - radar_check_2d_icon_options(); + + // Resolve mine proximity ship type/class names now that ships are fully loaded + weapon_post_ship_init(); } } diff --git a/code/weapon/weapon.h b/code/weapon/weapon.h index c491d97f200..ece95bd1dc4 100644 --- a/code/weapon/weapon.h +++ b/code/weapon/weapon.h @@ -444,6 +444,14 @@ struct weapon_info float arm_radius; float det_range; float det_radius; //How far from target or target subsystem it blows up + float proximity_radius; // Scan radius for proximity detonation; 0 = disabled + float mine_arm_time; // seconds after creation before proximity detonation is active; 0 = instantly armed (default) + SCP_vector proximity_iff; // IFF indices that trigger detonation; empty = any IFF + SCP_vector proximity_species; // species indices that trigger detonation; empty = any species + SCP_vector proximity_type; // ship type indices that trigger detonation; empty = any type + SCP_vector proximity_class; // ship class (ship_info) indices that trigger detonation; empty = any class + float mine_sensors_range; // range at which mine shows as a distorted blip; -1.0f = always, 0.0f = never + float mine_targetable_range; // range at which mine is fully targetable; -1.0f = always, 0.0f = never float flak_detonation_accuracy; //How far away from a target a flak shell will blow up. Standard is 65.0f float flak_targeting_accuracy; //Determines the amount of jitter applied to flak targeting. USE WITH CAUTION! float untargeted_flak_range_penalty; //Untargeted flak shells detonate after travelling max range - this parameter. Default 20.0f @@ -923,6 +931,7 @@ struct weapon_info inline bool is_locked_homing() const { return wi_flags[Weapon::Info_Flags::Homing_aspect, Weapon::Info_Flags::Homing_javelin]; } inline bool hurts_big_ships() const { return wi_flags[Weapon::Info_Flags::Bomb, Weapon::Info_Flags::Beam, Weapon::Info_Flags::Huge, Weapon::Info_Flags::Big_only]; } inline bool is_interceptable() const { return wi_flags[Weapon::Info_Flags::Fighter_Interceptable, Weapon::Info_Flags::Turret_Interceptable]; } + inline bool is_mine() const { return wi_flags[Weapon::Info_Flags::Mine]; } const char* get_display_name() const; bool has_display_name() const; @@ -966,6 +975,7 @@ inline int weapon_info_size() } void weapon_init(); // called at game startup +void weapon_post_ship_init(); // called after ship_init() to resolve mine proximity ship type/class names void weapon_close(); // called at game shutdown void weapon_level_init(); // called before the start of each level void weapon_render(object* obj, model_draw_list *scene); diff --git a/code/weapon/weapon_flags.h b/code/weapon/weapon_flags.h index 40ddfe294aa..e86eccb0654 100644 --- a/code/weapon/weapon_flags.h +++ b/code/weapon/weapon_flags.h @@ -97,6 +97,7 @@ namespace Weapon { Ignores_countermeasures, // The weapon will never be affected by countermeasures Freespace_1_missile_behavior, // Bundles several observed behaviors missiles had in the freespace 1 release Dogfight_weapon, // Dogfight weapons are intended as balanced variants for multiplayer. This flag can be used to filter them out when necessary. + Mine, // weapon is a stationary proximity mine (subset of secondary): zero velocity, infinite lifetime NUM_VALUES }; diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index fed3239797e..0f001d4decc 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -25,6 +25,7 @@ #include "hud/hud.h" #include "hud/hudartillery.h" #include "iff_defs/iff_defs.h" +#include "species_defs/species_defs.h" #include "io/joy_ff.h" #include "io/timer.h" #include "math/curve.h" @@ -43,6 +44,7 @@ #include "parse/parsehi.h" #include "parse/parselo.h" #include "scripting/global_hooks.h" +#include "scripting/api/objs/vecmath.h" #include "particle/particle.h" #include "playerman/player.h" #include "radar/radar.h" @@ -84,6 +86,10 @@ typedef struct delayed_ssm_index_data { SCP_unordered_map Delayed_SSM_indices_data; SCP_vector Delayed_SSM_indices; +// Temporary storage for mine proximity type/class names pending resolution after ship_init() +static SCP_unordered_map> Pending_mine_type_names; +static SCP_unordered_map> Pending_mine_class_names; + #ifndef NDEBUG int Weapon_flyby_sound_enabled = 1; @@ -260,6 +266,7 @@ special_flag_def_list_new&>); @@ -1350,6 +1357,91 @@ int parse_weapon(int subtype, bool replace, const char *filename) stuff_float(&wip->det_radius); } + if (optional_string("$MineInfo:")) { + wip->wi_flags.set(Weapon::Info_Flags::Mine); + + if (optional_string("+Sensors Range:")) { + stuff_float(&wip->mine_sensors_range); + } + + if (optional_string("+Targetable Range:")) { + stuff_float(&wip->mine_targetable_range); + } + + if (optional_string("+Hitpoints:")) { + stuff_int(&wip->weapon_hitpoints); + } else { + wip->weapon_hitpoints = 50; // default: mines are destructable + } + + if (wip->weapon_hitpoints > 0) { + wip->wi_flags.set(Weapon::Info_Flags::Turret_Interceptable); + wip->wi_flags.set(Weapon::Info_Flags::Fighter_Interceptable); + } + + if (optional_string("+Arming Time:")) { + stuff_float(&wip->mine_arm_time); + if (wip->mine_arm_time < 0.0f) { + wip->mine_arm_time = 0.0f; + Warning(LOCATION, "Mine weapon '%s': +Arming Time cannot be negative. Setting to 0.\n", wip->name); + } + } + + if (optional_string("+Proximity Radius:")) { + stuff_float(&wip->proximity_radius); + if (wip->proximity_radius < 0.0f) { + wip->proximity_radius = 0.0f; + Warning(LOCATION, "Mine weapon '%s': +Proximity Radius cannot be negative. Setting to 0.\n", wip->name); + } else if (wip->proximity_radius == 0.0f) { + Warning(LOCATION, "Mine weapon '%s': +Proximity Radius is 0, proximity detonation will be disabled.\n", wip->name); + } + } + + if (optional_string("+Proximity IFF:")) { + SCP_vector iff_names; + stuff_string_list(iff_names); + for (const SCP_string& iff_name : iff_names) { + int iff_idx = iff_lookup(iff_name.c_str()); + if (iff_idx < 0) + Warning(LOCATION, "Mine weapon '%s': +Proximity IFF entry '%s' not found.\n", wip->name, iff_name.c_str()); + else + wip->proximity_iff.push_back(iff_idx); + } + } + + if (optional_string("+Proximity Species:")) { + SCP_vector species_names; + stuff_string_list(species_names); + for (const SCP_string& species_name : species_names) { + int species_idx = species_info_lookup(species_name.c_str()); + if (species_idx < 0) + Warning(LOCATION, "Mine weapon '%s': +Proximity Species entry '%s' not found.\n", wip->name, species_name.c_str()); + else + wip->proximity_species.push_back(species_idx); + } + } + + if (optional_string("+Proximity Type:")) { + stuff_string_list(Pending_mine_type_names[wip->name]); + } + + if (optional_string("+Proximity Class:")) { + stuff_string_list(Pending_mine_class_names[wip->name]); + } + + // Warn if targetable range makes sensors range unreachable + bool targetable_infinite = (wip->mine_targetable_range < 0.0f); + bool sensors_infinite = (wip->mine_sensors_range < 0.0f); + if (!sensors_infinite) { + if (targetable_infinite) { + Warning(LOCATION, "Mine weapon '%s': +Targetable Range is infinite but +Sensors Range is finite — the distorted blip state will never occur.\n", wip->name); + } else if (wip->mine_targetable_range >= wip->mine_sensors_range) { + Warning(LOCATION, "Mine weapon '%s': +Targetable Range (%.1f) >= +Sensors Range (%.1f) — the distorted blip state will never occur.\n", + wip->name, wip->mine_targetable_range, wip->mine_sensors_range); + } + } + } + if(optional_string("$Flak Detonation Accuracy:")) { stuff_float(&wip->flak_detonation_accuracy); } @@ -4940,6 +5032,39 @@ void weapon_do_post_parse() translate_spawn_types(); } +/** + * Called after ship_init() to resolve mine proximity ship type/class names into indices. + */ +void weapon_post_ship_init() +{ + for (weapon_info& wip : Weapon_info) { + auto type_it = Pending_mine_type_names.find(wip.name); + if (type_it != Pending_mine_type_names.end()) { + for (const SCP_string& type_name : type_it->second) { + int idx = ship_type_name_lookup(type_name.c_str()); + if (idx < 0) + Warning(LOCATION, "Mine weapon '%s': +Proximity Type entry '%s' not found.\n", wip.name, type_name.c_str()); + else + wip.proximity_type.push_back(idx); + } + } + + auto class_it = Pending_mine_class_names.find(wip.name); + if (class_it != Pending_mine_class_names.end()) { + for (const SCP_string& class_name : class_it->second) { + int idx = ship_info_lookup(class_name.c_str()); + if (idx < 0) + Warning(LOCATION, "Mine weapon '%s': +Proximity Class entry '%s' not found.\n", wip.name, class_name.c_str()); + else + wip.proximity_class.push_back(idx); + } + } + } + + Pending_mine_type_names.clear(); + Pending_mine_class_names.clear(); +} + /** * This will get called once at game startup */ @@ -6069,6 +6194,90 @@ void weapon_process_pre( object *obj, float frame_time) } } + // Proximity detonation: scan nearby ships and detonate if a qualifying one is within range + if (wip->proximity_radius > 0.0f) { + // Check arming time: mine will not detonate until this many seconds after creation + bool armed = f2fl(Missiontime - wp->creation_time) >= wip->mine_arm_time; + + if (armed) { + float prox_sq = wip->proximity_radius * wip->proximity_radius; + + for (object *check_obj = GET_FIRST(&obj_used_list); + check_obj != END_OF_LIST(&obj_used_list); + check_obj = GET_NEXT(check_obj)) + { + if (check_obj->flags[Object::Object_Flags::Should_be_dead]) + continue; + + // Mines only trigger on ships + if (check_obj->type != OBJ_SHIP) + continue; + + // Skip the weapon itself + if (check_obj == obj) + continue; + + // Distance check first (cheap rejection) + if (vm_vec_dist_squared(&obj->pos, &check_obj->pos) > prox_sq) + continue; + + // Apply filters: AND across categories, OR within each category + // Empty category = pass all + const ship* sp = &Ships[check_obj->instance]; + const ship_info* sip = &Ship_info[sp->ship_info_index]; + + if (!wip->proximity_iff.empty()) { + int target_team = sp->team; + bool matched = false; + for (int iff_idx : wip->proximity_iff) { + if (target_team == iff_idx) { matched = true; break; } + } + if (!matched) continue; + } + + if (!wip->proximity_species.empty()) { + bool matched = false; + for (int spec_idx : wip->proximity_species) { + if (sip->species == spec_idx) { matched = true; break; } + } + if (!matched) continue; + } + + if (!wip->proximity_type.empty()) { + bool matched = false; + for (int type_idx : wip->proximity_type) { + if (sip->class_type == type_idx) { matched = true; break; } + } + if (!matched) continue; + } + + if (!wip->proximity_class.empty()) { + bool matched = false; + for (int class_idx : wip->proximity_class) { + if (sp->ship_info_index == class_idx) { matched = true; break; } + } + if (!matched) continue; + } + + // Set the triggering ship as the homing object so child spawns with + // 'inherit parent target' will home on it + wp->homing_object = check_obj; + + if (scripting::hooks::OnMineDetonated->isActive()) { + scripting::hooks::OnMineDetonated->run( + scripting::hooks::MineDetonatedConditions{ wp, &Ships[check_obj->instance] }, + scripting::hook_param_list( + scripting::hook_param("Mine", 'o', obj), + scripting::hook_param("Ship", 'o', check_obj), + scripting::hook_param("Position", 'o', scripting::api::l_Vector.Set(obj->pos)) + )); + } + weapon_detonate(obj); + return; + } + } // if (armed) + } + // If this flag is false missile turning is evaluated in weapon_process_post() if (Framerate_independent_turning) { weapon_do_homing_behavior(obj, frame_time); @@ -6232,10 +6441,13 @@ void weapon_process_post(object * obj, float frame_time) wp = &Weapons[num]; - wp->lifeleft -= frame_time; - wip = &Weapon_info[wp->weapon_info_index]; + // Mines have infinite lifetime - skip the countdown + if (!wip->wi_flags[Weapon::Info_Flags::Mine]) { + wp->lifeleft -= frame_time; + } + // do continuous spawns if (wip->wi_flags[Weapon::Info_Flags::Spawn]) { for (int i = 0; i < wip->num_spawn_weapons_defined; i++) { @@ -7041,6 +7253,15 @@ int weapon_create( const vec3d *pos, const matrix *porient, int weapon_type, int wp->launch_speed += pspeed; } + // Mines are stationary: zero all velocity after all other velocity setup + if (wip->wi_flags[Weapon::Info_Flags::Mine]) { + vm_vec_zero(&objp->phys_info.vel); + vm_vec_zero(&objp->phys_info.desired_vel); + objp->phys_info.speed = 0.0f; + wp->weapon_max_vel = 0.0f; + wp->launch_speed = 0.0f; + } + // create the corkscrew if ( wip->wi_flags[Weapon::Info_Flags::Corkscrew] ) { wp->cscrew_index = (short)cscrew_create(objp); @@ -9473,6 +9694,14 @@ void weapon_info::reset() this->arm_radius = 0.0f; this->det_range = 0.0f; this->det_radius = 0.0f; + this->proximity_radius = 0.0f; + this->mine_arm_time = 0.0f; + this->proximity_iff.clear(); + this->proximity_species.clear(); + this->proximity_type.clear(); + this->proximity_class.clear(); + this->mine_sensors_range = -1.0f; + this->mine_targetable_range = -1.0f; this->flak_detonation_accuracy = 65.0f; this->flak_targeting_accuracy = 60.0f; // Standard value as defined in flak.cpp From 6bcadb37f31e2ab007538868866d0a9a8e4c827f Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 8 Apr 2026 08:52:30 -0500 Subject: [PATCH 02/11] some fixes and cleanup --- code/ai/ai_flags.h | 2 +- code/ai/ai_profiles.cpp | 2 +- code/ai/aicode.cpp | 6 +- code/hud/hudtarget.cpp | 2 +- code/radar/radarsetup.cpp | 8 +++ code/scripting/global_hooks.cpp | 2 +- code/scripting/global_hooks.h | 2 +- code/weapon/weapons.cpp | 121 ++++++++++++++++---------------- 8 files changed, 78 insertions(+), 67 deletions(-) diff --git a/code/ai/ai_flags.h b/code/ai/ai_flags.h index 0e9fa4d0d5e..0f66a6a55f8 100644 --- a/code/ai/ai_flags.h +++ b/code/ai/ai_flags.h @@ -132,7 +132,7 @@ namespace AI { Smart_shield_management, Smart_subsystem_targeting_for_turrets, Strict_turret_tagged_only_targeting, - Strikecraft_intercept_mines, // strikecraft autonomously engage hostile mines within mine_targetable_range of themselves + Ships_intercept_mines, // all AI-controlled ships autonomously engage hostile mines within mine_targetable_range of themselves Support_dont_add_primaries, //Prevents support ship from equipping new primary as requested in https://scp.indiegames.us/mantis/view.php?id=3198 Turrets_ignore_target_radius, Use_actual_primary_range, diff --git a/code/ai/ai_profiles.cpp b/code/ai/ai_profiles.cpp index 84a8ed49b81..55834ba5ddb 100644 --- a/code/ai/ai_profiles.cpp +++ b/code/ai/ai_profiles.cpp @@ -744,7 +744,7 @@ void parse_ai_profiles_tbl(const char *filename) set_flag(profile, "$cancel future waves of any wing launched from an exited ship:", AI::Profile_Flags::Cancel_future_waves_of_any_wing_launched_from_an_exited_ship); - set_flag(profile, "$strikecraft intercept mines:", AI::Profile_Flags::Strikecraft_intercept_mines); + set_flag(profile, "$ships intercept mines:", AI::Profile_Flags::Ships_intercept_mines); // end of options ---------------------------------------- diff --git a/code/ai/aicode.cpp b/code/ai/aicode.cpp index e9e1edc00ce..0fed481df82 100644 --- a/code/ai/aicode.cpp +++ b/code/ai/aicode.cpp @@ -10506,7 +10506,7 @@ void maybe_update_guard_object(object *hit_objp, object *hitter_objp) // return 1 if bomb is found (and targeted by guarding_objp), otherwise return 0 /** * Find the closest hostile mine within its own mine_targetable_range of ai_objp. - * Used for strikecraft self-protection when the Strikecraft_intercept_mines AI profile flag is set. + * Used for ship self-protection when the Ships_intercept_mines AI profile flag is set. * Returns the mine object pointer, or nullptr if none found. */ static object *ai_find_nearby_mine_threat(object *ai_objp) @@ -10748,7 +10748,7 @@ void ai_guard_find_nearby_object() } // if still not attacking anything and flag is set, engage mines near the guarding ship itself - if (aip->target_objnum == -1 && The_mission.ai_profile->flags[AI::Profile_Flags::Strikecraft_intercept_mines]) { + if (aip->target_objnum == -1 && The_mission.ai_profile->flags[AI::Profile_Flags::Ships_intercept_mines]) { object *mine_objp = ai_find_nearby_mine_threat(Pl_objp); if (mine_objp) guard_object_was_hit(Pl_objp, mine_objp); @@ -15401,7 +15401,7 @@ void ai_frame(int objnum) { En_objp = &Objects[target_objnum]; } - } else if (aip->mode != AIM_GUARD && The_mission.ai_profile->flags[AI::Profile_Flags::Strikecraft_intercept_mines]) { + } else if (aip->mode != AIM_GUARD && The_mission.ai_profile->flags[AI::Profile_Flags::Ships_intercept_mines]) { // No enemy found so check for nearby hostile mines within their targetable range object *mine_objp = ai_find_nearby_mine_threat(Pl_objp); if (mine_objp) { diff --git a/code/hud/hudtarget.cpp b/code/hud/hudtarget.cpp index ec28e7cf3bc..8f6c0d8be88 100644 --- a/code/hud/hudtarget.cpp +++ b/code/hud/hudtarget.cpp @@ -485,7 +485,7 @@ static bool weapon_is_player_targetable(object *objp) weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; if (wip->is_mine()) { if (wip->mine_targetable_range < 0.0f) return true; // infinite range - return vm_vec_dist(&Player_obj->pos, &objp->pos) <= wip->mine_targetable_range; + return vm_vec_dist_quick(&Player_obj->pos, &objp->pos) <= wip->mine_targetable_range; } return wip->wi_flags[Weapon::Info_Flags::Can_be_targeted] || wip->wi_flags[Weapon::Info_Flags::Bomb]; } diff --git a/code/radar/radarsetup.cpp b/code/radar/radarsetup.cpp index 437651d35bf..c900a6327e8 100644 --- a/code/radar/radarsetup.cpp +++ b/code/radar/radarsetup.cpp @@ -239,6 +239,14 @@ void radar_plot_object( object *objp ) weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; if (wip->wi_flags[Weapon::Info_Flags::Mine]) { + // if explicitly hidden, return + if (wip->wi_flags[Weapon::Info_Flags::Dont_show_on_radar]) + return; + + // if we don't attack the mine, return + if ( !wip->wi_flags[Weapon::Info_Flags::Show_friendly] && !iff_x_attacks_y(Player_ship->team, obj_team(objp)) ) + return; + // Mine range-based detection... visibility determined after distance is calculated below break; } diff --git a/code/scripting/global_hooks.cpp b/code/scripting/global_hooks.cpp index 8f415bfd5f9..9c9eaccc6be 100644 --- a/code/scripting/global_hooks.cpp +++ b/code/scripting/global_hooks.cpp @@ -297,7 +297,7 @@ const std::shared_ptr> OnMissileDeath = Hook> OnMineDetonated = Hook::Factory( +const std::shared_ptr> OnMineDetonated = OverridableHook::Factory( "On Mine Detonated", "Called when a mine detonates via proximity trigger.", { {"Mine", "weapon", "The mine weapon that detonated."}, diff --git a/code/scripting/global_hooks.h b/code/scripting/global_hooks.h index f4e64e0826e..fd3ae423d3c 100644 --- a/code/scripting/global_hooks.h +++ b/code/scripting/global_hooks.h @@ -55,7 +55,7 @@ extern const std::shared_ptr> OnShipDeathStarted; extern const std::shared_ptr> OnShipDeath; extern const std::shared_ptr> OnMissileDeathStarted; extern const std::shared_ptr> OnMissileDeath; -extern const std::shared_ptr> OnMineDetonated; +extern const std::shared_ptr> OnMineDetonated; extern const std::shared_ptr> OnBeamDeath; extern const std::shared_ptr> OnAsteroidDeath; extern const std::shared_ptr> OnDebrisDeath; diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index 0f001d4decc..f06691b42a5 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -6200,81 +6200,84 @@ void weapon_process_pre( object *obj, float frame_time) bool armed = f2fl(Missiontime - wp->creation_time) >= wip->mine_arm_time; if (armed) { - float prox_sq = wip->proximity_radius * wip->proximity_radius; + float prox_sq = wip->proximity_radius * wip->proximity_radius; - for (object *check_obj = GET_FIRST(&obj_used_list); - check_obj != END_OF_LIST(&obj_used_list); - check_obj = GET_NEXT(check_obj)) - { - if (check_obj->flags[Object::Object_Flags::Should_be_dead]) - continue; + for (object *check_obj = GET_FIRST(&obj_used_list); + check_obj != END_OF_LIST(&obj_used_list); + check_obj = GET_NEXT(check_obj)) + { + if (check_obj->flags[Object::Object_Flags::Should_be_dead]) + continue; - // Mines only trigger on ships - if (check_obj->type != OBJ_SHIP) - continue; + // Mines only trigger on ships + if (check_obj->type != OBJ_SHIP) + continue; - // Skip the weapon itself - if (check_obj == obj) - continue; + // Skip the weapon itself + if (check_obj == obj) + continue; - // Distance check first (cheap rejection) - if (vm_vec_dist_squared(&obj->pos, &check_obj->pos) > prox_sq) - continue; + // Distance check first (cheap rejection) + if (vm_vec_dist_squared(&obj->pos, &check_obj->pos) > prox_sq) + continue; - // Apply filters: AND across categories, OR within each category - // Empty category = pass all - const ship* sp = &Ships[check_obj->instance]; - const ship_info* sip = &Ship_info[sp->ship_info_index]; + // Apply filters: AND across categories, OR within each category + // Empty category = pass all + const ship* sp = &Ships[check_obj->instance]; + const ship_info* sip = &Ship_info[sp->ship_info_index]; - if (!wip->proximity_iff.empty()) { - int target_team = sp->team; - bool matched = false; - for (int iff_idx : wip->proximity_iff) { - if (target_team == iff_idx) { matched = true; break; } + if (!wip->proximity_iff.empty()) { + int target_team = sp->team; + bool matched = false; + for (int iff_idx : wip->proximity_iff) { + if (target_team == iff_idx) { matched = true; break; } + } + if (!matched) continue; } - if (!matched) continue; - } - if (!wip->proximity_species.empty()) { - bool matched = false; - for (int spec_idx : wip->proximity_species) { - if (sip->species == spec_idx) { matched = true; break; } + if (!wip->proximity_species.empty()) { + bool matched = false; + for (int spec_idx : wip->proximity_species) { + if (sip->species == spec_idx) { matched = true; break; } + } + if (!matched) continue; } - if (!matched) continue; - } - if (!wip->proximity_type.empty()) { - bool matched = false; - for (int type_idx : wip->proximity_type) { - if (sip->class_type == type_idx) { matched = true; break; } + if (!wip->proximity_type.empty()) { + bool matched = false; + for (int type_idx : wip->proximity_type) { + if (sip->class_type == type_idx) { matched = true; break; } + } + if (!matched) continue; } - if (!matched) continue; - } - if (!wip->proximity_class.empty()) { - bool matched = false; - for (int class_idx : wip->proximity_class) { - if (sp->ship_info_index == class_idx) { matched = true; break; } + if (!wip->proximity_class.empty()) { + bool matched = false; + for (int class_idx : wip->proximity_class) { + if (sp->ship_info_index == class_idx) { matched = true; break; } + } + if (!matched) continue; } - if (!matched) continue; - } - // Set the triggering ship as the homing object so child spawns with - // 'inherit parent target' will home on it - wp->homing_object = check_obj; + // Set the triggering ship as the homing object so child spawns with + // 'inherit parent target' will home on it + wp->homing_object = check_obj; - if (scripting::hooks::OnMineDetonated->isActive()) { - scripting::hooks::OnMineDetonated->run( - scripting::hooks::MineDetonatedConditions{ wp, &Ships[check_obj->instance] }, - scripting::hook_param_list( - scripting::hook_param("Mine", 'o', obj), - scripting::hook_param("Ship", 'o', check_obj), - scripting::hook_param("Position", 'o', scripting::api::l_Vector.Set(obj->pos)) - )); + auto mineParamList = scripting::hook_param_list( + scripting::hook_param("Mine", 'o', obj), + scripting::hook_param("Ship", 'o', check_obj), + scripting::hook_param("Position", 'o', scripting::api::l_Vector.Set(obj->pos)) + ); + scripting::hooks::MineDetonatedConditions mineConds{ wp, &Ships[check_obj->instance] }; + if (scripting::hooks::OnMineDetonated->isActive()) { + bool overridden = scripting::hooks::OnMineDetonated->isOverride(mineConds, mineParamList); + scripting::hooks::OnMineDetonated->run(mineConds, mineParamList); + if (overridden) + return; + } + weapon_detonate(obj); + return; } - weapon_detonate(obj); - return; - } } // if (armed) } From e310c86d554590e78214945b9e81e63ed27c7ba4 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 8 Apr 2026 08:59:15 -0500 Subject: [PATCH 03/11] fix some parsing issues --- code/weapon/weapon.h | 2 +- code/weapon/weapons.cpp | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/code/weapon/weapon.h b/code/weapon/weapon.h index ece95bd1dc4..3e06338879b 100644 --- a/code/weapon/weapon.h +++ b/code/weapon/weapon.h @@ -444,7 +444,7 @@ struct weapon_info float arm_radius; float det_range; float det_radius; //How far from target or target subsystem it blows up - float proximity_radius; // Scan radius for proximity detonation; 0 = disabled + float proximity_radius; // Scan radius for proximity detonation; defaults to 50 float mine_arm_time; // seconds after creation before proximity detonation is active; 0 = instantly armed (default) SCP_vector proximity_iff; // IFF indices that trigger detonation; empty = any IFF SCP_vector proximity_species; // species indices that trigger detonation; empty = any species diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index f06691b42a5..da298b60447 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -1370,8 +1370,8 @@ int parse_weapon(int subtype, bool replace, const char *filename) if (optional_string("+Hitpoints:")) { stuff_int(&wip->weapon_hitpoints); - } else { - wip->weapon_hitpoints = 50; // default: mines are destructable + } else if (first_time) { + wip->weapon_hitpoints = 50; // default: mines are destructible } if (wip->weapon_hitpoints > 0) { @@ -1389,12 +1389,12 @@ int parse_weapon(int subtype, bool replace, const char *filename) if (optional_string("+Proximity Radius:")) { stuff_float(&wip->proximity_radius); - if (wip->proximity_radius < 0.0f) { - wip->proximity_radius = 0.0f; - Warning(LOCATION, "Mine weapon '%s': +Proximity Radius cannot be negative. Setting to 0.\n", wip->name); - } else if (wip->proximity_radius == 0.0f) { - Warning(LOCATION, "Mine weapon '%s': +Proximity Radius is 0, proximity detonation will be disabled.\n", wip->name); + if (wip->proximity_radius <= 0.0f) { + wip->proximity_radius = 1.0f; + Warning(LOCATION, "Mine weapon '%s': +Proximity Radius must be positive. Setting to 1.\n", wip->name); } + } else if (first_time) { + wip->proximity_radius = 50.0f; // default proximity trigger radius } if (optional_string("+Proximity IFF:")) { From 40985f8436e4229f8dac1ade73af174943b2150a Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 8 Apr 2026 21:06:16 -0500 Subject: [PATCH 04/11] make sure mines get plotted --- code/weapon/weapons.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index da298b60447..85f8acb5ec8 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -6513,7 +6513,7 @@ void weapon_process_post(object * obj, float frame_time) } // plot homing missiles on the radar - if (((wip->wi_flags[Weapon::Info_Flags::Bomb]) || (wip->wi_flags[Weapon::Info_Flags::Shown_on_radar])) && !(wip->wi_flags[Weapon::Info_Flags::Dont_show_on_radar])) { + if (((wip->wi_flags[Weapon::Info_Flags::Bomb]) || (wip->wi_flags[Weapon::Info_Flags::Mine]) || (wip->wi_flags[Weapon::Info_Flags::Shown_on_radar])) && !(wip->wi_flags[Weapon::Info_Flags::Dont_show_on_radar])) { if ( hud_gauge_active(HUD_RADAR) ) { radar_plot_object( obj ); } From 70be685101ad9a3a7d24156df7cb3de4620fce5b Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 8 Apr 2026 21:44:47 -0500 Subject: [PATCH 05/11] make mine spawned objects can get a correct target --- code/weapon/weapons.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index 85f8acb5ec8..2e769f457cf 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -6259,9 +6259,12 @@ void weapon_process_pre( object *obj, float frame_time) if (!matched) continue; } - // Set the triggering ship as the homing object so child spawns with - // 'inherit parent target' will home on it + // Set the triggering ship as the homing object and target so child + // spawns with 'inherit parent target' will home on it. Both fields + // must be set: homing_object is used by weapon_has_homing_object(), + // and target_num is what weapon_set_tracking_info() actually receives. wp->homing_object = check_obj; + wp->target_num = OBJ_INDEX(check_obj); auto mineParamList = scripting::hook_param_list( scripting::hook_param("Mine", 'o', obj), From 924604f16c8c0af07c4626ca150b645b0c80a73a Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 8 Apr 2026 22:05:45 -0500 Subject: [PATCH 06/11] some cleanup and additional features --- code/weapon/weapon.h | 4 ++ code/weapon/weapons.cpp | 108 ++++++++++++++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/code/weapon/weapon.h b/code/weapon/weapon.h index 3e06338879b..64a8a0ff090 100644 --- a/code/weapon/weapon.h +++ b/code/weapon/weapon.h @@ -263,6 +263,8 @@ typedef struct spawn_weapon_info float spawn_interval_delay; // A delay before starting continuous spawn float spawn_chance; // Liklihood of spawning on every spawn interval particle::ParticleEffectHandle spawn_effect; // Effect for continuous spawnings + bool spawn_aimed; // If true, aim child toward the parent's homing_object instead of a random cone; default false + float spawn_aim_lead; // Seconds of constant-velocity lead extrapolation for aimed spawns; 0.0 = no lead; default 0.0 } spawn_weapon_info; // use this to extend a beam to "infinity" @@ -452,6 +454,8 @@ struct weapon_info SCP_vector proximity_class; // ship class (ship_info) indices that trigger detonation; empty = any class float mine_sensors_range; // range at which mine shows as a distorted blip; -1.0f = always, 0.0f = never float mine_targetable_range; // range at which mine is fully targetable; -1.0f = always, 0.0f = never + float mine_detonate_chance; // probability [0,1] that a proximity contact actually detonates the mine; default 1.0 + float mine_stealth_proximity_multiplier; // scale factor applied to proximity_radius for stealth ships; 0.0 = undetectable, 1.0 = normal range; default 1.0 float flak_detonation_accuracy; //How far away from a target a flak shell will blow up. Standard is 65.0f float flak_targeting_accuracy; //Determines the amount of jitter applied to flak targeting. USE WITH CAUTION! float untargeted_flak_range_penalty; //Untargeted flak shells detonate after travelling max range - this parameter. Default 20.0f diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index 2e769f457cf..59cea551368 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -1429,6 +1429,22 @@ int parse_weapon(int subtype, bool replace, const char *filename) stuff_string_list(Pending_mine_class_names[wip->name]); } + if (optional_string("+Detonate Chance:")) { + stuff_float(&wip->mine_detonate_chance); + if (wip->mine_detonate_chance < 0.0f || wip->mine_detonate_chance > 1.0f) { + wip->mine_detonate_chance = CLAMP(wip->mine_detonate_chance, 0.0f, 1.0f); + Warning(LOCATION, "Mine weapon '%s': +Detonate Chance must be in [0.0, 1.0]. Clamping.\n", wip->name); + } + } + + if (optional_string("+Stealth Proximity Multiplier:")) { + stuff_float(&wip->mine_stealth_proximity_multiplier); + if (wip->mine_stealth_proximity_multiplier < 0.0f) { + wip->mine_stealth_proximity_multiplier = 0.0f; + Warning(LOCATION, "Mine weapon '%s': +Stealth Proximity Multiplier cannot be negative. Setting to 0.\n", wip->name); + } + } + // Warn if targetable range makes sensors range unreachable bool targetable_infinite = (wip->mine_targetable_range < 0.0f); bool sensors_infinite = (wip->mine_sensors_range < 0.0f); @@ -2878,6 +2894,36 @@ int parse_weapon(int subtype, bool replace, const char *filename) } } + spawn_weap = 0; + + while (optional_string("$Spawn Aimed:")) + { + bool dum_bool; + stuff_boolean(&dum_bool); + if (spawn_weap < MAX_SPAWN_TYPES_PER_WEAPON) + wip->spawn_info[spawn_weap++].spawn_aimed = dum_bool; + } + + spawn_weap = 0; + + while (optional_string("$Spawn Aim Lead:")) + { + stuff_float(&dum_float); + if (spawn_weap < MAX_SPAWN_TYPES_PER_WEAPON) { + if (dum_float < 0.0f) { + Warning(LOCATION, "Weapon '%s': $Spawn Aim Lead cannot be negative. Setting to 0.\n", wip->name); + dum_float = 0.0f; + } + wip->spawn_info[spawn_weap++].spawn_aim_lead = dum_float; + } + } + + // Cross-validate aimed spawn fields + for (int si = 0; si < wip->num_spawn_weapons_defined; si++) { + if (wip->spawn_info[si].spawn_aim_lead > 0.0f && !wip->spawn_info[si].spawn_aimed) + Warning(LOCATION, "Weapon '%s' spawn %d: $Spawn Aim Lead is set but $Spawn Aimed is NO — lead will be ignored.\n", wip->name, si); + } + if (optional_string("$Lifetime Variation Factor When Child:")) { stuff_float(&wip->lifetime_variation_factor_when_child); @@ -6200,7 +6246,7 @@ void weapon_process_pre( object *obj, float frame_time) bool armed = f2fl(Missiontime - wp->creation_time) >= wip->mine_arm_time; if (armed) { - float prox_sq = wip->proximity_radius * wip->proximity_radius; + float base_prox = wip->proximity_radius; for (object *check_obj = GET_FIRST(&obj_used_list); check_obj != END_OF_LIST(&obj_used_list); @@ -6217,8 +6263,19 @@ void weapon_process_pre( object *obj, float frame_time) if (check_obj == obj) continue; - // Distance check first (cheap rejection) - if (vm_vec_dist_squared(&obj->pos, &check_obj->pos) > prox_sq) + // Protected ships are immune to mine proximity detonation + if (check_obj->flags[Object::Object_Flags::Protected]) + continue; + + // Stealth ships may have a reduced proximity trigger radius + float effective_prox = base_prox; + if (wip->mine_stealth_proximity_multiplier < 1.0f && + Ships[check_obj->instance].flags[Ship::Ship_Flags::Stealth]) { + effective_prox *= wip->mine_stealth_proximity_multiplier; + } + + // Distance check (cheap rejection) + if (vm_vec_dist_squared(&obj->pos, &check_obj->pos) > effective_prox * effective_prox) continue; // Apply filters: AND across categories, OR within each category @@ -6259,6 +6316,12 @@ void weapon_process_pre( object *obj, float frame_time) if (!matched) continue; } + // Probabilistic detonation: skip this ship if the chance roll fails. + // This check sits before homing_object assignment and the scripting hook so + // that OnMineDetonated only fires when the mine actually detonates. + if (wip->mine_detonate_chance < 1.0f && frand() >= wip->mine_detonate_chance) + continue; + // Set the triggering ship as the homing object and target so child // spawns with 'inherit parent target' will home on it. Both fields // must be set: homing_object is used by weapon_has_homing_object(), @@ -7525,18 +7588,31 @@ void spawn_child_weapons(object *objp, int spawn_index_override) matrix orient; - // for multiplayer, use the static randvec functions based on the network signatures to provide - // the randomness so that it is the same on all machines. - if ( Game_mode & GM_MULTIPLAYER ) { - if (wip->spawn_info[i].spawn_min_angle <= 0) - static_rand_cone(objp->net_signature + j, &tvec, fvec, wip->spawn_info[i].spawn_angle); - else - static_rand_cone(objp->net_signature + j, &tvec, fvec, wip->spawn_info[i].spawn_min_angle, wip->spawn_info[i].spawn_angle); + // Compute spawn direction. + if (wip->spawn_info[i].spawn_aimed && wp != nullptr && weapon_has_homing_object(wp)) { + // Aimed spawn: point directly at the homing object with optional constant-velocity + // lead extrapolation. wp != nullptr guard handles beam-spawned weapons (which have + // no weapon struct) — they fall through to the random cone below. + // Direction is deterministic (derived from a known object position), so no + // static_rand seeding is needed for multiplayer consistency. + vec3d target_pos = wp->homing_object->pos; + if (wip->spawn_info[i].spawn_aim_lead > 0.0f) + vm_vec_scale_add2(&target_pos, &wp->homing_object->phys_info.vel, wip->spawn_info[i].spawn_aim_lead); + vm_vec_normalized_dir(&tvec, &target_pos, opos); } else { - if(wip->spawn_info[i].spawn_min_angle <= 0) - vm_vec_random_cone(&tvec, fvec, wip->spawn_info[i].spawn_angle); - else - vm_vec_random_cone(&tvec, fvec, wip->spawn_info[i].spawn_min_angle, wip->spawn_info[i].spawn_angle); + // Standard random cone — for multiplayer, use static randvec functions keyed on + // network signatures so all machines produce the same spread. + if (Game_mode & GM_MULTIPLAYER) { + if (wip->spawn_info[i].spawn_min_angle <= 0) + static_rand_cone(objp->net_signature + j, &tvec, fvec, wip->spawn_info[i].spawn_angle); + else + static_rand_cone(objp->net_signature + j, &tvec, fvec, wip->spawn_info[i].spawn_min_angle, wip->spawn_info[i].spawn_angle); + } else { + if (wip->spawn_info[i].spawn_min_angle <= 0) + vm_vec_random_cone(&tvec, fvec, wip->spawn_info[i].spawn_angle); + else + vm_vec_random_cone(&tvec, fvec, wip->spawn_info[i].spawn_min_angle, wip->spawn_info[i].spawn_angle); + } } vm_vec_scale_add(&pos, opos, &tvec, objp->radius); @@ -9708,6 +9784,8 @@ void weapon_info::reset() this->proximity_class.clear(); this->mine_sensors_range = -1.0f; this->mine_targetable_range = -1.0f; + this->mine_detonate_chance = 1.0f; + this->mine_stealth_proximity_multiplier = 1.0f; this->flak_detonation_accuracy = 65.0f; this->flak_targeting_accuracy = 60.0f; // Standard value as defined in flak.cpp @@ -9754,6 +9832,8 @@ void weapon_info::reset() this->spawn_info[i].spawn_interval = -1.f; this->spawn_info[i].spawn_interval_delay = -1.f; this->spawn_info[i].spawn_chance = 1.f; + this->spawn_info[i].spawn_aimed = false; + this->spawn_info[i].spawn_aim_lead = 0.0f; } this->lifetime_variation_factor_when_child = 0.2f; From b64fc4a9bfe21c7b7c997c0a8e08878ab01eae49 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 8 Apr 2026 22:19:23 -0500 Subject: [PATCH 07/11] chasing mines --- code/weapon/weapon.h | 6 +++ code/weapon/weapons.cpp | 90 ++++++++++++++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/code/weapon/weapon.h b/code/weapon/weapon.h index 64a8a0ff090..c2769e9763e 100644 --- a/code/weapon/weapon.h +++ b/code/weapon/weapon.h @@ -168,6 +168,9 @@ typedef struct weapon { float beam_per_shot_rot; // for type 5 beams modular_curves_entry_instance modular_curves_instance; + + TIMESTAMP mine_chase_expires; // when the active chase expires; TIMESTAMP::invalid() = not chasing + TIMESTAMP mine_chase_cooldown_expires; // when the post-chase cooldown ends; TIMESTAMP::invalid() = no cooldown active } weapon; @@ -456,6 +459,9 @@ struct weapon_info float mine_targetable_range; // range at which mine is fully targetable; -1.0f = always, 0.0f = never float mine_detonate_chance; // probability [0,1] that a proximity contact actually detonates the mine; default 1.0 float mine_stealth_proximity_multiplier; // scale factor applied to proximity_radius for stealth ships; 0.0 = undetectable, 1.0 = normal range; default 1.0 + float mine_chase_duration; // seconds mine chases the triggering ship after proximity contact; 0.0 = detonate immediately (default) + bool mine_detonates_on_chase_timeout; // if true, detonate when chase timer expires; if false, give up and return to stationary; default true + float mine_chase_cooldown; // seconds after a chase ends before proximity scanning re-enables; default 5.0 float flak_detonation_accuracy; //How far away from a target a flak shell will blow up. Standard is 65.0f float flak_targeting_accuracy; //Determines the amount of jitter applied to flak targeting. USE WITH CAUTION! float untargeted_flak_range_penalty; //Untargeted flak shells detonate after travelling max range - this parameter. Default 20.0f diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index 59cea551368..d34abd35bc6 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -1445,6 +1445,26 @@ int parse_weapon(int subtype, bool replace, const char *filename) } } + if (optional_string("+Chase Duration:")) { + stuff_float(&wip->mine_chase_duration); + if (wip->mine_chase_duration < 0.0f) { + wip->mine_chase_duration = 0.0f; + Warning(LOCATION, "Mine weapon '%s': +Chase Duration cannot be negative. Setting to 0 (immediate detonation).\n", wip->name); + } + } + + if (optional_string("+Detonates on Chase Timeout:")) { + stuff_boolean(&wip->mine_detonates_on_chase_timeout); + } + + if (optional_string("+Chase Cooldown:")) { + stuff_float(&wip->mine_chase_cooldown); + if (wip->mine_chase_cooldown < 0.0f) { + wip->mine_chase_cooldown = 0.0f; + Warning(LOCATION, "Mine weapon '%s': +Chase Cooldown cannot be negative. Setting to 0.\n", wip->name); + } + } + // Warn if targetable range makes sensors range unreachable bool targetable_infinite = (wip->mine_targetable_range < 0.0f); bool sensors_infinite = (wip->mine_sensors_range < 0.0f); @@ -6240,10 +6260,36 @@ void weapon_process_pre( object *obj, float frame_time) } } + // Chase timeout: check every frame whether an active chase has expired + if (wip->wi_flags[Weapon::Info_Flags::Mine] && wp->mine_chase_expires.isFinite()) { + if (timestamp_elapsed(wp->mine_chase_expires)) { + if (wip->mine_detonates_on_chase_timeout) { + weapon_detonate(obj); + return; + } else { + // Give up: return to stationary at current position + vm_vec_zero(&obj->phys_info.vel); + vm_vec_zero(&obj->phys_info.desired_vel); + obj->phys_info.speed = 0.0f; + wp->weapon_max_vel = 0.0f; + wp->homing_object = &obj_used_list; + wp->target_num = -1; + wp->target_sig = -1; + wp->mine_chase_expires = TIMESTAMP::invalid(); + if (wip->mine_chase_cooldown > 0.0f) + wp->mine_chase_cooldown_expires = _timestamp(fl2i(wip->mine_chase_cooldown * 1000.0f)); + } + } + } + // Proximity detonation: scan nearby ships and detonate if a qualifying one is within range if (wip->proximity_radius > 0.0f) { + // Skip if mine is actively chasing or sitting in post-chase cooldown + bool chasing = wp->mine_chase_expires.isFinite() && !timestamp_elapsed(wp->mine_chase_expires); + bool cooling_down = wp->mine_chase_cooldown_expires.isFinite() && !timestamp_elapsed(wp->mine_chase_cooldown_expires); + // Check arming time: mine will not detonate until this many seconds after creation - bool armed = f2fl(Missiontime - wp->creation_time) >= wip->mine_arm_time; + bool armed = !chasing && !cooling_down && f2fl(Missiontime - wp->creation_time) >= wip->mine_arm_time; if (armed) { float base_prox = wip->proximity_radius; @@ -6322,26 +6368,34 @@ void weapon_process_pre( object *obj, float frame_time) if (wip->mine_detonate_chance < 1.0f && frand() >= wip->mine_detonate_chance) continue; - // Set the triggering ship as the homing object and target so child - // spawns with 'inherit parent target' will home on it. Both fields + // Set the triggering ship as the homing object and target. Both fields // must be set: homing_object is used by weapon_has_homing_object(), // and target_num is what weapon_set_tracking_info() actually receives. wp->homing_object = check_obj; wp->target_num = OBJ_INDEX(check_obj); - auto mineParamList = scripting::hook_param_list( - scripting::hook_param("Mine", 'o', obj), - scripting::hook_param("Ship", 'o', check_obj), - scripting::hook_param("Position", 'o', scripting::api::l_Vector.Set(obj->pos)) - ); - scripting::hooks::MineDetonatedConditions mineConds{ wp, &Ships[check_obj->instance] }; - if (scripting::hooks::OnMineDetonated->isActive()) { - bool overridden = scripting::hooks::OnMineDetonated->isOverride(mineConds, mineParamList); - scripting::hooks::OnMineDetonated->run(mineConds, mineParamList); - if (overridden) - return; + if (wip->mine_chase_duration > 0.0f) { + // Chase mode: become a guided missile for the configured duration. + // Contact with the ship (handled by normal weapon collision) causes detonation. + // The OnMineDetonated hook does not fire here — the mine hasn't detonated yet. + wp->mine_chase_expires = _timestamp(fl2i(wip->mine_chase_duration * 1000.0f)); + wp->weapon_max_vel = wip->max_speed; + } else { + // Immediate detonation mode. + auto mineParamList = scripting::hook_param_list( + scripting::hook_param("Mine", 'o', obj), + scripting::hook_param("Ship", 'o', check_obj), + scripting::hook_param("Position", 'o', scripting::api::l_Vector.Set(obj->pos)) + ); + scripting::hooks::MineDetonatedConditions mineConds{ wp, &Ships[check_obj->instance] }; + if (scripting::hooks::OnMineDetonated->isActive()) { + bool overridden = scripting::hooks::OnMineDetonated->isOverride(mineConds, mineParamList); + scripting::hooks::OnMineDetonated->run(mineConds, mineParamList); + if (overridden) + return; + } + weapon_detonate(obj); } - weapon_detonate(obj); return; } } // if (armed) @@ -7402,6 +7456,9 @@ int weapon_create( const vec3d *pos, const matrix *porient, int weapon_type, int } } + wp->mine_chase_expires = TIMESTAMP::invalid(); + wp->mine_chase_cooldown_expires = TIMESTAMP::invalid(); + // Set detail levels for POF-type weapons. if (Weapon_info[wp->weapon_info_index].model_num != -1) { polymodel * pm; @@ -9786,6 +9843,9 @@ void weapon_info::reset() this->mine_targetable_range = -1.0f; this->mine_detonate_chance = 1.0f; this->mine_stealth_proximity_multiplier = 1.0f; + this->mine_chase_duration = 0.0f; + this->mine_detonates_on_chase_timeout = true; + this->mine_chase_cooldown = 5.0f; this->flak_detonation_accuracy = 65.0f; this->flak_targeting_accuracy = 60.0f; // Standard value as defined in flak.cpp From cc1701021e22876568d47b1ddc4beb2d236e20f5 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Wed, 8 Apr 2026 23:20:07 -0500 Subject: [PATCH 08/11] some Lua tweaks --- code/scripting/api/objs/weapon.cpp | 26 ++++++++++++++++++++++++++ code/scripting/global_hooks.cpp | 10 +++++----- code/scripting/global_hooks.h | 2 +- code/scripting/hook_conditions.cpp | 6 +++--- code/scripting/hook_conditions.h | 2 +- code/weapon/weapons.cpp | 12 ++++++------ 6 files changed, 42 insertions(+), 16 deletions(-) diff --git a/code/scripting/api/objs/weapon.cpp b/code/scripting/api/objs/weapon.cpp index 3e9dc9d860c..74f06e70ec1 100644 --- a/code/scripting/api/objs/weapon.cpp +++ b/code/scripting/api/objs/weapon.cpp @@ -421,6 +421,32 @@ ADE_FUNC(getSubmodelAnimationTime, l_Weapon, "string type, string triggeredBy", return ade_set_args(L, "f", time_s); } +ADE_VIRTVAR(isMine, l_Weapon, nullptr, "Whether this weapon is a mine", "boolean", "True if the weapon is a mine type, false otherwise or if handle is invalid") +{ + object_h *oh = nullptr; + if (!ade_get_args(L, "o", l_Weapon.GetPtr(&oh))) + return ade_set_error(L, "b", false); + + if (!oh->isValid()) + return ade_set_error(L, "b", false); + + const weapon_info *wip = &Weapon_info[Weapons[oh->objp()->instance].weapon_info_index]; + return ade_set_args(L, "b", wip->wi_flags[Weapon::Info_Flags::Mine]); +} + +ADE_FUNC(detonate, l_Weapon, nullptr, "Forces this weapon to detonate immediately.", "boolean", "True if successful, false if handle is invalid") +{ + object_h *oh = nullptr; + if (!ade_get_args(L, "o", l_Weapon.GetPtr(&oh))) + return ade_set_error(L, "b", false); + + if (!oh->isValid()) + return ade_set_error(L, "b", false); + + weapon_detonate(oh->objp()); + return ade_set_args(L, "b", true); +} + ADE_FUNC(vanish, l_Weapon, nullptr, "Vanishes this weapon from the mission.", "boolean", "True if the deletion was successful, false otherwise.") { diff --git a/code/scripting/global_hooks.cpp b/code/scripting/global_hooks.cpp index 9c9eaccc6be..4aa6cf4cf0f 100644 --- a/code/scripting/global_hooks.cpp +++ b/code/scripting/global_hooks.cpp @@ -297,12 +297,12 @@ const std::shared_ptr> OnMissileDeath = Hook> OnMineDetonated = OverridableHook::Factory( - "On Mine Detonated", "Called when a mine detonates via proximity trigger.", +const std::shared_ptr> OnMineProximityTriggered = OverridableHook::Factory( + "On Mine Proximity Triggered", "Called when a ship passes a mine's proximity checks. The mine may then detonate immediately or enter chase mode depending on its configuration.", { - {"Mine", "weapon", "The mine weapon that detonated."}, - {"Ship", "object", "The ship whose proximity triggered the detonation."}, - {"Position", "vector", "The world coordinates of the mine at the time of detonation."}, + {"Mine", "weapon", "The mine weapon that was triggered."}, + {"Ship", "object", "The ship whose proximity triggered the mine."}, + {"Position", "vector", "The world coordinates of the mine at the time of triggering."}, }); const std::shared_ptr> OnBeamDeath = Hook<>::Factory( diff --git a/code/scripting/global_hooks.h b/code/scripting/global_hooks.h index fd3ae423d3c..28d50d72977 100644 --- a/code/scripting/global_hooks.h +++ b/code/scripting/global_hooks.h @@ -55,7 +55,7 @@ extern const std::shared_ptr> OnShipDeathStarted; extern const std::shared_ptr> OnShipDeath; extern const std::shared_ptr> OnMissileDeathStarted; extern const std::shared_ptr> OnMissileDeath; -extern const std::shared_ptr> OnMineDetonated; +extern const std::shared_ptr> OnMineProximityTriggered; extern const std::shared_ptr> OnBeamDeath; extern const std::shared_ptr> OnAsteroidDeath; extern const std::shared_ptr> OnDebrisDeath; diff --git a/code/scripting/hook_conditions.cpp b/code/scripting/hook_conditions.cpp index 94812a4d4b4..e4be54abb8a 100644 --- a/code/scripting/hook_conditions.cpp +++ b/code/scripting/hook_conditions.cpp @@ -244,9 +244,9 @@ HOOK_CONDITIONS_START(WeaponDeathConditions) HOOK_CONDITION(WeaponDeathConditions, "Weapon class", "Specifies the class of the weapon that died.", dying_wep, conditionParseWeaponClass, conditionCompareWeaponClass); HOOK_CONDITIONS_END -HOOK_CONDITIONS_START(MineDetonatedConditions) - HOOK_CONDITION(MineDetonatedConditions, "Mine class", "Specifies the class of the mine that detonated.", mine_wep, conditionParseWeaponClass, conditionCompareWeaponClass); - HOOK_CONDITION_SHIPP(MineDetonatedConditions, "", "that triggered the mine.", trigger_shipp); +HOOK_CONDITIONS_START(MineProximityTriggeredConditions) + HOOK_CONDITION(MineProximityTriggeredConditions, "Mine class", "Specifies the class of the mine that was triggered.", mine_wep, conditionParseWeaponClass, conditionCompareWeaponClass); + HOOK_CONDITION_SHIPP(MineProximityTriggeredConditions, "", "that triggered the mine.", trigger_shipp); HOOK_CONDITIONS_END HOOK_CONDITIONS_START(ObjectDeathConditions) diff --git a/code/scripting/hook_conditions.h b/code/scripting/hook_conditions.h index e74487599a4..9b8574ab488 100644 --- a/code/scripting/hook_conditions.h +++ b/code/scripting/hook_conditions.h @@ -79,7 +79,7 @@ struct WeaponDeathConditions { const weapon* dying_wep; }; -struct MineDetonatedConditions { +struct MineProximityTriggeredConditions { HOOK_DEFINE_CONDITIONS; const weapon* mine_wep; const ship* trigger_shipp; diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index d34abd35bc6..8cfd05528dd 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -6364,7 +6364,7 @@ void weapon_process_pre( object *obj, float frame_time) // Probabilistic detonation: skip this ship if the chance roll fails. // This check sits before homing_object assignment and the scripting hook so - // that OnMineDetonated only fires when the mine actually detonates. + // that OnMineProximityTriggered only fires when the mine actually commits to an action. if (wip->mine_detonate_chance < 1.0f && frand() >= wip->mine_detonate_chance) continue; @@ -6377,7 +6377,7 @@ void weapon_process_pre( object *obj, float frame_time) if (wip->mine_chase_duration > 0.0f) { // Chase mode: become a guided missile for the configured duration. // Contact with the ship (handled by normal weapon collision) causes detonation. - // The OnMineDetonated hook does not fire here — the mine hasn't detonated yet. + // OnMineProximityTriggered does not fire here — the mine hasn't committed to detonation yet. wp->mine_chase_expires = _timestamp(fl2i(wip->mine_chase_duration * 1000.0f)); wp->weapon_max_vel = wip->max_speed; } else { @@ -6387,10 +6387,10 @@ void weapon_process_pre( object *obj, float frame_time) scripting::hook_param("Ship", 'o', check_obj), scripting::hook_param("Position", 'o', scripting::api::l_Vector.Set(obj->pos)) ); - scripting::hooks::MineDetonatedConditions mineConds{ wp, &Ships[check_obj->instance] }; - if (scripting::hooks::OnMineDetonated->isActive()) { - bool overridden = scripting::hooks::OnMineDetonated->isOverride(mineConds, mineParamList); - scripting::hooks::OnMineDetonated->run(mineConds, mineParamList); + scripting::hooks::MineProximityTriggeredConditions mineConds{ wp, &Ships[check_obj->instance] }; + if (scripting::hooks::OnMineProximityTriggered->isActive()) { + bool overridden = scripting::hooks::OnMineProximityTriggered->isOverride(mineConds, mineParamList); + scripting::hooks::OnMineProximityTriggered->run(mineConds, mineParamList); if (overridden) return; } From 140ef8049f5c0a435c39a1c864e9ba3413fd6cfb Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Thu, 14 May 2026 12:52:24 -0500 Subject: [PATCH 09/11] generalize proximity detonations --- code/ai/aicode.cpp | 103 +++++---- code/ai/aiturret.cpp | 2 +- code/hud/hudtarget.cpp | 3 +- code/radar/radarsetup.cpp | 23 +- code/scripting/api/objs/weapon.cpp | 4 +- code/scripting/global_hooks.cpp | 10 +- code/scripting/global_hooks.h | 2 +- code/scripting/hook_conditions.cpp | 6 +- code/scripting/hook_conditions.h | 4 +- code/weapon/weapon.h | 18 +- code/weapon/weapons.cpp | 334 +++++++++++++++++------------ 11 files changed, 279 insertions(+), 230 deletions(-) diff --git a/code/ai/aicode.cpp b/code/ai/aicode.cpp index 0fed481df82..b1bef354ece 100644 --- a/code/ai/aicode.cpp +++ b/code/ai/aicode.cpp @@ -7452,6 +7452,14 @@ void attack_set_accel(ai_info *aip, ship_info *sip, float dist_to_enemy, float d if (wip != nullptr && wip->optimum_range > 0) optimal_range = wip->optimum_range; + // Standoff for mines: keep at least 1.2x the target mine's proximity radius + // so we shoot it from outside its detonation zone. + if (En_objp->type == OBJ_WEAPON) { + weapon_info *target_wip = &Weapon_info[Weapons[En_objp->instance].weapon_info_index]; + if (target_wip->is_mine() && target_wip->proximity_radius > 0.0f) + optimal_range = MAX(optimal_range, target_wip->proximity_radius * 1.2f); + } + if (dist_to_enemy > optimal_range + vm_vec_mag_quick(&En_objp->phys_info.vel) * dot_from_enemy + Pl_objp->phys_info.speed * speed_ratio) { if (dist_to_enemy > optimal_range + 600.0f) { if (ai_willing_to_afterburn_hard(aip)) { @@ -10502,20 +10510,19 @@ void maybe_update_guard_object(object *hit_objp, object *hitter_objp) } } -// Scan missile list looking for bombs homing on guarded_objp -// return 1 if bomb is found (and targeted by guarding_objp), otherwise return 0 -/** - * Find the closest hostile mine within its own mine_targetable_range of ai_objp. - * Used for ship self-protection when the Ships_intercept_mines AI profile flag is set. - * Returns the mine object pointer, or nullptr if none found. - */ -static object *ai_find_nearby_mine_threat(object *ai_objp) +// Find the closest hostile mine threatening 'against_objp', ranked by distance from 'from_objp'. +// If 'against_objp' is null, defaults to 'from_objp' (i.e. find mines threatening the searcher itself). +// imminent_only: also requires the mine to be within proximity_radius * 1.5 of against_objp; +// used to preempt a current target when the mine is about to detonate. +static object *ai_find_nearby_mine_threat(object *from_objp, object *against_objp = nullptr, bool imminent_only = false) { - missile_obj *mo; + if (against_objp == nullptr) + against_objp = from_objp; + object *closest_mine = nullptr; - float closest_dist = 999999.0f; + float closest_dist_from = std::numeric_limits::max(); - for ( mo = GET_NEXT(&Missile_obj_list); mo != END_OF_LIST(&Missile_obj_list); mo = GET_NEXT(mo) ) { + for (missile_obj *mo = GET_NEXT(&Missile_obj_list); mo != END_OF_LIST(&Missile_obj_list); mo = GET_NEXT(mo)) { Assert(mo->objnum >= 0 && mo->objnum < MAX_OBJECTS); object *mine_objp = &Objects[mo->objnum]; if (mine_objp->flags[Object::Object_Flags::Should_be_dead]) @@ -10531,17 +10538,23 @@ static object *ai_find_nearby_mine_threat(object *ai_objp) if (!wip->wi_flags[Weapon::Info_Flags::Fighter_Interceptable]) continue; - // Only engage mines hostile to this ship - if (!iff_x_attacks_y(wp->team, obj_team(ai_objp))) + // Only engage mines hostile to the threatened ship + if (!iff_x_attacks_y(wp->team, obj_team(against_objp))) continue; - // Mine must be within its own targetable range of this ship - float dist = vm_vec_dist_quick(&mine_objp->pos, &ai_objp->pos); - if (wip->mine_targetable_range >= 0.0f && dist > wip->mine_targetable_range) + float dist_against = vm_vec_dist(&mine_objp->pos, &against_objp->pos); + + // Mine must be within its own targetable range of the threatened ship + if (wip->mine_targetable_range >= 0.0f && dist_against > wip->mine_targetable_range) continue; - if (dist < closest_dist) { - closest_dist = dist; + // Imminent gate: only consider mines about to detonate on the threatened ship + if (imminent_only && wip->proximity_radius > 0.0f && dist_against > wip->proximity_radius * 1.5f) + continue; + + float dist_from = (from_objp == against_objp) ? dist_against : vm_vec_dist(&mine_objp->pos, &from_objp->pos); + if (dist_from < closest_dist_from) { + closest_dist_from = dist_from; closest_mine = mine_objp; } } @@ -10594,44 +10607,9 @@ int ai_guard_find_nearby_bomb(object *guarding_objp, object *guarded_objp) return 1; } - // No homing threat found... check for mines within targeting range of the guarded ship. + // No homing threat found... check for mines threatening the guarded ship. // Mines are stationary so they won't home; handle them as a lower priority threat. - object *closest_mine_objp = nullptr; - float closest_mine_dist_to_guarding = 999999.0f; - - for ( mo = GET_NEXT(&Missile_obj_list); mo != END_OF_LIST(&Missile_obj_list); mo = GET_NEXT(mo) ) { - Assert(mo->objnum >= 0 && mo->objnum < MAX_OBJECTS); - bomb_objp = &Objects[mo->objnum]; - if (bomb_objp->flags[Object::Object_Flags::Should_be_dead]) - continue; - - wp = &Weapons[bomb_objp->instance]; - wip = &Weapon_info[wp->weapon_info_index]; - - if (!wip->is_mine()) - continue; - - // Only engage destructible mines - if (!wip->wi_flags[Weapon::Info_Flags::Fighter_Interceptable]) - continue; - - // Only engage mines that threaten the guarded ship's team - if (!iff_x_attacks_y(wp->team, obj_team(guarded_objp))) - continue; - - // Mine must be within its targetable range of the guarded ship - float dist_to_guarded = vm_vec_dist_quick(&bomb_objp->pos, &guarded_objp->pos); - if (wip->mine_targetable_range >= 0.0f && dist_to_guarded > wip->mine_targetable_range) - continue; - - float dist_to_guarding = vm_vec_dist_quick(&bomb_objp->pos, &guarding_objp->pos); - if (dist_to_guarding < closest_mine_dist_to_guarding) { - closest_mine_dist_to_guarding = dist_to_guarding; - closest_mine_objp = bomb_objp; - } - } - - if (closest_mine_objp) { + if (object *closest_mine_objp = ai_find_nearby_mine_threat(guarding_objp, guarded_objp)) { guard_object_was_hit(guarding_objp, closest_mine_objp); return 1; } @@ -15382,6 +15360,21 @@ void ai_frame(int objnum) ai_maybe_depart(Pl_objp); + // Imminent-mine override: even if we have a current target, swap to a mine + // that is about to detonate on us (within proximity_radius * 1.5). + if (The_mission.ai_profile->flags[AI::Profile_Flags::Ships_intercept_mines] + && aip->mode != AIM_GUARD && aip->mode != AIM_EVADE_WEAPON + && Ship_info[shipp->ship_info_index].class_type > -1 + && Ship_types[Ship_info[shipp->ship_info_index].class_type].flags[Ship::Type_Info_Flags::AI_auto_attacks]) { + object *imminent_mine = ai_find_nearby_mine_threat(Pl_objp, nullptr, true); + if (imminent_mine != nullptr && (target_objnum < 0 || &Objects[target_objnum] != imminent_mine)) { + int new_target = OBJ_INDEX(imminent_mine); + if (aip->target_objnum != new_target) + aip->aspect_locked_time = 0.0f; + target_objnum = set_target_objnum(aip, new_target); + } + } + // Find an enemy if don't already have one. En_objp = NULL; if ( ai_need_new_target(Pl_objp, target_objnum) ) { diff --git a/code/ai/aiturret.cpp b/code/ai/aiturret.cpp index 09bd6991829..ecdfd74303b 100644 --- a/code/ai/aiturret.cpp +++ b/code/ai/aiturret.cpp @@ -481,7 +481,7 @@ int valid_turret_enemy(object *objp, object *turret_parent) // Mines are only targetable within their configured detection range if (wip->is_mine() && wip->mine_targetable_range >= 0.0f) { - if (vm_vec_dist_quick(&objp->pos, &turret_parent->pos) > wip->mine_targetable_range) + if (vm_vec_dist(&objp->pos, &turret_parent->pos) > wip->mine_targetable_range) return 0; } diff --git a/code/hud/hudtarget.cpp b/code/hud/hudtarget.cpp index 8f6c0d8be88..6c251e4ca19 100644 --- a/code/hud/hudtarget.cpp +++ b/code/hud/hudtarget.cpp @@ -485,7 +485,8 @@ static bool weapon_is_player_targetable(object *objp) weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; if (wip->is_mine()) { if (wip->mine_targetable_range < 0.0f) return true; // infinite range - return vm_vec_dist_quick(&Player_obj->pos, &objp->pos) <= wip->mine_targetable_range; + Assertion(Player_obj != nullptr, "weapon_is_player_targetable called with no Player_obj"); + return vm_vec_dist(&Player_obj->pos, &objp->pos) <= wip->mine_targetable_range; } return wip->wi_flags[Weapon::Info_Flags::Can_be_targeted] || wip->wi_flags[Weapon::Info_Flags::Bomb]; } diff --git a/code/radar/radarsetup.cpp b/code/radar/radarsetup.cpp index c900a6327e8..a7527775353 100644 --- a/code/radar/radarsetup.cpp +++ b/code/radar/radarsetup.cpp @@ -177,6 +177,7 @@ void radar_plot_object( object *objp ) vec3d pos, tempv; float awacs_level, dist, max_radar_dist; vec3d world_pos = objp->pos; + bool mine_in_targetable_range = true; // for mines, computed below after distance check; non-mines unaffected // don't process anything here. Somehow, a jumpnode object caused this function // to get entered on server side. @@ -238,7 +239,7 @@ void radar_plot_object( object *objp ) { weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; - if (wip->wi_flags[Weapon::Info_Flags::Mine]) { + if (wip->is_mine()) { // if explicitly hidden, return if (wip->wi_flags[Weapon::Info_Flags::Dont_show_on_radar]) return; @@ -299,10 +300,10 @@ void radar_plot_object( object *objp ) // Mine range-based visibility: determine state from mine-specific ranges if (objp->type == OBJ_WEAPON) { weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; - if (wip->wi_flags[Weapon::Info_Flags::Mine]) { - bool in_targetable = (wip->mine_targetable_range < 0.0f || dist <= wip->mine_targetable_range); - bool in_sensors = (wip->mine_sensors_range < 0.0f || dist <= wip->mine_sensors_range); - if (!in_targetable && !in_sensors) + if (wip->is_mine()) { + mine_in_targetable_range = (wip->mine_targetable_range < 0.0f || dist <= wip->mine_targetable_range); + bool in_sensors = (wip->mine_sensors_range < 0.0f || dist <= wip->mine_sensors_range); + if (!mine_in_targetable_range && !in_sensors) return; // beyond all detection ranges } } @@ -363,15 +364,9 @@ void radar_plot_object( object *objp ) // see if blip should be drawn distorted // also determine if alternate image was defined for this ship - if (objp->type == OBJ_WEAPON) { - weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; - if (wip->wi_flags[Weapon::Info_Flags::Mine]) { - // Distorted if beyond targetable range but within sensors range - bool in_targetable = (wip->mine_targetable_range < 0.0f || dist <= wip->mine_targetable_range); - if (!in_targetable) - b->flags |= BLIP_DRAW_DISTORTED; - } - } + // Mines outside their targetable range (but inside sensors range, by the earlier gate) get a distorted blip + if (!mine_in_targetable_range) + b->flags |= BLIP_DRAW_DISTORTED; if (objp->type == OBJ_SHIP) { diff --git a/code/scripting/api/objs/weapon.cpp b/code/scripting/api/objs/weapon.cpp index 74f06e70ec1..ecc84998432 100644 --- a/code/scripting/api/objs/weapon.cpp +++ b/code/scripting/api/objs/weapon.cpp @@ -421,7 +421,7 @@ ADE_FUNC(getSubmodelAnimationTime, l_Weapon, "string type, string triggeredBy", return ade_set_args(L, "f", time_s); } -ADE_VIRTVAR(isMine, l_Weapon, nullptr, "Whether this weapon is a mine", "boolean", "True if the weapon is a mine type, false otherwise or if handle is invalid") +ADE_FUNC(isMine, l_Weapon, nullptr, "Returns whether this weapon is a mine.", "boolean", "True if the weapon is a mine type, false otherwise or if handle is invalid") { object_h *oh = nullptr; if (!ade_get_args(L, "o", l_Weapon.GetPtr(&oh))) @@ -431,7 +431,7 @@ ADE_VIRTVAR(isMine, l_Weapon, nullptr, "Whether this weapon is a mine", "boolean return ade_set_error(L, "b", false); const weapon_info *wip = &Weapon_info[Weapons[oh->objp()->instance].weapon_info_index]; - return ade_set_args(L, "b", wip->wi_flags[Weapon::Info_Flags::Mine]); + return ade_set_args(L, "b", wip->is_mine()); } ADE_FUNC(detonate, l_Weapon, nullptr, "Forces this weapon to detonate immediately.", "boolean", "True if successful, false if handle is invalid") diff --git a/code/scripting/global_hooks.cpp b/code/scripting/global_hooks.cpp index 4aa6cf4cf0f..38a5a6bc1eb 100644 --- a/code/scripting/global_hooks.cpp +++ b/code/scripting/global_hooks.cpp @@ -297,12 +297,12 @@ const std::shared_ptr> OnMissileDeath = Hook> OnMineProximityTriggered = OverridableHook::Factory( - "On Mine Proximity Triggered", "Called when a ship passes a mine's proximity checks. The mine may then detonate immediately or enter chase mode depending on its configuration.", +const std::shared_ptr> OnWeaponProximityTriggered = OverridableHook::Factory( + "On Weapon Proximity Triggered", "Called when a ship passes a weapon's proximity checks. The weapon may then detonate immediately or, if it is a mine with a chase duration, enter chase mode.", { - {"Mine", "weapon", "The mine weapon that was triggered."}, - {"Ship", "object", "The ship whose proximity triggered the mine."}, - {"Position", "vector", "The world coordinates of the mine at the time of triggering."}, + {"Weapon", "weapon", "The weapon that was triggered."}, + {"Ship", "object", "The ship whose proximity triggered the weapon."}, + {"Position", "vector", "The world coordinates of the weapon at the time of triggering."}, }); const std::shared_ptr> OnBeamDeath = Hook<>::Factory( diff --git a/code/scripting/global_hooks.h b/code/scripting/global_hooks.h index 28d50d72977..1b0ac0cbdf8 100644 --- a/code/scripting/global_hooks.h +++ b/code/scripting/global_hooks.h @@ -55,7 +55,7 @@ extern const std::shared_ptr> OnShipDeathStarted; extern const std::shared_ptr> OnShipDeath; extern const std::shared_ptr> OnMissileDeathStarted; extern const std::shared_ptr> OnMissileDeath; -extern const std::shared_ptr> OnMineProximityTriggered; +extern const std::shared_ptr> OnWeaponProximityTriggered; extern const std::shared_ptr> OnBeamDeath; extern const std::shared_ptr> OnAsteroidDeath; extern const std::shared_ptr> OnDebrisDeath; diff --git a/code/scripting/hook_conditions.cpp b/code/scripting/hook_conditions.cpp index e4be54abb8a..9fb0c79d915 100644 --- a/code/scripting/hook_conditions.cpp +++ b/code/scripting/hook_conditions.cpp @@ -244,9 +244,9 @@ HOOK_CONDITIONS_START(WeaponDeathConditions) HOOK_CONDITION(WeaponDeathConditions, "Weapon class", "Specifies the class of the weapon that died.", dying_wep, conditionParseWeaponClass, conditionCompareWeaponClass); HOOK_CONDITIONS_END -HOOK_CONDITIONS_START(MineProximityTriggeredConditions) - HOOK_CONDITION(MineProximityTriggeredConditions, "Mine class", "Specifies the class of the mine that was triggered.", mine_wep, conditionParseWeaponClass, conditionCompareWeaponClass); - HOOK_CONDITION_SHIPP(MineProximityTriggeredConditions, "", "that triggered the mine.", trigger_shipp); +HOOK_CONDITIONS_START(WeaponProximityTriggeredConditions) + HOOK_CONDITION(WeaponProximityTriggeredConditions, "Weapon class", "Specifies the class of the weapon that was triggered.", triggered_wep, conditionParseWeaponClass, conditionCompareWeaponClass); + HOOK_CONDITION_SHIPP(WeaponProximityTriggeredConditions, "", "that triggered the weapon.", trigger_shipp); HOOK_CONDITIONS_END HOOK_CONDITIONS_START(ObjectDeathConditions) diff --git a/code/scripting/hook_conditions.h b/code/scripting/hook_conditions.h index 9b8574ab488..28d49fe7734 100644 --- a/code/scripting/hook_conditions.h +++ b/code/scripting/hook_conditions.h @@ -79,9 +79,9 @@ struct WeaponDeathConditions { const weapon* dying_wep; }; -struct MineProximityTriggeredConditions { +struct WeaponProximityTriggeredConditions { HOOK_DEFINE_CONDITIONS; - const weapon* mine_wep; + const weapon* triggered_wep; const ship* trigger_shipp; }; diff --git a/code/weapon/weapon.h b/code/weapon/weapon.h index c2769e9763e..4908633f49b 100644 --- a/code/weapon/weapon.h +++ b/code/weapon/weapon.h @@ -78,6 +78,16 @@ constexpr int BANK_SWITCH_DELAY = 250; // after switching banks, 1/4 second dela #define MAX_SPAWN_TYPES_PER_WEAPON 5 +namespace Weapon { +// Bitmask filter applied during proximity detonation, evaluated relative to the launcher's team. +// 0 = no relation filter (any relation passes). +namespace Proximity { + constexpr uint8_t Relation_Hostile = 1 << 0; // launcher's team attacks target's team + constexpr uint8_t Relation_Friendly = 1 << 1; // same team as launcher + constexpr uint8_t Relation_Neutral = 1 << 2; // neither attacks the other and not same team +} +} + // homing missiles have an extended lifetime so they don't appear to run out of gas before they can hit a moving target at extreme // range. Check the comment in weapon_set_tracking_info() for more details #define LOCKED_HOMING_EXTENDED_LIFE_FACTOR 1.2f @@ -449,16 +459,16 @@ struct weapon_info float arm_radius; float det_range; float det_radius; //How far from target or target subsystem it blows up - float proximity_radius; // Scan radius for proximity detonation; defaults to 50 - float mine_arm_time; // seconds after creation before proximity detonation is active; 0 = instantly armed (default) + float proximity_radius; // Scan radius for proximity detonation; 0 = disabled. $MineInfo: defaults this to 50. SCP_vector proximity_iff; // IFF indices that trigger detonation; empty = any IFF SCP_vector proximity_species; // species indices that trigger detonation; empty = any species SCP_vector proximity_type; // ship type indices that trigger detonation; empty = any type SCP_vector proximity_class; // ship class (ship_info) indices that trigger detonation; empty = any class + uint8_t proximity_relation_mask; // bitmask of Weapon::Proximity::Relation_Flags; 0 = any relation + float proximity_detonate_chance; // probability [0,1] that a proximity contact actually triggers detonation; default 1.0 + float proximity_stealth_multiplier; // scale factor applied to proximity_radius for stealth ships; 0.0 = undetectable, 1.0 = normal range; default 1.0 float mine_sensors_range; // range at which mine shows as a distorted blip; -1.0f = always, 0.0f = never float mine_targetable_range; // range at which mine is fully targetable; -1.0f = always, 0.0f = never - float mine_detonate_chance; // probability [0,1] that a proximity contact actually detonates the mine; default 1.0 - float mine_stealth_proximity_multiplier; // scale factor applied to proximity_radius for stealth ships; 0.0 = undetectable, 1.0 = normal range; default 1.0 float mine_chase_duration; // seconds mine chases the triggering ship after proximity contact; 0.0 = detonate immediately (default) bool mine_detonates_on_chase_timeout; // if true, detonate when chase timer expires; if false, give up and return to stationary; default true float mine_chase_cooldown; // seconds after a chase ends before proximity scanning re-enables; default 5.0 diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index 8cfd05528dd..d303a775f44 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -86,9 +86,10 @@ typedef struct delayed_ssm_index_data { SCP_unordered_map Delayed_SSM_indices_data; SCP_vector Delayed_SSM_indices; -// Temporary storage for mine proximity type/class names pending resolution after ship_init() -static SCP_unordered_map> Pending_mine_type_names; -static SCP_unordered_map> Pending_mine_class_names; +// Temporary storage for proximity type/class names pending resolution after ship_init(). +// Keyed by weapon_info_index (not name) so a duplicate-named entry can't clobber another's pending list. +static SCP_unordered_map> Pending_proximity_type_names; +static SCP_unordered_map> Pending_proximity_class_names; #ifndef NDEBUG @@ -1357,44 +1358,13 @@ int parse_weapon(int subtype, bool replace, const char *filename) stuff_float(&wip->det_radius); } - if (optional_string("$MineInfo:")) { - wip->wi_flags.set(Weapon::Info_Flags::Mine); - - if (optional_string("+Sensors Range:")) { - stuff_float(&wip->mine_sensors_range); - } - - if (optional_string("+Targetable Range:")) { - stuff_float(&wip->mine_targetable_range); - } - - if (optional_string("+Hitpoints:")) { - stuff_int(&wip->weapon_hitpoints); - } else if (first_time) { - wip->weapon_hitpoints = 50; // default: mines are destructible - } - - if (wip->weapon_hitpoints > 0) { - wip->wi_flags.set(Weapon::Info_Flags::Turret_Interceptable); - wip->wi_flags.set(Weapon::Info_Flags::Fighter_Interceptable); - } - - if (optional_string("+Arming Time:")) { - stuff_float(&wip->mine_arm_time); - if (wip->mine_arm_time < 0.0f) { - wip->mine_arm_time = 0.0f; - Warning(LOCATION, "Mine weapon '%s': +Arming Time cannot be negative. Setting to 0.\n", wip->name); - } - } - - if (optional_string("+Proximity Radius:")) { - stuff_float(&wip->proximity_radius); - if (wip->proximity_radius <= 0.0f) { - wip->proximity_radius = 1.0f; - Warning(LOCATION, "Mine weapon '%s': +Proximity Radius must be positive. Setting to 1.\n", wip->name); - } - } else if (first_time) { - wip->proximity_radius = 50.0f; // default proximity trigger radius + // Generic proximity detonation. Any weapon (mine, flak, missile, etc.) can opt in by setting + // $Proximity Radius:. The mine-specific behavior (chase, sensors range, etc.) lives in $MineInfo:. + if (optional_string("$Proximity Radius:")) { + stuff_float(&wip->proximity_radius); + if (wip->proximity_radius <= 0.0f) { + wip->proximity_radius = 1.0f; + Warning(LOCATION, "Weapon '%s': $Proximity Radius must be positive. Setting to 1.\n", wip->name); } if (optional_string("+Proximity IFF:")) { @@ -1403,7 +1373,7 @@ int parse_weapon(int subtype, bool replace, const char *filename) for (const SCP_string& iff_name : iff_names) { int iff_idx = iff_lookup(iff_name.c_str()); if (iff_idx < 0) - Warning(LOCATION, "Mine weapon '%s': +Proximity IFF entry '%s' not found.\n", wip->name, iff_name.c_str()); + Warning(LOCATION, "Weapon '%s': +Proximity IFF entry '%s' not found.\n", wip->name, iff_name.c_str()); else wip->proximity_iff.push_back(iff_idx); } @@ -1415,35 +1385,80 @@ int parse_weapon(int subtype, bool replace, const char *filename) for (const SCP_string& species_name : species_names) { int species_idx = species_info_lookup(species_name.c_str()); if (species_idx < 0) - Warning(LOCATION, "Mine weapon '%s': +Proximity Species entry '%s' not found.\n", wip->name, species_name.c_str()); + Warning(LOCATION, "Weapon '%s': +Proximity Species entry '%s' not found.\n", wip->name, species_name.c_str()); else wip->proximity_species.push_back(species_idx); } } + const int wi_index = static_cast(wip - Weapon_info.data()); + if (optional_string("+Proximity Type:")) { - stuff_string_list(Pending_mine_type_names[wip->name]); + stuff_string_list(Pending_proximity_type_names[wi_index]); } if (optional_string("+Proximity Class:")) { - stuff_string_list(Pending_mine_class_names[wip->name]); + stuff_string_list(Pending_proximity_class_names[wi_index]); + } + + // Launcher-relative filter: hostile/friendly/neutral, combinable. Default (no entry) = any relation. + if (optional_string("+Proximity Relation:")) { + SCP_vector relation_names; + stuff_string_list(relation_names); + for (const SCP_string& rel : relation_names) { + if (!stricmp(rel.c_str(), "hostile")) + wip->proximity_relation_mask |= Weapon::Proximity::Relation_Hostile; + else if (!stricmp(rel.c_str(), "friendly")) + wip->proximity_relation_mask |= Weapon::Proximity::Relation_Friendly; + else if (!stricmp(rel.c_str(), "neutral")) + wip->proximity_relation_mask |= Weapon::Proximity::Relation_Neutral; + else + Warning(LOCATION, "Weapon '%s': +Proximity Relation entry '%s' is not one of hostile/friendly/neutral.\n", wip->name, rel.c_str()); + } } if (optional_string("+Detonate Chance:")) { - stuff_float(&wip->mine_detonate_chance); - if (wip->mine_detonate_chance < 0.0f || wip->mine_detonate_chance > 1.0f) { - wip->mine_detonate_chance = CLAMP(wip->mine_detonate_chance, 0.0f, 1.0f); - Warning(LOCATION, "Mine weapon '%s': +Detonate Chance must be in [0.0, 1.0]. Clamping.\n", wip->name); + stuff_float(&wip->proximity_detonate_chance); + if (wip->proximity_detonate_chance < 0.0f || wip->proximity_detonate_chance > 1.0f) { + CLAMP(wip->proximity_detonate_chance, 0.0f, 1.0f); + Warning(LOCATION, "Weapon '%s': +Detonate Chance must be in [0.0, 1.0]. Clamping.\n", wip->name); } } if (optional_string("+Stealth Proximity Multiplier:")) { - stuff_float(&wip->mine_stealth_proximity_multiplier); - if (wip->mine_stealth_proximity_multiplier < 0.0f) { - wip->mine_stealth_proximity_multiplier = 0.0f; - Warning(LOCATION, "Mine weapon '%s': +Stealth Proximity Multiplier cannot be negative. Setting to 0.\n", wip->name); + stuff_float(&wip->proximity_stealth_multiplier); + if (wip->proximity_stealth_multiplier < 0.0f) { + wip->proximity_stealth_multiplier = 0.0f; + Warning(LOCATION, "Weapon '%s': +Stealth Proximity Multiplier cannot be negative. Setting to 0.\n", wip->name); } } + } + + if (optional_string("$MineInfo:")) { + wip->wi_flags.set(Weapon::Info_Flags::Mine); + + // Mines without an explicit $Proximity Radius: get the historical default + if (wip->proximity_radius <= 0.0f) + wip->proximity_radius = 50.0f; + + if (optional_string("+Sensors Range:")) { + stuff_float(&wip->mine_sensors_range); + } + + if (optional_string("+Targetable Range:")) { + stuff_float(&wip->mine_targetable_range); + } + + if (optional_string("+Hitpoints:")) { + stuff_int(&wip->weapon_hitpoints); + } else if (first_time) { + wip->weapon_hitpoints = 50; // default: mines are destructible + } + + if (wip->weapon_hitpoints > 0) { + wip->wi_flags.set(Weapon::Info_Flags::Turret_Interceptable); + wip->wi_flags.set(Weapon::Info_Flags::Fighter_Interceptable); + } if (optional_string("+Chase Duration:")) { stuff_float(&wip->mine_chase_duration); @@ -1470,9 +1485,9 @@ int parse_weapon(int subtype, bool replace, const char *filename) bool sensors_infinite = (wip->mine_sensors_range < 0.0f); if (!sensors_infinite) { if (targetable_infinite) { - Warning(LOCATION, "Mine weapon '%s': +Targetable Range is infinite but +Sensors Range is finite — the distorted blip state will never occur.\n", wip->name); + Warning(LOCATION, "Mine weapon '%s': +Targetable Range is infinite but +Sensors Range is finite. The distorted blip state will never occur.\n", wip->name); } else if (wip->mine_targetable_range >= wip->mine_sensors_range) { - Warning(LOCATION, "Mine weapon '%s': +Targetable Range (%.1f) >= +Sensors Range (%.1f) — the distorted blip state will never occur.\n", + Warning(LOCATION, "Mine weapon '%s': +Targetable Range (%.1f) >= +Sensors Range (%.1f). The distorted blip state will never occur.\n", wip->name, wip->mine_targetable_range, wip->mine_sensors_range); } } @@ -2941,7 +2956,7 @@ int parse_weapon(int subtype, bool replace, const char *filename) // Cross-validate aimed spawn fields for (int si = 0; si < wip->num_spawn_weapons_defined; si++) { if (wip->spawn_info[si].spawn_aim_lead > 0.0f && !wip->spawn_info[si].spawn_aimed) - Warning(LOCATION, "Weapon '%s' spawn %d: $Spawn Aim Lead is set but $Spawn Aimed is NO — lead will be ignored.\n", wip->name, si); + Warning(LOCATION, "Weapon '%s' spawn %d: $Spawn Aim Lead is set but $Spawn Aimed is NO. Lead will be ignored.\n", wip->name, si); } if (optional_string("$Lifetime Variation Factor When Child:")) @@ -5098,37 +5113,45 @@ void weapon_do_post_parse() translate_spawn_types(); } -/** - * Called after ship_init() to resolve mine proximity ship type/class names into indices. - */ +// Called after ship_init() to resolve proximity ship type/class names into indices. void weapon_post_ship_init() { - for (weapon_info& wip : Weapon_info) { - auto type_it = Pending_mine_type_names.find(wip.name); - if (type_it != Pending_mine_type_names.end()) { - for (const SCP_string& type_name : type_it->second) { - int idx = ship_type_name_lookup(type_name.c_str()); - if (idx < 0) - Warning(LOCATION, "Mine weapon '%s': +Proximity Type entry '%s' not found.\n", wip.name, type_name.c_str()); - else - wip.proximity_type.push_back(idx); - } + const int num_weapons = static_cast(Weapon_info.size()); + + for (const auto& entry : Pending_proximity_type_names) { + const int wi_index = entry.first; + if (wi_index < 0 || wi_index >= num_weapons) { + Warning(LOCATION, "Proximity types: stale weapon_info index %d. Entries dropped.\n", wi_index); + continue; + } + weapon_info& wip = Weapon_info[wi_index]; + for (const SCP_string& type_name : entry.second) { + int idx = ship_type_name_lookup(type_name.c_str()); + if (idx < 0) + Warning(LOCATION, "Weapon '%s': +Proximity Type entry '%s' not found.\n", wip.name, type_name.c_str()); + else + wip.proximity_type.push_back(idx); } + } - auto class_it = Pending_mine_class_names.find(wip.name); - if (class_it != Pending_mine_class_names.end()) { - for (const SCP_string& class_name : class_it->second) { - int idx = ship_info_lookup(class_name.c_str()); - if (idx < 0) - Warning(LOCATION, "Mine weapon '%s': +Proximity Class entry '%s' not found.\n", wip.name, class_name.c_str()); - else - wip.proximity_class.push_back(idx); - } + for (const auto& entry : Pending_proximity_class_names) { + const int wi_index = entry.first; + if (wi_index < 0 || wi_index >= num_weapons) { + Warning(LOCATION, "Proximity classes: stale weapon_info index %d. Entries dropped.\n", wi_index); + continue; + } + weapon_info& wip = Weapon_info[wi_index]; + for (const SCP_string& class_name : entry.second) { + int idx = ship_info_lookup(class_name.c_str()); + if (idx < 0) + Warning(LOCATION, "Weapon '%s': +Proximity Class entry '%s' not found.\n", wip.name, class_name.c_str()); + else + wip.proximity_class.push_back(idx); } } - Pending_mine_type_names.clear(); - Pending_mine_class_names.clear(); + Pending_proximity_type_names.clear(); + Pending_proximity_class_names.clear(); } /** @@ -6260,53 +6283,71 @@ void weapon_process_pre( object *obj, float frame_time) } } - // Chase timeout: check every frame whether an active chase has expired - if (wip->wi_flags[Weapon::Info_Flags::Mine] && wp->mine_chase_expires.isFinite()) { - if (timestamp_elapsed(wp->mine_chase_expires)) { + // Mine chase/cooldown lifecycle: handle every frame for any mine, independent of proximity_radius + // (which can theoretically be cleared at runtime). The proximity scan itself is gated on + // proximity_radius > 0 below, so future non-mine weapons can opt into proximity detonation + // without inheriting the mine-specific chase state machine. + if (wip->is_mine()) { + // Chase timeout: check whether an active chase has expired + if (wp->mine_chase_expires.isFinite() && timestamp_elapsed(wp->mine_chase_expires)) { if (wip->mine_detonates_on_chase_timeout) { weapon_detonate(obj); return; - } else { - // Give up: return to stationary at current position - vm_vec_zero(&obj->phys_info.vel); - vm_vec_zero(&obj->phys_info.desired_vel); - obj->phys_info.speed = 0.0f; - wp->weapon_max_vel = 0.0f; - wp->homing_object = &obj_used_list; - wp->target_num = -1; - wp->target_sig = -1; - wp->mine_chase_expires = TIMESTAMP::invalid(); - if (wip->mine_chase_cooldown > 0.0f) - wp->mine_chase_cooldown_expires = _timestamp(fl2i(wip->mine_chase_cooldown * 1000.0f)); } + // Give up: return to stationary at current position. State restored to "pre-chase" + // so the mine reappears on radar/sensors and is targetable as before. + vm_vec_zero(&obj->phys_info.vel); + vm_vec_zero(&obj->phys_info.desired_vel); + obj->phys_info.speed = 0.0f; + wp->weapon_max_vel = 0.0f; + wp->homing_object = &obj_used_list; + wp->target_num = -1; + wp->target_sig = -1; + wp->mine_chase_expires = TIMESTAMP::invalid(); + if (wip->mine_chase_cooldown > 0.0f) + wp->mine_chase_cooldown_expires = _timestamp(fl2i(wip->mine_chase_cooldown * 1000.0f)); + else + wp->mine_chase_cooldown_expires = TIMESTAMP::invalid(); } + + // Cooldown lapsed - clear it so the field stays clean + if (wp->mine_chase_cooldown_expires.isFinite() && timestamp_elapsed(wp->mine_chase_cooldown_expires)) + wp->mine_chase_cooldown_expires = TIMESTAMP::invalid(); } - // Proximity detonation: scan nearby ships and detonate if a qualifying one is within range + // Proximity detonation: scan nearby ships and detonate if a qualifying one is within range. + // Gated on proximity_radius (not is_mine()) so future non-mine weapon types can use this path. if (wip->proximity_radius > 0.0f) { - // Skip if mine is actively chasing or sitting in post-chase cooldown - bool chasing = wp->mine_chase_expires.isFinite() && !timestamp_elapsed(wp->mine_chase_expires); - bool cooling_down = wp->mine_chase_cooldown_expires.isFinite() && !timestamp_elapsed(wp->mine_chase_cooldown_expires); - - // Check arming time: mine will not detonate until this many seconds after creation - bool armed = !chasing && !cooling_down && f2fl(Missiontime - wp->creation_time) >= wip->mine_arm_time; - - if (armed) { + // Skip if mine is actively chasing + bool chasing = wp->mine_chase_expires.isFinite() && !timestamp_elapsed(wp->mine_chase_expires); + bool cooling_down = wp->mine_chase_cooldown_expires.isFinite(); + + // Arming gate: reuse the standard $Arm time: field - a proximity weapon won't trigger + // until this many fix-seconds after creation. weapon_armed() also enforces this for + // direct collisions; checking here additionally suppresses proximity triggers. + bool armed = !chasing && !cooling_down && (Missiontime - wp->creation_time) >= wip->arm_time; + + // Roll detonate chance once per ~62ms time bucket (Missiontime >> 12) + // Within a bucket the result is sticky across frames and across all candidate ships. + // Deterministic in multiplayer because Missiontime is lockstep-synced; the multiplicative + // hash gives per-weapon variation that XOR alone couldn't (net_signature is 16-bit). + bool roll_passed = true; + if (armed && wip->proximity_detonate_chance < 1.0f) { + uint32_t roll_seed = (obj->net_signature * 2654435761u) ^ static_cast(Missiontime >> 12); + roll_passed = (static_randf(static_cast(roll_seed & 0x7FFFFFFFu)) < wip->proximity_detonate_chance); + } + + if (armed && roll_passed) { float base_prox = wip->proximity_radius; - for (object *check_obj = GET_FIRST(&obj_used_list); - check_obj != END_OF_LIST(&obj_used_list); - check_obj = GET_NEXT(check_obj)) - { + for (auto sop : list_range(&Ship_obj_list)) { + object *check_obj = &Objects[sop->objnum]; if (check_obj->flags[Object::Object_Flags::Should_be_dead]) continue; - // Mines only trigger on ships - if (check_obj->type != OBJ_SHIP) - continue; - - // Skip the weapon itself - if (check_obj == obj) + // Dying or departing ships are no longer valid proximity targets + if (Ships[check_obj->instance].flags[Ship::Ship_Flags::Dying] + || Ships[check_obj->instance].flags[Ship::Ship_Flags::Depart_warp]) continue; // Protected ships are immune to mine proximity detonation @@ -6315,25 +6356,23 @@ void weapon_process_pre( object *obj, float frame_time) // Stealth ships may have a reduced proximity trigger radius float effective_prox = base_prox; - if (wip->mine_stealth_proximity_multiplier < 1.0f && + if (wip->proximity_stealth_multiplier < 1.0f && Ships[check_obj->instance].flags[Ship::Ship_Flags::Stealth]) { - effective_prox *= wip->mine_stealth_proximity_multiplier; + effective_prox *= wip->proximity_stealth_multiplier; } // Distance check (cheap rejection) if (vm_vec_dist_squared(&obj->pos, &check_obj->pos) > effective_prox * effective_prox) continue; - // Apply filters: AND across categories, OR within each category - // Empty category = pass all + // Apply filters: AND across categories, OR within each category. Empty category = pass all. const ship* sp = &Ships[check_obj->instance]; const ship_info* sip = &Ship_info[sp->ship_info_index]; if (!wip->proximity_iff.empty()) { - int target_team = sp->team; bool matched = false; for (int iff_idx : wip->proximity_iff) { - if (target_team == iff_idx) { matched = true; break; } + if (sp->team == iff_idx) { matched = true; break; } } if (!matched) continue; } @@ -6362,13 +6401,20 @@ void weapon_process_pre( object *obj, float frame_time) if (!matched) continue; } - // Probabilistic detonation: skip this ship if the chance roll fails. - // This check sits before homing_object assignment and the scripting hook so - // that OnMineProximityTriggered only fires when the mine actually commits to an action. - if (wip->mine_detonate_chance < 1.0f && frand() >= wip->mine_detonate_chance) - continue; + // Launcher-relative relation filter. Categories are mutually exclusive and exhaustive: + // friendly = same team; hostile = launcher attacks target; neutral = everything else. + if (wip->proximity_relation_mask != 0) { + bool is_friendly = (wp->team == sp->team); + bool is_hostile = !is_friendly && iff_x_attacks_y(wp->team, sp->team); + bool is_neutral = !is_friendly && !is_hostile; + bool matched = + ((wip->proximity_relation_mask & Weapon::Proximity::Relation_Hostile) && is_hostile) || + ((wip->proximity_relation_mask & Weapon::Proximity::Relation_Friendly) && is_friendly) || + ((wip->proximity_relation_mask & Weapon::Proximity::Relation_Neutral) && is_neutral); + if (!matched) continue; + } - // Set the triggering ship as the homing object and target. Both fields + // Set the triggering ship as the homing object and target. Both fields // must be set: homing_object is used by weapon_has_homing_object(), // and target_num is what weapon_set_tracking_info() actually receives. wp->homing_object = check_obj; @@ -6377,20 +6423,24 @@ void weapon_process_pre( object *obj, float frame_time) if (wip->mine_chase_duration > 0.0f) { // Chase mode: become a guided missile for the configured duration. // Contact with the ship (handled by normal weapon collision) causes detonation. - // OnMineProximityTriggered does not fire here — the mine hasn't committed to detonation yet. + // OnWeaponProximityTriggered does not fire here... the weapon hasn't committed to detonation yet. + // NOTE: chase reuses the existing homing-missile movement code, so the weapon must + // have a homing flag (Homing_aspect/Homing_heat/Homing_javelin) set in its table for + // it to actually move. A mine with mine_chase_duration > 0 but no homing flag will + // sit still until the chase timer expires - by design. wp->mine_chase_expires = _timestamp(fl2i(wip->mine_chase_duration * 1000.0f)); wp->weapon_max_vel = wip->max_speed; } else { // Immediate detonation mode. - auto mineParamList = scripting::hook_param_list( - scripting::hook_param("Mine", 'o', obj), + auto proxParamList = scripting::hook_param_list( + scripting::hook_param("Weapon", 'o', obj), scripting::hook_param("Ship", 'o', check_obj), scripting::hook_param("Position", 'o', scripting::api::l_Vector.Set(obj->pos)) ); - scripting::hooks::MineProximityTriggeredConditions mineConds{ wp, &Ships[check_obj->instance] }; - if (scripting::hooks::OnMineProximityTriggered->isActive()) { - bool overridden = scripting::hooks::OnMineProximityTriggered->isOverride(mineConds, mineParamList); - scripting::hooks::OnMineProximityTriggered->run(mineConds, mineParamList); + scripting::hooks::WeaponProximityTriggeredConditions proxConds{ wp, &Ships[check_obj->instance] }; + if (scripting::hooks::OnWeaponProximityTriggered->isActive()) { + bool overridden = scripting::hooks::OnWeaponProximityTriggered->isOverride(proxConds, proxParamList); + scripting::hooks::OnWeaponProximityTriggered->run(proxConds, proxParamList); if (overridden) return; } @@ -6398,7 +6448,7 @@ void weapon_process_pre( object *obj, float frame_time) } return; } - } // if (armed) + } } // If this flag is false missile turning is evaluated in weapon_process_post() @@ -6567,7 +6617,7 @@ void weapon_process_post(object * obj, float frame_time) wip = &Weapon_info[wp->weapon_info_index]; // Mines have infinite lifetime - skip the countdown - if (!wip->wi_flags[Weapon::Info_Flags::Mine]) { + if (!wip->is_mine()) { wp->lifeleft -= frame_time; } @@ -6633,7 +6683,7 @@ void weapon_process_post(object * obj, float frame_time) } // plot homing missiles on the radar - if (((wip->wi_flags[Weapon::Info_Flags::Bomb]) || (wip->wi_flags[Weapon::Info_Flags::Mine]) || (wip->wi_flags[Weapon::Info_Flags::Shown_on_radar])) && !(wip->wi_flags[Weapon::Info_Flags::Dont_show_on_radar])) { + if (((wip->wi_flags[Weapon::Info_Flags::Bomb]) || wip->is_mine() || (wip->wi_flags[Weapon::Info_Flags::Shown_on_radar])) && !(wip->wi_flags[Weapon::Info_Flags::Dont_show_on_radar])) { if ( hud_gauge_active(HUD_RADAR) ) { radar_plot_object( obj ); } @@ -7377,7 +7427,7 @@ int weapon_create( const vec3d *pos, const matrix *porient, int weapon_type, int } // Mines are stationary: zero all velocity after all other velocity setup - if (wip->wi_flags[Weapon::Info_Flags::Mine]) { + if (wip->is_mine()) { vm_vec_zero(&objp->phys_info.vel); vm_vec_zero(&objp->phys_info.desired_vel); objp->phys_info.speed = 0.0f; @@ -7649,7 +7699,7 @@ void spawn_child_weapons(object *objp, int spawn_index_override) if (wip->spawn_info[i].spawn_aimed && wp != nullptr && weapon_has_homing_object(wp)) { // Aimed spawn: point directly at the homing object with optional constant-velocity // lead extrapolation. wp != nullptr guard handles beam-spawned weapons (which have - // no weapon struct) — they fall through to the random cone below. + // no weapon struct). They fall through to the random cone below. // Direction is deterministic (derived from a known object position), so no // static_rand seeding is needed for multiplayer consistency. vec3d target_pos = wp->homing_object->pos; @@ -7657,7 +7707,7 @@ void spawn_child_weapons(object *objp, int spawn_index_override) vm_vec_scale_add2(&target_pos, &wp->homing_object->phys_info.vel, wip->spawn_info[i].spawn_aim_lead); vm_vec_normalized_dir(&tvec, &target_pos, opos); } else { - // Standard random cone — for multiplayer, use static randvec functions keyed on + // Standard random cone: for multiplayer, use static randvec functions keyed on // network signatures so all machines produce the same spread. if (Game_mode & GM_MULTIPLAYER) { if (wip->spawn_info[i].spawn_min_angle <= 0) @@ -9834,15 +9884,15 @@ void weapon_info::reset() this->det_range = 0.0f; this->det_radius = 0.0f; this->proximity_radius = 0.0f; - this->mine_arm_time = 0.0f; this->proximity_iff.clear(); this->proximity_species.clear(); this->proximity_type.clear(); this->proximity_class.clear(); + this->proximity_relation_mask = 0; + this->proximity_detonate_chance = 1.0f; + this->proximity_stealth_multiplier = 1.0f; this->mine_sensors_range = -1.0f; this->mine_targetable_range = -1.0f; - this->mine_detonate_chance = 1.0f; - this->mine_stealth_proximity_multiplier = 1.0f; this->mine_chase_duration = 0.0f; this->mine_detonates_on_chase_timeout = true; this->mine_chase_cooldown = 5.0f; From 42af661a8ebd6ba31094b33ef8089d605e778caf Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Thu, 14 May 2026 14:34:27 -0500 Subject: [PATCH 10/11] minor tweaks --- code/ai/aicode.cpp | 13 ++++++------- code/ai/aiturret.cpp | 2 +- code/hud/hudtarget.cpp | 21 ++++++++++----------- code/radar/radarsetup.cpp | 4 ++-- code/weapon/weapon.h | 4 ++-- code/weapon/weapons.cpp | 37 ++++++++++++++++++++----------------- 6 files changed, 41 insertions(+), 40 deletions(-) diff --git a/code/ai/aicode.cpp b/code/ai/aicode.cpp index b1bef354ece..5b26bb0aad8 100644 --- a/code/ai/aicode.cpp +++ b/code/ai/aicode.cpp @@ -10545,11 +10545,12 @@ static object *ai_find_nearby_mine_threat(object *from_objp, object *against_obj float dist_against = vm_vec_dist(&mine_objp->pos, &against_objp->pos); // Mine must be within its own targetable range of the threatened ship - if (wip->mine_targetable_range >= 0.0f && dist_against > wip->mine_targetable_range) + if (dist_against > wip->mine_targetable_range) continue; - // Imminent gate: only consider mines about to detonate on the threatened ship - if (imminent_only && wip->proximity_radius > 0.0f && dist_against > wip->proximity_radius * 1.5f) + // Imminent gate: only consider mines about to detonate on the threatened ship. + // proximity_radius is always > 0 for mines (parser enforces). + if (imminent_only && dist_against > wip->proximity_radius * 1.5f) continue; float dist_from = (from_objp == against_objp) ? dist_against : vm_vec_dist(&mine_objp->pos, &from_objp->pos); @@ -15368,10 +15369,8 @@ void ai_frame(int objnum) && Ship_types[Ship_info[shipp->ship_info_index].class_type].flags[Ship::Type_Info_Flags::AI_auto_attacks]) { object *imminent_mine = ai_find_nearby_mine_threat(Pl_objp, nullptr, true); if (imminent_mine != nullptr && (target_objnum < 0 || &Objects[target_objnum] != imminent_mine)) { - int new_target = OBJ_INDEX(imminent_mine); - if (aip->target_objnum != new_target) - aip->aspect_locked_time = 0.0f; - target_objnum = set_target_objnum(aip, new_target); + aip->aspect_locked_time = 0.0f; + target_objnum = set_target_objnum(aip, OBJ_INDEX(imminent_mine)); } } diff --git a/code/ai/aiturret.cpp b/code/ai/aiturret.cpp index ecdfd74303b..974e37557f4 100644 --- a/code/ai/aiturret.cpp +++ b/code/ai/aiturret.cpp @@ -480,7 +480,7 @@ int valid_turret_enemy(object *objp, object *turret_parent) } // Mines are only targetable within their configured detection range - if (wip->is_mine() && wip->mine_targetable_range >= 0.0f) { + if (wip->is_mine()) { if (vm_vec_dist(&objp->pos, &turret_parent->pos) > wip->mine_targetable_range) return 0; } diff --git a/code/hud/hudtarget.cpp b/code/hud/hudtarget.cpp index 6c251e4ca19..54e14b6b1b6 100644 --- a/code/hud/hudtarget.cpp +++ b/code/hud/hudtarget.cpp @@ -478,15 +478,14 @@ int hud_target_invalid_awacs(object *objp) return 0; } -// Returns true if the weapon object can currently be targeted by the player. +// Returns true if the weapon object can currently be targeted from source_objp. // For mines, uses range-based detection (mine_targetable_range). For other weapons, uses flags. -static bool weapon_is_player_targetable(object *objp) +static bool weapon_is_targetable_from(object *source_objp, object *objp) { weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; if (wip->is_mine()) { - if (wip->mine_targetable_range < 0.0f) return true; // infinite range - Assertion(Player_obj != nullptr, "weapon_is_player_targetable called with no Player_obj"); - return vm_vec_dist(&Player_obj->pos, &objp->pos) <= wip->mine_targetable_range; + Assertion(source_objp != nullptr, "weapon_is_targetable_from called with null source_objp"); + return vm_vec_dist(&source_objp->pos, &objp->pos) <= wip->mine_targetable_range; } return wip->wi_flags[Weapon::Info_Flags::Can_be_targeted] || wip->wi_flags[Weapon::Info_Flags::Bomb]; } @@ -1200,7 +1199,7 @@ void hud_target_common(int team_mask, int next_flag) continue; if (A->type == OBJ_WEAPON) { - if (!weapon_is_player_targetable(A)) + if (!weapon_is_targetable_from(Player_obj, A)) continue; if (Weapons[A->instance].lssm_stage == 3) @@ -1517,7 +1516,7 @@ void hud_target_hostile_bomb_or_bomber(object* source_obj, int next_flag, bool t weapon* wp = &Weapons[A->instance]; // only allow targeting of bombs/targetable weapons/mines in range - if (!weapon_is_player_targetable(A)) + if (!weapon_is_targetable_from(source_obj, A)) continue; if (wp->lssm_stage == 3) @@ -2509,7 +2508,7 @@ void hud_target_in_reticle_new() } if ( A->type == OBJ_WEAPON ) { - if (!weapon_is_player_targetable(A)) + if (!weapon_is_targetable_from(Player_obj, A)) continue; if (Weapons[A->instance].lssm_stage==3){ continue; @@ -2615,7 +2614,7 @@ void hud_target_in_reticle_old() } if ( A->type == OBJ_WEAPON ) { - if (!weapon_is_player_targetable(A)) + if (!weapon_is_targetable_from(Player_obj, A)) continue; if (Weapons[A->instance].lssm_stage==3){ @@ -4193,7 +4192,7 @@ void HudGaugeLeadIndicator::renderLeadCurrentTarget(bool config) // only allow bombs to have lead indicator displayed if ( targetp->type == OBJ_WEAPON ) { - if (!weapon_is_player_targetable(targetp)) + if (!weapon_is_targetable_from(Player_obj, targetp)) return; } @@ -4365,7 +4364,7 @@ void HudGaugeLeadIndicator::renderLeadQuick(vec3d *target_world_pos, object *tar // only allow bombs to have lead indicator displayed if ( targetp->type == OBJ_WEAPON ) { - if (!weapon_is_player_targetable(targetp)) + if (!weapon_is_targetable_from(Player_obj, targetp)) return; } diff --git a/code/radar/radarsetup.cpp b/code/radar/radarsetup.cpp index a7527775353..77c1926db76 100644 --- a/code/radar/radarsetup.cpp +++ b/code/radar/radarsetup.cpp @@ -301,8 +301,8 @@ void radar_plot_object( object *objp ) if (objp->type == OBJ_WEAPON) { weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; if (wip->is_mine()) { - mine_in_targetable_range = (wip->mine_targetable_range < 0.0f || dist <= wip->mine_targetable_range); - bool in_sensors = (wip->mine_sensors_range < 0.0f || dist <= wip->mine_sensors_range); + mine_in_targetable_range = (dist <= wip->mine_targetable_range); + bool in_sensors = (dist <= wip->mine_sensors_range); if (!mine_in_targetable_range && !in_sensors) return; // beyond all detection ranges } diff --git a/code/weapon/weapon.h b/code/weapon/weapon.h index 4908633f49b..27e6bcbbce4 100644 --- a/code/weapon/weapon.h +++ b/code/weapon/weapon.h @@ -467,8 +467,8 @@ struct weapon_info uint8_t proximity_relation_mask; // bitmask of Weapon::Proximity::Relation_Flags; 0 = any relation float proximity_detonate_chance; // probability [0,1] that a proximity contact actually triggers detonation; default 1.0 float proximity_stealth_multiplier; // scale factor applied to proximity_radius for stealth ships; 0.0 = undetectable, 1.0 = normal range; default 1.0 - float mine_sensors_range; // range at which mine shows as a distorted blip; -1.0f = always, 0.0f = never - float mine_targetable_range; // range at which mine is fully targetable; -1.0f = always, 0.0f = never + float mine_sensors_range; // range at which mine shows as a distorted blip; 0.0f = never. Always finite for mines after parsing. + float mine_targetable_range; // range at which mine is fully targetable; 0.0f = never. Always finite for mines after parsing. float mine_chase_duration; // seconds mine chases the triggering ship after proximity contact; 0.0 = detonate immediately (default) bool mine_detonates_on_chase_timeout; // if true, detonate when chase timer expires; if false, give up and return to stationary; default true float mine_chase_cooldown; // seconds after a chase ends before proximity scanning re-enables; default 5.0 diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index d303a775f44..df936693ab1 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -1443,12 +1443,27 @@ int parse_weapon(int subtype, bool replace, const char *filename) if (optional_string("+Sensors Range:")) { stuff_float(&wip->mine_sensors_range); + if (wip->mine_sensors_range < 0.0f) { + wip->mine_sensors_range = 0.0f; + Warning(LOCATION, "Mine weapon '%s': +Sensors Range cannot be negative. Setting to 0.\n", wip->name); + } } if (optional_string("+Targetable Range:")) { stuff_float(&wip->mine_targetable_range); + if (wip->mine_targetable_range < 0.0f) { + wip->mine_targetable_range = 0.0f; + Warning(LOCATION, "Mine weapon '%s': +Targetable Range cannot be negative. Setting to 0.\n", wip->name); + } } + // Mines must have finite detection ranges. Defaults scale off proximity_radius so a tiny + // proximity radius doesn't accidentally produce a galaxy-spanning blip. + if (wip->mine_sensors_range < 0.0f) + wip->mine_sensors_range = wip->proximity_radius * 60.0f; + if (wip->mine_targetable_range < 0.0f) + wip->mine_targetable_range = wip->proximity_radius * 30.0f; + if (optional_string("+Hitpoints:")) { stuff_int(&wip->weapon_hitpoints); } else if (first_time) { @@ -1481,15 +1496,9 @@ int parse_weapon(int subtype, bool replace, const char *filename) } // Warn if targetable range makes sensors range unreachable - bool targetable_infinite = (wip->mine_targetable_range < 0.0f); - bool sensors_infinite = (wip->mine_sensors_range < 0.0f); - if (!sensors_infinite) { - if (targetable_infinite) { - Warning(LOCATION, "Mine weapon '%s': +Targetable Range is infinite but +Sensors Range is finite. The distorted blip state will never occur.\n", wip->name); - } else if (wip->mine_targetable_range >= wip->mine_sensors_range) { - Warning(LOCATION, "Mine weapon '%s': +Targetable Range (%.1f) >= +Sensors Range (%.1f). The distorted blip state will never occur.\n", - wip->name, wip->mine_targetable_range, wip->mine_sensors_range); - } + if (wip->mine_targetable_range >= wip->mine_sensors_range) { + Warning(LOCATION, "Mine weapon '%s': +Targetable Range (%.1f) >= +Sensors Range (%.1f). The distorted blip state will never occur.\n", + wip->name, wip->mine_targetable_range, wip->mine_sensors_range); } } @@ -5120,10 +5129,7 @@ void weapon_post_ship_init() for (const auto& entry : Pending_proximity_type_names) { const int wi_index = entry.first; - if (wi_index < 0 || wi_index >= num_weapons) { - Warning(LOCATION, "Proximity types: stale weapon_info index %d. Entries dropped.\n", wi_index); - continue; - } + Assertion(wi_index >= 0 && wi_index < num_weapons, "Pending proximity type entry has out-of-range weapon_info index %d (num_weapons=%d).", wi_index, num_weapons); weapon_info& wip = Weapon_info[wi_index]; for (const SCP_string& type_name : entry.second) { int idx = ship_type_name_lookup(type_name.c_str()); @@ -5136,10 +5142,7 @@ void weapon_post_ship_init() for (const auto& entry : Pending_proximity_class_names) { const int wi_index = entry.first; - if (wi_index < 0 || wi_index >= num_weapons) { - Warning(LOCATION, "Proximity classes: stale weapon_info index %d. Entries dropped.\n", wi_index); - continue; - } + Assertion(wi_index >= 0 && wi_index < num_weapons, "Pending proximity class entry has out-of-range weapon_info index %d (num_weapons=%d).", wi_index, num_weapons); weapon_info& wip = Weapon_info[wi_index]; for (const SCP_string& class_name : entry.second) { int idx = ship_info_lookup(class_name.c_str()); From 2579261e6b2cfb093bdb3bbfd2c5f65463a589f7 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Thu, 14 May 2026 14:37:35 -0500 Subject: [PATCH 11/11] collapse namespace --- code/weapon/weapon.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/code/weapon/weapon.h b/code/weapon/weapon.h index 27e6bcbbce4..e7eecff9e00 100644 --- a/code/weapon/weapon.h +++ b/code/weapon/weapon.h @@ -78,15 +78,13 @@ constexpr int BANK_SWITCH_DELAY = 250; // after switching banks, 1/4 second dela #define MAX_SPAWN_TYPES_PER_WEAPON 5 -namespace Weapon { // Bitmask filter applied during proximity detonation, evaluated relative to the launcher's team. // 0 = no relation filter (any relation passes). -namespace Proximity { +namespace Weapon::Proximity { constexpr uint8_t Relation_Hostile = 1 << 0; // launcher's team attacks target's team constexpr uint8_t Relation_Friendly = 1 << 1; // same team as launcher constexpr uint8_t Relation_Neutral = 1 << 2; // neither attacks the other and not same team } -} // homing missiles have an extended lifetime so they don't appear to run out of gas before they can hit a moving target at extreme // range. Check the comment in weapon_set_tracking_info() for more details