Make the Black Ops II tactical insertion a tactical with its own model

The T6 tactical insertion sits in the tactical slot and throws with the
tactical button beside the lethal: the class script is handed a smoke
grenade standing for it, and its giving starts IW4's tactical insertion
perk. The glow stick IW4's scripts plant for it carries the T6 planted
model, drawn as a projectile mesh standing on end, and the thrown grenade
is removed once it is planted.

IW4's own tactical insertion chosen as the lethal is given as the
equipment perk instead of a frag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
neptune
2026-10-02 02:50:36 -04:00
parent 1677322267
commit efa20d65ec
12 changed files with 294 additions and 6 deletions
+2 -1
View File
@@ -31,7 +31,8 @@ mod weapon_catalog;
mod weapon_families;
mod weapon_t6;
pub use weapon_t6::{
MELEE_WEAPON as T6_MELEE_WEAPON, capture_t6_string_table, stand_in_for as t6_stand_in_for,
MELEE_WEAPON as T6_MELEE_WEAPON, capture_t6_string_table,
planted_model as t6_planted_model, stand_in_for as t6_stand_in_for,
};
pub use arena::*;
+10 -1
View File
@@ -444,6 +444,9 @@ pub enum CacOffhandBucket {
Tactical,
}
/// IW4's `smoke` offhand class, thrown with the tactical button.
const OFFHAND_CLASS_SMOKE: i32 = 2;
pub fn cac_offhand_bucket(offhand_class: i32) -> Option<CacOffhandBucket> {
match offhand_class {
1 | 4 | 5 => Some(CacOffhandBucket::Lethal),
@@ -2474,7 +2477,8 @@ impl WeaponCatalog {
.map(t6_model_name),
projectile_model: weapon
.def_asset_name(fastfile_t6::weapon::def::PROJECTILE_MODEL)
.map(t6_model_name),
.map(t6_model_name)
.or_else(|| crate::weapon_t6::planted_model(name).map(str::to_owned)),
rocket_model: None,
sz_xanims: t6_sz_xanims(weapon),
// Paired by name when the rows are dressed (`X_dw` with `X_lh`).
@@ -6580,6 +6584,11 @@ impl WeaponBuild {
if facts.offhand_class != 0 && stand_in_facts.offhand_class != 0 {
facts.offhand_class = stand_in_facts.offhand_class;
}
// T6 tactical equipment throws with the tactical button whatever
// its stand-in is (IW4's tactical insertion is equipment).
if crate::weapon_t6::is_tactical_equipment(&own.name) {
facts.offhand_class = OFFHAND_CLASS_SMOKE;
}
// A T6 riot shield bashes with the fire button.
facts.fire_melees = facts.weap_type == weapon_iw4::WEAPTYPE_SHIELD;
// T6 weapons carry no penetration multiplier: without the
+24
View File
@@ -70,6 +70,30 @@ const STAND_INS: &[(&str, &str)] = &[
("tactical_insertion", "flare_mp"),
];
/// T6 tactical equipment whose stand-in is not a tactical grenade in IW4
/// (`flare_mp` is IW4's tactical insertion, equipment there).
const TACTICAL_EQUIPMENT: &[&str] = &["tactical_insertion"];
/// Whether a T6 weapon is tactical equipment the IW4 stand-in's offhand
/// class would file as lethal.
pub fn is_tactical_equipment(name: &str) -> bool {
TACTICAL_EQUIPMENT.contains(&base_name(name))
}
/// T6 equipment whose scripts plant a model of their own, which its weapon
/// does not name: it stands for the thrown one.
const PLANTED_MODELS: &[(&str, &str)] = &[("tactical_insertion", "t6_wpn_tac_insert_world")];
/// The model a T6 weapon's scripts plant it as, when its weapon does not
/// name one.
pub fn planted_model(name: &str) -> Option<&'static str> {
let base = base_name(name);
PLANTED_MODELS
.iter()
.find(|(t6, _)| *t6 == base)
.map(|(_, model)| *model)
}
/// The T6 weapon whose knife and swings every gun's melee borrows when
/// the gun has no melee clip of its own (T6 rifles and snipers do not).
pub const MELEE_WEAPON: &str = "knife_mp";
+1
View File
@@ -999,6 +999,7 @@ fn capture_content(
(weapon.def_asset_array_name(def::GUN_XMODEL, 0), true),
(weapon.def_asset_array_name(def::WORLD_MODEL, 0), false),
(weapon.def_asset_name(def::PROJECTILE_MODEL), false),
(weapon.name().and_then(asset_game::t6_planted_model), false),
] {
if let Some(name) = name {
wanted
+10 -1
View File
@@ -983,7 +983,16 @@ pub(super) fn bind_t6_content(
} else if model.view {
&fpv.get(Iw4, weapons.gun_xmodel_of(id)?)?.material_keys
} else {
&world.get(Iw4, weapons.world_model_of(id)?)?.material_keys
// A stand-in with no world model of its own (IW4's
// tactical insertion is planted by script) lends its
// first-person model's.
match weapons
.world_model_of(id)
.and_then(|name| world.get(Iw4, name))
{
Some(gun) => &gun.material_keys,
None => &fpv.get(Iw4, weapons.gun_xmodel_of(id)?)?.material_keys,
}
};
// The gun's (or arms') lit body: the material binding both a
// colour and a normal map, not a sight, glow or decal. The
+71 -1
View File
@@ -13,11 +13,14 @@ use anim_iw4::DOBJ_RADIUS_PARENT_ROOT;
use render_scene::{
HostGfxScene, ModelLightingOwner, ModelLightingRequest, ModelLightingRequests,
SmodelPassMaterial, TessMaterials, WorldModelLightingAtlas, WorldPresentFacts,
XModelSurfaceDraw, scene_quat_from_angles,
WorldScriptModelInstance, XModelSurfaceDraw, scene_quat_from_angles,
};
pub const MISSILE_LIGHTING_Z_OFS: f32 = 4.0;
/// The pitch a planted weapon model stands at, its forward axis up.
const PLANTED_PITCH: f32 = -90.0;
#[derive(Resource, Default)]
pub struct MissileOccupancy {
pub rows: Vec<OccupiedMissile>,
@@ -151,6 +154,7 @@ fn occupy_missile_scene_ents(
mut scene_submissions: MessageWriter<AnimDObjSceneSubmission>,
cg_clock: Option<Res<net::FrameClock>>,
local: Option<Res<net::LocalPresentClient>>,
script_models: Query<(Entity, &WorldScriptModelInstance, &Transform, &Visibility)>,
) {
occupancy.rows.clear();
let Some(snapshot) = presented.as_ref() else {
@@ -254,6 +258,72 @@ fn occupy_missile_scene_ents(
lighting_owner,
});
}
// A script model carrying a weapon's thrown model (a foreign tactical
// insertion planted by IW4's scripts) is drawn as that weapon's
// projectile: the script model catalog cannot resolve the attachment,
// so the script model itself is not drawn.
for (entity, owner, transform, visibility) in &script_models {
if *visibility == Visibility::Hidden {
continue;
}
let Some(entnum) = owner.gentity_number.map(u32::from) else {
continue;
};
let Some((namespace, name, entry)) = owner
.dobj_state
.composition
.models
.iter()
.skip(1)
.find_map(|attached| {
let weapon = sim::weapon_model_attachment(&attached.model)?;
let reg = weapons_reg?;
let name = reg.projectile_model_of(weapon)?;
let ns = reg
.namespace_of(weapon)
.unwrap_or(asset_core::AssetNamespace::Iw4);
Some((ns, name, catalog.get(ns, name)?))
})
else {
continue;
};
if entry.skel.pose.is_none() {
continue;
}
let origin = transform.translation.to_array();
// A weapon's model lies along its forward axis; planted, it stands
// on its end.
let (yaw, pitch, roll) = transform.rotation.to_euler(EulerRot::ZYX);
let angles = [pitch.to_degrees() + PLANTED_PITCH, yaw.to_degrees(), roll.to_degrees()];
let lighting_origin = missile_lighting_origin(origin);
scene_submissions.write(AnimDObjSceneSubmission {
render_fx_flags: 0,
has_tree: false,
origin,
lighting_origin,
radius: entry.skel.radius,
entnum,
quat: Some(scene_quat_from_angles(angles)),
occupy_model_n: 1,
models: vec![scene_skels.shared(name, &entry.skel, 0)],
hide_part_bits: [0; 6],
store_skin: true,
});
let index = rows.len() + occupancy.rows.len();
occupancy.rows.push(OccupiedMissile {
index,
id: None,
entnum: Some(entnum),
weapon: 0,
ignited: false,
origin,
angles,
name: name.to_owned(),
namespace,
lighting_origin,
lighting_owner: ModelLightingOwner::ScriptModel(entity),
});
}
}
fn publish_missile_dobj_poses(
+9
View File
@@ -126,6 +126,15 @@ pub use spawn::{
spawn_candidate_indices, spawn_candidate_indices_for,
};
pub use step::phase_materialize_entity_dobjs;
/// A script model attachment standing for a weapon's thrown model
/// (`#weapon:<index>`), which the renderer draws from its own catalog.
pub const WEAPON_MODEL_PREFIX: &str = "#weapon:";
/// The weapon a [`WEAPON_MODEL_PREFIX`] attachment stands for.
pub fn weapon_model_attachment(model: &str) -> Option<u32> {
model.strip_prefix(WEAPON_MODEL_PREFIX)?.parse().ok()
}
pub use world::{
ClientId, HitvolDumpRow, PendingLocalSound, PendingPlayerCardEvent, PendingPlayerCardKind,
PendingPrint, PlayerKitCollision, SimBrush, SimClipBsp, SimClipCmodels, SimClipMesh,
@@ -979,6 +979,7 @@ fn register_appearance(registry: &mut NativeRegistry) {
let model = string(args, 0)?;
let id = entity_id(world, receiver)?;
runtime(world).set_object_field(id, "model", Value::string(&model));
crate::script::host::players::dress_insertion_glow(world, id, &model);
Ok(Value::Undefined)
});
registry.register(
@@ -1170,9 +1170,11 @@ fn register_inventory(registry: &mut NativeRegistry) {
);
registry.register(Method, "giveweapon", |world, receiver, args| {
let id = client_of(world, receiver)?;
let named = weapon_arg(world, args, 0)?;
let weapon = player_weapon(world, id, args, 0)?;
let akimbo = optional(args, 2, int)?.unwrap_or(0) != 0;
script_player::give_weapon(&mut FrameWorld::from_world(world), id, weapon, akimbo)?;
super::super::players::give_carried_insertion(world, id.0, receiver, named)?;
Ok(Value::Undefined)
});
registry.register(Method, "takeweapon", |world, receiver, args| {
+152 -2
View File
@@ -375,6 +375,146 @@ fn bridge_offhand(world: &mut World, client: u32, weapon: u32) {
bridge.push((stand_in, weapon));
}
/// IW4's tactical insertion, which its class script gives only as
/// equipment (the `specialty_tacticalinsertion` perk).
const INSERTION: &str = "flare_mp";
/// The special grenade a tactical insertion in the tactical slot is handed
/// to the class script as: one it accepts, given with the smoke class.
const INSERTION_CARRIER: &str = "smoke_grenade_mp";
const GIVE_PERK: &str = "maps/mp/perks/_perks::giveperk";
/// The model IW4's scripts plant a tactical insertion's glow stick with.
const INSERTION_GLOW_MODEL: &str = "mil_emergency_flare_mp";
/// How far from its thrower a tactical insertion's glow stick is planted:
/// at the thrower's last spot on the ground.
const INSERTION_PLANT_REACH: f32 = 256.0;
/// IW4's scripts plant a thrown tactical insertion as a glow stick of their
/// own and leave the grenade lying; a foreign one is noted for the glow
/// stick to carry its model in the grenade's place.
pub(crate) fn note_insertion_throw(
world: &mut World,
client: u32,
script: u32,
native: u32,
grenade: u64,
) {
if script == native || FrameWorld::from_world(world).weapon_script_name(script) != INSERTION {
return;
}
let model: Arc<str> = format!("{}{native}", crate::WEAPON_MODEL_PREFIX).into();
world
.resource_mut::<Runtime>()
.thrown_insertions
.insert(client, (model, grenade));
}
/// A glow stick the scripts plant for a foreign tactical insertion, or put
/// where one stood, carries that insertion's model.
pub(crate) fn dress_insertion_glow(world: &mut World, object: u64, model: &str) {
if model != INSERTION_GLOW_MODEL {
return;
}
let origin = match world.resource_mut::<Runtime>().object_field(object, "origin") {
Value::Vector(origin) => origin,
_ => return,
};
let near = |a: [f32; 3], b: [f32; 3], reach: f32| {
a.iter().zip(b).map(|(a, b)| (a - b) * (a - b)).sum::<f32>() <= reach * reach
};
let placed = world
.resource::<Runtime>()
.insertion_spots
.iter()
.find(|(spot, _)| near(*spot, origin, 1.0))
.map(|(_, model)| model.clone());
let model = match placed {
Some(model) => model,
None => {
let throwers: Vec<u32> = world
.resource::<Runtime>()
.thrown_insertions
.keys()
.copied()
.collect();
let frame = FrameWorld::from_world(world);
let thrower = throwers.into_iter().find(|&client| {
frame
.player(ClientId(client))
.is_some_and(|ps| near(ps.origin, origin, INSERTION_PLANT_REACH))
});
let mut runtime = world.resource_mut::<Runtime>();
let Some((model, grenade)) =
thrower.and_then(|client| runtime.thrown_insertions.remove(&client))
else {
return;
};
if runtime.entities.contains_key(&grenade) && !runtime.pending_deletes.contains(&grenade)
{
runtime.pending_deletes.push(grenade);
}
if runtime.insertion_spots.len() >= 32 {
runtime.insertion_spots.remove(0);
}
runtime.insertion_spots.push((origin, model.clone()));
model
}
};
let mut runtime = world.resource_mut::<Runtime>();
let Some(entity) = runtime.entities.get_mut(&object) else {
return;
};
let tag: Arc<str> = "tag_origin".into();
if !entity.attachments.iter().any(|(m, _)| *m == model) {
entity.attachments.push((model, tag));
}
}
/// A tactical insertion chosen as the tactical is given in its carrier's
/// place too.
fn bridge_insertion_carrier(world: &mut World, client: u32, weapon: u32) {
let frame = FrameWorld::from_world(world);
let Some(carrier) = frame.weapon_index_by_script_name(INSERTION_CARRIER) else {
return;
};
let stand_in = offhand_stand_in(world, weapon).unwrap_or(weapon);
if weapon == 0 || FrameWorld::from_world(world).weapon_script_name(stand_in) != INSERTION {
return;
}
let mut runtime = world.resource_mut::<Runtime>();
let bridge = runtime.weapon_bridge.entry(client).or_default();
bridge.retain(|(from, _)| *from != carrier);
bridge.push((carrier, weapon));
}
/// When the class script gives a tactical insertion's carrier, the perk
/// that plants it is given as well: the class script never gives it
/// beside a lethal.
pub(crate) fn give_carried_insertion(
world: &mut World,
client: u32,
receiver: &Value,
weapon: u32,
) -> Result<(), String> {
let frame = FrameWorld::from_world(world);
if frame.weapon_script_name(weapon) != INSERTION_CARRIER
|| bridged_weapon(world, client, weapon) == weapon
{
return Ok(());
}
crate::script::start(
world,
GIVE_PERK,
receiver.clone(),
vec![Value::String("specialty_tacticalinsertion".into())],
)
.map(|_| ())
.map_err(|fault| fault.to_string())
}
pub(crate) fn bridged_weapon(world: &World, client: u32, weapon: u32) -> u32 {
world
.resource::<Runtime>()
@@ -429,6 +569,7 @@ pub(crate) fn choose_class(world: &mut World, client: u32, class: &crate::ClassD
}
bridge_offhand(world, client, class.lethal);
bridge_offhand(world, client, class.tactical);
bridge_insertion_carrier(world, client, class.tactical);
let data = class_profile_data(world, class);
push_answer(
world,
@@ -482,7 +623,13 @@ fn class_profile_data(world: &mut World, class: &crate::ClassDef) -> Vec<(String
put(format!("{key}.attachment.1"), &attachments[1]);
put(format!("{key}.camo"), "none");
}
put(format!("{prefix}.perks.0"), &name(lethal));
// IW4's class script takes the tactical insertion as equipment only as
// the perk that gives its flare (any other name is a frag).
let equipment = match name(lethal) {
insertion if insertion == INSERTION => "specialty_tacticalinsertion".to_owned(),
other => other,
};
put(format!("{prefix}.perks.0"), &equipment);
let mut perks = ["specialty_null"; 3];
for id in class.perks {
if let (Some(slot), Some(perk)) = (
@@ -501,7 +648,10 @@ fn class_profile_data(world: &mut World, class: &crate::ClassDef) -> Vec<(String
&class.deathstreak
};
put(format!("{prefix}.perks.4"), deathstreak);
let tactical = name(tactical);
let tactical = match name(tactical) {
insertion if insertion == INSERTION => INSERTION_CARRIER.to_owned(),
other => other,
};
put(
format!("{prefix}.specialgrenade"),
tactical.strip_suffix("_mp").unwrap_or(&tactical),
+7
View File
@@ -322,6 +322,13 @@ fn adopt_fired(world: &mut World) {
}
};
let weapon = super::players::script_weapon(world, projectile.owner.0, projectile.weapon);
super::players::note_insertion_throw(
world,
projectile.owner.0,
weapon,
projectile.weapon,
object,
);
let name = weapon_name(world, weapon);
raise(world, player, notify, vec![Value::Object(object), name]);
}
+5
View File
@@ -48,6 +48,11 @@ pub(crate) struct Runtime {
pub(crate) menu_answers: BTreeMap<u32, VecDeque<host::players::MenuAnswer>>,
pub(crate) personal_classes: BTreeMap<(u32, u32), crate::ClassDef>,
pub(crate) weapon_bridge: BTreeMap<u32, Vec<(u32, u32)>>,
/// The model and grenade of each client's foreign tactical insertion
/// thrown and not yet planted; see `players::dress_insertion_glow`.
pub(crate) thrown_insertions: BTreeMap<u32, (Arc<str>, u64)>,
/// Where foreign tactical insertions were planted, with their model.
pub(crate) insertion_spots: Vec<([f32; 3], Arc<str>)>,
pub(crate) disconnects: std::collections::BTreeSet<u32>,
pub(crate) kicks: BTreeMap<u32, String>,
pub(crate) joined: std::collections::BTreeSet<u32>,