use std::fmt::Debug;
use crate::correction::Correction;
#[derive(Debug, Clone, Copy)]
pub struct HeldInputConfig {
pub blend: f32,
}
impl Default for HeldInputConfig {
fn default() -> Self {
Self { blend: 0.25 }
}
}
pub struct HeldInputPredictor<State: Clone + Debug, Input: Clone + Debug, Ctx = ()> {
state: State,
held: Input,
advance: fn(&mut State, &Input, f32, &Ctx),
lerp: fn(&State, &State, f32) -> State,
blend: f32,
ctx: Ctx,
active: bool,
teleport: Option<(fn(&State, &State) -> f32, f32)>,
}
impl<State: Clone + Debug, Input: Clone + Debug + Default, Ctx: Default> HeldInputPredictor<State, Input, Ctx> {
pub fn new(
initial: State,
config: HeldInputConfig,
advance: fn(&mut State, &Input, f32, &Ctx),
lerp: fn(&State, &State, f32) -> State,
) -> Self {
Self {
state: initial,
held: Input::default(),
advance,
lerp,
blend: config.blend.clamp(0.0, 1.0),
ctx: Ctx::default(),
active: true,
teleport: None,
}
}
}
impl<State: Clone + Debug, Input: Clone + Debug, Ctx> HeldInputPredictor<State, Input, Ctx> {
pub fn with_teleport(mut self, distance: fn(&State, &State) -> f32, beyond: f32) -> Self {
self.teleport = Some((distance, beyond));
self
}
pub fn hold(&mut self, input: Input) {
self.held = input;
}
pub fn held(&self) -> &Input {
&self.held
}
pub fn advance(&mut self, dt_secs: f32) {
if self.active {
(self.advance)(&mut self.state, &self.held, dt_secs, &self.ctx);
}
}
pub fn project(&self, authoritative: &State, age_secs: f32) -> State {
let mut projected = authoritative.clone();
(self.advance)(&mut projected, &self.held, age_secs, &self.ctx);
projected
}
pub fn reconcile(&mut self, authoritative: State, age_secs: f32) -> Correction<State> {
let seen = self.state.clone();
if !self.active {
self.state = authoritative;
return Correction {
seen,
settled: self.state.clone(),
};
}
let target = self.project(&authoritative, age_secs);
let discontinuous = self
.teleport
.is_some_and(|(distance, beyond)| distance(&self.state, &target) > beyond);
self.state = if discontinuous {
target
} else {
(self.lerp)(&self.state, &target, self.blend)
};
Correction {
seen,
settled: self.state.clone(),
}
}
pub fn teleport(&mut self, state: State) {
self.state = state;
}
pub fn set_active(&mut self, active: bool) {
self.active = active;
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn set_context(&mut self, ctx: Ctx) {
self.ctx = ctx;
}
pub fn context(&self) -> &Ctx {
&self.ctx
}
pub fn logical(&self) -> &State {
&self.state
}
pub fn render(&self) -> State {
self.state.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct P(f32);
fn integrate(p: &mut P, held: &f32, dt: f32, _ctx: &()) {
p.0 += *held * dt;
}
fn lerp(a: &P, b: &P, t: f32) -> P {
P(a.0 + (b.0 - a.0) * t)
}
fn distance(a: &P, b: &P) -> f32 {
(a.0 - b.0).abs()
}
fn predictor(blend: f32) -> HeldInputPredictor<P, f32> {
HeldInputPredictor::new(P(0.0), HeldInputConfig { blend }, integrate, lerp)
}
#[derive(Default)]
struct HeldServer {
pos: f32,
held: f32,
}
impl HeldServer {
fn step(&mut self, dt: f32) {
self.pos += self.held * dt;
}
}
#[test]
fn dead_reckoning_a_held_input_matches_the_server_exactly() {
let mut me = predictor(0.25);
let mut server = HeldServer::default();
me.hold(10.0);
server.held = 10.0;
for _ in 0..120 {
me.advance(1.0 / 60.0);
server.step(1.0 / 60.0);
}
let age = 0.05;
let sent_at = P(server.pos - 10.0 * age);
let correction = me.reconcile(sent_at, age);
assert!(
distance(&correction.seen, &correction.settled) < 0.001,
"a shared rule on a held input needs no correction, moved {}",
distance(&correction.seen, &correction.settled)
);
}
#[test]
fn a_threshold_snap_sawtooths_where_a_continuous_blend_does_not() {
const DRIFT_PER_PACKET: f32 = 6.0;
const THRESHOLD: f32 = 24.0;
let mut threshold_pos = 0.0f32;
let mut server = 0.0f32;
let mut biggest_snap = 0.0f32;
for _ in 0..60 {
server += DRIFT_PER_PACKET;
if (server - threshold_pos).abs() > THRESHOLD {
let snap = (server - threshold_pos).abs();
biggest_snap = biggest_snap.max(snap);
threshold_pos = server;
}
}
let mut me = predictor(0.25);
let mut server = 0.0f32;
let mut biggest_move = 0.0f32;
for _ in 0..60 {
server += DRIFT_PER_PACKET;
let correction = me.reconcile(P(server), 0.0);
biggest_move = biggest_move.max(distance(&correction.seen, &correction.settled));
}
assert!(biggest_snap > THRESHOLD, "the threshold policy really does snap: {biggest_snap}");
assert!(
biggest_move < biggest_snap / 2.0,
"continuous easing must never move as far in one go: {biggest_move} vs {biggest_snap}"
);
assert!(
distance(me.logical(), &P(server)) < THRESHOLD,
"and it still keeps up with the server"
);
}
#[test]
fn a_projection_targets_now_rather_than_the_packets_past() {
let mut me = predictor(1.0);
me.hold(100.0);
me.advance(0.1); assert_eq!(me.logical().0, 10.0);
let correction = me.reconcile(P(5.0), 0.05);
assert_eq!(correction.settled.0, 10.0, "5.0 advanced by 50ms of held input is 10.0");
}
#[test]
fn a_frozen_entity_tracks_the_server_instead_of_predicting_into_it() {
let mut me = predictor(0.25);
me.hold(100.0);
me.set_active(false);
me.advance(1.0);
assert_eq!(me.logical().0, 0.0, "a frozen entity does not dead reckon");
let correction = me.reconcile(P(42.0), 0.05);
assert_eq!(correction.settled.0, 42.0, "it tracks the held position exactly");
assert_eq!(me.logical().0, 42.0);
me.set_active(true);
me.advance(0.1);
assert!((me.logical().0 - 52.0).abs() < 1e-3, "and resumes from there");
}
#[test]
fn a_discontinuity_snaps_while_ordinary_drift_eases() {
let mut me = predictor(0.25).with_teleport(distance, 200.0);
let correction = me.reconcile(P(40.0), 0.0);
assert!(correction.settled.0 < 40.0, "drift is eased, not snapped");
let correction = me.reconcile(P(5000.0), 0.0);
assert_eq!(correction.settled.0, 5000.0, "a discontinuity arrives at once");
}
#[test]
fn a_forced_entity_integrates_its_context() {
fn with_current(p: &mut P, held: &f32, dt: f32, current: &f32) {
p.0 += (*held + *current) * dt;
}
let mut me: HeldInputPredictor<P, f32, f32> =
HeldInputPredictor::new(P(0.0), HeldInputConfig::default(), with_current, lerp);
me.set_context(3.0);
me.hold(10.0);
me.advance(1.0);
assert_eq!(me.logical().0, 13.0, "the world's push is part of the rule");
}
}