diff --git a/code/ai/ai_flags.h b/code/ai/ai_flags.h index 5e8362a463c..0f66a6a55f8 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, + 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 57303161423..55834ba5ddb 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, "$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 ad556704659..5b26bb0aad8 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, flagsetoptimum_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)) { @@ -10497,8 +10510,59 @@ 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 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) +{ + if (against_objp == nullptr) + against_objp = from_objp; + + object *closest_mine = nullptr; + float closest_dist_from = std::numeric_limits::max(); + + 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]) + 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 the threatened ship + if (!iff_x_attacks_y(wp->team, obj_team(against_objp))) + continue; + + 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 (dist_against > wip->mine_targetable_range) + continue; + + // 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); + if (dist_from < closest_dist_from) { + closest_dist_from = dist_from; + closest_mine = mine_objp; + } + } + + return closest_mine; +} + int ai_guard_find_nearby_bomb(object *guarding_objp, object *guarded_objp) { missile_obj *mo; @@ -10544,6 +10608,13 @@ int ai_guard_find_nearby_bomb(object *guarding_objp, object *guarded_objp) return 1; } + // 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. + 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; + } + return 0; } @@ -10654,6 +10725,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::Ships_intercept_mines]) { + object *mine_objp = ai_find_nearby_mine_threat(Pl_objp); + if (mine_objp) + guard_object_was_hit(Pl_objp, mine_objp); + } } } @@ -15283,6 +15361,19 @@ 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)) { + aip->aspect_locked_time = 0.0f; + target_objnum = set_target_objnum(aip, OBJ_INDEX(imminent_mine)); + } + } + // Find an enemy if don't already have one. En_objp = NULL; if ( ai_need_new_target(Pl_objp, target_objnum) ) { @@ -15302,6 +15393,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::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) { + 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..974e37557f4 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()) { + if (vm_vec_dist(&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..54e14b6b1b6 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 from source_objp. +// For mines, uses range-based detection (mine_targetable_range). For other weapons, uses flags. +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()) { + 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]; +} + 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_targetable_from(Player_obj, 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_targetable_from(source_obj, 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_targetable_from(Player_obj, 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_targetable_from(Player_obj, 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_targetable_from(Player_obj, 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_targetable_from(Player_obj, 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..77c1926db76 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. @@ -236,17 +237,32 @@ void radar_plot_object( object *objp ) case OBJ_WEAPON: { + weapon_info *wip = &Weapon_info[Weapons[objp->instance].weapon_info_index]; + + if (wip->is_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; + } + // 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 +270,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 +297,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->is_mine()) { + 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 + } + } + // determine the range within which the radar blip is bright if (timestamp_elapsed(Radar_calc_bright_dist_timer)) { @@ -337,6 +364,10 @@ void radar_plot_object( object *objp ) // see if blip should be drawn distorted // also determine if alternate image was defined for this ship + // 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) { // ships specifically hidden from sensors diff --git a/code/scripting/api/objs/weapon.cpp b/code/scripting/api/objs/weapon.cpp index 3e9dc9d860c..ecc84998432 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_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))) + 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->is_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 0d82cb0d4c7..38a5a6bc1eb 100644 --- a/code/scripting/global_hooks.cpp +++ b/code/scripting/global_hooks.cpp @@ -297,6 +297,14 @@ const std::shared_ptr> OnMissileDeath = Hook> 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.", + { + {"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( "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..1b0ac0cbdf8 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> 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 1b62727430b..9fb0c79d915 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(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) 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..28d49fe7734 100644 --- a/code/scripting/hook_conditions.h +++ b/code/scripting/hook_conditions.h @@ -79,6 +79,12 @@ struct WeaponDeathConditions { const weapon* dying_wep; }; +struct WeaponProximityTriggeredConditions { + HOOK_DEFINE_CONDITIONS; + const weapon* triggered_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..e7eecff9e00 100644 --- a/code/weapon/weapon.h +++ b/code/weapon/weapon.h @@ -78,6 +78,14 @@ constexpr int BANK_SWITCH_DELAY = 250; // after switching banks, 1/4 second dela #define MAX_SPAWN_TYPES_PER_WEAPON 5 +// Bitmask filter applied during proximity detonation, evaluated relative to the launcher's team. +// 0 = no relation filter (any relation passes). +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 #define LOCKED_HOMING_EXTENDED_LIFE_FACTOR 1.2f @@ -168,6 +176,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; @@ -263,6 +274,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" @@ -444,6 +457,19 @@ 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. $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; 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 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 +949,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 +993,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..df936693ab1 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,11 @@ typedef struct delayed_ssm_index_data { SCP_unordered_map Delayed_SSM_indices_data; SCP_vector Delayed_SSM_indices; +// 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 int Weapon_flyby_sound_enabled = 1; @@ -260,6 +267,7 @@ special_flag_def_list_new&>); @@ -1350,6 +1358,150 @@ int parse_weapon(int subtype, bool replace, const char *filename) stuff_float(&wip->det_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:")) { + 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, "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, "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_proximity_type_names[wi_index]); + } + + if (optional_string("+Proximity Class:")) { + 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->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->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 (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) { + 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); + 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 + 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); } @@ -2786,6 +2938,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); @@ -4940,6 +5122,41 @@ void weapon_do_post_parse() translate_spawn_types(); } +// Called after ship_init() to resolve proximity ship type/class names into indices. +void weapon_post_ship_init() +{ + const int num_weapons = static_cast(Weapon_info.size()); + + for (const auto& entry : Pending_proximity_type_names) { + const int wi_index = entry.first; + 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()); + 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); + } + } + + for (const auto& entry : Pending_proximity_class_names) { + const int wi_index = entry.first; + 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()); + 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_proximity_type_names.clear(); + Pending_proximity_class_names.clear(); +} + /** * This will get called once at game startup */ @@ -6069,6 +6286,174 @@ void weapon_process_pre( object *obj, float frame_time) } } + // 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; + } + // 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. + // 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 + 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 (auto sop : list_range(&Ship_obj_list)) { + object *check_obj = &Objects[sop->objnum]; + if (check_obj->flags[Object::Object_Flags::Should_be_dead]) + continue; + + // 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 + 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->proximity_stealth_multiplier < 1.0f && + Ships[check_obj->instance].flags[Ship::Ship_Flags::Stealth]) { + 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. + const ship* sp = &Ships[check_obj->instance]; + const ship_info* sip = &Ship_info[sp->ship_info_index]; + + if (!wip->proximity_iff.empty()) { + bool matched = false; + for (int iff_idx : wip->proximity_iff) { + if (sp->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; + } + + // 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 + // 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); + + 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. + // 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 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::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; + } + weapon_detonate(obj); + } + return; + } + } + } + // 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 +6617,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->is_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++) { @@ -6298,7 +6686,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->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 ); } @@ -7041,6 +7429,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->is_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); @@ -7112,6 +7509,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; @@ -7298,18 +7698,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); @@ -9473,6 +9886,19 @@ 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->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_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 @@ -9519,6 +9945,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;