diff --git a/crates/asset_game/src/fx_catalog.rs b/crates/asset_game/src/fx_catalog.rs index da7e15b..c896838 100644 --- a/crates/asset_game/src/fx_catalog.rs +++ b/crates/asset_game/src/fx_catalog.rs @@ -274,6 +274,8 @@ pub struct OwnedFxElemDef { pub trail_def: Option, pub spark_fountain_def: Option, + + pub phys_pieces: Vec>, } impl OwnedFxElemDef { @@ -338,6 +340,11 @@ impl OwnedFxElemDef { } } + pub fn phys_piece(&self, random_seed: u64) -> Option { + let idx = fx_iw4::elem_visual_index(self.view.visual_count, random_seed); + self.phys_pieces.get(idx).copied().flatten() + } + pub fn decal_mark_pair(&self, random_seed: u64) -> Option<&OwnedFxVisual> { if self.view.elem_type != elem_type::DECAL { return None; @@ -953,6 +960,15 @@ impl FxDefinitions { *edge = model_hint_edge(ns, hint.as_deref(), models); } } + elem.phys_pieces = + if fx_iw4::elem_skips_position_update(elem.view.elem_type, elem.view.flags) { + elem.visuals + .iter() + .map(|vis| model_phys_piece(vis, models)) + .collect() + } else { + Vec::new() + }; } } } @@ -1446,6 +1462,7 @@ fn capture_elem( has_extended, trail_def, spark_fountain_def, + phys_pieces: Vec::new(), }) } @@ -1695,6 +1712,17 @@ fn model_hint_edge( } } +fn model_phys_piece( + vis: &OwnedFxVisual, + models: &crate::FxModelCatalog, +) -> Option { + let OwnedFxVisual::Model { edge, .. } = vis else { + return None; + }; + let (mid, half) = models.get_at(edge.bound_index()?)?.skel.bounds?; + fx_iw4::FxPhysPiece::from_bounds(mid, half) +} + fn resolve_material_visual( s: &impl FxRead, materials: &MaterialCatalog, @@ -2056,6 +2084,7 @@ fn leftover_capture_elem_t5( has_extended, trail_def: None, spark_fountain_def: None, + phys_pieces: Vec::new(), }) } @@ -2544,5 +2573,6 @@ fn capture_elem_t6( has_extended: false, trail_def: None, spark_fountain_def: None, + phys_pieces: Vec::new(), }) } diff --git a/crates/asset_game/src/menu_catalog.rs b/crates/asset_game/src/menu_catalog.rs index 6b513ef..6b3dfb3 100644 --- a/crates/asset_game/src/menu_catalog.rs +++ b/crates/asset_game/src/menu_catalog.rs @@ -552,6 +552,23 @@ pub const HUD_CHROME_MENUS: &[&str] = &[ "perks_info_hd", ]; +fn cover_wide_hud_overlays(catalog: &mut MenuCatalog) { + for name in HUD_CHROME_MENUS { + let Some(menu) = catalog.menus.get_mut(*name) else { + continue; + }; + for item in &mut menu.items { + let rect = &item.rect; + if !item.background.is_empty() + && (rect.x, rect.y, rect.w, rect.h) == (-427.0, -240.0, 854.0, 480.0) + && (rect.horz_align, rect.vert_align) == (2, 2) + { + item.cover = true; + } + } + } +} + pub fn ui_games_root(games: &GamesRoot) -> Result { let anchor = find_zone_file(games, "iw4:common_mp")?; game_root_for_zone(&anchor.path).map(GamesRoot) @@ -1025,6 +1042,7 @@ impl MenuSink { fn finish_and_take(mut self, games: Option<&Path>) -> MenuCatalog { self.finish_ui_images(games); + cover_wide_hud_overlays(&mut self.catalog); self.catalog } diff --git a/crates/assets/src/match_load.rs b/crates/assets/src/match_load.rs index 735b1a7..9f36850 100644 --- a/crates/assets/src/match_load.rs +++ b/crates/assets/src/match_load.rs @@ -36,6 +36,8 @@ pub struct MatchLoadRequest { struct MatchLoadTask { task: Task, MapLoadFailed>>, request_id: u64, + load_key: frame::LocalLoadKey, + zone: String, progress: LoadProgress, } @@ -141,6 +143,7 @@ fn start_match_load( let load_key = request.load_key; let zone = request.zone.clone(); let zone_accepted = zone.clone(); + let zone_task = zone.clone(); let cancel_handle = progress.clone(); // The process outlives the overlay and every task this spawns: it is what // the table reads, what admission finishes, and what the bench still has @@ -224,6 +227,8 @@ fn start_match_load( MatchLoadTask { task, request_id, + load_key, + zone: zone_task, progress: cancel_handle, }, frame::MatchScope::Loading, @@ -356,8 +361,13 @@ fn poll_match_load( task.request_id ); } - let Some(outcome) = future::block_on(future::poll_once(&mut task.task)) else { - return; + let polled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + future::block_on(future::poll_once(&mut task.task)) + })); + let outcome = match polled { + Ok(None) => return, + Ok(Some(outcome)) => outcome, + Err(panic) => Err(walk_panic_failure(task, panic.as_ref())), }; let request_id = task.request_id; inflight.0 = false; @@ -418,6 +428,26 @@ fn poll_match_load( }); } +fn walk_panic_failure(task: &MatchLoadTask, panic: &(dyn std::any::Any + Send)) -> MapLoadFailed { + let message = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or("no message"); + diag::error!( + World, + "match load: walk request #{} (`{}`) panicked: {message} — reported as a failed map load", + task.request_id, + task.zone + ); + MapLoadFailed { + request_id: task.request_id, + load_key: task.load_key, + zone: task.zone.clone(), + error: format!("The load stopped on an internal error: {message}"), + } +} + fn expire_match_load_abort( mut commands: Commands, abort: Option>, diff --git a/crates/fx/src/draw.rs b/crates/fx/src/draw.rs index e8dde36..84b366c 100644 --- a/crates/fx/src/draw.rs +++ b/crates/fx/src/draw.rs @@ -205,7 +205,7 @@ pub struct FxElemLightInstance { pub is_spot: bool, pub origin: [f32; 3], pub radius: f32, - pub color_bgr: [f32; 3], + pub color_rgb: [f32; 3], pub axis: [[f32; 3]; 3], } diff --git a/crates/fx/src/lib.rs b/crates/fx/src/lib.rs index 76d0435..678838e 100644 --- a/crates/fx/src/lib.rs +++ b/crates/fx/src/lib.rs @@ -37,7 +37,9 @@ pub use marks::{ FxMarksSystemHost, GfxMarkMeshCensus, GfxMarkMeshSurf, MarkImpactRequest, MarkImpactResult, MarkReceiverEnable, MarkTraceRecord, }; -pub use motion::{evaluate_elem_collide_motion, evaluate_elem_motion}; +pub use motion::{ + evaluate_elem_collide_motion, evaluate_elem_motion, evaluate_elem_physics_motion, +}; pub use play::{ FxPlayPose, FxPlayRequest, PlayResult, axis_from_hit_normal, axis_from_impact_velocity, play_at_origin, play_bolted, play_oriented, spawn_impact_or_death_effect, spawn_oriented, diff --git a/crates/fx/src/motion.rs b/crates/fx/src/motion.rs index 3fe9ccd..e5a63fa 100644 --- a/crates/fx/src/motion.rs +++ b/crates/fx/src/motion.rs @@ -1,10 +1,11 @@ use fx_iw4::{ - FX_ELEM_AT_REST_NONE, collide_marks_at_rest, collide_substep_schedule, - collision_reflect_base_vel_delta, elem_dies_on_touch, elem_gravity_accel_z, - elem_update_has_velocity_graph, elem_uses_collision, elem_uses_vel_local, elem_uses_vel_world, - get_at_rest_fraction, get_velocity_at_time_sampled, impact_child_speed_allows, - integrate_velocity_graph_sampled, orientation_pos_from_world, orientation_pos_to_world, - sample_reflection_factor, trace_mask, vec3_length_sq, + FX_ELEM_AT_REST_NONE, FX_PHYS_PIECE_HALF, collide_marks_at_rest, collide_on_ground, + collide_substep_schedule, collision_reflect_base_vel_delta, elem_dies_on_touch, + elem_gravity_accel_z, elem_launch_velocity, elem_update_has_velocity_graph, + elem_uses_collision, elem_uses_vel_local, elem_uses_vel_world, get_at_rest_fraction, + get_velocity_at_time_sampled, impact_child_speed_allows, integrate_velocity_graph_sampled, + orientation_pos_from_world, orientation_pos_to_world, sample_reflection_factor, trace_mask, + vec3_length_sq, }; use crate::update::{FxElemMotionQuery, FxElemMotionResult, FxElemTraceHit, FxImpactSpawn}; @@ -41,6 +42,7 @@ pub fn evaluate_elem_collide_motion( coll_maxs: [f32; 3], use_item_clip: bool, has_effect_on_impact: bool, + leaves_start_solid: bool, vel_graph_local: &[fx_iw4::FxElemVec3Range], vel_graph_world: &[fx_iw4::FxElemVec3Range], q: FxElemMotionQuery<'_>, @@ -125,7 +127,7 @@ pub fn evaluate_elem_collide_motion( orientation_pos_to_world(q.orient.origin, q.orient.axis, end_local), ); let hit = trace(start, end, coll_mins, coll_maxs, mask); - if hit.startsolid || hit.allsolid { + if hit.allsolid || (hit.startsolid && !leaves_start_solid) { base_vel = [0.0; 3]; break; } @@ -224,6 +226,142 @@ pub fn evaluate_elem_collide_motion( }) } +const FX_PHYS_GRAVITY: f32 = 800.0; +const FX_PHYS_BOUNCE: f32 = 0.2; +const FX_PHYS_FRICTION: f32 = 0.5; +const FX_PHYS_REST_SPEED: f32 = 20.0; +const FX_PHYS_UNSTICK_MAX_LIFT: f32 = 16.0; +const FX_PHYS_CONTACT_SLOP: f32 = 0.125; + +pub fn evaluate_elem_physics_motion( + flags: i32, + use_item_clip: bool, + vel_graph_local: &[fx_iw4::FxElemVec3Range], + vel_graph_world: &[fx_iw4::FxElemVec3Range], + q: FxElemMotionQuery<'_>, + mut trace: impl FnMut([f32; 3], [f32; 3], [f32; 3], [f32; 3], u32) -> FxElemTraceHit, +) -> FxElemMotionResult { + let mut landed = (q.at_rest_fraction != FX_ELEM_AT_REST_NONE).then_some(q.at_rest_fraction); + let mut result = FxElemMotionResult { + origin_delta: [0.0; 3], + base_vel: q.base_vel, + remove: false, + at_rest_fraction: landed, + spawn_impact: None, + }; + if landed.is_some() && q.base_vel == [0.0; 3] { + return result; + } + let from = q.prev_msec.max(q.msec_begin); + if q.msec_now <= from { + return result; + } + let mut vel = if q.prev_msec <= q.msec_begin { + elem_launch_velocity( + flags, + vel_graph_local, + vel_graph_world, + q.orient.axis, + q.motion_random.velocity, + ) + } else { + q.base_vel + }; + let mask = trace_mask(use_item_clip); + let (mins, maxs) = ([-FX_PHYS_PIECE_HALF; 3], [FX_PHYS_PIECE_HALF; 3]); + let mut pos = orientation_pos_to_world(q.orient.origin, q.orient.axis, q.origin); + for step in collide_substep_schedule(from, q.msec_now) { + let dt = (step.msec_end.saturating_sub(step.msec_start)).max(0) as f32 * 0.001; + vel[2] -= FX_PHYS_GRAVITY * dt; + let end = [ + pos[0] + vel[0] * dt, + pos[1] + vel[1] * dt, + pos[2] + vel[2] * dt, + ]; + let hit = trace(pos, end, mins, maxs, mask); + if hit.allsolid { + vel = [0.0; 3]; + match lift_out_of_solid(pos, mins, maxs, mask, &mut trace) { + Some(lifted) => { + pos = lifted; + continue; + } + None => { + landed.get_or_insert(at_rest_byte(step.msec_start, q.msec_begin, q.life_ms)); + break; + } + } + } + if hit.fraction >= 1.0 { + pos = end; + continue; + } + pos = [ + pos[0] + (end[0] - pos[0]) * hit.fraction, + pos[1] + (end[1] - pos[1]) * hit.fraction, + pos[2] + (end[2] - pos[2]) * hit.fraction, + ]; + let n = hit.normal; + let into = dot(vel, n); + if into < 0.0 { + let push = (1.0 + FX_PHYS_BOUNCE) * into; + for (v, n) in vel.iter_mut().zip(n) { + *v -= push * n; + } + let along = dot(vel, n); + for (v, n) in vel.iter_mut().zip(n) { + let normal_part = along * n; + *v = normal_part + (*v - normal_part) * (1.0 - FX_PHYS_FRICTION); + } + } + if collide_on_ground(n[2]) { + landed.get_or_insert(at_rest_byte(step.msec_end, q.msec_begin, q.life_ms)); + if vec3_length_sq(vel) < FX_PHYS_REST_SPEED * FX_PHYS_REST_SPEED { + vel = [0.0; 3]; + break; + } + } + } + let stored = orientation_pos_from_world(q.orient.origin, q.orient.axis, pos); + result.origin_delta = [ + stored[0] - q.origin[0], + stored[1] - q.origin[1], + stored[2] - q.origin[2], + ]; + result.base_vel = vel; + result.at_rest_fraction = landed; + result +} + +fn lift_out_of_solid( + pos: [f32; 3], + mins: [f32; 3], + maxs: [f32; 3], + mask: u32, + trace: &mut impl FnMut([f32; 3], [f32; 3], [f32; 3], [f32; 3], u32) -> FxElemTraceHit, +) -> Option<[f32; 3]> { + let mut dz = -mins[2] + FX_PHYS_CONTACT_SLOP; + let cap = dz.max(FX_PHYS_UNSTICK_MAX_LIFT); + while dz <= cap { + let lifted = [pos[0], pos[1], pos[2] + dz]; + let probe = trace(lifted, lifted, mins, maxs, mask); + if !probe.startsolid && !probe.allsolid { + return Some(lifted); + } + dz *= 2.0; + } + None +} + +fn at_rest_byte(msec: i32, msec_begin: i32, life_ms: f32) -> u8 { + let frac = get_at_rest_fraction(msec as f32, msec_begin as f32, 1.0 / life_ms.max(1.0)); + frac.clamp(0.0, 254.0) as u8 +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + fn integrate_free_flight( flags: i32, gravity_base: f32, diff --git a/crates/fx/src/system.rs b/crates/fx/src/system.rs index 34d360e..a9b30db 100644 --- a/crates/fx/src/system.rs +++ b/crates/fx/src/system.rs @@ -860,6 +860,14 @@ impl FxSystemHost { Some(idx as u8) } + fn free_bolt_record(&mut self, index: u8) { + let Some(next) = self.bolt_next.get_mut(usize::from(index)) else { + return; + }; + *next = self.bolt_first_free; + self.bolt_first_free = i32::from(index); + } + pub fn spawn_effect( &mut self, def_name: &str, @@ -873,6 +881,9 @@ impl FxSystemHost { if wants_spotlight && self.spotlight_count >= FX_SPOT_LIGHT_LIMIT { return Err(SpawnFail::TooManySpotlights); } + if self.ring_occupancy() >= FX_EFFECT_HANDLE_RING_SIZE as i32 { + return Err(SpawnFail::RingFull); + } let bolt_index = if bolt == FX_SPAWN_BOLT_NONE { 0xff } else { @@ -883,9 +894,6 @@ impl FxSystemHost { }; let alloc = self.first_free_effect; - if self.ring_occupancy() >= FX_EFFECT_HANDLE_RING_SIZE as i32 { - return Err(SpawnFail::RingFull); - } self.first_free_effect = alloc.wrapping_add(1); let ring_i = (alloc as u32 & (FX_EFFECT_HANDLE_RING_SIZE - 1)) as usize; @@ -986,6 +994,10 @@ impl FxSystemHost { self.set_handle_at_ring(active_index as u32, handle); active_index = active_index.wrapping_add(1); if let Some(slot) = handle_to_slot(handle) { + let bolt = self.effects[slot].bolt; + if bolt != 0xff { + self.free_bolt_record(bolt); + } self.effects[slot] = FxEffectSlot::default(); } } diff --git a/crates/fx/src/update.rs b/crates/fx/src/update.rs index 013ec54..da5efa5 100644 --- a/crates/fx/src/update.rs +++ b/crates/fx/src/update.rs @@ -2,8 +2,8 @@ use fx_iw4::{ FX_ELEM_TYPE_SPARK_CLOUD, FX_ELEM_TYPE_SPARK_FOUNTAIN, FX_STATUS_HAS_PENDING_LOOP_ELEMS, FX_STATUS_REF_COUNT_MASK_IW4, FxUpdateEffectBolt, axis_to_quat, begin_iterating_over_effects_exclusive, bolt_compose_orientation, bolt_mark_lost, - elem_norm_time, elem_uses_collision, end_iterating_over_effects, end_iterating_runs_gc, - unit_quat_to_axis, update_effect_bolt, vector_vectors, + elem_norm_time, elem_skips_position_update, elem_uses_collision, end_iterating_over_effects, + end_iterating_runs_gc, unit_quat_to_axis, update_effect_bolt, vector_vectors, }; use std::collections::HashMap; @@ -384,7 +384,7 @@ fn pending_collide_for_elem( if msec_now >= death { return None; } - if !elem_uses_collision(elem.flags) { + if !elem_uses_collision(elem.flags) && !elem_skips_position_update(elem.elem_type, elem.flags) { return None; } let def_index = elem.def_index; diff --git a/crates/fx_iw4/src/collide.rs b/crates/fx_iw4/src/collide.rs index af3f71a..f22abe2 100644 --- a/crates/fx_iw4/src/collide.rs +++ b/crates/fx_iw4/src/collide.rs @@ -119,3 +119,59 @@ pub fn collision_reflect_base_vel_delta( reflected[2] - pre_impact_vel[2], ] } + +pub const FX_PHYS_PIECE_HALF: f32 = 1.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FxPhysPiece { + flat_axis: usize, + flat_mid: f32, + flat_half: f32, +} + +impl FxPhysPiece { + pub fn from_bounds(mid: [f32; 3], half: [f32; 3]) -> Option { + if !mid.iter().chain(&half).all(|v| v.is_finite()) || half.iter().any(|v| *v <= 0.0) { + return None; + } + let flat_axis = (0..3).min_by(|&a, &b| half[a].total_cmp(&half[b]))?; + Some(Self { + flat_axis, + flat_mid: mid[flat_axis], + flat_half: half[flat_axis], + }) + } + + pub fn rest_pose(&self, axis: [[f32; 3]; 3], scale: f32) -> ([[f32; 3]; 3], f32) { + let a = axis[self.flat_axis]; + let up = if a[2] >= 0.0 { 1.0 } else { -1.0 }; + let v = [a[1] * up, -a[0] * up, 0.0]; + let k = 1.0 / (1.0 + a[2] * up); + let turn = |p: [f32; 3]| { + let vxp = cross(v, p); + let vxvxp = cross(v, vxp); + [ + p[0] + vxp[0] + vxvxp[0] * k, + p[1] + vxp[1] + vxvxp[1] * k, + p[2] + vxp[2] + vxvxp[2] * k, + ] + }; + let lowest = if up > 0.0 { + self.flat_mid - self.flat_half + } else { + -(self.flat_mid + self.flat_half) + }; + ( + [turn(axis[0]), turn(axis[1]), turn(axis[2])], + -FX_PHYS_PIECE_HALF - lowest * scale, + ) + } +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} diff --git a/crates/fx_iw4/src/cull.rs b/crates/fx_iw4/src/cull.rs index 5987701..84c9d22 100644 --- a/crates/fx_iw4/src/cull.rs +++ b/crates/fx_iw4/src/cull.rs @@ -94,11 +94,8 @@ pub fn cull_elem_light( } #[inline] -pub fn elem_light_color_bgr(color_rgba: [u8; 4], scale: f32) -> [f32; 3] { +pub fn elem_light_color_rgb(color_rgba: [u8; 4], scale: f32) -> [f32; 3] { let s = scale * (crate::FX_RECIP_255 as f32); - [ - color_rgba[2] as f32 * s, - color_rgba[1] as f32 * s, - color_rgba[0] as f32 * s, - ] + let [r, g, b, _a] = color_rgba; + [r as f32 * s, g as f32 * s, b as f32 * s] } diff --git a/crates/fx_iw4/src/lib.rs b/crates/fx_iw4/src/lib.rs index d8695f0..d7b05b5 100644 --- a/crates/fx_iw4/src/lib.rs +++ b/crates/fx_iw4/src/lib.rs @@ -88,14 +88,15 @@ pub use bolt::{ update_effect_bolt, }; pub use collide::{ - FX_COLLIDE_SUBSTEP_MS, FX_COLLISION_REFLECT_SCALE, FX_IMPACT_CHILD_MIN_SPEED_SQ, FX_TRACE_MASK, - FX_TRACE_MASK_ITEM_CLIP, FxCollideSubstep, collide_marks_at_rest, collide_on_ground, - collide_substep_schedule, collision_reflect_base_vel_delta, impact_child_speed_allows, - sample_reflection_factor, trace_mask, + FX_COLLIDE_SUBSTEP_MS, FX_COLLISION_REFLECT_SCALE, FX_IMPACT_CHILD_MIN_SPEED_SQ, + FX_PHYS_PIECE_HALF, FX_TRACE_MASK, FX_TRACE_MASK_ITEM_CLIP, FxCollideSubstep, FxPhysPiece, + collide_marks_at_rest, collide_on_ground, collide_substep_schedule, + collision_reflect_base_vel_delta, impact_child_speed_allows, sample_reflection_factor, + trace_mask, }; pub use cull::{ FX_ELEM_FLAG_CULL_DRAW_5_PLANES, cull_cloud, cull_cloud_plane_count, cull_cloud_radius, - cull_elem_for_spawn_allows, cull_elem_light, cull_sphere, elem_light_color_bgr, + cull_elem_for_spawn_allows, cull_elem_light, cull_sphere, elem_light_color_rgb, }; pub use draw::{FX_DRAW_ELEM_HANDLER_PRESENT, FxElemType, draw_elem_handler_present}; pub use effect_def::FxEffectDef; @@ -341,7 +342,9 @@ pub use vec::{ FX_PERP_VECTOR_UNIT, effect_orient_arc, perpendicular_vector, vec3_distance, vec3_length_sq, vec3_normalize, vector_vectors, }; -pub use velocity::{FX_VEL_AT_TIME_SCALE, get_velocity_at_time, get_velocity_at_time_sampled}; +pub use velocity::{ + FX_VEL_AT_TIME_SCALE, elem_launch_velocity, get_velocity_at_time, get_velocity_at_time_sampled, +}; pub use view::{ FX_EFFECT_DEF_SIZE, FX_ELEM_DEF_STRIDE, FxEffectDefView, FxElemDefView, effect_def_view, elem_def_gravity_accel_z, elem_def_view, elem_def_view_x64, diff --git a/crates/fx_iw4/src/velocity.rs b/crates/fx_iw4/src/velocity.rs index 7850433..8fed1e2 100644 --- a/crates/fx_iw4/src/velocity.rs +++ b/crates/fx_iw4/src/velocity.rs @@ -75,3 +75,36 @@ pub fn get_velocity_at_time_sampled( } out } + +#[inline] +pub fn elem_launch_velocity( + flags: i32, + local_samples: &[crate::FxElemVec3Range], + world_samples: &[crate::FxElemVec3Range], + orient_axis: [[f32; 3]; 3], + random: [f32; 3], +) -> [f32; 3] { + let mut out = [0.0f32; 3]; + if elem_uses_vel_world(flags) && world_samples.len() >= 2 { + let s = sample_vel_graph_at_age_sampled(world_samples, 0.0, random); + let scale = graph_speed_scale(world_samples.len()); + for (out, s) in out.iter_mut().zip(s) { + *out += s * scale; + } + } + if elem_uses_vel_local(flags) && local_samples.len() >= 2 { + let s = sample_vel_graph_at_age_sampled(local_samples, 0.0, random); + let scale = graph_speed_scale(local_samples.len()); + for (axis, out) in out.iter_mut().enumerate() { + let w = s[0] * orient_axis[0][axis] + + s[1] * orient_axis[1][axis] + + s[2] * orient_axis[2][axis]; + *out += w * scale; + } + } + out +} + +fn graph_speed_scale(fenceposts: usize) -> f32 { + (fenceposts - 1) as f32 * FX_VEL_AT_TIME_SCALE as f32 +} diff --git a/crates/hud/src/chrome.rs b/crates/hud/src/chrome.rs index 9e8090e..f1b6cf8 100644 --- a/crates/hud/src/chrome.rs +++ b/crates/hud/src/chrome.rs @@ -948,10 +948,12 @@ fn push_stretch( ); if rest.w > f32::EPSILON && rest.w < surface.width() { let grow = surface.width() / rest.w; - let centre = applied.x + applied.w * 0.5; + let centre_x = applied.x + applied.w * 0.5; + let centre_y = applied.y + applied.h * 0.5; applied.w *= grow; applied.h *= grow; - applied.x = centre - applied.w * 0.5; + applied.x = centre_x - applied.w * 0.5; + applied.y = centre_y - applied.h * 0.5; } } let color = style.fill_color(item); diff --git a/crates/hud/src/weaponbar.rs b/crates/hud/src/weaponbar.rs index b2ea179..d1cc20a 100644 --- a/crates/hud/src/weaponbar.rs +++ b/crates/hud/src/weaponbar.rs @@ -106,7 +106,14 @@ impl WeaponbarExprHost<'_> { impl ExprHost for WeaponbarExprHost<'_> { fn binding_label(&self, command: &str) -> Option<&str> { - self.input?.binding_keys.get(command).map(String::as_str) + let keys = &self.input?.binding_keys; + keys.get(command) + .or_else(|| match command { + "+activate" => keys.get("+usereload"), + "+usereload" => keys.get("+activate"), + _ => None, + }) + .map(String::as_str) } fn ui_active(&self) -> Result { diff --git a/crates/lighting_iw4/src/scene_light.rs b/crates/lighting_iw4/src/scene_light.rs index 1b0fb11..547fce7 100644 --- a/crates/lighting_iw4/src/scene_light.rs +++ b/crates/lighting_iw4/src/scene_light.rs @@ -212,10 +212,10 @@ pub fn add_omni_light_to_scene_allows( Ok(()) } -pub fn omni_light_pack(origin: [f32; 3], radius: f32, color_bgr: [f32; 3]) -> GfxLightPack { +pub fn omni_light_pack(origin: [f32; 3], radius: f32, color_rgb: [f32; 3]) -> GfxLightPack { GfxLightPack { light_type: GFX_LIGHT_TYPE_OMNI, - color: color_bgr, + color: color_rgb, direction: [0.0; 3], origin, radius, @@ -281,7 +281,7 @@ pub fn spot_light_pack( origin: [f32; 3], forward: [f32; 3], radius: f32, - color_bgr: [f32; 3], + color_rgb: [f32; 3], cone: SpotLightConeDvars, ) -> GfxLightPack { let end = spot_light_clamp_end(cone.start_radius, cone.end_radius, radius); @@ -301,9 +301,9 @@ pub fn spot_light_pack( GfxLightPack { light_type: GFX_LIGHT_TYPE_SPOT, color: [ - color_bgr[0] * cone.brightness, - color_bgr[1] * cone.brightness, - color_bgr[2] * cone.brightness, + color_rgb[0] * cone.brightness, + color_rgb[1] * cone.brightness, + color_rgb[2] * cone.brightness, ], direction: dir, origin, diff --git a/crates/math_iw4/src/angles.rs b/crates/math_iw4/src/angles.rs index bb7217b..56ec2d4 100644 --- a/crates/math_iw4/src/angles.rs +++ b/crates/math_iw4/src/angles.rs @@ -83,9 +83,9 @@ pub fn vec_to_yaw(x: f32, y: f32) -> f32 { const VECT_TO_ANGLES_PITCH_SCALE: f32 = -57.2957763671875_f32; -const VECT_TO_ANGLES_PITCH_DOWN: f32 = 270.0; +const VECT_TO_ANGLES_PITCH_DOWN: f32 = 90.0; -const VECT_TO_ANGLES_PITCH_UP: f32 = 90.0; +const VECT_TO_ANGLES_PITCH_UP: f32 = 270.0; pub fn vect_to_angles(forward: [f32; 3]) -> [f32; 3] { let [x, y, z] = forward; @@ -118,7 +118,7 @@ fn axis_to_angles_roll_atan(v: [f32; 3]) -> f32 { let [x, y, z] = v; if y == 0.0 && x == 0.0 { return if 0.0 <= -z { - VECT_TO_ANGLES_PITCH_UP + VECT_TO_ANGLES_PITCH_DOWN } else { AXIS_TO_ANGLES_ROLL_PITCH_NEG }; diff --git a/crates/net/src/client/presentation/centity_runtime.rs b/crates/net/src/client/presentation/centity_runtime.rs index 09ff883..f41b4a8 100644 --- a/crates/net/src/client/presentation/centity_runtime.rs +++ b/crates/net/src/client/presentation/centity_runtime.rs @@ -64,9 +64,11 @@ impl CEntityRuntime { return; }; if !self.in_next_snap() - || !matches!(self.pose_e_type as i32, ET_PLAYER | ET_PLAYER_CORPSE) - || previous.pos.tr_type != TR_INTERPOLATE - || self.current.pos.tr_type != TR_INTERPOLATE + || !lerps_between_snapshots( + self.pose_e_type as i32, + previous.pos.tr_type, + self.current.pos.tr_type, + ) { return; } @@ -92,6 +94,19 @@ impl CEntityRuntime { } } +fn lerps_between_snapshots(e_type: i32, previous_tr_type: i32, current_tr_type: i32) -> bool { + match e_type { + ET_PLAYER_CORPSE => true, + ET_PLAYER => previous_tr_type == TR_INTERPOLATE && current_tr_type == TR_INTERPOLATE, + _ => false, + } +} + +fn another_body(current: &EntityState, next: &EntityState) -> bool { + next.e_type == ET_PLAYER_CORPSE + && (current.time2 != next.time2 || current.client_num != next.client_num) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PlayerDrawGate { pub eyes_entity_num: i32, @@ -246,8 +261,9 @@ pub fn apply_existing(rt: &mut CEntityRuntime, next: EntityState, at_time_ms: i3 let was_in = rt.in_next_snap(); let e_type_changed = rt.next_state.e_type != next.e_type; let teleport = (rt.next_state.e_flags ^ next.e_flags) & EFLAGS_TELEPORT != 0; - if !was_in || e_type_changed || teleport { - let new_entity = !was_in || e_type_changed; + let other_body = another_body(&rt.next_state, &next); + if !was_in || e_type_changed || teleport || other_body { + let new_entity = !was_in || e_type_changed || other_body; reset_entity(rt, next, at_time_ms, new_entity); return; } @@ -335,6 +351,7 @@ pub fn corpse_slot_to_entity_state(slot: &PlayerCorpseSlot) -> EntityState { es.number = slot.entnum; es.e_type = ET_PLAYER_CORPSE; es.client_num = i32::try_from(slot.victim.0).unwrap_or(0); + es.time2 = slot.spawn_time_ms; es.e_flags = slot.e_flags; es.tr_type = slot.tr_type; es.tr_time = slot.tr_time; diff --git a/crates/net/src/client/presentation/presented.rs b/crates/net/src/client/presentation/presented.rs index 63ddb55..34ad5bd 100644 --- a/crates/net/src/client/presentation/presented.rs +++ b/crates/net/src/client/presentation/presented.rs @@ -194,6 +194,10 @@ impl PresentedSnapshot { self.trajectory_time_ms.unwrap_or(render_time_ms) } + pub fn is_archived_replay(&self) -> bool { + self.trajectory_time_ms.is_some() + } + pub(crate) fn set_trajectory_sample( &mut self, time_ms: Option, diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 0419ea7..c67f5b7 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -175,6 +175,6 @@ pub use transport::wire::{WireError, WireReader, WireWriter}; pub use svc_script_audio::SvcScriptAudio; -pub const PROTOCOL_VERSION: u32 = 116; +pub const PROTOCOL_VERSION: u32 = 118; pub use client::fire_verdict::{FireVerdictState, PredictedFireStatus}; diff --git a/crates/net/src/policy/killcam.rs b/crates/net/src/policy/killcam.rs index 77ac580..7867233 100644 --- a/crates/net/src/policy/killcam.rs +++ b/crates/net/src/policy/killcam.rs @@ -86,7 +86,7 @@ pub(crate) fn play_script_seats( focus_life, focus, ends_at_ms: i32::MAX, - final_kill: false, + final_kill: seat.final_kill && seat.archive_ms > 0, started_at_ms, killcamoffset_ms: seat.archive_ms, predelay_ms: 0, diff --git a/crates/net/src/policy/seat.rs b/crates/net/src/policy/seat.rs index 6c4bdc9..3c4e3bb 100644 --- a/crates/net/src/policy/seat.rs +++ b/crates/net/src/policy/seat.rs @@ -273,6 +273,13 @@ fn seat_snapshot( return (live.clone(), None); } let archived = lookup.tick.and_then(|tick| archive.frame(tick)); + overlay_archived_cameras( + &mut out, + live, + archived.map(|f| &f.snapshot), + viewer, + session.focus_client, + ); overlay_killcam_hud( &mut out, live, @@ -341,6 +348,31 @@ fn overlay_archived_world( out } +fn overlay_archived_cameras( + out: &mut Snapshot, + live: &Snapshot, + archived: Option<&Snapshot>, + viewer: ClientId, + focus: ClientId, +) { + let focus_row = archived.and_then(|snap| snap.meta.for_client(focus)); + let delta_ms = archived.map_or(0, |snap| { + sim::level_time_ms(live.tick).wrapping_sub(sim::level_time_ms(snap.tick)) + }); + let linked_weapon_view = focus_row.and_then(|meta| meta.linked_weapon_view); + let remote_missile = + focus_row + .and_then(|meta| meta.remote_missile) + .map(|link| sim::RemoteMissile { + unlink_at_ms: link.unlink_at_ms.map(|ms| ms.wrapping_add(delta_ms)), + ..link + }); + if let Some((_, meta)) = out.meta.clients.iter_mut().find(|(id, _)| *id == viewer) { + meta.linked_weapon_view = linked_weapon_view; + meta.remote_missile = remote_missile; + } +} + fn rebase_entity_state(es: &mut entity_iw4::EntityState, delta_ms: i32) { if es.tr_time != 0 { es.tr_time = es.tr_time.wrapping_add(delta_ms); @@ -377,6 +409,15 @@ fn rebase_archived_world(out: &mut Snapshot, viewer: ClientId, delta_ms: i32) { p.detonate_at_ms = p.detonate_at_ms.map(|t| t.wrapping_add(delta_ms)); p.cleanup_at_ms = p.cleanup_at_ms.wrapping_add(delta_ms); } + let bodies = out.meta.corpses.slots.iter_mut(); + for body in bodies.filter(|body| body.occupied) { + if body.tr_time != 0 { + body.tr_time = body.tr_time.wrapping_add(delta_ms); + } + if body.spawn_time_ms != 0 { + body.spawn_time_ms = body.spawn_time_ms.wrapping_add(delta_ms); + } + } for (id, ps) in &mut out.players { if *id != viewer { rebase_archived_timers(ps, delta_ms); diff --git a/crates/net/src/transport/meta_wire.rs b/crates/net/src/transport/meta_wire.rs index bb2a565..5d6467e 100644 --- a/crates/net/src/transport/meta_wire.rs +++ b/crates/net/src/transport/meta_wire.rs @@ -2210,6 +2210,9 @@ fn encode_corpse_pool(out: &mut WireWriter, pool: &PlayerCorpsePool) { } out.put_u8(u8::from(slot.falling)); out.put_i32(slot.ground_entity_num); + out.put_i32(slot.spawn_time_ms); + out.put_u32(slot.shield.map_or(0, |shield| shield.weapon)); + out.put_u8(u8::from(slot.shield.is_some_and(|shield| shield.on_back))); } } @@ -2243,6 +2246,8 @@ fn decode_corpse_pool(input: &mut WireReader<'_>) -> Result return Err(WireError::Malformed("bad corpse slot tag")), @@ -2251,6 +2256,18 @@ fn decode_corpse_pool(input: &mut WireReader<'_>) -> Result, +) -> Result, WireError> { + let weapon = input.get_u32()?; + let on_back = match input.get_u8()? { + 0 => false, + 1 => true, + _ => return Err(WireError::Malformed("bad corpse shield tag")), + }; + Ok((weapon != 0).then_some(sim::ShieldAttachment { weapon, on_back })) +} + fn encode_entity_states(out: &mut WireWriter, rows: &[entity_iw4::EntityState]) { debug_assert!(rows.len() <= u16::MAX as usize); out.put_u16(rows.len() as u16); diff --git a/crates/render_anim/src/anim/remote_animation.rs b/crates/render_anim/src/anim/remote_animation.rs index 66af2ed..956bd6f 100644 --- a/crates/render_anim/src/anim/remote_animation.rs +++ b/crates/render_anim/src/anim/remote_animation.rs @@ -12,7 +12,7 @@ pub struct PersistentRemoteTree { pub(crate) legs_restart: bool, pub(crate) torso_restart: bool, - pub(crate) occupation_tr_time: Option, + pub(crate) occupation_key: Option, pub(crate) legs_rate_sample: ClientAnimSample, @@ -88,16 +88,36 @@ pub fn skip_frozen_corpse_dobj( is_corpse && tree_reused && last_was_cache_hit && has_cache } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CorpseOccupationKey { + pub spawn_time_ms: i32, + pub victim: i32, +} + +pub fn corpse_occupation_key(es: &entity_iw4::EntityState) -> CorpseOccupationKey { + CorpseOccupationKey { + spawn_time_ms: es.time2, + victim: es.client_num, + } +} + +pub fn fresh_corpse_clip_time(age_ms: i32, clip_length_ms: i32) -> f32 { + if clip_length_ms <= 0 || age_ms <= 0 { + return 0.0; + } + (age_ms as f32 / clip_length_ms as f32).min(1.0) +} + pub fn clone_corpse_tree_from_victim( trees: &mut RemoteBodyTrees, corpse_ent: u32, victim: u32, - tr_time: i32, + key: CorpseOccupationKey, ) { if trees .by_ent .get(&corpse_ent) - .is_some_and(|slot| slot.occupation_tr_time == Some(tr_time)) + .is_some_and(|slot| slot.occupation_key == Some(key)) { return; } @@ -108,7 +128,7 @@ pub fn clone_corpse_tree_from_victim( let mut dst = src; dst.dobj = None; dst.reuse_key = None; - dst.occupation_tr_time = Some(tr_time); + dst.occupation_key = Some(key); trees.by_ent.insert(corpse_ent, dst); } @@ -129,6 +149,7 @@ pub fn advance_remote_tree( dt: f32, origin: [f32; 3], pose_time_ms: i32, + corpse_age_ms: Option, trees: &mut RemoteBodyTrees, ) -> Result { let tree = binding.tree(); @@ -139,7 +160,7 @@ pub fn advance_remote_tree( let legs_restart = legs.restart_toggle(); let torso_restart = torso.restart_toggle(); let previous = trees.by_ent.remove(&persist_key); - let occupation_tr_time = previous.as_ref().and_then(|slot| slot.occupation_tr_time); + let occupation_key = previous.as_ref().and_then(|slot| slot.occupation_key); let prev = previous.filter(|slot| Arc::ptr_eq(&slot.binding, binding)); let prev_restart_changed_legs = prev .as_ref() @@ -178,6 +199,7 @@ pub fn advance_remote_tree( let overlayed = overlay_legs_clip(&clip, &torso_clip); (Arc::new(overlayed), Some(torso_clip)) }; + let fresh = prev.is_none(); let old_legs = prev.as_ref().map(|slot| slot.legs).unwrap_or(0); let old_torso = prev.as_ref().map(|slot| slot.torso).unwrap_or(0); let old_legs_moving = prev @@ -225,7 +247,7 @@ pub fn advance_remote_tree( torso: 0, legs_restart: false, torso_restart: false, - occupation_tr_time, + occupation_key, legs_rate_sample: ClientAnimSample::default(), torso_rate_sample: ClientAnimSample::default(), }, @@ -292,6 +314,23 @@ pub fn advance_remote_tree( .set_state(id, state) .map_err(|error| error.to_string())?; } + if fresh && let Some(age_ms) = corpse_age_ms { + for (index, leaf_clip) in [ + (legs_index, Some(&clip)), + (torso_index, torso_clip.as_ref()), + ] { + let Some(leaf_clip) = leaf_clip else { + continue; + }; + let time = fresh_corpse_clip_time(age_ms, leaf_clip.length_msec()); + let mut state = slot.runtime.states()[index as usize]; + state.time = time; + state.old_time = time; + slot.runtime + .set_state(xmodel_runtime::XAnimNodeId(index), state) + .map_err(|error| error.to_string())?; + } + } clips = Some(PoseClips { clip, torso_clip, diff --git a/crates/render_anim/src/occupancy/dyn_ent_phys.rs b/crates/render_anim/src/occupancy/dyn_ent_phys.rs index 82d095f..f082f70 100644 --- a/crates/render_anim/src/occupancy/dyn_ent_phys.rs +++ b/crates/render_anim/src/occupancy/dyn_ent_phys.rs @@ -17,6 +17,10 @@ const MSEC_STEP: i32 = 17; const MAX_ITERS: i32 = 2; const CONTENTS_SOLID: u32 = 1; +const CONTENTS_ITEMCLIP: u32 = 0x400; +const WAKE_MASKS: [u32; 2] = [CONTENTS_SOLID | CONTENTS_ITEMCLIP, CONTENTS_SOLID]; +const WAKE_LIFTS: [f32; 6] = [0.0, CONTACT_SLOP, 0.25, 0.5, 1.0, 2.0]; +const WAKE_FOOTPRINTS: [f32; 3] = [1.0, 0.75, 0.5]; const SLEEP_SPEED: f32 = 4.0; const GROUND_NZ: f32 = 0.7; const CONTACT_SLOP: f32 = 0.125; @@ -56,6 +60,7 @@ struct DynEntPhysBody { maxs: [f32; 3], bounce: f32, friction: f32, + mask: u32, on_ground: bool, asleep_ms: i32, } @@ -99,6 +104,7 @@ pub fn step_phys_world0( } let (mins, maxs) = xmodel_phys_hull(catalog, &inst.current_model); let add_vel = impulse.impulse / preset.mass; + let phys_clip = clip.0.as_deref(); world .bodies .entry(entity) @@ -106,15 +112,22 @@ pub fn step_phys_world0( body.vel += add_vel; body.asleep_ms = 0; }) - .or_insert_with(|| DynEntPhysBody { - origin: transform.translation, - vel: add_vel, - mins, - maxs, - bounce: preset.bounce, - friction: preset.friction, - on_ground: false, - asleep_ms: 0, + .or_insert_with(|| { + let pose = phys_clip + .and_then(|clip| clear_wake_pose(clip, transform.translation, mins, maxs)); + let (origin, mins, maxs, mask) = + pose.unwrap_or((transform.translation, mins, maxs, CONTENTS_SOLID)); + DynEntPhysBody { + origin, + vel: add_vel, + mins, + maxs, + bounce: preset.bounce, + friction: preset.friction, + mask, + on_ground: false, + asleep_ms: 0, + } }); } @@ -185,6 +198,39 @@ pub fn step_phys_world0( } } +fn clear_wake_pose( + clip: &ClipCollision, + origin: Vec3, + mins: [f32; 3], + maxs: [f32; 3], +) -> Option<(Vec3, [f32; 3], [f32; 3], u32)> { + for mask in WAKE_MASKS { + for scale in WAKE_FOOTPRINTS { + let (mins, maxs) = narrowed_footprint(mins, maxs, scale); + for lift in WAKE_LIFTS { + let at = (origin + Vec3::Z * lift).to_array(); + let probe = clip.sweep_box(at, at, mins, maxs, mask); + if !probe.startsolid && !probe.allsolid { + return Some((Vec3::from_array(at), mins, maxs, mask)); + } + } + } + } + None +} + +fn narrowed_footprint(mins: [f32; 3], maxs: [f32; 3], scale: f32) -> ([f32; 3], [f32; 3]) { + let mut lo = mins; + let mut hi = maxs; + for axis in 0..2 { + let mid = (mins[axis] + maxs[axis]) * 0.5; + let half = (maxs[axis] - mins[axis]) * 0.5 * scale; + lo[axis] = mid - half; + hi[axis] = mid + half; + } + (lo, hi) +} + fn integrate_free(body: &mut DynEntPhysBody, dt: f32) { body.vel.z += GRAVITY_Z * dt; body.origin += body.vel * dt; @@ -200,7 +246,7 @@ fn integrate_body(body: &mut DynEntPhysBody, clip: &ClipCollision, dt: f32) { end.to_array(), body.mins, body.maxs, - CONTENTS_SOLID, + body.mask, ); if hit.allsolid || hit.startsolid { body.vel = Vec3::ZERO; diff --git a/crates/render_anim/src/occupancy/remote_body.rs b/crates/render_anim/src/occupancy/remote_body.rs index 07fcdb8..48005ce 100644 --- a/crates/render_anim/src/occupancy/remote_body.rs +++ b/crates/render_anim/src/occupancy/remote_body.rs @@ -3,10 +3,10 @@ use crate::anim::remote_body::{ RemoteBodySkinnedItem, RemoteBodySkinnedQueue, RemoteBodyTrees, RemoteModelLods, RemoteSkinModels, RemoteSkinPoseHashes, SkinAfterPose, WorldGunGap, advance_remote_tree, bind_remote_skin_models, clone_corpse_tree_from_victim, commit_assembled_body, - ensure_remote_dobj, hash_skin_matrices, occupy_lod_byte, occupy_remote_kit_dobj, packed_anim, - pose_remote_dobj, push_cached_surfaces, radii, remote_player_controller, select_remote_lods, - select_remote_models, skin_after_pose, skin_slot_need, skip_frozen_corpse_dobj, - take_unique_geom, validate_remote_tracks, zero_anim, + corpse_occupation_key, ensure_remote_dobj, hash_skin_matrices, occupy_lod_byte, + occupy_remote_kit_dobj, packed_anim, pose_remote_dobj, push_cached_surfaces, radii, + remote_player_controller, select_remote_lods, select_remote_models, skin_after_pose, + skin_slot_need, skip_frozen_corpse_dobj, take_unique_geom, validate_remote_tracks, zero_anim, }; use crate::anim::scene_submission::{AnimDObjSceneSkels, AnimDObjSceneSubmission, AnimSceneSubmit}; use crate::anim::world_weapon::PreparedItemCompositions; @@ -52,6 +52,26 @@ pub struct RemotePlayer { pub shield: Option, } +fn drawn_shield( + snapshot: &sim::Snapshot, + number: u16, + client: sim::ClientId, +) -> Option { + let body = snapshot + .meta + .corpses + .slots + .iter() + .find(|slot| slot.occupied && slot.entnum == i32::from(number)); + match body { + Some(body) => body.shield, + None => snapshot + .meta + .for_client(client) + .and_then(|meta| meta.shield), + } +} + #[derive(Component, Default)] pub struct RemoteFxBolts { pub flash: Option, @@ -384,7 +404,9 @@ fn occupy_remote_scene_ents( if !soldier.owns_bodies(&bodies.0) { continue; } - let shield = meta.and_then(|meta| meta.shield); + let shield = presented + .snapshot() + .and_then(|snap| drawn_shield(snap, identity.number(), client)); let held = remote_pose_sample(runtime).weapon; let (kit_models, radius, hide_part_bits) = match live_kits.filter(|_| shield.is_none()) { Some((kits, _world)) => { @@ -556,7 +578,9 @@ fn sync_remote_bodies( let marker = RemotePlayer { ffa_team, client_state_team, - shield: meta.and_then(|meta| meta.shield), + shield: presented + .snapshot() + .and_then(|snap| drawn_shield(snap, identity.number(), client)), }; if remote.is_some() { commands.entity(entity).insert((pose, marker)); @@ -868,16 +892,13 @@ impl<'a> RemotePoseFrame<'a> { let persist_key = u32::from(identity.number()); let is_corpse = runtime.next_state.e_type == ET_PLAYER_CORPSE || runtime.pose_e_type == ET_PLAYER_CORPSE as u8; - let occupation_tr_time = is_corpse.then_some(runtime.next_state.tr_time); - if is_corpse { + let occupation_key = is_corpse.then(|| corpse_occupation_key(&runtime.next_state)); + if let Some(key) = occupation_key { let victim = u32::try_from(runtime.next_state.client_num).unwrap_or(0); - clone_corpse_tree_from_victim( - self.trees, - persist_key, - victim, - runtime.next_state.tr_time, - ); + clone_corpse_tree_from_victim(self.trees, persist_key, victim, key); } + let corpse_age_ms = + occupation_key.map(|key| runtime.pose_time_ms.wrapping_sub(key.spawn_time_ms)); if runtime.previous_pose.is_none() { if let Some(slot) = self.trees.get_mut(persist_key) { @@ -955,6 +976,7 @@ impl<'a> RemotePoseFrame<'a> { dt, sample.rate_origin, sample.rate_time_ms, + corpse_age_ms, trees, )?; @@ -1051,11 +1073,11 @@ impl<'a> RemotePoseFrame<'a> { } Ok(PoseOneOutcome::Posed) })(); - if let Some(tr_time) = occupation_tr_time + if let Some(key) = occupation_key && let Some(slot) = trees.get_mut(persist_key) - && slot.occupation_tr_time.is_none() + && slot.occupation_key.is_none() { - slot.occupation_tr_time = Some(tr_time); + slot.occupation_key = Some(key); } result } diff --git a/crates/render_anim/src/occupancy/third_person.rs b/crates/render_anim/src/occupancy/third_person.rs index c3c3406..c8ccf47 100644 --- a/crates/render_anim/src/occupancy/third_person.rs +++ b/crates/render_anim/src/occupancy/third_person.rs @@ -1,11 +1,11 @@ use asset_world::ClipCollision; -use net::PresentedSnapshot; +use net::{AUTHORITY_MS, PresentedProjectile, PresentedSnapshot}; use playerstate_iw4::{ CG_CAMERA_PULLBACK_BOX_HALF, CG_CAMERA_PULLBACK_CLIPMASK, CG_THIRD_PERSON_RANGE_DEFAULT, KillCamMode, OffsetThirdPersonViewInputs, ThirdPersonViewInputs, is_third_person_view, offset_third_person_view, }; -use sim::ClientId; +use sim::{ClientId, LifeSequence, ProjectileId, RemoteMissile}; use render_scene::WorldCameraPose; @@ -29,7 +29,7 @@ pub fn presented_is_third_person( if in_killcam && ps.kill_cam_entity != playerstate_iw4::ENTITYNUM_NONE { return true; } - if remote_missile_camera(presented, local, 0).is_some() { + if remote_missile_link(presented, local).is_some() { return true; } is_third_person_view(ThirdPersonViewInputs { @@ -42,25 +42,160 @@ pub fn presented_is_third_person( }) } -pub fn remote_missile_camera( +fn remote_missile_link( presented: &PresentedSnapshot, local: ClientId, +) -> Option<(RemoteMissile, LifeSequence)> { + let meta = presented.snapshot()?.meta.for_client(local)?; + Some((meta.remote_missile?, meta.life_sequence)) +} + +// The missile leaves the snapshot on impact, before the link goes and the +// static starts. The last pose is held until then so that neither the +// player's own view nor a black frame shows in between. +#[derive(Default)] +pub struct RemoteMissileView { + held: Option, + turn: MissileTurn, +} + +#[derive(Clone, Copy, Debug)] +struct HeldMissileView { + life: LifeSequence, + projectile: ProjectileId, + pose: WorldCameraPose, +} + +const TURN_SAMPLES: usize = 4; + +#[derive(Default)] +struct MissileTurn { + missile: Option<(LifeSequence, ProjectileId)>, + archived: bool, + samples: Vec<(i32, [f32; 3])>, +} + +impl MissileTurn { + fn record( + &mut self, + missile: (LifeSequence, ProjectileId), + archived: bool, + snapshot_ms: i32, + angles: [f32; 3], + ) { + // The final killcam of the pilot's own kill replays the same missile + // of the same life from seat snapshots stamped with the live tick, + // later than every sample of the flight, so a switch between live and + // archived presentation starts a fresh history. + if self.missile != Some(missile) || self.archived != archived { + self.missile = Some(missile); + self.archived = archived; + self.samples.clear(); + } + match self.samples.last_mut() { + Some(last) if last.0 == snapshot_ms => last.1 = angles, + Some(last) if last.0 > snapshot_ms => { + self.samples.clear(); + self.samples.push((snapshot_ms, angles)); + } + _ => { + if self.samples.len() == TURN_SAMPLES { + self.samples.remove(0); + } + self.samples.push((snapshot_ms, angles)); + } + } + } + + fn angles_at(&self, at_time: i32) -> Option<[f32; 3]> { + let shown = at_time.saturating_sub(AUTHORITY_MS); + let &(first_ms, first) = self.samples.first()?; + if shown <= first_ms { + return Some(first); + } + let Some(pair) = self.samples.windows(2).find(|pair| shown < pair[1].0) else { + return self.samples.last().map(|&(_, newest)| newest); + }; + let ((from_ms, from), (to_ms, to)) = (pair[0], pair[1]); + let fraction = (shown - from_ms) as f32 / (to_ms - from_ms) as f32; + Some(std::array::from_fn(|i| { + from[i] + math_iw4::angle_subtract(to[i], from[i]) * fraction + })) + } +} + +impl RemoteMissileView { + pub fn camera( + &mut self, + presented: &PresentedSnapshot, + local: ClientId, + render_time: i32, + clip: Option<&ClipCollision>, + ) -> Option { + let at_time = presented.trajectory_time_ms(render_time); + let Some((link, life)) = remote_missile_link(presented, local) else { + self.held = None; + self.turn = MissileTurn::default(); + return None; + }; + self.held = self + .held + .filter(|held| held.life == life && held.projectile == link.projectile); + let flying = link + .unlink_at_ms + .is_none() + .then(|| { + presented + .presented_projectiles() + .iter() + .find(|p| p.authoritative_id() == Some(link.projectile)) + }) + .flatten(); + let snapshot_ms = presented.tick().map_or(at_time, sim::level_time_ms); + if let Some(missile) = flying { + self.turn.record( + (life, link.projectile), + presented.is_archived_replay(), + snapshot_ms, + link.angles, + ); + let pose = WorldCameraPose { + origin: missile_eye(missile, snapshot_ms, at_time, clip), + angles: self.turn.angles_at(at_time).unwrap_or(link.angles), + }; + self.held = Some(HeldMissileView { + life, + projectile: link.projectile, + pose, + }); + } + self.held.map(|held| held.pose) + } +} + +fn missile_eye( + missile: &PresentedProjectile, + snapshot_ms: i32, at_time: i32, -) -> Option { - let link = presented - .snapshot()? - .meta - .for_client(local)? - .remote_missile - .filter(|link| link.unlink_at_ms.is_none())?; - let missile = presented - .presented_projectiles() - .iter() - .find(|p| p.authoritative_id() == Some(link.projectile))?; - Some(WorldCameraPose { - origin: missile.origin_at(at_time), - angles: link.angles, - }) + clip: Option<&ClipCollision>, +) -> [f32; 3] { + let known = missile.origin_at(at_time.min(snapshot_ms)); + if at_time <= snapshot_ms { + return known; + } + let ahead = missile.origin_at(at_time.min(snapshot_ms.saturating_add(AUTHORITY_MS))); + let fraction = clip.map_or(1.0, |clip| { + let half = CG_CAMERA_PULLBACK_BOX_HALF; + let hit = clip.sweep_box( + known, + ahead, + [-half; 3], + [half; 3], + CG_CAMERA_PULLBACK_CLIPMASK, + ); + if hit.allsolid { 0.0 } else { hit.fraction } + }); + std::array::from_fn(|i| known[i] + (ahead[i] - known[i]) * fraction.clamp(0.0, 1.0)) } pub fn linked_weapon_camera( diff --git a/crates/render_anim/src/occupancy/view_kick.rs b/crates/render_anim/src/occupancy/view_kick.rs index 59d5984..5b81a10 100644 --- a/crates/render_anim/src/occupancy/view_kick.rs +++ b/crates/render_anim/src/occupancy/view_kick.rs @@ -21,7 +21,7 @@ use crate::anim::view_kick_state::{KickParams, ViewKickState, add_kick_to_viewan use crate::anim::view_sway::ViewSwayState; use crate::occupancy::remote_body::RemotePlayer; use crate::occupancy::third_person::{ - linked_weapon_camera, presented_is_third_person, remote_missile_camera, third_person_camera, + RemoteMissileView, linked_weapon_camera, presented_is_third_person, third_person_camera, }; use render_scene::{FlyCamera, FpvLens, SimCamera, transform_from_iw_view}; use render_scene::{WorldCameraPose, WorldScriptModelInstance}; @@ -228,6 +228,7 @@ pub fn sync_camera_from_presented( mut lenses: Query<&mut Projection, With>, view: Res, death_cam_clip: Res, + mut missile_view: Local, ) { kick.killcam_focus_distance = None; if let Some(view) = sim_cam.view_override { @@ -283,7 +284,12 @@ pub fn sync_camera_from_presented( kick.killcam_focus_distance = focus_distance; return; } - if let Some(pose) = remote_missile_camera(&presented, local.0, clock.time()) { + if let Some(pose) = missile_view.camera( + &presented, + local.0, + clock.time(), + death_cam_clip.0.as_deref(), + ) { let pose = earthquake_pose(pose, &presented, clock.time()); let eye = transform_from_iw_view(pose); for mut transform in &mut q { diff --git a/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs b/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs index 3b3709b..fd1fa7b 100644 --- a/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs +++ b/crates/render_frontend/src/adapters/anim/dyn_ent_wake.rs @@ -52,6 +52,14 @@ pub struct DynEntWakeBroadphase { pub query_ms: f32, } +#[derive(Clone, Copy, Debug, Message)] +pub struct DynEntPelletHit { + pub world: frame::WorldStamp, + pub weapon: u32, + pub start: Vec3, + pub end: Vec3, +} + #[derive(Clone, Copy, Debug)] struct DynEntWakeEntry { entity: Entity, @@ -787,6 +795,59 @@ pub(crate) fn on_entity_bullet_hit( } } +#[allow(clippy::too_many_arguments)] +pub(crate) fn hit_dyn_ents_from_pellets( + mut hits: MessageReader, + weapons: Option>, + catalog: Option>, + mut broadphase: ResMut, + mut instances: Query< + ( + Entity, + &mut WorldDynEntInstance, + &Transform, + &mut Visibility, + ), + With, + >, + mut impulses: MessageWriter, + mut phys: ResMut, + mut host: Option>, + fx: Option>, + scene: Option>, + entity_marks: Res, +) { + let catalog = catalog.as_deref(); + let fx = fx.as_deref(); + let scene = scene.as_deref(); + let marks = entity_marks.as_ref(); + for hit in hits.read() { + let Some((entity, pose, bullet_scale)) = + closest_segment_hit(hit.start, hit.end, catalog, &mut broadphase, &instances) + else { + continue; + }; + let weapons = weapons + .as_deref() + .and_then(|weapons| weapons.for_event(hit.world).ok()); + hit_one_dyn_ent( + entity, + pose, + hit.start, + hit.end, + bullet_scale, + weapon_hit_damage(weapons.as_ref(), hit.weapon), + &mut instances, + &mut impulses, + phys.as_mut(), + host.as_deref_mut(), + fx, + scene, + marks, + ); + } +} + pub(crate) fn on_entity_event_sound( sound: On, weapons: Option>, @@ -917,6 +978,7 @@ fn on_physics_sphere( pub fn register_dyn_ent_wake(app: &mut App) { app.scoped::(frame::MatchScope::Live) + .scoped_message::(frame::MatchScope::Live) .add_observer(on_entity_explosion) .add_observer(on_physics_sphere) .add_observer(on_entity_bullet_hit) @@ -927,6 +989,12 @@ pub fn register_dyn_ent_wake(app: &mut App) { .in_set(frame::InMatch) .in_set(net::ClientSet::Receive), ) + .add_systems( + Update, + hit_dyn_ents_from_pellets + .in_set(frame::InMatch) + .in_set(net::ClientSet::Present), + ) .add_systems( Update, wake_player_overlap diff --git a/crates/render_frontend/src/adapters/fx/entity_mark.rs b/crates/render_frontend/src/adapters/fx/entity_mark.rs index 9733c39..4cb25e7 100644 --- a/crates/render_frontend/src/adapters/fx/entity_mark.rs +++ b/crates/render_frontend/src/adapters/fx/entity_mark.rs @@ -47,6 +47,11 @@ pub(crate) fn generate( models: &asset_world::MapXModelSceneCatalog, ) { let mut state = marks.lock(); + state.attached.retain(|a| { + host.marks + .constructed(a.handle) + .is_some_and(|m| m.context as u8 == 3 && (m.context >> 16) as u16 == a.entity) + }); let pending = std::mem::take(&mut state.pending); let before = state.unsupported_triangles; let before_receivers = state.unsupported_receivers; @@ -378,6 +383,7 @@ fn stage( host.marks .set_material_name(handle, Some(&request.material)); host.note_models_go_fire(); + state.attached.retain(|a| a.handle != handle); state.attached.push(EntityMarkAttachment { entity: request.entity, model: model.to_string(), diff --git a/crates/render_frontend/src/adapters/fx/system.rs b/crates/render_frontend/src/adapters/fx/system.rs index 22e3b8a..b720e69 100644 --- a/crates/render_frontend/src/adapters/fx/system.rs +++ b/crates/render_frontend/src/adapters/fx/system.rs @@ -707,7 +707,7 @@ fn commit_fx_transaction( light.origin, light.axis[0], light.radius, - light.color_bgr, + light.color_rgb, spot_cone, )), Err(lighting_iw4::AddOmniLightRefuse::Cap) => { @@ -725,7 +725,7 @@ fn commit_fx_transaction( Ok(()) => env.dlights.scene.push(lighting_iw4::omni_light_pack( light.origin, light.radius, - light.color_bgr, + light.color_rgb, )), Err(lighting_iw4::AddOmniLightRefuse::Cap) => { env.dlights.cap_full = env.dlights.cap_full.saturating_add(1); @@ -3261,12 +3261,13 @@ fn drain_pellet_fx( tracers: Option>, mut tracer_world: ResMut, mut gate: ResMut, - (local, generation, timeline, mut occurrences, verdicts): ( + (local, generation, timeline, mut occurrences, verdicts, mut dyn_ent_hits): ( Res, Res, Res, ResMut, Res, + MessageWriter, ), mut host: ResMut, mut cursor: ResMut, @@ -3280,6 +3281,14 @@ fn drain_pellet_fx( if world != generation.stamp() { continue; } + if domain != net::EntityEventDomain::Predicted && record.normal != [0.0; 3] { + dyn_ent_hits.write(crate::adapters::anim::dyn_ent_wake::DynEntPelletHit { + world, + weapon: record.weapon, + start: Vec3::from_array(record.start), + end: Vec3::from_array(record.end), + }); + } let now = host.0.msec_now; let request = FireFxRequest { world, diff --git a/crates/render_fx/src/present.rs b/crates/render_fx/src/present.rs index 8178b88..bedb2a2 100644 --- a/crates/render_fx/src/present.rs +++ b/crates/render_fx/src/present.rs @@ -12,16 +12,17 @@ use fx::{ FxModelInstance, FxPackedLightingSrc, FxPlayPose, FxPlayRequest, FxSparkDrawQuery, FxSparkFillQuery, FxSparkFillVisual, FxSpriteInstance, FxSystemHost, FxTrailCollideHit, FxTrailDrawDef, FxTrailSampleVisual, PendingCollide, PlayResult, axis_from_hit_normal, - evaluate_elem_collide_motion, evaluate_elem_motion, generate_verts_with_trails, play_bolted, - play_oriented, spawn_impact_or_death_effect, spawn_looping_partial, spawn_oriented, - update as fx_update, + evaluate_elem_collide_motion, evaluate_elem_motion, evaluate_elem_physics_motion, + generate_verts_with_trails, play_bolted, play_oriented, spawn_impact_or_death_effect, + spawn_looping_partial, spawn_oriented, update as fx_update, }; use fx_iw4::{ - FX_ELEM_ATLAS_OFF, FX_ELEM_ATLAS_SIZE, FxElemType, FxRandomChannel, apply_lighting_frac_bgra, - build_cloud, clamp_elem_rotation_time, cull_cloud, cull_elem_light, - effect_def_needs_lighting_sample, elem_light_color_bgr, elem_uses_lighting_frac, + FX_ELEM_AT_REST_NONE, FX_ELEM_ATLAS_OFF, FX_ELEM_ATLAS_SIZE, FxElemType, FxRandomChannel, + apply_lighting_frac_bgra, build_cloud, clamp_elem_rotation_time, cull_cloud, cull_elem_light, + effect_def_needs_lighting_sample, elem_light_color_rgb, elem_uses_lighting_frac, evaluate_rotation_total, evaluate_scale, evaluate_size0, evaluate_size1, get_elem_angles_axis, - get_velocity_at_time, sample_f32, sprite_atlas_uv, vec3_normalize, vis_blocker_add_prepared, + get_velocity_at_time, msec_for_sampling_axis, sample_f32, sprite_atlas_uv, vec3_normalize, + vis_blocker_add_prepared, }; use math_iw4::angle_vectors; use sim::SimWorld; @@ -658,6 +659,14 @@ fn eval_pending_collide( let Some(elem) = effect.elems.get(q.def_index as usize) else { return out; }; + if fx_iw4::elem_skips_position_update(elem.view.elem_type, elem.view.flags) { + let Some(world) = clip_world else { + out.gap = Some(q.def_index); + return out; + }; + out.result = Some(evaluate_physics_piece(elem, world, q)); + return out; + } if fx_iw4::elem_uses_collision(elem.view.flags) { let Some(world) = clip_world else { out.gap = Some(q.def_index); @@ -673,6 +682,7 @@ fn eval_pending_collide( elem.view.coll_maxs, elem.view.use_item_clip != 0, !elem.effect_on_impact.is_absent(), + elem.view.elem_type == FxElemType::Model as u8, elem.vel_graph_local.as_slice(), elem.vel_graph_world.as_slice(), q, @@ -699,6 +709,29 @@ fn eval_pending_collide( out } +fn evaluate_physics_piece( + elem: &asset_game::OwnedFxElemDef, + world: &SimWorld, + q: fx::FxElemMotionQuery<'_>, +) -> FxElemMotionResult { + evaluate_elem_physics_motion( + elem.view.flags, + elem.view.use_item_clip != 0, + elem.vel_graph_local.as_slice(), + elem.vel_graph_world.as_slice(), + q, + |start, end, mins, maxs, mask| { + let t = world.trace_world(start, end, mins, maxs, mask); + FxElemTraceHit { + fraction: t.fraction, + normal: t.normal, + startsolid: t.startsolid != 0, + allsolid: t.allsolid != 0, + } + }, + ) +} + const COLLIDE_JOBS_PER_WORKER: usize = 16; fn collide_worker_count(jobs: usize) -> usize { @@ -830,6 +863,13 @@ fn tick_fx_pass( let out = (|| { let effect = catalog_lookup(catalog, q.catalog_index)?; let elem = effect.elems.get(q.def_index as usize)?; + if fx_iw4::elem_skips_position_update(elem.view.elem_type, elem.view.flags) { + let Some(world) = clip_world else { + collide_gap_indices.borrow_mut().push(q.def_index); + return None; + }; + return Some(evaluate_physics_piece(elem, world, q)); + } if fx_iw4::elem_uses_collision(elem.view.flags) { let Some(world) = clip_world else { collide_gap_indices.borrow_mut().push(q.def_index); @@ -845,6 +885,7 @@ fn tick_fx_pass( elem.view.coll_maxs, elem.view.use_item_clip != 0, !elem.effect_on_impact.is_absent(), + elem.view.elem_type == FxElemType::Model as u8, elem.vel_graph_local.as_slice(), elem.vel_graph_world.as_slice(), q, @@ -1467,7 +1508,7 @@ pub fn build_fx_verts( is_spot: ctx.elem_type == FxElemType::SpotLight as u8, origin: ctx.origin, radius: size0, - color_bgr: elem_light_color_bgr(color, scale), + color_rgb: elem_light_color_rgb(color, scale), axis, }) }; @@ -1521,24 +1562,36 @@ fn evaluate_fx_model_instance( if scale == 0.0 { return None; } - let rot_t = clamp_elem_rotation_time( - ctx.age_msec as f32, - ctx.life_msec as f32, - ctx.at_rest_fraction, - ); - let axis = get_elem_angles_axis( + let landed_piece = ctx.at_rest_fraction != FX_ELEM_AT_REST_NONE + && fx_iw4::elem_skips_position_update(elem.view.elem_type, elem.view.flags); + let rot_t = if landed_piece { + msec_for_sampling_axis(ctx.at_rest_fraction, ctx.life_msec as f32).min(ctx.age_msec as f32) + } else { + clamp_elem_rotation_time( + ctx.age_msec as f32, + ctx.life_msec as f32, + ctx.at_rest_fraction, + ) + }; + let mut axis = get_elem_angles_axis( elem.view.spawn_angles, elem.view.angular_velocity, ctx.elem_random_seed, rot_t, ctx.axis, ); + let mut origin = ctx.origin; + if landed_piece && let Some(piece) = elem.phys_piece(ctx.elem_random_seed) { + let (flat, lift) = piece.rest_pose(axis, scale); + axis = flat; + origin[2] += lift; + } Some(FxModelInstance { def_name: ctx.def_name.to_owned(), def_index: ctx.def_index, model_index, elem_handle: ctx.elem_handle, - origin: ctx.origin, + origin, axis, scale, flags: ctx.flags, diff --git a/crates/sim/src/bullet_collision.rs b/crates/sim/src/bullet_collision.rs index 4fa21ac..daff3d6 100644 --- a/crates/sim/src/bullet_collision.rs +++ b/crates/sim/src/bullet_collision.rs @@ -9,6 +9,8 @@ use weapon_iw4::{ pub const CONTENTS_SOLID: u32 = 0x0000_0001; +pub const CONTENTS_SKY: u32 = 0x0000_0800; + pub const MASK_PLAYER_SOLID: u32 = 0x0281_0011; pub const MASK_SHOT: u32 = 0x0280_6831; diff --git a/crates/sim/src/corpse.rs b/crates/sim/src/corpse.rs index 4a960a8..292e930 100644 --- a/crates/sim/src/corpse.rs +++ b/crates/sim/src/corpse.rs @@ -1,6 +1,8 @@ use crate::frame::FrameWorld; use crate::world::ClientId; -use entity_iw4::{TR_GRAVITY, TR_INTERPOLATE, Trajectory, evaluate_trajectory}; +use entity_iw4::{ + TR_GRAVITY, TR_INTERPOLATE, Trajectory, evaluate_trajectory, evaluate_trajectory_delta, +}; use playerstate_iw4::{ AnimPair, ENTITYNUM_NONE, LINK_FLAGS_WEAPON_VIEW_ONLY, MAX_CLIENT_CORPSES, PLAYER_CORPSE_ENTITY_BASE, PlayerState, @@ -34,6 +36,10 @@ pub struct PlayerCorpseSlot { pub falling: bool, pub ground_entity_num: i32, + + pub spawn_time_ms: i32, + + pub shield: Option, } impl Default for PlayerCorpseSlot { @@ -55,6 +61,8 @@ impl Default for PlayerCorpseSlot { tr_base: [0.0; 3], falling: false, ground_entity_num: ENTITYNUM_NONE, + spawn_time_ms: 0, + shield: None, } } } @@ -155,6 +163,10 @@ pub(crate) fn occupy_player_clone( ps: &PlayerState, time_ms: i32, ) -> u8 { + let shield = world + .client_meta(victim) + .and_then(|meta| meta.shield) + .filter(|shield| shield.on_back); let pool = world.corpses_mut(); let entnum = pool.alloc_clone_entity(); let slot = pool.get_free(ps.origin); @@ -176,6 +188,8 @@ pub(crate) fn occupy_player_clone( tr_delta: clamp_clone_tr_delta(ps.velocity), tr_base: ps.origin, falling: true, + spawn_time_ms: time_ms, + shield, ..PlayerCorpseSlot::default() }, ); @@ -209,11 +223,8 @@ fn corpse_land_angles(yaw: f32, normal: [f32; 3]) -> [f32; 3] { } fn clamp_clone_tr_delta(mut delta: [f32; 3]) -> [f32; 3] { - if delta[0] > G_CLONE_PLAYER_MAX_VELOCITY { - delta[0] = G_CLONE_PLAYER_MAX_VELOCITY; - } - if delta[1] > G_CLONE_PLAYER_MAX_VELOCITY { - delta[1] = G_CLONE_PLAYER_MAX_VELOCITY; + for speed in &mut delta[..2] { + *speed = speed.clamp(-G_CLONE_PLAYER_MAX_VELOCITY, G_CLONE_PLAYER_MAX_VELOCITY); } delta } @@ -224,7 +235,9 @@ const CORPSE_ANIM_AXIS_Y_SCALE: f32 = -1.0; const CORPSE_GROUND_PROBE_DROP: f32 = 1.0; -const CORPSE_ANIM_DELTA_TO_VELOCITY: f32 = 20.0; +const CORPSE_STEP_DOWN: f32 = 18.0; + +const CORPSE_WALL_SLIDE_GAP: f32 = 0.125; const CORPSE_UNSTICK_Z_LIFT: f32 = 32.0; @@ -245,109 +258,210 @@ pub(crate) fn phase_run_corpse_move(world: &mut FrameWorld, time_ms: i32) { if !slot.occupied { continue; } - let ragdoll = is_ragdoll_tr_type(slot.tr_type); - - let anim_delta = world - .corpse_dobj_tree_delta(slot.entnum, crate::MATCH_TICK_MS as i32) - .filter(|d| { - d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > CORPSE_ANIM_DELTA_LEN_SQ_MIN - && (!slot.falling || !ragdoll) - }); - if !slot.falling && anim_delta.is_none() { - continue; - } - - let mut desired = evaluate_trajectory(&slot.trajectory(), time_ms); - let world_delta = anim_delta.map(|delta| corpse_anim_world_delta(slot.viewangles, delta)); - if let Some(world_delta) = world_delta { - for axis in 0..3 { - desired[axis] += world_delta[axis]; - } - } - - let hit = world.trace_clip( - slot.origin, - desired, - PLAYER_MINS, - PLAYER_MAXS, - MASK_PLAYER_SOLID, - ); - let mut fraction = hit.fraction; - let endpos = if fraction >= 1.0 { desired } else { hit.endpos }; - world.corpses_mut().slots[i].origin = endpos; - if hit.startsolid != 0 { - fraction = 0.0; - } - - if fraction >= 1.0 { - let Some(world_delta) = world_delta else { - continue; - }; - - { - let slot = &mut world.corpses_mut().slots[i]; - slot.tr_type = if ragdoll { - TR_RAGDOLL_SETTLED - } else { - TR_INTERPOLATE - }; - slot.tr_base = endpos; - slot.tr_time = 0; - slot.tr_duration = 0; - slot.tr_delta = [0.0; 3]; - } - if ragdoll { - let mut probe = desired; - probe[2] -= CORPSE_GROUND_PROBE_DROP; - let ground = - world.trace_clip(endpos, probe, PLAYER_MINS, PLAYER_MAXS, MASK_PLAYER_SOLID); - if ground.fraction < 1.0 || ground.startsolid != 0 { - let slot = &mut world.corpses_mut().slots[i]; - slot.falling = true; - let mut vel = slot.tr_delta; - for axis in 0..3 { - vel[axis] = (vel[axis] + world_delta[axis]) * CORPSE_ANIM_DELTA_TO_VELOCITY; - } - slot.tr_delta = vel; - slot.tr_type = TR_BOUNCE_GRAVITY; - slot.tr_time = time_ms; - slot.tr_duration = 0; - continue; - } - } - world.corpses_mut().slots[i].falling = false; - continue; - } - - if !slot.falling { - continue; - } - - if hit.allsolid != 0 { - let mut lifted = world.corpses().slots[i].origin; - lifted[2] += CORPSE_UNSTICK_Z_LIFT; - let retry = world.trace_clip( - lifted, - desired, - PLAYER_MINS, - PLAYER_MAXS, - MASK_PLAYER_SOLID & !CORPSE_UNSTICK_CLIPMASK_DROP, - ); - if retry.allsolid == 0 { - let f = retry.fraction; - world.corpses_mut().slots[i].origin = [ - lifted[0] + (desired[0] - lifted[0]) * f, - lifted[1] + (desired[1] - lifted[1]) * f, - lifted[2] + (desired[2] - lifted[2]) * f, - ]; - } - } - corpse_land(world, i, hit, time_ms); + let anim_delta = world.corpse_dobj_tree_delta(slot.entnum, crate::MATCH_TICK_MS as i32); + let frame = &*world; + let stepped = step_corpse(slot, anim_delta, time_ms, |start, end, mask| { + frame.trace_clip(start, end, PLAYER_MINS, PLAYER_MAXS, mask) + }); + world.corpses_mut().slots[i] = stepped; } let live = world.corpses().entnums(); world.corpse_dobj_tree_retain(&live); } +fn step_corpse( + mut slot: PlayerCorpseSlot, + anim_delta: Option<[f32; 3]>, + time_ms: i32, + mut trace: impl FnMut([f32; 3], [f32; 3], u32) -> trace_iw4::Trace, +) -> PlayerCorpseSlot { + let was_falling = slot.falling; + let ragdoll = is_ragdoll_tr_type(slot.tr_type); + let anim_delta = anim_delta.filter(|d| { + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > CORPSE_ANIM_DELTA_LEN_SQ_MIN + && (!was_falling || !ragdoll) + }); + if !was_falling && anim_delta.is_none() { + return slot; + } + let world_delta = anim_delta.map(|delta| { + let step = corpse_anim_world_delta(slot.viewangles, delta); + if was_falling { + step + } else { + [step[0], step[1], 0.0] + } + }); + + let mut desired = evaluate_trajectory(&slot.trajectory(), time_ms); + if let Some(step) = world_delta { + for axis in 0..3 { + desired[axis] += step[axis]; + } + } + let hit = trace(slot.origin, desired, MASK_PLAYER_SOLID); + let endpos = if hit.fraction >= 1.0 { + desired + } else { + hit.endpos + }; + slot.origin = endpos; + + if hit.fraction >= 1.0 && hit.startsolid == 0 { + let Some(step) = world_delta else { + return slot; + }; + if was_falling { + for (base, step) in slot.tr_base.iter_mut().zip(step) { + *base += step; + } + return slot; + } + slot.tr_type = if ragdoll { + TR_RAGDOLL_SETTLED + } else { + TR_INTERPOLATE + }; + slot.tr_base = endpos; + slot.tr_time = 0; + slot.tr_duration = 0; + slot.tr_delta = [0.0; 3]; + follow_ground_or_fall(&mut slot, time_ms, ragdoll, &mut trace); + return slot; + } + + if !was_falling { + slot.tr_base = endpos; + if hit.fraction > 0.0 && hit.startsolid == 0 { + follow_ground_or_fall(&mut slot, time_ms, ragdoll, &mut trace); + } + return slot; + } + + if hit.allsolid != 0 { + let mut lifted = slot.origin; + lifted[2] += CORPSE_UNSTICK_Z_LIFT; + let retry = trace( + lifted, + desired, + MASK_PLAYER_SOLID & !CORPSE_UNSTICK_CLIPMASK_DROP, + ); + if retry.allsolid == 0 { + let f = retry.fraction; + slot.origin = [ + lifted[0] + (desired[0] - lifted[0]) * f, + lifted[1] + (desired[1] - lifted[1]) * f, + lifted[2] + (desired[2] - lifted[2]) * f, + ]; + } + land_corpse(&mut slot, &hit); + return slot; + } + if hit.normal[2] <= 0.0 { + if hit.startsolid != 0 { + for axis in 0..3 { + slot.origin[axis] += hit.normal[axis]; + } + slot.tr_base = slot.origin; + slot.tr_time = time_ms; + slot.tr_delta = [0.0; 3]; + return slot; + } + slide_down_wall(&mut slot, &hit, desired, time_ms, &mut trace); + return slot; + } + land_corpse(&mut slot, &hit); + slot +} + +fn follow_ground_or_fall( + slot: &mut PlayerCorpseSlot, + time_ms: i32, + ragdoll: bool, + trace: &mut impl FnMut([f32; 3], [f32; 3], u32) -> trace_iw4::Trace, +) { + let at = slot.origin; + let mut probe = at; + probe[2] -= CORPSE_GROUND_PROBE_DROP; + let ground = trace(at, probe, MASK_PLAYER_SOLID); + if ground.fraction < 1.0 || ground.startsolid != 0 { + slot.falling = false; + return; + } + let mut step_probe = at; + step_probe[2] -= CORPSE_STEP_DOWN; + let step = trace(at, step_probe, MASK_PLAYER_SOLID); + if step.fraction < 1.0 && step.startsolid == 0 && step.allsolid == 0 && step.normal[2] > 0.0 { + slot.origin = step.endpos; + slot.tr_base = step.endpos; + slot.falling = false; + slot.ground_entity_num = i32::from(trace_iw4::trace_get_entity_hit_id( + step.hit_type, + step.hit_id, + )); + return; + } + slot.falling = true; + slot.tr_type = if ragdoll { + TR_BOUNCE_GRAVITY + } else { + TR_GRAVITY + }; + slot.tr_base = at; + slot.tr_time = time_ms; + slot.tr_duration = 0; + slot.tr_delta = [0.0; 3]; +} + +fn slide_down_wall( + slot: &mut PlayerCorpseSlot, + hit: &trace_iw4::Trace, + desired: [f32; 3], + time_ms: i32, + trace: &mut impl FnMut([f32; 3], [f32; 3], u32) -> trace_iw4::Trace, +) { + let n = hit.normal; + let mut start = hit.endpos; + let mut rest = [0.0f32; 3]; + for axis in 0..3 { + start[axis] += n[axis] * CORPSE_WALL_SLIDE_GAP; + rest[axis] = desired[axis] - hit.endpos[axis]; + } + let into_wall = dot(rest, n); + let mut end = start; + for axis in 0..3 { + end[axis] += rest[axis] - into_wall * n[axis]; + } + let slid = trace(start, end, MASK_PLAYER_SOLID); + if slid.allsolid == 0 && slid.startsolid == 0 { + slot.origin = if slid.fraction >= 1.0 { + end + } else { + slid.endpos + }; + if slid.fraction < 1.0 && slid.normal[2] > 0.0 { + land_corpse(slot, &slid); + return; + } + } + let speed = evaluate_trajectory_delta(&slot.trajectory(), time_ms); + let mut velocity = [0.0, 0.0, speed[2]]; + let into = dot(velocity, n); + if into < 0.0 { + for axis in 0..3 { + velocity[axis] -= into * n[axis]; + } + } + slot.tr_base = slot.origin; + slot.tr_time = time_ms; + slot.tr_duration = 0; + slot.tr_delta = velocity; +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + fn corpse_anim_world_delta(angles: [f32; 3], delta: [f32; 3]) -> [f32; 3] { let (mut fwd, right, up) = math_iw4::angle_vectors(angles); let mut left = [ @@ -364,18 +478,9 @@ fn corpse_anim_world_delta(angles: [f32; 3], delta: [f32; 3]) -> [f32; 3] { out } -fn corpse_land(world: &mut FrameWorld, i: usize, hit: trace_iw4::Trace, time_ms: i32) { - let slot = &mut world.corpses_mut().slots[i]; +fn land_corpse(slot: &mut PlayerCorpseSlot, hit: &trace_iw4::Trace) { let ragdoll = is_ragdoll_tr_type(slot.tr_type); slot.tr_delta = [0.0; 3]; - if hit.allsolid == 0 && hit.normal[2] <= 0.0 { - for axis in 0..3 { - slot.origin[axis] += hit.normal[axis]; - } - slot.tr_base = slot.origin; - slot.tr_time = time_ms; - return; - } slot.falling = false; slot.tr_type = if ragdoll { TR_RAGDOLL_SETTLED diff --git a/crates/sim/src/equipment.rs b/crates/sim/src/equipment.rs index bd802fb..14431a0 100644 --- a/crates/sim/src/equipment.rs +++ b/crates/sim/src/equipment.rs @@ -647,6 +647,8 @@ pub(crate) fn think_projectile(world: &mut FrameWorld, tick: Tick, entnum: i32) } return; } + let remote = crate::remote_missile::is_remote(world, projectile.id); + let mask = crate::remote_missile::projectile_mask(remote); let mut trace_start = start; let mut hops = 0u32; let mut hop_capped = false; @@ -665,7 +667,7 @@ pub(crate) fn think_projectile(world: &mut FrameWorld, tick: Tick, entnum: i32) &BulletTraceQuery { start: trace_start, end, - mask: MASK_BULLET_WORLD, + mask, ignore: Some(projectile.owner), ignore_hit: None, ignore_model: None, @@ -737,6 +739,22 @@ pub(crate) fn think_projectile(world: &mut FrameWorld, tick: Tick, entnum: i32) } break outcome; }; + let outcome = if remote { + let sky = crate::world::clip_trace( + brushes, + bsp, + mesh, + start, + end, + [0.0; 3], + [0.0; 3], + crate::bullet_collision::CONTENTS_SKY, + &|_| true, + ); + crate::remote_missile::sky_wall_outcome(outcome, start, &sky) + } else { + outcome + }; if world.publishes_snapshot() && projectile.live && crate::script_player::means( diff --git a/crates/sim/src/frame.rs b/crates/sim/src/frame.rs index 055b48a..b6c2f02 100644 --- a/crates/sim/src/frame.rs +++ b/crates/sim/src/frame.rs @@ -608,6 +608,8 @@ impl FrameWorld<'_> { pub fn shutdown_game(&mut self) { *self.deref_mut() = SimState::new(); clear_payload_rows(self.ecs); + self.ecs + .remove_resource::(); } } diff --git a/crates/sim/src/match_state/client_view.rs b/crates/sim/src/match_state/client_view.rs index a802bdc..db8101d 100644 --- a/crates/sim/src/match_state/client_view.rs +++ b/crates/sim/src/match_state/client_view.rs @@ -6,6 +6,7 @@ pub struct ScriptSeat { pub archive_ms: i32, pub ps_offset_ms: i32, pub length_ms: i32, + pub final_kill: bool, } impl Default for ScriptSeat { @@ -17,6 +18,7 @@ impl Default for ScriptSeat { archive_ms: 0, ps_offset_ms: 0, length_ms: 0, + final_kill: false, } } } diff --git a/crates/sim/src/remote_missile.rs b/crates/sim/src/remote_missile.rs index cbdef31..23cf438 100644 --- a/crates/sim/src/remote_missile.rs +++ b/crates/sim/src/remote_missile.rs @@ -1,6 +1,9 @@ +use crate::ProjectileId; +use crate::bullet_collision::{CONTENTS_SKY, ColliderId, MASK_BULLET_WORLD, TraceOutcome}; use crate::frame::FrameWorld; use crate::match_state::ClientLifecycle; use crate::world::{ClientId, Tick}; +use bevy_ecs::prelude::{Resource, World}; const PITCH_RANGE: [f32; 2] = [1.0, 87.0]; const PITCH_RATE: f32 = 15.0; @@ -9,6 +12,87 @@ const SPEED_RANGE: [f32; 2] = [3_000.0, 6_000.0]; const SPEED_UP: f32 = 2_000.0; const SPEED_DOWN: f32 = 500.0; +const SKY_CEILING_MIN_NORMAL_Z: f32 = 0.7; + +// The mark is kept for the missile's whole flight. The pilot's control link is +// dropped when the pilot dies, and the missile must still pass the sky ceiling then. +#[derive(Resource, Debug, Default)] +pub(crate) struct RemoteMissiles(Vec); + +pub(crate) fn mark_remote(world: &mut World, id: ProjectileId) { + let mut marked = world.get_resource_or_insert_with(RemoteMissiles::default); + if !marked.0.contains(&id) { + marked.0.push(id); + } +} + +pub(crate) fn is_remote(world: &mut FrameWorld, id: ProjectileId) -> bool { + world + .ecs() + .get_resource::() + .is_some_and(|marked| marked.0.contains(&id)) +} + +fn forget_gone_missiles(world: &mut FrameWorld) { + let marked = match world.ecs().get_resource::() { + Some(marked) if !marked.0.is_empty() => marked.0.clone(), + _ => return, + }; + let mut flying = Vec::with_capacity(marked.len()); + world.visit_projectiles(|p| { + if marked.contains(&p.id) { + flying.push(p.id); + } + }); + if flying.len() != marked.len() { + world.ecs().resource_mut::().0 = flying; + } +} + +pub(crate) fn projectile_mask(remote: bool) -> u32 { + if remote { + MASK_BULLET_WORLD & !CONTENTS_SKY + } else { + MASK_BULLET_WORLD + } +} + +pub(crate) fn sky_wall_outcome( + outcome: TraceOutcome, + start: [f32; 3], + sky: &trace_iw4::Trace, +) -> TraceOutcome { + if sky.fraction >= 1.0 + || sky.startsolid != 0 + || sky.allsolid != 0 + || sky.normal[2] > SKY_CEILING_MIN_NORMAL_Z + { + return outcome; + } + let reach = match outcome { + TraceOutcome::Hit { end, .. } | TraceOutcome::Miss { end } => distance(start, end), + TraceOutcome::StartSolid { .. } | TraceOutcome::Invalid { .. } => return outcome, + }; + if distance(start, sky.endpos) >= reach { + return outcome; + } + TraceOutcome::Hit { + fraction: sky.fraction, + end: sky.endpos, + normal: sky.normal, + collider: ColliderId::World { + surface_flags: sky.surface_flags, + contents: sky.contents, + glass_encoded: 0, + }, + } +} + +fn distance(a: [f32; 3], b: [f32; 3]) -> f32 { + let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() +} + pub(crate) fn steer( world: &mut FrameWorld, id: ClientId, @@ -49,6 +133,7 @@ pub(crate) fn advance(world: &mut FrameWorld, tick: Tick) { if !world.publishes_snapshot() { return; } + forget_gone_missiles(world); let now = crate::level_time_ms(tick); for id in world.client_ids_sorted() { let Some(mut link) = world.client_meta(id).and_then(|m| m.remote_missile) else { diff --git a/crates/sim/src/script/host/controls.rs b/crates/sim/src/script/host/controls.rs index a27f292..3097295 100644 --- a/crates/sim/src/script/host/controls.rs +++ b/crates/sim/src/script/host/controls.rs @@ -419,7 +419,7 @@ pub(crate) fn select_location( pub(crate) fn command_buttons(command: &str) -> u32 { match command { "+attack" | "mouse1" => buttons::ATTACK, - "+activate" | "+usereload" => buttons::USE, + "+activate" | "+usereload" => buttons::USE | buttons::USE_RELOAD, "+melee" => buttons::MELEE_CHARGE, "+frag" => buttons::FRAG, "+smoke" => buttons::SMOKE, diff --git a/crates/sim/src/script/host/players.rs b/crates/sim/src/script/host/players.rs index 0890db5..00d7365 100644 --- a/crates/sim/src/script/host/players.rs +++ b/crates/sim/src/script/host/players.rs @@ -1061,19 +1061,34 @@ impl PlayerSlot { } pub(crate) fn script_seats(world: &World) -> Vec<(ClientId, crate::ScriptSeat)> { - world - .resource::() + let runtime = world.resource::(); + let final_kill = level_flag(runtime, "showingfinalkillcam"); + runtime .players .iter() .filter(|(_, slot)| &*slot.sessionstate == "spectator" && slot.spectator.target.is_some()) .map(|(client, slot)| { let mut seat = slot.seat; seat.spectator_client = slot.spectator.target.unwrap() as i32; + seat.final_kill = final_kill; (ClientId(*client), seat) }) .collect() } +fn level_flag(runtime: &RoundScript, name: &str) -> bool { + let symbol = runtime + .program + .as_ref() + .and_then(|program| program.symbol_ids.get(name)) + .or_else(|| runtime.dynamic_symbols.get(name)); + match symbol.and_then(|symbol| runtime.objects.get(&0)?.get(symbol)) { + Some(Value::Int(n)) => *n != 0, + Some(Value::Float(f)) => *f != 0.0, + _ => false, + } +} + const SEAT_FIELDS: [&str; 6] = [ "forcespectatorclient", "killcamentity", diff --git a/crates/sim/src/script/host/presence.rs b/crates/sim/src/script/host/presence.rs index 5ea8178..b9d2226 100644 --- a/crates/sim/src/script/host/presence.rs +++ b/crates/sim/src/script/host/presence.rs @@ -313,25 +313,54 @@ fn present(world: &mut World, now: i32) { } } -fn tag_lookup(world: &mut World, object: u64, tag: &str) -> Option> { +fn with_tag_dobj( + world: &mut World, + object: u64, + read: impl FnOnce(&AuthorityDObjState) -> Option, +) -> Option { let presence = world .resource::() .entities .get(&object)? .presence?; let frame = settled(world); - let dobj = frame + if let Some(dobj) = frame .entity_collision_capabilities() .iter() .find(|row| row.owner.script_model() == Some(presence))? .dobj - .as_ref()?; - Some(dobj.tag_world_pose(tag).map(|(at, _)| { - dobj.world_from_model - .inverse() - .transform_point3(glam::Vec3::from(at)) - .to_array() - })) + .as_ref() + { + return read(dobj); + } + let (model, origin, angles) = { + let mut runtime = world.resource_mut::(); + ( + model_field(&mut runtime, object)?, + vector(&mut runtime, object, "origin"), + vector(&mut runtime, object, "angles"), + ) + }; + let capability = FrameWorld::from_world(world) + .model_capability(&model) + .flatten()?; + read(&AuthorityDObjState::at_pose( + &model, + Some(capability), + origin, + angles, + )) +} + +fn tag_lookup(world: &mut World, object: u64, tag: &str) -> Option> { + with_tag_dobj(world, object, |dobj| { + Some(dobj.tag_world_pose(tag).map(|(at, _)| { + dobj.world_from_model + .inverse() + .transform_point3(glam::Vec3::from(at)) + .to_array() + })) + }) } pub(crate) fn tag_world( @@ -339,19 +368,7 @@ pub(crate) fn tag_world( object: u64, tag: &str, ) -> Option<([f32; 3], [[f32; 3]; 3])> { - let presence = world - .resource::() - .entities - .get(&object)? - .presence?; - let frame = settled(world); - let matrix = frame - .entity_collision_capabilities() - .iter() - .find(|row| row.owner.script_model() == Some(presence))? - .dobj - .as_ref()? - .tag_world_matrix(tag)?; + let matrix = with_tag_dobj(world, object, |dobj| dobj.tag_world_matrix(tag))?; let axis = |v: glam::Vec4| v.truncate().normalize_or_zero().to_array(); Some(( matrix.w_axis.truncate().to_array(), diff --git a/crates/sim/src/script/host/spectators.rs b/crates/sim/src/script/host/spectators.rs index 8274fb1..20d71df 100644 --- a/crates/sim/src/script/host/spectators.rs +++ b/crates/sim/src/script/host/spectators.rs @@ -97,6 +97,32 @@ pub(crate) fn advance(world: &mut World) { update(world, true); } +pub(crate) fn hold_killcam_viewer( + world: &mut World, + client: ClientId, + cmd: &mut playerstate_iw4::UserCmd, +) { + let seated = world + .get_resource::() + .and_then(|runtime| runtime.players.get(&client.0)) + .is_some_and(|slot| { + &*slot.sessionstate == "spectator" + && slot.seat.archive_ms > 0 + && slot.spectator.target.is_some() + }); + if !seated { + return; + } + use playerstate_iw4::buttons; + let held = FrameWorld::from_world(world) + .player(client) + .map_or(0, |ps| ps.weapon); + cmd.forwardmove = 0; + cmd.rightmove = 0; + cmd.buttons &= buttons::PRONE | buttons::CROUCH | buttons::STANCE_HELD; + cmd.weapon = held as u16; +} + fn update(world: &mut World, input: bool) { let replacement = RoundScript::new(world.resource::()); let mut runtime = std::mem::replace(&mut *world.resource_mut::(), replacement); @@ -148,7 +174,9 @@ fn update(world: &mut World, input: bool) { && !archived && pressed & (playerstate_iw4::buttons::ATTACK | playerstate_iw4::buttons::ADS) != 0; if let Some(forced) = forced { - slot.spectator.target = allowed.contains(&forced).then_some(forced); + let own_replay = + archived && forced == client && targets.iter().any(|(id, ..)| *id == client); + slot.spectator.target = (own_replay || allowed.contains(&forced)).then_some(forced); slot.spectator.free = false; } else if input && pressed & playerstate_iw4::buttons::USE != 0 diff --git a/crates/sim/src/script/host/vehicles.rs b/crates/sim/src/script/host/vehicles.rs index cbf1a9a..2f107af 100644 --- a/crates/sim/src/script/host/vehicles.rs +++ b/crates/sim/src/script/host/vehicles.rs @@ -180,14 +180,45 @@ fn aim_point(world: &mut World, aim: TurretAim) -> Option<[f32; 3]> { } } -fn gunner_input(world: &mut World, client: u32) -> Option<([f32; 3], bool)> { - let mut frame = crate::frame::FrameWorld::from_world(world); - let angles = frame.player(crate::ClientId(client))?.viewangles; - let held = crate::script_player::buttons(&mut frame, crate::ClientId(client)); - Some(( - math_iw4::angle_vectors(angles).0, - held & playerstate_iw4::buttons::ATTACK != 0, - )) +fn gunner_input(world: &mut World, heli: u64, client: u32) -> Option<([f32; 3], bool)> { + let id = crate::ClientId(client); + let (eye, angles, held) = { + let mut frame = crate::frame::FrameWorld::from_world(world); + let ps = *frame.player(id)?; + let eye = frame + .client_meta(id) + .and_then(|meta| meta.linked_weapon_view) + .map_or( + [ + ps.origin[0], + ps.origin[1], + ps.origin[2] + ps.view_height_current, + ], + |view| view.origin, + ); + ( + eye, + ps.viewangles, + crate::script_player::buttons(&mut frame, id), + ) + }; + let dir = math_iw4::angle_vectors(angles).0; + let end = std::array::from_fn(|i| eye[i] + dir[i] * GUNNER_RANGE); + let ignore = super::natives::engine::TraceIgnore { + client: Some(id), + ..super::turrets::ignore_self(world, heli) + }; + let point = match super::natives::engine::entity_trace( + world, + eye, + end, + crate::bullet_collision::MASK_SHOT, + ignore, + ) { + crate::bullet_collision::TraceOutcome::Hit { end, .. } => end, + _ => end, + }; + Some((point, held & playerstate_iw4::buttons::ATTACK != 0)) } fn fire_weapon(world: &mut World, receiver: &Value, args: &[Value]) -> Result { @@ -992,7 +1023,7 @@ pub(crate) fn advance(world: &mut World) { .map_or((None, None), |heli| (heli.gunner, heli.weapon)); let control = gunner .filter(|client| world.resource::().players.contains_key(client)) - .and_then(|client| gunner_input(world, client)); + .and_then(|client| gunner_input(world, id, client)); let fire_ms = weapon .and_then(|weapon| crate::frame::FrameWorld::from_world(world).combat_facts_for(weapon)) .map_or(100, |facts| facts.fire_time_ms.max(1)); @@ -1111,10 +1142,8 @@ pub(crate) fn advance(world: &mut World) { } let [pitch, yaw, roll] = angles; match control { - Some((view, attack)) => { - heli.turret = Some(TurretAim::Point(std::array::from_fn(|i| { - next[i] + view[i] * GUNNER_RANGE - }))); + Some((point, attack)) => { + heli.turret = Some(TurretAim::Point(point)); heli.on_target = true; if attack && now >= heli.next_fire_ms { heli.next_fire_ms = now + fire_ms; diff --git a/crates/sim/src/script/host/weapons.rs b/crates/sim/src/script/host/weapons.rs index ecc27f4..5651d54 100644 --- a/crates/sim/src/script/host/weapons.rs +++ b/crates/sim/src/script/host/weapons.rs @@ -170,6 +170,7 @@ pub(crate) fn register(registry: &mut NativeRegistry) { angles, ..Default::default() }); + crate::remote_missile::mark_remote(frame.ecs(), id); } Ok(Value::Undefined) }); diff --git a/crates/sim/src/script/mod.rs b/crates/sim/src/script/mod.rs index fe256a7..749778a 100644 --- a/crates/sim/src/script/mod.rs +++ b/crates/sim/src/script/mod.rs @@ -37,6 +37,7 @@ pub(crate) use host::players::{ pub(crate) use host::presence::sync_presence; pub use host::registry::{Native, NativeRegistry}; pub(crate) use host::restart::restart_level; +pub(crate) use host::spectators::hold_killcam_viewer; pub(crate) use host::weapons::{publish_projectile_launches, sync_engine_events}; pub use ir::IR_VERSION; pub(crate) use ir::{Binary, Callee, Function, Global, Op, Unary}; diff --git a/crates/sim/src/script_player.rs b/crates/sim/src/script_player.rs index 0b1e01b..547b750 100644 --- a/crates/sim/src/script_player.rs +++ b/crates/sim/src/script_player.rs @@ -49,6 +49,7 @@ pub(crate) fn spawn( }; world.unlink_player_area(id); world.client_meta_mut(id).shield = None; + let prev_health = world.player(id).map(|p| p.health); let mut ps = spawn_player_state(origin, angles); if let Some(max) = world .client_meta(id) @@ -56,8 +57,12 @@ pub(crate) fn spawn( .filter(|&n| n > 0) { ps.max_health = max; - ps.health = max; } + ps.health = match sessionstate { + "playing" => ps.max_health, + "dead" | "intermission" => prev_health.unwrap_or(0), + _ => 0, + }; let cmd = spawn_command_angles(world, id); ps.delta_angles = std::array::from_fn(|axis| angles[axis] - cmd[axis] as f32 * SHORT2ANGLE); let prev_teleport = world diff --git a/crates/sim/src/step.rs b/crates/sim/src/step.rs index 4f09414..8ca418d 100644 --- a/crates/sim/src/step.rs +++ b/crates/sim/src/step.rs @@ -393,6 +393,7 @@ fn run_players_system(ecs: &mut World) { crate::script::player_commands(world.ecs(), id.0, cmd.buttons, old_buttons); let mut cmd = *cmd; crate::script_player::constrain_cmd(&mut world, *id, &mut cmd); + crate::script::hold_killcam_viewer(world.ecs(), *id, &mut cmd); crate::script::select_location(world.ecs(), id.0, &mut cmd, old_buttons); if world .client_meta(*id)