diff --git a/crates/asset_game/src/lib.rs b/crates/asset_game/src/lib.rs index a83f1ce..c9b37a3 100644 --- a/crates/asset_game/src/lib.rs +++ b/crates/asset_game/src/lib.rs @@ -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::*; diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index 369b696..da6cb49 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -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 { 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 diff --git a/crates/asset_game/src/weapon_t6.rs b/crates/asset_game/src/weapon_t6.rs index 5b9e377..4764460 100644 --- a/crates/asset_game/src/weapon_t6.rs +++ b/crates/asset_game/src/weapon_t6.rs @@ -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"; diff --git a/crates/assets/src/lane/t6.rs b/crates/assets/src/lane/t6.rs index 67eb60a..b4bb56e 100644 --- a/crates/assets/src/lane/t6.rs +++ b/crates/assets/src/lane/t6.rs @@ -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 diff --git a/crates/assets/src/session_load/common_walks.rs b/crates/assets/src/session_load/common_walks.rs index 9732417..9ee6ec3 100644 --- a/crates/assets/src/session_load/common_walks.rs +++ b/crates/assets/src/session_load/common_walks.rs @@ -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 diff --git a/crates/render_anim/src/occupancy/missile.rs b/crates/render_anim/src/occupancy/missile.rs index 1cd8d85..6481111 100644 --- a/crates/render_anim/src/occupancy/missile.rs +++ b/crates/render_anim/src/occupancy/missile.rs @@ -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, @@ -151,6 +154,7 @@ fn occupy_missile_scene_ents( mut scene_submissions: MessageWriter, cg_clock: Option>, local: Option>, + 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( diff --git a/crates/sim/src/lib.rs b/crates/sim/src/lib.rs index 10d2f80..29777ac 100644 --- a/crates/sim/src/lib.rs +++ b/crates/sim/src/lib.rs @@ -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:`), 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 { + model.strip_prefix(WEAPON_MODEL_PREFIX)?.parse().ok() +} pub use world::{ ClientId, HitvolDumpRow, PendingLocalSound, PendingPlayerCardEvent, PendingPlayerCardKind, PendingPrint, PlayerKitCollision, SimBrush, SimClipBsp, SimClipCmodels, SimClipMesh, diff --git a/crates/sim/src/script/host/natives/engine.rs b/crates/sim/src/script/host/natives/engine.rs index 9b26e60..786292b 100644 --- a/crates/sim/src/script/host/natives/engine.rs +++ b/crates/sim/src/script/host/natives/engine.rs @@ -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( diff --git a/crates/sim/src/script/host/natives/player.rs b/crates/sim/src/script/host/natives/player.rs index a0a7278..9f1cec5 100644 --- a/crates/sim/src/script/host/natives/player.rs +++ b/crates/sim/src/script/host/natives/player.rs @@ -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| { diff --git a/crates/sim/src/script/host/players.rs b/crates/sim/src/script/host/players.rs index a54387c..bdeb9f3 100644 --- a/crates/sim/src/script/host/players.rs +++ b/crates/sim/src/script/host/players.rs @@ -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 = format!("{}{native}", crate::WEAPON_MODEL_PREFIX).into(); + world + .resource_mut::() + .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::().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::() <= reach * reach + }; + let placed = world + .resource::() + .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 = world + .resource::() + .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::(); + 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::(); + let Some(entity) = runtime.entities.get_mut(&object) else { + return; + }; + let tag: Arc = "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::(); + 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::() @@ -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), diff --git a/crates/sim/src/script/host/weapons.rs b/crates/sim/src/script/host/weapons.rs index 7098002..70e6f36 100644 --- a/crates/sim/src/script/host/weapons.rs +++ b/crates/sim/src/script/host/weapons.rs @@ -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]); } diff --git a/crates/sim/src/script/runtime/state.rs b/crates/sim/src/script/runtime/state.rs index b028817..54a9649 100644 --- a/crates/sim/src/script/runtime/state.rs +++ b/crates/sim/src/script/runtime/state.rs @@ -48,6 +48,11 @@ pub(crate) struct Runtime { pub(crate) menu_answers: BTreeMap>, pub(crate) personal_classes: BTreeMap<(u32, u32), crate::ClassDef>, pub(crate) weapon_bridge: BTreeMap>, + /// 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, u64)>, + /// Where foreign tactical insertions were planted, with their model. + pub(crate) insertion_spots: Vec<([f32; 3], Arc)>, pub(crate) disconnects: std::collections::BTreeSet, pub(crate) kicks: BTreeMap, pub(crate) joined: std::collections::BTreeSet,