From d6eb8a948cb7ce6a789acc1609b4427710e54082 Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Wed, 8 Jul 2026 16:16:39 -0400 Subject: [PATCH 1/6] animation: forward shutdown for seamless loops, spin-up segment, warmup queries Three engine additions in preparation for migrating external weapon model gun rotation to the animation system: - "seamless forward shutdown" animation flag: while a "seamless with startup" animation plays its shutdown (or is otherwise reversed), its motion is mirrored about the pose where the reversal began, so the motion continues forward while winding down (a decelerating forward spin for gun barrels) instead of retracing backwards. The mirror pivot is captured whenever an animation enters reverse; the mirrored pose is this animation's delta reflected about its pivot delta, composed onto the apply buffer in place of the normal calculation. - $Spin Up: segment: rotates a submodel in PBH with constant acceleration up to a target angular velocity and ends while still moving at that velocity, unlike $Rotation:, which always brakes to hit its target angle. Intended as the startup portion of a seamless looping animation, followed by a constant-velocity $Rotation: covering one full cycle, so pose and velocity are continuous at the loop seam. - AnimationList::isFullyStarted() (true once every animation is "up to speed": playing forward within the seamless loop portion, or completed for non-looping animations) and AnimationList::startShutdown() (looping animations finish their current loop and stop -- entering the seamless shutdown -- while others play in reverse). These are the firing-gate and trigger-release primitives for weapon warmup animations. Also, an explicit forward start now cancels a pending or in-progress seamless shutdown, so a re-triggered animation spins back up from its current state. Co-Authored-By: Claude Fable 5 --- code/model/animation/modelanimation.cpp | 134 +++++++++++++++++- code/model/animation/modelanimation.h | 14 +- .../animation/modelanimation_segments.cpp | 90 +++++++++++- .../model/animation/modelanimation_segments.h | 31 ++++ 4 files changed, 262 insertions(+), 7 deletions(-) diff --git a/code/model/animation/modelanimation.cpp b/code/model/animation/modelanimation.cpp index b98eebdda8b..2a7b548608a 100644 --- a/code/model/animation/modelanimation.cpp +++ b/code/model/animation/modelanimation.cpp @@ -1,3 +1,5 @@ +#include + #include "math/curve.h" #include "model/animation/modelanimation.h" #include "model/animation/modelanimation_driver.h" @@ -27,7 +29,8 @@ namespace animation { //While this flag implies Reset_at_completion semantically, it's implemented in a conflicting manner. Hence, make sure that seamless will take priority, and force-disable reset at completion if set anim.m_flags.set(animation::Animation_Flags::Reset_at_completion, false); - }} + }}, + { "seamless forward shutdown", animation::Animation_Flags::Seamless_forward_shutdown, true } }; SCP_unordered_map ModelAnimationSet::s_runningAnimations; @@ -114,6 +117,8 @@ namespace animation { instanceData.time = animationTimeWrap(instanceData.time - instanceData.duration, 0, anim.m_flagData.loopsFrom, AnimationTimeWrapMode::BOUNCE_ONCE).first; instanceData.canonicalDirection = ModelAnimationDirection::RWD; instanceData.instance_flags.set(Animation_Instance_Flags::Seamless_loop_shutdown); + //the shutdown enters the startup ramp with the pose it has at the loop boundary + instanceData.reverseStartTime = anim.m_flagData.loopsFrom; } else { //Loop from start @@ -184,9 +189,9 @@ namespace animation { ModelAnimationState ModelAnimation::play(float frametime, polymodel_instance* pmi, ModelAnimationSubmodelBuffer& applyBuffer, bool applyOnly) { instance_data& instanceData = m_instances[pmi->id]; - + if (applyOnly) { - m_animation->calculateAnimation(applyBuffer, instanceData.time, pmi->id); + calculateCurrentAnimation(applyBuffer, instanceData.time, pmi); return instanceData.state; } @@ -240,7 +245,7 @@ namespace animation { driver(*this, instanceData, pmi); } - m_animation->calculateAnimation(applyBuffer, instanceData.time, pmi->id); + calculateCurrentAnimation(applyBuffer, instanceData.time, pmi); if ((prevTime > instanceData.time && instanceData.canonicalDirection == ModelAnimationDirection::FWD) || (prevTime < instanceData.time && instanceData.canonicalDirection == ModelAnimationDirection::RWD)) { //We _should_ have been going FWD, but we appear to have gone backwards (or we _should_ have been going FWD, but we appear to have gone forwards). @@ -261,6 +266,60 @@ namespace animation { return instanceData.state; } + void ModelAnimation::calculateCurrentAnimation(ModelAnimationSubmodelBuffer& applyBuffer, float time, polymodel_instance* pmi) { + const instance_data& instanceData = m_instances[pmi->id]; + + if (m_flags[Animation_Flags::Seamless_forward_shutdown] && instanceData.canonicalDirection == ModelAnimationDirection::RWD) { + //Mirror this animation's motion about the pose it had when the reversal began, so that the motion continues + //forward while the animation winds down, rather than retracing backwards. For a spin-up ramp played in + //reverse, this yields a decelerating forward spin. + + //Compute this animation's pose at the current time and at the mirror pivot, each on a clean base + ModelAnimationSubmodelBuffer currentBuffer, pivotBuffer; + m_set->initializeSubmodelBuffer(pmi, currentBuffer); + m_set->initializeSubmodelBuffer(pmi, pivotBuffer); + m_animation->calculateAnimation(currentBuffer, time, pmi->id); + m_animation->calculateAnimation(pivotBuffer, instanceData.reverseStartTime, pmi->id); + + for (auto& current : currentBuffer) { + if (!current.second.modified) + continue; + + const auto& pivot = pivotBuffer[current.first]; + const ModelAnimationData<>& base = current.first->getInitialData(pmi); + + //The buffers contain delta-on-base; factor the base back out to get this animation's deltas + matrix baseTransp, currentDelta, pivotDelta; + vm_copy_transpose(&baseTransp, &base.orientation); + vm_matrix_x_matrix(¤tDelta, ¤t.second.data.orientation, &baseTransp); + vm_matrix_x_matrix(&pivotDelta, &pivot.data.orientation, &baseTransp); + + //Mirror the current delta about the pivot delta: mirrored = pivot * current^-1 * pivot + matrix currentDeltaTransp, tmp; + ModelAnimationData mirrored; + vm_copy_transpose(¤tDeltaTransp, ¤tDelta); + vm_matrix_x_matrix(&tmp, &pivotDelta, ¤tDeltaTransp); + matrix mirroredOrient; + vm_matrix_x_matrix(&mirroredOrient, &tmp, &pivotDelta); + mirrored.orientation = mirroredOrient; + + vec3d currentPos, pivotPos, posDelta, mirroredPos; + vm_vec_sub(¤tPos, ¤t.second.data.position, &base.position); + vm_vec_sub(&pivotPos, &pivot.data.position, &base.position); + vm_vec_sub(&posDelta, &pivotPos, ¤tPos); + vm_vec_add(&mirroredPos, &pivotPos, &posDelta); + mirrored.position = mirroredPos; + + applyBuffer[current.first].data.applyDelta(mirrored); + applyBuffer[current.first].modified = true; + } + + return; + } + + m_animation->calculateAnimation(applyBuffer, time, pmi->id); + } + void ModelAnimation::start(polymodel_instance* pmi, ModelAnimationDirection direction, bool force, bool instant, bool pause, const float* multiOverrideTime) { if (pmi == nullptr) return; @@ -308,7 +367,11 @@ namespace animation { if (direction == ModelAnimationDirection::RWD) { instanceData.state = ModelAnimationState::RUNNING; - instanceData.time -= timeOffset; + instanceData.time -= timeOffset; + if (instanceData.canonicalDirection != ModelAnimationDirection::RWD) { + //the pose at this time is the mirror pivot for Seamless_forward_shutdown + instanceData.reverseStartTime = instanceData.time; + } instanceData.canonicalDirection = ModelAnimationDirection::RWD; if (force) instanceData.time = instant ? 0 : instanceData.duration - timeOffset; @@ -319,6 +382,12 @@ namespace animation { instanceData.canonicalDirection = ModelAnimationDirection::FWD; if (force) instanceData.time = instant ? instanceData.duration : 0 + timeOffset; + + //an explicit forward start cancels a pending or in-progress seamless shutdown + if (m_flags[Animation_Flags::Seamless_with_startup]) { + instanceData.instance_flags.set(Animation_Instance_Flags::Stop_after_next_loop, false); + instanceData.instance_flags.set(Animation_Instance_Flags::Seamless_loop_shutdown, false); + } } //Since initial types never get stepped, they need to be manually applied here once. @@ -363,6 +432,28 @@ namespace animation { return 0.0f; } + bool ModelAnimation::isFullyStarted(int pmi_id) const { + auto instance = m_instances.find(pmi_id); + if (instance == m_instances.end()) + return false; + + const instance_data& instanceData = instance->second; + + if (instanceData.state == ModelAnimationState::UNTRIGGERED || instanceData.state == ModelAnimationState::NEED_RECALC) + return false; + + //A seamless animation is fully started once it is playing forward within the seamless loop portion + if (m_flags[Animation_Flags::Seamless_with_startup]) + return instanceData.canonicalDirection == ModelAnimationDirection::FWD && instanceData.time >= m_flagData.loopsFrom; + + //Other looping animations have no startup portion to wait for + if (m_flags[Animation_Flags::Loop]) + return instanceData.canonicalDirection == ModelAnimationDirection::FWD; + + //Non-looping animations are fully started once they have played to completion + return instanceData.canonicalDirection == ModelAnimationDirection::FWD && instanceData.time >= instanceData.duration; + } + void ModelAnimation::stepAnimations(float frametime, polymodel_instance* pmi) { auto animListIt = ModelAnimationSet::s_runningAnimations.find(pmi->id); @@ -946,6 +1037,33 @@ namespace animation { return !animations.empty(); } + void ModelAnimationSet::AnimationList::startShutdown() const { + if (pmi_id < 0) + return; + + polymodel_instance* pmi = model_get_instance(pmi_id); + for (const auto& anim : animations) { + if (anim->m_instances[pmi_id].state == ModelAnimationState::UNTRIGGERED) + continue; + + if (anim->m_flags[Animation_Flags::Loop]) { + //Looping animations finish their current loop and then stop; for seamless animations this plays their shutdown + anim->m_instances[pmi_id].instance_flags.set(Animation_Instance_Flags::Stop_after_next_loop); + } + else { + anim->start(pmi, ModelAnimationDirection::RWD); + } + } + } + + bool ModelAnimationSet::AnimationList::isFullyStarted() const { + if (pmi_id < 0) + return true; + + return std::all_of(animations.cbegin(), animations.cend(), + [this](const std::shared_ptr& anim) { return anim->isFullyStarted(pmi_id); }); + } + int ModelAnimationSet::AnimationList::getTime() const { if (pmi_id < 0) return 0; @@ -1374,6 +1492,11 @@ namespace animation { for (const auto& flag : unparsed) { error_display(0, "Unknown flag %s in flag list!", flag.c_str()); } + + if (animation->m_flags[Animation_Flags::Seamless_forward_shutdown] && !animation->m_flags[Animation_Flags::Seamless_with_startup]) { + error_display(0, "Animation flag \"seamless forward shutdown\" requires \"seamless with startup\". Ignoring it."); + animation->m_flags.set(Animation_Flags::Seamless_forward_shutdown, false); + } } if (Animation_types.find(type)->second.second) { @@ -1834,6 +1957,7 @@ namespace animation { {"$Set Angle:", ModelAnimationSegmentSetAngle::parser}, {"$Rotation:", ModelAnimationSegmentRotation::parser}, {"$Axis Rotation:", ModelAnimationSegmentAxisRotation::parser}, + {"$Spin Up:", ModelAnimationSegmentSpinUp::parser}, {"$Translation:", ModelAnimationSegmentTranslation::parser}, {"$Sound During:", ModelAnimationSegmentSoundDuring::parser}, {"$Particles During:", ModelAnimationSegmentParticlesDuring::parser}, diff --git a/code/model/animation/modelanimation.h b/code/model/animation/modelanimation.h index 13d89007220..93343f9aa52 100644 --- a/code/model/animation/modelanimation.h +++ b/code/model/animation/modelanimation.h @@ -60,6 +60,7 @@ namespace animation { Random_starting_phase, //When an animation is started from an untriggered state, will randomize its time to any possible time of the animation + possibly on the reverse, if the animation would automatically enter that Pause_on_reverse, //Will cause any start in RWD direction to behave as a call to pause the animation. Required (and also only really useful) when a looping animation is supposed to be triggered by an internal engine trigger Seamless_with_startup, //Provides automatic handling of animations that loop with an initialization part (effectively looping from a specific time) + Seamless_forward_shutdown, //While a Seamless_with_startup animation plays its shutdown (or is otherwise reversed), mirror its motion about the pose where the reversal began, so that the motion continues forward while winding down (e.g. decelerating gun barrels) instead of retracing backwards. Requires Seamless_with_startup. NUM_VALUES }; @@ -216,6 +217,7 @@ namespace animation { float duration = 0.0f; flagset instance_flags; float speed = 1.0f; + float reverseStartTime = 0.0f; //the time at which the animation last started playing in reverse; the mirror pivot for Seamless_forward_shutdown }; private: @@ -242,6 +244,8 @@ namespace animation { private: static void driverTime(ModelAnimation& anim, instance_data& instance, polymodel_instance* pmi, float frametime); ModelAnimationState play(float frametime, polymodel_instance* pmi, ModelAnimationSubmodelBuffer& applyBuffer, bool applyOnly = false); + //Calculates the animation at the given time into the buffer, applying the Seamless_forward_shutdown mirror if applicable + void calculateCurrentAnimation(ModelAnimationSubmodelBuffer& applyBuffer, float time, polymodel_instance* pmi); //The main driver for the animation "time" std::function m_driver = driverTime; @@ -266,7 +270,11 @@ namespace animation { void stop(polymodel_instance* pmi, bool cleanup = true, bool forceStop = false); float getTime(int pmi_id) const; - + + //True once the animation has finished starting up: for Seamless_with_startup animations, once it is playing forward in the seamless loop portion; + //for other animations, once it has completed. Used to gate things (like weapon fire) on an animation being "up to speed". + bool isFullyStarted(int pmi_id) const; + static void stepAnimations(float frametime, polymodel_instance* pmi); unsigned int id = 0; @@ -348,7 +356,11 @@ namespace animation { public: inline AnimationList() : AnimationList(-1) {} bool start(ModelAnimationDirection direction, bool forced = false, bool instant = false, bool pause = false) const; + //Winds the animations down: looping animations stop once their current loop completes (entering their shutdown for seamless animations), others play in reverse + void startShutdown() const; int getTime() const; + //True once every animation in the list is fully started (see ModelAnimation::isFullyStarted); true for an empty list + bool isFullyStarted() const; void setFlag(Animation_Instance_Flags flag, bool set = true) const; void setSpeed(float speed = 1.0f) const; AnimationList& operator+=(const AnimationList& rhs); diff --git a/code/model/animation/modelanimation_segments.cpp b/code/model/animation/modelanimation_segments.cpp index 44b4d36dcea..7d5bc2b19b8 100644 --- a/code/model/animation/modelanimation_segments.cpp +++ b/code/model/animation/modelanimation_segments.cpp @@ -920,7 +920,95 @@ namespace animation { return segment; } - + + + ModelAnimationSegmentSpinUp::ModelAnimationSegmentSpinUp(std::shared_ptr submodel, const angles& velocity, const angles& acceleration) : + m_submodel(std::move(submodel)), m_velocity(velocity), m_acceleration(acceleration) { } + + ModelAnimationSegment* ModelAnimationSegmentSpinUp::copy() const { + return new ModelAnimationSegmentSpinUp(*this); + } + + void ModelAnimationSegmentSpinUp::recalculate(ModelAnimationSubmodelBuffer& /*base*/, ModelAnimationSubmodelBuffer& /*currentAnimDelta*/, polymodel_instance* pmi) { + instance_data& instanceData = m_instances[pmi->id]; //NOLINT(misc-const-correctness) - clang-tidy does not recognize the pointer-to-member writes below as mutations + auto submodel_info = m_submodel->findSubmodel(pmi).second; + if (submodel_info == nullptr) { + m_duration[pmi->id] = 0.0f; + return; + } + + float duration = 0.0f; + + for (float angles::* i : pbh) { + if (m_velocity.*i != 0.0f && m_acceleration.*i != 0.0f) { + instanceData.m_actualAccel.*i = copysignf(m_acceleration.*i, m_velocity.*i); + instanceData.m_accelTime.*i = m_velocity.*i / instanceData.m_actualAccel.*i; + duration = fmaxf(duration, instanceData.m_accelTime.*i); + } + else { + //no acceleration: rotate at constant velocity from the start + instanceData.m_actualAccel.*i = 0.0f; + instanceData.m_accelTime.*i = 0.0f; + } + } + + m_duration[pmi->id] = duration; + } + + void ModelAnimationSegmentSpinUp::calculateAnimation(ModelAnimationSubmodelBuffer& base, float time, int pmi_id) const { + const instance_data& instanceData = m_instances.at(pmi_id); + + angles currentRot{ 0, 0, 0 }; + + for (float angles::* i : pbh) { + float accelTime = fminf(time, instanceData.m_accelTime.*i); + currentRot.*i = 0.5f * instanceData.m_actualAccel.*i * accelTime * accelTime; + + //once this axis has reached its target velocity, it continues at that velocity for the rest of the segment + float linearTime = time - instanceData.m_accelTime.*i; + if (linearTime > 0.0f) + currentRot.*i += m_velocity.*i * linearTime; + } + + matrix orient; + vm_angles_2_matrix(&orient, ¤tRot); + + ModelAnimationData delta; + delta.orientation = orient; + + base[m_submodel].data.applyDelta(delta); + base[m_submodel].modified = true; + } + + void ModelAnimationSegmentSpinUp::exchangeSubmodelPointers(ModelAnimationSet& replaceWith) { + m_submodel = replaceWith.getSubmodel(m_submodel); + } + + std::shared_ptr ModelAnimationSegmentSpinUp::parser(ModelAnimationParseHelper* data) { + angles velocity, acceleration; + + required_string("+Velocity:"); + stuff_angles_deg_phb(&velocity); + + required_string("+Acceleration:"); + stuff_angles_deg_phb(&acceleration); + + for (float angles::* i : pbh) { + if (velocity.*i != 0.0f && acceleration.*i == 0.0f) + error_display(0, "Spin up segment has zero acceleration on an axis with nonzero velocity; that axis will rotate at constant velocity from the start."); + } + + auto submodel = ModelAnimationParseHelper::parseSubmodel(); + if (!submodel) { + if (data->parentSubmodel) + submodel = data->parentSubmodel; + else + error_display(1, "Spin up segment has no target submodel!"); + } + + return std::make_shared(std::move(submodel), velocity, acceleration); + } + ModelAnimationSegmentTranslation::ModelAnimationSegmentTranslation(std::shared_ptr submodel, std::optional target, std::optional velocity, std::optional time, std::optional acceleration, CoordinateSystem coordType, ModelAnimationCoordinateRelation relationType) : m_submodel(std::move(submodel)), m_target(target), m_velocity(velocity), m_time(time), m_acceleration(acceleration), m_coordType(coordType), m_relationType(relationType) { } diff --git a/code/model/animation/modelanimation_segments.h b/code/model/animation/modelanimation_segments.h index cf1e09b02d0..a90f9c591f9 100644 --- a/code/model/animation/modelanimation_segments.h +++ b/code/model/animation/modelanimation_segments.h @@ -213,6 +213,37 @@ namespace animation { }; + //This segment rotates a submodel in PBH with constant acceleration up to a target angular velocity, ending while still + //moving at that velocity. Intended as the startup portion of a "seamless with startup" looping animation, typically + //followed by a constant-velocity $Rotation: covering one full cycle of the submodel's motion. + class ModelAnimationSegmentSpinUp : public ModelAnimationSegment { + struct instance_data { + angles m_actualAccel{ 0, 0, 0 }; + angles m_accelTime{ 0, 0, 0 }; + }; + + //PMI ID -> Instance Data + std::map m_instances; + + //configurables: + public: + std::shared_ptr m_submodel; + angles m_velocity; + angles m_acceleration; + + private: + ModelAnimationSegment* copy() const override; + void recalculate(ModelAnimationSubmodelBuffer& base, ModelAnimationSubmodelBuffer& currentAnimDelta, polymodel_instance* pmi) override; + void calculateAnimation(ModelAnimationSubmodelBuffer& base, float time, int pmi_id) const override; + void executeAnimation(const ModelAnimationSubmodelBuffer& /*state*/, float /*timeboundLower*/, float /*timeboundUpper*/, ModelAnimationDirection /*direction*/, int /*pmi_id*/) override { }; + void exchangeSubmodelPointers(ModelAnimationSet& replaceWith) override; + + public: + static std::shared_ptr parser(ModelAnimationParseHelper* data); + ModelAnimationSegmentSpinUp(std::shared_ptr submodel, const angles& velocity, const angles& acceleration); + + }; + class ModelAnimationSegmentTranslation : public ModelAnimationSegment { struct instance_data { vec3d m_actualVelocity; From 55518db42e6370f6577875ecd18331a79de4dba6 Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Wed, 8 Jul 2026 16:34:35 -0400 Subject: [PATCH 2/6] add weapon-warmup animations on external weapon models Adds the "weapon-warmup" animation trigger type for weapon-owned animation sets ($Animations: in weapons.tbl, which already existed for the in-flight model's on-spawn and scripted triggers). Warmup animations target the weapon's external display model: they start when the bank tries to fire, the bank holds fire until they are fully started, and they wind down when the bank stops firing. Weapon-owned primary-fired and secondary-fired animations now also trigger on the bank's external model with each shot, enabling recoil animations. Wiring: - ship_get_external_weapon_model_instance() is generalized to any bank (secondaries can now have instances) and creates an instance whenever the weapon has animations, not just for Gun_rotation submodels. It also stops running animations before deleting a stale instance, as does ship_delete(). - update_external_weapon_animations() (formerly ..._spin) ensures the bank instance exists even for ships that are never rendered, starts or shuts down warmup animations on firing-state transitions, and steps each bank instance's animations -- these instances belong to no object, so the object-loop stepping never reaches them. - ship_fire_primary() gates firing on AnimationList::isFullyStarted() for the weapon's warmup animations, parallel to the legacy submodel-rotation gate. Also, ModelAnimation::start() no longer takes the multiplayer early-return path for model instances that belong to no object (external weapon models, skybox, cockpit): such animations cannot be synced by object and now simply run locally on each machine; previously multiplayer clients would never start them at all. Co-Authored-By: Claude Fable 5 --- code/hud/hudtarget.cpp | 2 +- code/model/animation/modelanimation.cpp | 19 ++-- code/model/animation/modelanimation.h | 1 + code/ship/ship.cpp | 137 +++++++++++++++++++----- code/ship/ship.h | 4 +- 5 files changed, 124 insertions(+), 39 deletions(-) diff --git a/code/hud/hudtarget.cpp b/code/hud/hudtarget.cpp index e18c7a48930..70da0c7cc20 100644 --- a/code/hud/hudtarget.cpp +++ b/code/hud/hudtarget.cpp @@ -8006,7 +8006,7 @@ void HudGaugeHardpoints::render(float /*frametime*/, bool config) renderCircle((int)draw_point.screen.xyw.x + position[0], (int)draw_point.screen.xyw.y + position[1], 10); } } else { - int external_model_instance = ship_get_external_weapon_model_instance(swp, i, display_model_num); + int external_model_instance = ship_get_external_weapon_model_instance(&swp->primary_bank_external_weapon[i], swp->primary_bank_weapons[i], display_model_num); for ( k = 0; k < bank->num_slots; k++ ) { model_render_params weapon_render_info; diff --git a/code/model/animation/modelanimation.cpp b/code/model/animation/modelanimation.cpp index 2a7b548608a..4a8835bdee6 100644 --- a/code/model/animation/modelanimation.cpp +++ b/code/model/animation/modelanimation.cpp @@ -326,17 +326,15 @@ namespace animation { instance_data& instanceData = m_instances[pmi->id]; - if (multiOverrideTime == nullptr && m_isMultiCompatible && (Game_mode & GM_MULTIPLAYER) && id != 0) { + //Model instances that don't belong to an object (external weapon models, the skybox, cockpits) cannot be + //multi-synced; their animations just run locally on each machine. + if (multiOverrideTime == nullptr && m_isMultiCompatible && (Game_mode & GM_MULTIPLAYER) && id != 0 && pmi->objnum >= 0) { //We are in multiplayer. Send animation to server to start. Server starts animation online, and sends start request back (which'll have multiOverride == true). //If we _are_ the server, also just start the animation - object* objp = pmi->objnum >= 0 ? &Objects[pmi->objnum] : nullptr; + object* objp = &Objects[pmi->objnum]; - if(objp != nullptr) - send_animation_triggered_packet(id, objp, 0, direction, force, instant, pause); - else { - //Find special mode based on id and send - } + send_animation_triggered_packet(id, objp, 0, direction, force, instant, pause); if(MULTIPLAYER_CLIENT) return; @@ -1140,6 +1138,10 @@ namespace animation { return getAll(pmi, type, subtype); + case ModelAnimationTriggerType::WeaponWarmup: + //Weapon-owned animations have no subtype + return getAll(pmi, type); + case ModelAnimationTriggerType::DockBayDoor: //Index of the dock bay door subtype = atoi(triggeredBy.c_str()); @@ -1290,7 +1292,8 @@ namespace animation { {ModelAnimationTriggerType::Scripted, {"scripted", false}}, {ModelAnimationTriggerType::TurretFired, {"turret-fired", true}}, {ModelAnimationTriggerType::PrimaryFired, {"primary-fired", true}}, - {ModelAnimationTriggerType::SecondaryFired, {"secondary-fired", true}} + {ModelAnimationTriggerType::SecondaryFired, {"secondary-fired", true}}, + {ModelAnimationTriggerType::WeaponWarmup, {"weapon-warmup", false}} }; ModelAnimationTriggerType anim_match_type(const char* p) diff --git a/code/model/animation/modelanimation.h b/code/model/animation/modelanimation.h index 93343f9aa52..77153949878 100644 --- a/code/model/animation/modelanimation.h +++ b/code/model/animation/modelanimation.h @@ -49,6 +49,7 @@ namespace animation { TurretFired, // Triggered after a turret has fired -The E PrimaryFired, // Triggered when a primary weapon has fired. SecondaryFired, // Triggered when a secondary weapon has fired. + WeaponWarmup, // Weapon-owned animations (on the weapon's external model): plays while the weapon's bank is trying to fire; the bank holds fire until the animation is fully started (e.g. gatling barrel spin-up). MaxAnimationTypes }; diff --git a/code/ship/ship.cpp b/code/ship/ship.cpp index 1ce3aeb0b06..fc535f8dac3 100644 --- a/code/ship/ship.cpp +++ b/code/ship/ship.cpp @@ -8691,6 +8691,7 @@ void ship_delete( object * obj ) { if (ext.model_instance >= 0) { + animation::ModelAnimationSet::stopAnimations(model_get_instance(ext.model_instance)); model_delete_instance(ext.model_instance); ext.model_instance = -1; } @@ -8699,6 +8700,7 @@ void ship_delete( object * obj ) { if (ext.model_instance >= 0) { + animation::ModelAnimationSet::stopAnimations(model_get_instance(ext.model_instance)); model_delete_instance(ext.model_instance); ext.model_instance = -1; } @@ -10546,13 +10548,13 @@ void ship_do_thruster_sounds(object *obj) } } -void update_external_weapon_spin(ship *shipp, float frametime); +void update_external_weapon_animations(ship *shipp, float frametime); void ship_process_pre(object *obj, float frametime) { - // update the external weapon model spin before any firing attempts this frame + // update the external weapon model animations before any firing attempts this frame if ( (obj != nullptr) && (obj->type == OBJ_SHIP) && (frametime != 0.0f) ) - update_external_weapon_spin(&Ships[obj->instance], frametime); + update_external_weapon_animations(&Ships[obj->instance], frametime); // Cyborg, to enable turrets movement on clients, we need to process them here before bailing if (MULTIPLAYER_CLIENT) @@ -10685,10 +10687,46 @@ void update_firing_sounds(object* objp, ship* shipp) } } +// Updates the animation state of one bank's external weapon model: makes sure the bank's model +// instance exists, starts or winds down the weapon's warmup animations based on whether the +// bank tried to fire this frame, and steps the instance's running animations. +static void update_external_weapon_bank_animations(external_weapon_state *ext, int weapon_idx, float frametime) +{ + if (weapon_idx < 0) + return; + + weapon_info *wip = &Weapon_info[weapon_idx]; + + // the instance is also created at render time, but a ship that is never rendered still + // needs its animations (and the warmup firing gate) to work + int display_model_num = (wip->external_model_num >= 0) ? wip->external_model_num : wip->model_num; + int instance_num = ship_get_external_weapon_model_instance(ext, weapon_idx, display_model_num); + + if (instance_num < 0) + return; + + polymodel_instance *pmi = model_get_instance(instance_num); + + bool warmup_wanted = ext->warmup_requested; + ext->warmup_requested = false; + + if (warmup_wanted != ext->warmup_active) + { + auto warmup_anims = wip->animations.getAll(pmi, animation::ModelAnimationTriggerType::WeaponWarmup); + if (warmup_wanted) + warmup_anims.start(animation::ModelAnimationDirection::FWD); + else + warmup_anims.startShutdown(); + ext->warmup_active = warmup_wanted; + } + + animation::ModelAnimation::stepAnimations(frametime, pmi); +} + // Spins the Gun_rotation submodels of external weapon models up or down. A bank that tried to // fire this frame requests spin-up (see ship_fire_primary, which also refuses to fire until the // barrels are up to speed); all other banks wind down. Only primary banks spin. -void update_external_weapon_spin(ship *shipp, float frametime) +void update_external_weapon_animations(ship *shipp, float frametime) { ship_weapon *swp = &shipp->weapons; @@ -10700,6 +10738,7 @@ void update_external_weapon_spin(ship *shipp, float frametime) { ext.rotate_rate = 0.0f; ext.spin_up_requested = false; + ext.warmup_requested = false; continue; } @@ -10725,6 +10764,12 @@ void update_external_weapon_spin(ship *shipp, float frametime) while (ext.rotate_ang > PI2) ext.rotate_ang -= PI2; } + + update_external_weapon_bank_animations(&ext, swp->primary_bank_weapons[i], frametime); + } + + for (int i = 0; i < swp->num_secondary_banks; i++) { + update_external_weapon_bank_animations(&swp->secondary_bank_external_weapon[i], swp->secondary_bank_weapons[i], frametime); } } @@ -13047,12 +13092,23 @@ int ship_fire_primary(object * obj, int force, bool rollback_shot) if (winfo_p->weapon_submodel_rotate_vel > 0.0f) { auto &ext = swp->primary_bank_external_weapon[bank_to_fire]; - // spin up the Gun_rotation submodels (see update_external_weapon_spin), and + // spin up the Gun_rotation submodels (see update_external_weapon_animations), and // don't fire until the barrels are up to speed ext.spin_up_requested = true; if (ext.rotate_rate < winfo_p->weapon_submodel_rotate_vel) continue; } + + if (!winfo_p->animations.isEmpty()) { + auto &ext = swp->primary_bank_external_weapon[bank_to_fire]; + + // start the weapon's warmup animations (see update_external_weapon_animations), and + // don't fire until they are fully started + ext.warmup_requested = true; + if (ext.model_instance >= 0 + && !winfo_p->animations.getAll(model_get_instance(ext.model_instance), animation::ModelAnimationTriggerType::WeaponWarmup).isFullyStarted()) + continue; + } // if this is a targeting laser, start it up ///- only targeting laser if it is tag-c, otherwise it's a fighter beam -Bobboau if((winfo_p->wi_flags[Weapon::Info_Flags::Beam]) && (winfo_p->tag_level == 3) && (shipp->flags[Ship_Flags::Trigger_down]) && (winfo_p->b_info.beam_type == BeamType::TARGETING) ){ ship_start_targeting_laser(shipp); @@ -13862,6 +13918,13 @@ int ship_fire_primary(object * obj, int force, bool rollback_shot) if (banks_fired & (1 << bank)) { //Start Animation in Forced mode: Always restart it from its initial position rather than just flip it to FWD motion if it was still moving. This is to make it work best for uses like recoil. sip->animations.getAll(model_get_instance(shipp->model_instance_num), animation::ModelAnimationTriggerType::PrimaryFired, bank).start(animation::ModelAnimationDirection::FWD, true); + + // also fire any primary-fired animations on the weapon's own external model (e.g. recoil) + int fired_weapon = shipp->weapons.primary_bank_weapons[bank]; + if (fired_weapon >= 0 && shipp->weapons.primary_bank_external_weapon[bank].model_instance >= 0) { + Weapon_info[fired_weapon].animations.getAll(model_get_instance(shipp->weapons.primary_bank_external_weapon[bank].model_instance), animation::ModelAnimationTriggerType::PrimaryFired).start(animation::ModelAnimationDirection::FWD, true); + } + firedWeapons.emplace_back(shipp->weapons.primary_bank_weapons[bank]); } } @@ -14629,6 +14692,11 @@ int ship_fire_secondary( object *obj, int allow_swarm, bool rollback_shot ) //Start Animation in Forced mode: Always restart it from its initial position rather than just flip it to FWD motion if it was still moving. This is to make it work best for uses like recoil. sip->animations.getAll(model_get_instance(shipp->model_instance_num), animation::ModelAnimationTriggerType::SecondaryFired, bank).start(animation::ModelAnimationDirection::FWD, true); + // also fire any secondary-fired animations on the weapon's own external model (e.g. launcher recoil) + if (swp->secondary_bank_external_weapon[bank].model_instance >= 0) { + wip->animations.getAll(model_get_instance(swp->secondary_bank_external_weapon[bank].model_instance), animation::ModelAnimationTriggerType::SecondaryFired).start(animation::ModelAnimationDirection::FWD, true); + } + if (scripting::hooks::OnWeaponFired->isActive() || scripting::hooks::OnSecondaryFired->isActive()) { auto param_list = scripting::hook_param_list( scripting::hook_param("User", 'o', objp), @@ -21684,47 +21752,56 @@ void ship_render_batch_thrusters(object *obj) } } -// Lazily creates the model instance used to spin the Gun_rotation submodels of the external -// weapon model in the given primary bank, recreating it if the bank's weapon has changed since -// the last call (weapons can be swapped mid-mission by SEXPs, scripts, or rearming). The ideal -// place to create the instance would be in parse_object_create_sub, but the player can alter -// the ship loadout after that function runs. +// Lazily creates the model instance used to animate a bank's external weapon model (spinning +// Gun_rotation submodels or playing the weapon's animations), recreating it if the bank's +// weapon has changed since the last call (weapons can be swapped mid-mission by SEXPs, scripts, +// or rearming). The ideal place to create the instance would be in parse_object_create_sub, +// but the player can alter the ship loadout after that function runs. // display_model_num is the model the caller renders for this bank (the weapon's external model, // or its own model as the fallback). // Returns the model instance number, or -1 if the bank's weapon doesn't need an instance. -int ship_get_external_weapon_model_instance(ship_weapon *swp, int bank, int display_model_num) +int ship_get_external_weapon_model_instance(external_weapon_state *ext, int weapon_idx, int display_model_num) { - int weapon_idx = swp->primary_bank_weapons[bank]; - auto &ext = swp->primary_bank_external_weapon[bank]; - - if (ext.model_instance_weapon != weapon_idx) + if (ext->model_instance_weapon != weapon_idx) { // the weapon changed, so any existing instance belongs to the old weapon's model - if (ext.model_instance >= 0) + if (ext->model_instance >= 0) { - model_delete_instance(ext.model_instance); - ext.model_instance = -1; + animation::ModelAnimationSet::stopAnimations(model_get_instance(ext->model_instance)); + model_delete_instance(ext->model_instance); + ext->model_instance = -1; } + ext->warmup_requested = false; + ext->warmup_active = false; if (weapon_idx >= 0 && display_model_num >= 0) { - auto pm = model_get(display_model_num); + // create a model instance only if something will animate it: either the weapon has + // animations, or the model has old-style gun rotation submodels + bool needs_instance = !Weapon_info[weapon_idx].animations.isEmpty(); - // create a model instance only if at least one submodel has gun rotation - for (int mn = 0; mn < pm->n_models; mn++) + if (!needs_instance) { - if (pm->submodel[mn].flags[Model::Submodel_flags::Gun_rotation]) + auto pm = model_get(display_model_num); + + for (int mn = 0; mn < pm->n_models; mn++) { - ext.model_instance = model_create_instance(model_objnum_special::OBJNUM_NONE, display_model_num); - break; + if (pm->submodel[mn].flags[Model::Submodel_flags::Gun_rotation]) + { + needs_instance = true; + break; + } } } + + if (needs_instance) + ext->model_instance = model_create_instance(model_objnum_special::OBJNUM_NONE, display_model_num); } - ext.model_instance_weapon = weapon_idx; + ext->model_instance_weapon = weapon_idx; } - return ext.model_instance; + return ext->model_instance; } // Computes the position and orientation, in the ship model's frame, at which to render an @@ -21794,7 +21871,7 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw continue; } - int external_model_instance = ship_get_external_weapon_model_instance(swp, i, display_model_num); + int external_model_instance = ship_get_external_weapon_model_instance(&swp->primary_bank_external_weapon[i], swp->primary_bank_weapons[i], display_model_num); auto bank = &ship_pm->gun_banks[i]; @@ -21837,6 +21914,8 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw continue; } + int external_model_instance = ship_get_external_weapon_model_instance(&swp->secondary_bank_external_weapon[i], swp->secondary_bank_weapons[i], display_model_num); + auto bank = &ship_pm->missile_banks[i]; if (wip->wi_flags[Weapon::Info_Flags::External_weapon_lnch]) { @@ -21845,7 +21924,7 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw matrix slot_orient; ship_get_weapon_model_slot_transform(bank, k, 0.0f, &slot_pnt, &slot_orient); - model_render_queue(ship_render_info, scene, display_model_num, &slot_orient, &slot_pnt); + model_render_queue(ship_render_info, scene, display_model_num, external_model_instance, &slot_orient, &slot_pnt); } } else { auto weapon_pm = model_get(display_model_num); @@ -21867,7 +21946,7 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw matrix slot_orient; ship_get_weapon_model_slot_transform(bank, k, (1.0f - reload_pct) * weapon_pm->rad, &slot_pnt, &slot_orient); - model_render_queue(ship_render_info, scene, display_model_num, &slot_orient, &slot_pnt); + model_render_queue(ship_render_info, scene, display_model_num, external_model_instance, &slot_orient, &slot_pnt); } } } diff --git a/code/ship/ship.h b/code/ship/ship.h index a0cb2b59894..470901ac7cb 100644 --- a/code/ship/ship.h +++ b/code/ship/ship.h @@ -102,6 +102,8 @@ struct external_weapon_state float rotate_rate = 0.0f; // current spin rate of the model's Gun_rotation submodels (primaries only) float rotate_ang = 0.0f; // current spin angle of the model's Gun_rotation submodels (primaries only) bool spin_up_requested = false; // set each frame the bank tries to fire; consumed by update_external_weapon_spin() + bool warmup_requested = false; // set each frame the bank tries to fire; consumed by update_external_weapon_animations() + bool warmup_active = false; // whether the weapon-warmup animations are currently triggered }; class ship_weapon { @@ -1834,7 +1836,7 @@ extern int ship_stop_fire_primary(object * obj); extern int ship_fire_primary(object * objp, int force = 0, bool rollback_shot = false); extern vec3d ship_get_external_model_fp_offset(external_weapon_state *ext, const weapon_info *wip, const polymodel *weapon_model, const w_bank *ship_bank, int slot, bool advance_counter, int sub_shot = 0); extern void ship_get_weapon_model_slot_transform(const w_bank *bank, int slot, float reload_slide_back, vec3d *outpnt, matrix *outorient); -extern int ship_get_external_weapon_model_instance(ship_weapon *swp, int bank, int display_model_num); +extern int ship_get_external_weapon_model_instance(external_weapon_state *ext, int weapon_idx, int display_model_num); extern int ship_fire_secondary(object * objp, int allow_swarm = 0, bool rollback_shot = false ); extern bool ship_secondary_bank_can_dual_fire(const ship *shipp, int bank); bool ship_start_secondary_fire(object* objp); From 90aea96606365f59522ae469b17c161cbc03dcde Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Wed, 8 Jul 2026 16:53:01 -0400 Subject: [PATCH 3/6] migrate old-style gun rotation to the animation system $Submodel Rotation Speed: / $Submodel Rotation Acceleration: plus the Gun_rotation submodel flag are now a parse-time shim: at weapon page-in the tabled values are converted into a synthesized weapon-warmup animation on the weapon's display model, unless the modder already supplied one. Each Gun_rotation submodel gets a $Spin Up: ramp to the tabled velocity about its bank axis followed by one full revolution at speed, looping seamlessly, with the forward-shutdown flag so barrels decelerate forward on release -- reproducing the old hardcoded behavior, including the firing gate. If the display model has no Gun_rotation submodels, the old system still delayed firing by the spin-up time, so a segmentless wait animation preserves that gate. The hand-rolled spin machinery is deleted: the per-bank rotate rate/angle state, the integration in update_external_weapon_spin, the firing gate on rotate_rate, and the render-time bashing of canonical_orient on Gun_rotation submodels are all replaced by the warmup animation path added in the previous commit. One deliberate behavior change: a weapon tabled with rotation velocity but zero acceleration could never fire under the old system (its spin rate never increased); it now spins up instantly and fires. Co-Authored-By: Claude Fable 5 --- code/model/animation/modelanimation.h | 4 +- code/ship/ship.cpp | 100 ++++---------------------- code/ship/ship.h | 5 +- code/weapon/weapons.cpp | 77 ++++++++++++++++++++ 4 files changed, 92 insertions(+), 94 deletions(-) diff --git a/code/model/animation/modelanimation.h b/code/model/animation/modelanimation.h index 77153949878..94f7c27cc9c 100644 --- a/code/model/animation/modelanimation.h +++ b/code/model/animation/modelanimation.h @@ -414,8 +414,6 @@ namespace animation { static SCP_unordered_map s_animationsById; static SCP_unordered_map> s_moveablesById; - static unsigned int getUniqueAnimationID(const SCP_string& animName, char uniquePrefix, const SCP_string& parentName); - //Internal Parsing Methods static void parseSingleAnimation(); static void parseSingleMoveable(); @@ -423,6 +421,8 @@ namespace animation { public: + static unsigned int getUniqueAnimationID(const SCP_string& animName, char uniquePrefix, const SCP_string& parentName); + std::shared_ptr parseSegment(); //Per Animation parsing Data SCP_string m_animationName; diff --git a/code/ship/ship.cpp b/code/ship/ship.cpp index fc535f8dac3..4c8621f866f 100644 --- a/code/ship/ship.cpp +++ b/code/ship/ship.cpp @@ -10693,7 +10693,10 @@ void update_firing_sounds(object* objp, ship* shipp) static void update_external_weapon_bank_animations(external_weapon_state *ext, int weapon_idx, float frametime) { if (weapon_idx < 0) + { + ext->warmup_requested = false; return; + } weapon_info *wip = &Weapon_info[weapon_idx]; @@ -10723,54 +10726,18 @@ static void update_external_weapon_bank_animations(external_weapon_state *ext, i animation::ModelAnimation::stepAnimations(frametime, pmi); } -// Spins the Gun_rotation submodels of external weapon models up or down. A bank that tried to -// fire this frame requests spin-up (see ship_fire_primary, which also refuses to fire until the -// barrels are up to speed); all other banks wind down. Only primary banks spin. +// Updates the animations of all of a ship's external weapon models. A bank that tried to fire +// this frame starts its warmup animations (see ship_fire_primary, which also refuses to fire +// until they are fully started); all other banks wind theirs down. void update_external_weapon_animations(ship *shipp, float frametime) { ship_weapon *swp = &shipp->weapons; for (int i = 0; i < swp->num_primary_banks; i++) - { - auto &ext = swp->primary_bank_external_weapon[i]; - - if (swp->primary_bank_weapons[i] < 0) - { - ext.rotate_rate = 0.0f; - ext.spin_up_requested = false; - ext.warmup_requested = false; - continue; - } + update_external_weapon_bank_animations(&swp->primary_bank_external_weapon[i], swp->primary_bank_weapons[i], frametime); - auto wip = &Weapon_info[swp->primary_bank_weapons[i]]; - - if (ext.spin_up_requested && wip->weapon_submodel_rotate_vel > 0.0f) - { - ext.rotate_rate += wip->weapon_submodel_rotate_accell * frametime; - if (ext.rotate_rate > wip->weapon_submodel_rotate_vel) - ext.rotate_rate = wip->weapon_submodel_rotate_vel; - } - else if (ext.rotate_rate > 0.0f) - { - ext.rotate_rate -= wip->weapon_submodel_rotate_accell * frametime; - if (ext.rotate_rate < 0.0f) - ext.rotate_rate = 0.0f; - } - ext.spin_up_requested = false; - - if (ext.rotate_rate > 0.0f) - { - ext.rotate_ang += ext.rotate_rate * frametime; - while (ext.rotate_ang > PI2) - ext.rotate_ang -= PI2; - } - - update_external_weapon_bank_animations(&ext, swp->primary_bank_weapons[i], frametime); - } - - for (int i = 0; i < swp->num_secondary_banks; i++) { + for (int i = 0; i < swp->num_secondary_banks; i++) update_external_weapon_bank_animations(&swp->secondary_bank_external_weapon[i], swp->secondary_bank_weapons[i], frametime); - } } // This was previously part of obj_move_call_physics(), but secondary_point_reload_pct is only used for rendering and has nothing to do with physics at all. @@ -12758,7 +12725,7 @@ static int ship_stop_fire_primary_bank(object * obj, int bank_to_stop) shipp = &Ships[obj->instance]; - // (external weapon model spin-down is handled by update_external_weapon_spin) + // (external weapon model warmup shutdown is handled by update_external_weapon_animations) if(shipp->was_firing_last_frame[bank_to_stop] == 0) return 0; @@ -13089,16 +13056,6 @@ int ship_fire_primary(object * obj, int force, bool rollback_shot) vm_vec_scale_sub2(&target_velocity_vec, &obj->phys_info.vel, winfo_p->vel_inherit_amount); } - if (winfo_p->weapon_submodel_rotate_vel > 0.0f) { - auto &ext = swp->primary_bank_external_weapon[bank_to_fire]; - - // spin up the Gun_rotation submodels (see update_external_weapon_animations), and - // don't fire until the barrels are up to speed - ext.spin_up_requested = true; - if (ext.rotate_rate < winfo_p->weapon_submodel_rotate_vel) - continue; - } - if (!winfo_p->animations.isEmpty()) { auto &ext = swp->primary_bank_external_weapon[bank_to_fire]; @@ -21776,25 +21733,9 @@ int ship_get_external_weapon_model_instance(external_weapon_state *ext, int weap if (weapon_idx >= 0 && display_model_num >= 0) { - // create a model instance only if something will animate it: either the weapon has - // animations, or the model has old-style gun rotation submodels - bool needs_instance = !Weapon_info[weapon_idx].animations.isEmpty(); - - if (!needs_instance) - { - auto pm = model_get(display_model_num); - - for (int mn = 0; mn < pm->n_models; mn++) - { - if (pm->submodel[mn].flags[Model::Submodel_flags::Gun_rotation]) - { - needs_instance = true; - break; - } - } - } - - if (needs_instance) + // create a model instance only if the weapon has animations to play on it + // (old-style Gun_rotation submodels get a warmup animation synthesized at page-in) + if (!Weapon_info[weapon_idx].animations.isEmpty()) ext->model_instance = model_create_instance(model_objnum_special::OBJNUM_NONE, display_model_num); } @@ -21875,23 +21816,6 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw auto bank = &ship_pm->gun_banks[i]; - if ( external_model_instance >= 0 ) - { - auto pmi = model_get_instance(external_model_instance); - auto pm = model_get(pmi->model_num); - - // spin the submodels by the gun rotation - for (int mn = 0; mn < pm->n_models; ++mn) - { - if (pm->submodel[mn].flags[Model::Submodel_flags::Gun_rotation]) - { - angles angs = vmd_zero_angles; - angs.b = swp->primary_bank_external_weapon[i].rotate_ang; - vm_angles_2_matrix(&pmi->submodel[mn].canonical_orient, &angs); - } - } - } - for ( k = 0; k < bank->num_slots; k++ ) { vec3d slot_pnt; matrix slot_orient; diff --git a/code/ship/ship.h b/code/ship/ship.h index 470901ac7cb..51e4307107a 100644 --- a/code/ship/ship.h +++ b/code/ship/ship.h @@ -96,12 +96,9 @@ struct reinforcements { // points when the ship enables $Show Primary Models: / $Show Secondary Models:. struct external_weapon_state { - int model_instance = -1; // model instance used to spin Gun_rotation submodels, or -1 if the weapon doesn't need one + int model_instance = -1; // model instance used to animate the external model, or -1 if the weapon doesn't need one int model_instance_weapon = -1; // the weapon the model instance state was created for, or -1 if not yet checked int fp_counter = 0; // cycles through the model's firing points, for "chain external model fps" weapons - float rotate_rate = 0.0f; // current spin rate of the model's Gun_rotation submodels (primaries only) - float rotate_ang = 0.0f; // current spin angle of the model's Gun_rotation submodels (primaries only) - bool spin_up_requested = false; // set each frame the bank tries to fire; consumed by update_external_weapon_spin() bool warmup_requested = false; // set each frame the bank tries to fire; consumed by update_external_weapon_animations() bool warmup_active = false; // whether the weapon-warmup animations are currently triggered }; diff --git a/code/weapon/weapons.cpp b/code/weapon/weapons.cpp index 697f5bc9966..49b36862ef7 100644 --- a/code/weapon/weapons.cpp +++ b/code/weapon/weapons.cpp @@ -33,6 +33,7 @@ #include "missionui/missionweaponchoice.h" #include "mod_table/mod_table.h" #include "model/animation/modelanimation_driver.h" +#include "model/animation/modelanimation_segments.h" #include "nebula/neb.h" #include "network/multi.h" #include "network/multimsgs.h" @@ -8758,6 +8759,78 @@ void weapon_mark_as_used(int weapon_type) } } +/** + * Backwards compatibility: synthesizes a weapon-warmup animation for weapons using the old gun + * rotation system ($Submodel Rotation Speed: / $Submodel Rotation Acceleration: in weapons.tbl, + * plus the Gun_rotation flag on submodels of the weapon's display model). The animation + * accelerates each Gun_rotation submodel about its bank (Z) axis up to the tabled velocity, + * loops seamlessly at speed, and decelerates forward when the weapon stops firing, matching the + * old hardcoded behavior. If the display model has no Gun_rotation submodels, the old system + * still delayed firing by the spin-up time, so a segmentless wait animation preserves the gate. + * + * This must run after the weapon's models are loaded; it does nothing if the weapon already has + * a warmup animation, whether modder-supplied or from a previous call. + */ +static void weapon_maybe_create_gun_rotation_animation(weapon_info *wip) +{ + int display_model_num = (wip->external_model_num >= 0) ? wip->external_model_num : wip->model_num; + if (display_model_num < 0) + return; + + for (const auto &trigger : wip->animations.getRegisteredTriggers()) + { + if (trigger.type == animation::ModelAnimationTriggerType::WeaponWarmup) + return; + } + + polymodel *pm = model_get(display_model_num); + + float ramp_time = (wip->weapon_submodel_rotate_accell > 0.0f) ? wip->weapon_submodel_rotate_vel / wip->weapon_submodel_rotate_accell : 0.0f; + + angles velocity = { 0.0f, wip->weapon_submodel_rotate_vel, 0.0f }; + angles acceleration = { 0.0f, wip->weapon_submodel_rotate_accell, 0.0f }; + + auto spin_segments = std::make_shared(); + bool found_submodel = false; + + for (int mn = 0; mn < pm->n_models; mn++) + { + if (!pm->submodel[mn].flags[Model::Submodel_flags::Gun_rotation]) + continue; + found_submodel = true; + + auto submodel = wip->animations.getSubmodel(pm->submodel[mn].name); + + auto serial = std::make_shared(); + // spin up to speed, then one full revolution at constant velocity, which the animation loops over + serial->addSegment(std::make_shared(submodel, velocity, acceleration)); + serial->addSegment(std::make_shared(submodel, std::nullopt, velocity, PI2 / wip->weapon_submodel_rotate_vel, std::nullopt)); + spin_segments->addSegment(serial); + } + + auto warmup_anim = std::make_shared(); + + if (found_submodel) + warmup_anim->setAnimation(spin_segments); + else + { + // no submodels to spin; just reproduce the old firing delay + auto serial = std::make_shared(); + serial->addSegment(std::make_shared(ramp_time)); + serial->addSegment(std::make_shared(1.0f)); + warmup_anim->setAnimation(serial); + } + + warmup_anim->m_flags.set(animation::Animation_Flags::Loop); + warmup_anim->m_flags.set(animation::Animation_Flags::Seamless_with_startup); + warmup_anim->m_flags.set(animation::Animation_Flags::Seamless_forward_shutdown); + warmup_anim->m_flagData.loopsFrom = ramp_time; + + SCP_string name = "legacy-gun-rotation"; + wip->animations.emplace(warmup_anim, name, name, animation::ModelAnimationTriggerType::WeaponWarmup, animation::ModelAnimationSet::SUBTYPE_DEFAULT, + animation::ModelAnimationParseHelper::getUniqueAnimationID(name + animation::Animation_types.at(animation::ModelAnimationTriggerType::WeaponWarmup).first, 'v', wip->name)); +} + /** * Pages in the model(s) and, optionally, all graphics for a single weapon. * @@ -8824,6 +8897,10 @@ static void weapon_page_in_one(weapon_info *wip, bool load_graphics) Warning(LOCATION, "External model %s of weapon %s has %d gun banks; only the firing points of the first bank are used.", wip->external_model_name, wip->name, external_pm->n_guns); } + // convert old-style gun rotation to a weapon-warmup animation + if (wip->weapon_submodel_rotate_vel > 0.0f) + weapon_maybe_create_gun_rotation_animation(wip); + //Load shockwaves shockwave_create_info_load(&wip->shockwave); shockwave_create_info_load(&wip->dinky_shockwave); From a4530c7918b45239560c960842c4b7d2a200fc91 Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Wed, 8 Jul 2026 19:05:56 -0400 Subject: [PATCH 4/6] add external weapon models on turrets New subsystem flag "show external weapon model": the turret's weapon model (first weapon with a displayable model, secondaries preferred; external model with fallback to the weapon's own model) is rendered at each of the turret's firing points, transformed through the turret's submodel chain so it tracks base rotation and barrel elevation. This makes a missile visible in its launcher while a turret-firing (trap door) animation plays, per-firing-point: - When the turret fires, the fired point's model is hidden exactly as the live missile spawns at the same transformed position, making the swap seamless (also orientation-seamless for "fire down normals" turrets). - The model stays hidden until the turret's firing and post-firing (turret-fired) animations have fully completed, tracked with the new AnimationList::anyActive() query rather than timestamps -- which also gives multiplayer clients exact timing, since those animations arrive via the existing animation sync. - The new "weapon-reload" animation type then plays if the ship defines one; the weapon model reappears when it completes, since the animation may show the round being moved into place. Without one, the model simply reappears. The weapon-reload type is dual-keyed: +Triggered By: takes either the name of a turret subsystem, or an integer, reserved for a secondary bank index for reload animations on ship weapon banks. Setting the subsystem flag implies the ship-level Draw_weapon_models flag so the render path activates. Destroyed turrets show nothing. Beam turrets never empty their points (no projectile to display). Multiplayer: the server empties firing points in turret_fire_weapon and turret_swarm_fire_from_turret; clients do the same in the turret- and flak-fired packet handlers, cycling their own firing points -- visually consistent even if the cycle drifts from the server's. Co-Authored-By: Claude Fable 5 --- code/ai/aiturret.cpp | 19 +++- code/model/animation/modelanimation.cpp | 39 ++++++- code/model/animation/modelanimation.h | 3 + code/model/model_flags.h | 1 + code/network/multimsgs.cpp | 12 ++- code/ship/ship.cpp | 135 +++++++++++++++++++++++- code/ship/ship.h | 25 ++++- 7 files changed, 228 insertions(+), 6 deletions(-) diff --git a/code/ai/aiturret.cpp b/code/ai/aiturret.cpp index c2b9a537d81..f7452093570 100644 --- a/code/ai/aiturret.cpp +++ b/code/ai/aiturret.cpp @@ -2013,7 +2013,9 @@ bool turret_fire_weapon(int weapon_num, wp->target_num = turret->turret_enemy_objnum; // AL 1-6-97: Store pointer to turret subsystem - wp->turret_subsys = turret; + wp->turret_subsys = turret; + + turret_external_weapon_model_fired(turret, turret->turret_next_fire_pos); if (scripting::hooks::OnTurretFired->isActive()) { scripting::hooks::OnTurretFired->run(scripting::hooks::WeaponUsedConditions{ parent_ship , turret_enemy_objp, SCP_vector{ turret_weapon_class }, wip->subtype == WP_LASER }, @@ -2099,6 +2101,18 @@ bool turret_fire_weapon(int weapon_num, return true; } +void turret_external_weapon_model_fired(ship_subsys *turret, int fire_pos) +{ + model_subsystem *tp = turret->system_info; + + if ( !(tp->flags[Model::Subsystem_Flags::Show_external_weapon_model]) || tp->turret_num_firing_points < 1 ) + return; + + // the point stays empty until the turret's post-firing animations complete and any + // weapon-reload animation has played; see update_turret_external_weapon_models() + turret->turret_external_weapon_state[fire_pos % tp->turret_num_firing_points] = TurretExternalWeaponState::EMPTY; +} + //void turret_swarm_fire_from_turret(ship_subsys *turret, int parent_objnum, int target_objnum, ship_subsys *target_subsys) void turret_swarm_fire_from_turret(turret_swarm_info *tsi) { @@ -2154,6 +2168,9 @@ void turret_swarm_fire_from_turret(turret_swarm_info *tsi) tsi->turret->last_fired_weapon_info_index = tsi->weapon_class; tsi->turret->turret_last_fired = _timestamp(); + // the firing point was incremented after the firing position was calculated above + turret_external_weapon_model_fired(tsi->turret, tsi->turret->turret_next_fire_pos - 1); + if (scripting::hooks::OnTurretFired->isActive()) { scripting::hooks::OnTurretFired->run(scripting::hooks::WeaponUsedConditions{ &Ships[Objects[tsi->parent_objnum].instance], diff --git a/code/model/animation/modelanimation.cpp b/code/model/animation/modelanimation.cpp index 4a8835bdee6..d8d23575b38 100644 --- a/code/model/animation/modelanimation.cpp +++ b/code/model/animation/modelanimation.cpp @@ -1062,6 +1062,17 @@ namespace animation { [this](const std::shared_ptr& anim) { return anim->isFullyStarted(pmi_id); }); } + bool ModelAnimationSet::AnimationList::anyActive() const { + if (pmi_id < 0) + return false; + + return std::any_of(animations.cbegin(), animations.cend(), + [this](const std::shared_ptr& anim) { + auto instance = anim->m_instances.find(pmi_id); + return instance != anim->m_instances.end() && instance->second.state != ModelAnimationState::UNTRIGGERED; + }); + } + int ModelAnimationSet::AnimationList::getTime() const { if (pmi_id < 0) return 0; @@ -1159,6 +1170,17 @@ namespace animation { return get(pmi, type, name); } + case ModelAnimationTriggerType::WeaponReload: { + //The name of the turret subsys, or the index of the secondary bank + if (can_construe_as_integer(triggeredBy.c_str())) + return getAll(pmi, type, atoi(triggeredBy.c_str()), true); + + SCP_string name(triggeredBy); + SCP_tolower(name); + + return get(pmi, type, name); + } + case ModelAnimationTriggerType::Afterburner: case ModelAnimationTriggerType::OnSpawn: //No triggered by specialization @@ -1293,7 +1315,8 @@ namespace animation { {ModelAnimationTriggerType::TurretFired, {"turret-fired", true}}, {ModelAnimationTriggerType::PrimaryFired, {"primary-fired", true}}, {ModelAnimationTriggerType::SecondaryFired, {"secondary-fired", true}}, - {ModelAnimationTriggerType::WeaponWarmup, {"weapon-warmup", false}} + {ModelAnimationTriggerType::WeaponWarmup, {"weapon-warmup", false}}, + {ModelAnimationTriggerType::WeaponReload, {"weapon-reload", true}} }; ModelAnimationTriggerType anim_match_type(const char* p) @@ -1479,6 +1502,20 @@ namespace animation { name = parsedname; break; } + case ModelAnimationTriggerType::WeaponReload: { + //The name of the turret subsys, or the index of the secondary bank + char parsedname[NAME_LENGTH]; + stuff_string(parsedname, F_NAME, NAME_LENGTH); + + if (can_construe_as_integer(parsedname)) + subtype = atoi(parsedname); + else { + strlwr(parsedname); + name = parsedname; + } + + break; + } case ModelAnimationTriggerType::Initial: case ModelAnimationTriggerType::Afterburner: diff --git a/code/model/animation/modelanimation.h b/code/model/animation/modelanimation.h index 94f7c27cc9c..db4e764a9c3 100644 --- a/code/model/animation/modelanimation.h +++ b/code/model/animation/modelanimation.h @@ -50,6 +50,7 @@ namespace animation { PrimaryFired, // Triggered when a primary weapon has fired. SecondaryFired, // Triggered when a secondary weapon has fired. WeaponWarmup, // Weapon-owned animations (on the weapon's external model): plays while the weapon's bank is trying to fire; the bank holds fire until the animation is fully started (e.g. gatling barrel spin-up). + WeaponReload, // Plays while a firing point reloads its external weapon model, which reappears when the animation completes. Keyed by turret subsystem name for turrets (see the "show external weapon model" subsystem flag), or by secondary bank index for ship weapon banks (replacing the slide-back reload visual). MaxAnimationTypes }; @@ -362,6 +363,8 @@ namespace animation { int getTime() const; //True once every animation in the list is fully started (see ModelAnimation::isFullyStarted); true for an empty list bool isFullyStarted() const; + //True if any animation in the list has been triggered and has not yet fully reset + bool anyActive() const; void setFlag(Animation_Instance_Flags flag, bool set = true) const; void setSpeed(float speed = 1.0f) const; AnimationList& operator+=(const AnimationList& rhs); diff --git a/code/model/model_flags.h b/code/model/model_flags.h index 268f0a8e437..aa77a213fb4 100644 --- a/code/model/model_flags.h +++ b/code/model/model_flags.h @@ -75,6 +75,7 @@ namespace Model { Turret_distant_firepoint, //Turret barrel is very long and should be taken into account when aiming -- Kiloku Override_submodel_impact, // if a weapon impacted a submodel, but this subsystem is within range, the subsystem takes priority -- Goober5000 Burst_ignores_RoF_Mult, // The turret's fire rate multiplier won't affect burst delay. + Show_external_weapon_model, // render the turret's weapon model at each loaded firing point; firing points empty when fired and reload after the post-firing animations -- Goober5000 NUM_VALUES }; diff --git a/code/network/multimsgs.cpp b/code/network/multimsgs.cpp index f482bb121e3..13992baa9e1 100644 --- a/code/network/multimsgs.cpp +++ b/code/network/multimsgs.cpp @@ -3480,7 +3480,12 @@ void process_turret_fired_packet( ubyte *data, header *hinfo ) if (weapon_objnum != -1) { if ( Weapon_info[wid].launch_snd.isValid() ) { snd_play_3d( gamesnd_get_game_sound(Weapon_info[wid].launch_snd), &pos, &View_position ); - } + } + + // hide the external weapon model at the firing point; the client cycles its own + // firing points, which stays visually consistent even if it drifts from the server + turret_external_weapon_model_fired(ssp, ssp->turret_next_fire_pos); + ssp->turret_next_fire_pos++; } } @@ -8786,6 +8791,11 @@ void process_flak_fired_packet(ubyte *data, header *hinfo) snd_play_3d( gamesnd_get_game_sound(Weapon_info[wid].launch_snd), &pos, &View_position ); } + // hide the external weapon model at the firing point; the client cycles its own + // firing points, which stays visually consistent even if it drifts from the server + turret_external_weapon_model_fired(ssp, ssp->turret_next_fire_pos); + ssp->turret_next_fire_pos++; + object& wp_obj = Objects[weapon_objnum]; const weapon& wp = Weapons[wp_obj.instance]; diff --git a/code/ship/ship.cpp b/code/ship/ship.cpp index 4c8621f866f..243743d0001 100644 --- a/code/ship/ship.cpp +++ b/code/ship/ship.cpp @@ -425,6 +425,7 @@ flag_def_list_new Subsystem_flags[] = { { "starts locked", Model::Subsystem_Flags::Turret_locked, true, false }, { "no aggregate", Model::Subsystem_Flags::No_aggregate, true, false }, { "wait for animation", Model::Subsystem_Flags::Turret_anim_wait, true, false }, + { "show external weapon model", Model::Subsystem_Flags::Show_external_weapon_model, true, false }, { "play fire sound for player", Model::Subsystem_Flags::Player_turret_sound, true, false }, { "only target if can fire", Model::Subsystem_Flags::Turret_only_target_if_can_fire, true, false }, { "no disappear", Model::Subsystem_Flags::No_disappear, true, false }, @@ -5620,6 +5621,10 @@ static void parse_ship_values(ship_info* sip, const bool is_template, const bool //If we've set any subsystem as landable, set a ship-info flag as a shortcut for later if (sp->flags[Model::Subsystem_Flags::Allow_landing]) sip->flags.set(Ship::Info_Flags::Allow_landings); + + //Turrets showing external weapon models render through the same path as bank weapon models + if (sp->flags[Model::Subsystem_Flags::Show_external_weapon_model]) + sip->flags.set(Ship::Info_Flags::Draw_weapon_models); } if (turret_has_barrel_fov) @@ -7743,6 +7748,11 @@ void ship_subsys::clear() turret_animation_position = MA_POS_NOT_SET; turret_animation_done_time = 0; + for (i = 0; i < MAX_TFP; i++) { + turret_external_weapon_state[i] = TurretExternalWeaponState::LOADED; + turret_external_weapon_reload_stamp[i] = 0; + } + for (i = 0; i < MAX_TFP; i++) turret_swarm_info_index[i] = -1; turret_swarm_num = 0; @@ -10726,9 +10736,65 @@ static void update_external_weapon_bank_animations(external_weapon_state *ext, i animation::ModelAnimation::stepAnimations(frametime, pmi); } +// Progresses the external weapon model state of one turret: firing points emptied by firing +// become loaded again once the turret's post-firing animations complete, playing the +// weapon-reload animation first if the ship has one. The weapon model reappears when the +// reload animation completes, since the animation may show the round being moved into place. +static void update_turret_external_weapon_models(ship *shipp, ship_subsys *pss) +{ + model_subsystem *tp = pss->system_info; + + if ( !(tp->flags[Model::Subsystem_Flags::Show_external_weapon_model]) || tp->turret_num_firing_points < 1 ) + return; + + auto sip_anims = &Ship_info[shipp->ship_info_index].animations; + polymodel_instance *pmi = model_get_instance(shipp->model_instance_num); + + // lazily computed: the model may not reappear while the turret's firing (door) or + // post-firing animations are still playing + bool anims_checked = false; + bool anims_idle = false; + + for (int k = 0; k < tp->turret_num_firing_points; k++) + { + auto &state = pss->turret_external_weapon_state[k]; + + if (state == TurretExternalWeaponState::EMPTY) + { + if (!anims_checked) + { + //For legacy animations using subtype for turret number + anims_idle = !(sip_anims->getAll(pmi, animation::ModelAnimationTriggerType::TurretFiring, tp->subobj_num, true) + //For modern animations using proper triggered-by-subsys name + + sip_anims->get(pmi, animation::ModelAnimationTriggerType::TurretFiring, animation::anim_name_from_subsys(tp))).anyActive() + && !sip_anims->get(pmi, animation::ModelAnimationTriggerType::TurretFired, animation::anim_name_from_subsys(tp)).anyActive(); + anims_checked = true; + } + + if (anims_idle) + { + auto reload_anims = sip_anims->get(pmi, animation::ModelAnimationTriggerType::WeaponReload, animation::anim_name_from_subsys(tp)); + if (reload_anims.start(animation::ModelAnimationDirection::FWD, true)) + { + state = TurretExternalWeaponState::RELOADING; + pss->turret_external_weapon_reload_stamp[k] = timestamp(reload_anims.getTime()); + } + else + state = TurretExternalWeaponState::LOADED; + } + } + else if (state == TurretExternalWeaponState::RELOADING) + { + if (timestamp_elapsed(pss->turret_external_weapon_reload_stamp[k])) + state = TurretExternalWeaponState::LOADED; + } + } +} + // Updates the animations of all of a ship's external weapon models. A bank that tried to fire // this frame starts its warmup animations (see ship_fire_primary, which also refuses to fire -// until they are fully started); all other banks wind theirs down. +// until they are fully started); all other banks wind theirs down. Turret firing points +// emptied by firing reload once the turret's post-firing animations complete. void update_external_weapon_animations(ship *shipp, float frametime) { ship_weapon *swp = &shipp->weapons; @@ -10738,6 +10804,9 @@ void update_external_weapon_animations(ship *shipp, float frametime) for (int i = 0; i < swp->num_secondary_banks; i++) update_external_weapon_bank_animations(&swp->secondary_bank_external_weapon[i], swp->secondary_bank_weapons[i], frametime); + + for (auto pss: list_range(&shipp->subsys_list)) + update_turret_external_weapon_models(shipp, pss); } // This was previously part of obj_move_call_physics(), but secondary_point_reload_pct is only used for rendering and has nothing to do with physics at all. @@ -21745,6 +21814,25 @@ int ship_get_external_weapon_model_instance(external_weapon_state *ext, int weap return ext->model_instance; } +// Picks the weapon whose model is displayed at a turret's firing points: the first weapon +// with a displayable model, secondaries preferred. Returns -1 if there is none. +static int turret_get_display_weapon(const ship_weapon *swp) +{ + for (int i = 0; i < swp->num_secondary_banks; i++) + { + int weapon_idx = swp->secondary_bank_weapons[i]; + if (weapon_idx >= 0 && (Weapon_info[weapon_idx].external_model_num >= 0 || Weapon_info[weapon_idx].model_num >= 0)) + return weapon_idx; + } + for (int i = 0; i < swp->num_primary_banks; i++) + { + int weapon_idx = swp->primary_bank_weapons[i]; + if (weapon_idx >= 0 && (Weapon_info[weapon_idx].external_model_num >= 0 || Weapon_info[weapon_idx].model_num >= 0)) + return weapon_idx; + } + return -1; +} + // Computes the position and orientation, in the ship model's frame, at which to render an // external weapon model on slot `slot` of weapon bank `bank`. The model points along the // slot's firing normal and is "banked" (rolled) by the slot's angle offset. @@ -21875,6 +21963,51 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw } } + //turret weapons + auto ship_pmi = model_get_instance(shipp->model_instance_num); + + for (ship_subsys *pss = GET_FIRST(&shipp->subsys_list); pss != END_OF_LIST(&shipp->subsys_list); pss = GET_NEXT(pss)) { + auto tp = pss->system_info; + + if ( !(tp->flags[Model::Subsystem_Flags::Show_external_weapon_model]) ) + continue; + if ( tp->turret_num_firing_points < 1 || tp->turret_gun_sobj < 0 ) + continue; + + // don't show weapons on a destroyed turret + if ( pss->max_hits > 0.0f && pss->current_hits <= 0.0f ) + continue; + + int weapon_idx = turret_get_display_weapon(&pss->weapons); + if ( weapon_idx < 0 ) + continue; + auto wip = &Weapon_info[weapon_idx]; + + // if the weapon has no dedicated external model, display the weapon's own model, if it has one + int display_model_num = (wip->external_model_num >= 0) ? wip->external_model_num : wip->model_num; + if ( display_model_num < 0 ) + continue; + + for ( k = 0; k < tp->turret_num_firing_points; k++ ) { + if ( pss->turret_external_weapon_state[k] != TurretExternalWeaponState::LOADED ) + continue; + + // the firing points are in the turret gun submodel's frame, so transform them + // (and the firing normal) through the submodel chain into the ship's frame + vec3d slot_pnt; + model_instance_local_to_global_point(&slot_pnt, &tp->turret_firing_point[k], ship_pm, ship_pmi, tp->turret_gun_sobj); + + vec3d fire_dir; + model_instance_local_to_global_dir(&fire_dir, &tp->turret_norm, ship_pm, ship_pmi, tp->turret_gun_sobj); + vm_vec_normalize_safe(&fire_dir); + + matrix slot_orient; + vm_vector_2_matrix_norm(&slot_orient, &fire_dir); + + model_render_queue(ship_render_info, scene, display_model_num, &slot_orient, &slot_pnt); + } + } + ship_render_info->set_flags(ship_render_flags); ship_render_info->set_attached_model_draw_distance(-1.0f); diff --git a/code/ship/ship.h b/code/ship/ship.h index 51e4307107a..ccdd306f22f 100644 --- a/code/ship/ship.h +++ b/code/ship/ship.h @@ -348,11 +348,22 @@ typedef struct lock_info { float lock_gauge_time_elapsed; float lock_anim_time_elapsed; } lock_info; + struct guard_range_entry { float range; int shipnum; guard_range_entry(float _range, int _shipnum) : range(_range), shipnum(_shipnum) {} }; + +// display state of the external weapon model at one of a turret's firing points +// (see the "show external weapon model" subsystem flag) +enum class TurretExternalWeaponState : ubyte +{ + LOADED, // the weapon model is shown at the firing point + EMPTY, // the firing point fired; nothing is shown until the turret's post-firing animations complete + RELOADING, // the weapon-reload animation is playing (it may show the round being moved into place); the weapon model appears when it completes +}; + // structure definition for a linked list of subsystems for a ship. Each subsystem has a pointer // to the static data for the subsystem. The obj_subsystem data is defined and read in the model // code. Other dynamic data (such as current_hits) should remain in this structure. @@ -404,9 +415,14 @@ class ship_subsys EModelAnimationPosition turret_animation_position; int turret_animation_done_time; + // external weapon model display state, per firing point (see the "show external weapon model" subsystem flag): + // a fired point's model is hidden until the turret's post-firing animations complete and any weapon-reload animation has played + TurretExternalWeaponState turret_external_weapon_state[MAX_TFP]; + int turret_external_weapon_reload_stamp[MAX_TFP]; // when a RELOADING firing point becomes LOADED again + // swarm (rapid fire) info - int turret_swarm_info_index[MAX_TFP]; - int turret_swarm_num; + int turret_swarm_info_index[MAX_TFP]; + int turret_swarm_num; // awacs info float awacs_intensity; @@ -2055,6 +2071,11 @@ int is_support_allowed(object *objp, bool do_simple_check = false); // *gvec: vector fro *gpos to *targetp void ship_get_global_turret_gun_info(const object *objp, const ship_subsys *ssp, vec3d *gpos, bool avg_origin, vec3d *gvec, bool use_angles, const vec3d *targetp); +// Marks the firing point a turret just fired from as empty, so that its external weapon model +// stops rendering until the turret reloads (see the "show external weapon model" subsystem +// flag). fire_pos is the turret_next_fire_pos value that was used for the shot. +void turret_external_weapon_model_fired(ship_subsys *turret, int fire_pos); + // Given an object and a turret on that object, return the global position and forward vector // of the turret. The gun normal is the unrotated gun normal, (the center of the FOV cone), not // the actual gun normal given using the current turret heading. But it _is_ rotated into the model's orientation From 00a0df6f2d0e71dafb1e48bfb62f6c2d57df43e7 Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Wed, 8 Jul 2026 18:47:00 -0400 Subject: [PATCH 5/6] support weapon-reload animations on ship secondary banks A weapon-reload animation keyed by secondary bank index (+Triggered By: ) now replaces the hardcoded slide-back reload visual for that bank's external weapon models: when a point fires, the animation starts and the point's missile stays hidden until it completes, then appears at its full position. Banks without a reload animation keep the classic slide-back. Applies to both the in-mission render and the HUD hardpoints gauge. The animation starts immediately on firing (banks have no door or post-firing phase to wait for, unlike turrets). If it is longer than the bank's fire wait, a fast-firing bank can fire from a still-hidden point -- coordinating those times is up to the ship designer, matching the stance taken for weapon-warmup. Since a bank's points share one animation, simultaneously reloading points restart it; staggered single fire looks correct. Multiplayer works without extra plumbing: ship_fire_secondary runs on every machine via the existing secondary-fired packets. Co-Authored-By: Claude Fable 5 --- code/hud/hudtarget.cpp | 20 ++++++++++++--- code/model/animation/modelanimation.h | 1 + code/ship/ship.cpp | 37 ++++++++++++++++++++++----- code/ship/ship.h | 1 + 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/code/hud/hudtarget.cpp b/code/hud/hudtarget.cpp index 70da0c7cc20..4ae1d939330 100644 --- a/code/hud/hudtarget.cpp +++ b/code/hud/hudtarget.cpp @@ -7938,14 +7938,26 @@ void HudGaugeHardpoints::render(float /*frametime*/, bool config) auto weapon_pm = model_get(display_model_num); num_secondaries_rendered = 0; + // a weapon-reload animation replaces the slide-back reload visual: reloading + // points are hidden until the animation completes + bool anim_reload = !sip->animations.getAll(model_get_instance(sp->model_instance_num), animation::ModelAnimationTriggerType::WeaponReload, i, true).isEmpty(); + for(k = 0; k < bank->num_slots; k++) { if (num_secondaries_rendered >= sp->weapons.secondary_bank_ammo[i]) break; - float reload_pct = sp->secondary_point_reload_pct.get(i, k); - if (reload_pct <= 0.0f) - continue; + float reload_slide_back = 0.0f; + + if (anim_reload) { + if ( !timestamp_elapsed(sp->secondary_point_reload_stamp.get(i, k)) ) + continue; + } else { + float reload_pct = sp->secondary_point_reload_pct.get(i, k); + if (reload_pct <= 0.0f) + continue; + reload_slide_back = (1.0f - reload_pct) * weapon_pm->rad; + } model_render_params weapon_render_info; @@ -7962,7 +7974,7 @@ void HudGaugeHardpoints::render(float /*frametime*/, bool config) vec3d slot_pnt; matrix slot_orient; - ship_get_weapon_model_slot_transform(bank, k, (1.0f - reload_pct) * weapon_pm->rad, &slot_pnt, &slot_orient); + ship_get_weapon_model_slot_transform(bank, k, reload_slide_back, &slot_pnt, &slot_orient); // We need to transform the position local to the model to be in "world" space relative to the rendered outline vec3d world_position; diff --git a/code/model/animation/modelanimation.h b/code/model/animation/modelanimation.h index db4e764a9c3..0aeb81b784f 100644 --- a/code/model/animation/modelanimation.h +++ b/code/model/animation/modelanimation.h @@ -365,6 +365,7 @@ namespace animation { bool isFullyStarted() const; //True if any animation in the list has been triggered and has not yet fully reset bool anyActive() const; + inline bool isEmpty() const { return animations.empty(); } void setFlag(Animation_Instance_Flags flag, bool set = true) const; void setSpeed(float speed = 1.0f) const; AnimationList& operator+=(const AnimationList& rhs); diff --git a/code/ship/ship.cpp b/code/ship/ship.cpp index 243743d0001..d2b0f3e423a 100644 --- a/code/ship/ship.cpp +++ b/code/ship/ship.cpp @@ -6993,6 +6993,7 @@ void ship::clear() } secondary_point_reload_pct.init(0, 0, 0.0f); + secondary_point_reload_stamp.init(0, 0, 0); // ---------- done with weapons init shield_hits = 0; @@ -7493,6 +7494,7 @@ static void ship_set(int ship_index, int objnum, int ship_type) max_points_per_bank = num_slots; } shipp->secondary_point_reload_pct.init(sip->num_secondary_banks, max_points_per_bank, 1.0f); + shipp->secondary_point_reload_stamp.init(sip->num_secondary_banks, max_points_per_bank, 0); shipp->armor_type_idx = sip->armor_type_idx; shipp->shield_armor_type_idx = sip->shield_armor_type_idx; @@ -14534,6 +14536,17 @@ int ship_fire_secondary( object *obj, int allow_swarm, bool rollback_shot ) pnt_index = 0; } shipp->secondary_point_reload_pct.set(bank, pnt_index, 0.0f); + + // a weapon-reload animation replaces the slide-back reload visual: start it and + // keep this point's missile hidden until it completes + { + auto reload_anims = sip->animations.getAll(model_get_instance(shipp->model_instance_num), animation::ModelAnimationTriggerType::WeaponReload, bank, true); + if (!reload_anims.isEmpty()) { + reload_anims.start(animation::ModelAnimationDirection::FWD, true); + shipp->secondary_point_reload_stamp.set(bank, pnt_index, timestamp(reload_anims.getTime())); + } + } + pnt = pm->missile_banks[bank].pnt[pnt_index]; vec3d dir; dir = pm->missile_banks[bank].norm[pnt_index]; @@ -21877,6 +21890,7 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw int i,k; ship_weapon *swp = &shipp->weapons; auto ship_pm = model_get(sip->model_num); + auto ship_pmi = model_get_instance(shipp->model_instance_num); scene->push_transform(&obj->pos, &obj->orient); @@ -21942,21 +21956,34 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw auto weapon_pm = model_get(display_model_num); int num_secondaries_rendered = 0; + // a weapon-reload animation replaces the slide-back reload visual: reloading + // points are hidden until the animation completes + bool anim_reload = !sip->animations.getAll(ship_pmi, animation::ModelAnimationTriggerType::WeaponReload, i, true).isEmpty(); + for ( k = 0; k < bank->num_slots; k++ ) { if ( num_secondaries_rendered >= shipp->weapons.secondary_bank_ammo[i] ) { break; } - float reload_pct = shipp->secondary_point_reload_pct.get(i, k); - if ( reload_pct <= 0.0f ) { - continue; + float reload_slide_back = 0.0f; + + if (anim_reload) { + if ( !timestamp_elapsed(shipp->secondary_point_reload_stamp.get(i, k)) ) { + continue; + } + } else { + float reload_pct = shipp->secondary_point_reload_pct.get(i, k); + if ( reload_pct <= 0.0f ) { + continue; + } + reload_slide_back = (1.0f - reload_pct) * weapon_pm->rad; } num_secondaries_rendered++; vec3d slot_pnt; matrix slot_orient; - ship_get_weapon_model_slot_transform(bank, k, (1.0f - reload_pct) * weapon_pm->rad, &slot_pnt, &slot_orient); + ship_get_weapon_model_slot_transform(bank, k, reload_slide_back, &slot_pnt, &slot_orient); model_render_queue(ship_render_info, scene, display_model_num, external_model_instance, &slot_orient, &slot_pnt); } @@ -21964,8 +21991,6 @@ void ship_render_weapon_models(model_render_params *ship_render_info, model_draw } //turret weapons - auto ship_pmi = model_get_instance(shipp->model_instance_num); - for (ship_subsys *pss = GET_FIRST(&shipp->subsys_list); pss != END_OF_LIST(&shipp->subsys_list); pss = GET_NEXT(pss)) { auto tp = pss->system_info; diff --git a/code/ship/ship.h b/code/ship/ship.h index ccdd306f22f..59f8c4aadf6 100644 --- a/code/ship/ship.h +++ b/code/ship/ship.h @@ -896,6 +896,7 @@ class ship int bay_doors_parent_shipnum; // our parent ship, what we are entering/leaving reload_pct secondary_point_reload_pct; //after fireing a secondary it takes some time for that secondary weapon to reload, this is how far along in that proces it is (from 0 to 1) + reload_pct secondary_point_reload_stamp; //for banks with a weapon-reload animation (which replaces the slide-back visual), when each point's missile reappears SCP_vector> rcs_activity; //Timestamp of when thrusters started //Sound index for thrusters From 64261d874bd30911aa1b09ae7397c8a08513d759 Mon Sep 17 00:00:00 2001 From: Goober5000 Date: Thu, 9 Jul 2026 02:05:17 -0400 Subject: [PATCH 6/6] add +Model Spawn Time: for weapon-reload animations For an auto-reversing reload animation (e.g. a loader arm that extends with the round and retracts empty), the external weapon model should reappear at the moment the round is placed -- mid-animation -- rather than at the end. The optional +Model Spawn Time: key (seconds, after $Flags:) lets the designer specify that moment; it is stored in the animation's flag data alongside loopsFrom. AnimationList::getModelSpawnTime() honors the key, falling back to the animation's duration when unset, and both reload consumers (turrets and secondary banks) now use it for their reappear stamps. The spawn time is deliberately not capped to the duration, since an auto-reversing animation's return leg plays past the one-way duration that getTime() reports. Co-Authored-By: Claude Fable 5 --- code/model/animation/modelanimation.cpp | 26 +++++++++++++++++++++++++ code/model/animation/modelanimation.h | 5 +++++ code/ship/ship.cpp | 4 ++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/code/model/animation/modelanimation.cpp b/code/model/animation/modelanimation.cpp index d8d23575b38..ae252a5dc0e 100644 --- a/code/model/animation/modelanimation.cpp +++ b/code/model/animation/modelanimation.cpp @@ -1089,6 +1089,25 @@ namespace animation { return (int)(duration * 1000.0f); } + int ModelAnimationSet::AnimationList::getModelSpawnTime() const { + if (pmi_id < 0) + return 0; + + float spawnTime = 0.0f; + + for (const auto& anim : animations) { + if (anim->m_instances[pmi_id].state == ModelAnimationState::UNTRIGGERED) + continue; + + //The spawn time is deliberately not capped to the duration: for an auto-reversing + //animation the return leg plays past the one-way duration that getTime() reports + float localTime = (anim->m_flagData.modelSpawnTime >= 0.0f) ? anim->m_flagData.modelSpawnTime : anim->m_instances[pmi_id].duration; + spawnTime = spawnTime < localTime ? localTime : spawnTime; + } + + return (int)(spawnTime * 1000.0f); + } + void ModelAnimationSet::AnimationList::setFlag(Animation_Instance_Flags flag, bool set) const { if (pmi_id < 0) return; @@ -1539,6 +1558,13 @@ namespace animation { } } + if (optional_string("+Model Spawn Time:")) { + stuff_float(&animation->m_flagData.modelSpawnTime); + + if (type != ModelAnimationTriggerType::WeaponReload) + error_display(0, "+Model Spawn Time: is only used by %s animations.", Animation_types.at(ModelAnimationTriggerType::WeaponReload).first); + } + if (Animation_types.find(type)->second.second) { //This is a type where the code does not reset the animation. The animation is expected to auto-reset in one way or another. //If it doesn't, nothing will crash, but the result is most likely unintended. diff --git a/code/model/animation/modelanimation.h b/code/model/animation/modelanimation.h index 0aeb81b784f..388abaf07a7 100644 --- a/code/model/animation/modelanimation.h +++ b/code/model/animation/modelanimation.h @@ -241,6 +241,9 @@ namespace animation { struct { //Seamless_with_startup float loopsFrom = 0.0f; + //WeaponReload: the time in seconds at which the external weapon model reappears (e.g. the moment + //an auto-reversing loader arm places the round), or -1 to reappear when the animation completes + float modelSpawnTime = -1.0f; } m_flagData; private: @@ -361,6 +364,8 @@ namespace animation { //Winds the animations down: looping animations stop once their current loop completes (entering their shutdown for seamless animations), others play in reverse void startShutdown() const; int getTime() const; + //Like getTime, but honors each animation's +Model Spawn Time: for when the external weapon model should reappear + int getModelSpawnTime() const; //True once every animation in the list is fully started (see ModelAnimation::isFullyStarted); true for an empty list bool isFullyStarted() const; //True if any animation in the list has been triggered and has not yet fully reset diff --git a/code/ship/ship.cpp b/code/ship/ship.cpp index d2b0f3e423a..d7a23f2dc52 100644 --- a/code/ship/ship.cpp +++ b/code/ship/ship.cpp @@ -10779,7 +10779,7 @@ static void update_turret_external_weapon_models(ship *shipp, ship_subsys *pss) if (reload_anims.start(animation::ModelAnimationDirection::FWD, true)) { state = TurretExternalWeaponState::RELOADING; - pss->turret_external_weapon_reload_stamp[k] = timestamp(reload_anims.getTime()); + pss->turret_external_weapon_reload_stamp[k] = timestamp(reload_anims.getModelSpawnTime()); } else state = TurretExternalWeaponState::LOADED; @@ -14543,7 +14543,7 @@ int ship_fire_secondary( object *obj, int allow_swarm, bool rollback_shot ) auto reload_anims = sip->animations.getAll(model_get_instance(shipp->model_instance_num), animation::ModelAnimationTriggerType::WeaponReload, bank, true); if (!reload_anims.isEmpty()) { reload_anims.start(animation::ModelAnimationDirection::FWD, true); - shipp->secondary_point_reload_stamp.set(bank, pnt_index, timestamp(reload_anims.getTime())); + shipp->secondary_point_reload_stamp.set(bank, pnt_index, timestamp(reload_anims.getModelSpawnTime())); } }