Skip to main content

kvas_engine/
runtime.rs

1//! The game loop shared by the editor's Play mode and the standalone player.
2//!
3//! A tick runs scripts, environment and procedural systems, then zero or more
4//! fixed physics steps and finally refreshes world transforms. The shell supplies
5//! input before ticking. Frame input edges remain visible to every fixed step in
6//! that frame; scripts should consume one-shot gameplay actions in `update`.
7//!
8//! ```
9//! use kvas_engine::{runtime::Runtime, scene::Scene};
10//! let mut runtime = Runtime::new(Scene::default(), None);
11//! runtime.tick(1.0 / 60.0);
12//! assert_eq!(runtime.time.frame, 1);
13//! runtime.set_paused(true);
14//! runtime.tick(1.0);
15//! assert_eq!(runtime.time.frame, 1);
16//! ```
17
18use crate::hierarchy;
19use crate::input::Input;
20use crate::physics::Physics;
21use crate::scene::Scene;
22use crate::script::{Phase, ScriptHost, ScriptRuntime, Time};
23
24const FIXED_DT: f32 = 1.0 / 60.0;
25const MAX_FRAME_DT: f32 = 0.1;
26
27/// Validated timing policy for a [`Runtime`]. Defaults retain the 60 Hz loop.
28#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
29#[serde(default)]
30pub struct RuntimeConfig {
31    /// Fixed simulation interval in seconds; must be finite and positive.
32    pub fixed_dt: f32,
33    /// Maximum accepted wall-clock frame interval, finite and positive.
34    pub max_frame_dt: f32,
35    /// Maximum fixed steps per tick. Excess whole steps are dropped, preserving
36    /// only the fractional remainder to prevent an unbounded catch-up spiral.
37    pub max_fixed_steps: u32,
38}
39
40impl Default for RuntimeConfig {
41    fn default() -> Self {
42        Self { fixed_dt: FIXED_DT, max_frame_dt: MAX_FRAME_DT, max_fixed_steps: 8 }
43    }
44}
45
46impl RuntimeConfig {
47    /// Checks timing bounds before constructing a runtime.
48    pub fn validate(&self) -> Result<(), String> {
49        if !self.fixed_dt.is_finite() || self.fixed_dt <= 0.0 {
50            return Err("fixed_dt must be finite and positive".into());
51        }
52        if !self.max_frame_dt.is_finite() || self.max_frame_dt <= 0.0 {
53            return Err("max_frame_dt must be finite and positive".into());
54        }
55        if self.max_fixed_steps == 0 {
56            return Err("max_fixed_steps must be positive".into());
57        }
58        Ok(())
59    }
60}
61
62/// Timing diagnostics for the most recent tick.
63#[derive(Clone, Copy, Debug, Default, PartialEq)]
64pub struct FrameStats {
65    /// Number of completed fixed simulation steps.
66    pub fixed_steps: u32,
67    /// Simulation seconds dropped by the fixed-step catch-up limit.
68    pub dropped_time: f32,
69    /// Remaining accumulator divided by the fixed interval, in `[0, 1)`.
70    /// Exposed for render interpolation; the engine does not interpolate poses itself.
71    pub interpolation_alpha: f32,
72}
73
74/// Owned gameplay state shared by editor Play mode and the standalone player.
75/// Dropping the runtime destroys script instances before unloading their library.
76pub struct Runtime {
77    /// Mutable gameplay world and environment.
78    pub scene: Scene,
79    /// Rapier simulation and ray queries, updated at the fixed cadence.
80    pub physics: Physics,
81    /// Script instances and hot-reload host.
82    pub scripts: ScriptRuntime,
83    /// Input snapshot supplied by the hosting shell before each tick.
84    pub input: Input,
85    /// Scaled simulation time visible to scripts.
86    pub time: Time,
87    /// Script messages and runtime diagnostics. The host may drain these at any time.
88    pub log: Vec<String>,
89    accumulator: f32,
90    config: RuntimeConfig,
91    paused: bool,
92    time_scale: f32,
93    stats: FrameStats,
94}
95
96impl Runtime {
97    /// Applies the project's input bindings (call right after `new`).
98    pub fn set_bindings<S: AsRef<str>>(&mut self, defs: &[(String, Vec<S>)]) {
99        self.input.set_bindings(defs);
100    }
101
102    /// Creates a 60 Hz runtime, initializing world transforms before the first tick.
103    pub fn new(scene: Scene, host: Option<ScriptHost>) -> Self {
104        Self::with_config(scene, host, RuntimeConfig::default()).expect("valid default timing")
105    }
106
107    /// Creates a runtime with a validated timing policy.
108    /// Returns an error for non-finite/non-positive intervals or a zero step limit.
109    pub fn with_config(mut scene: Scene, host: Option<ScriptHost>, config: RuntimeConfig) -> Result<Self, String> {
110        config.validate()?;
111        hierarchy::update_globals(&mut scene.world);
112        let mut scripts = ScriptRuntime::default();
113        scripts.set_host(host);
114        Ok(Self {
115            scene,
116            physics: Physics::new(),
117            scripts,
118            input: Input::default(),
119            time: Time::default(),
120            log: Vec::new(),
121            accumulator: 0.0,
122            config,
123            paused: false,
124            time_scale: 1.0,
125            stats: FrameStats::default(),
126        })
127    }
128
129    /// Active timing configuration. Construct a new runtime to change the fixed cadence.
130    pub fn config(&self) -> &RuntimeConfig {
131        &self.config
132    }
133
134    /// Whether regular ticks are suspended.
135    pub fn is_paused(&self) -> bool {
136        self.paused
137    }
138
139    /// Suspends or resumes ticks. Existing fractional fixed time is preserved.
140    pub fn set_paused(&mut self, paused: bool) {
141        self.paused = paused;
142    }
143
144    /// Current simulation speed multiplier.
145    pub fn time_scale(&self) -> f32 {
146        self.time_scale
147    }
148
149    /// Sets speed in `[0, 100]`; rejects invalid values without changing state.
150    /// Zero freezes simulation time like pause. Wall-frame clamping occurs before scaling.
151    pub fn set_time_scale(&mut self, scale: f32) -> Result<(), String> {
152        if !scale.is_finite() || !(0.0..=100.0).contains(&scale) {
153            return Err("time scale must be finite and between 0 and 100".into());
154        }
155        self.time_scale = scale;
156        Ok(())
157    }
158
159    /// Timing diagnostics from the most recent tick (zeroed on a skipped tick).
160    pub fn frame_stats(&self) -> FrameStats {
161        self.stats
162    }
163
164    /// Advances one fixed-size simulation frame even when paused or time scale is zero.
165    /// Useful for editor single-step controls. It does not consume shell input.
166    pub fn step_once(&mut self) {
167        self.advance(self.config.fixed_dt);
168    }
169
170    /// Drains pending script/runtime log messages, preserving their order.
171    pub fn drain_log(&mut self) -> std::vec::Drain<'_, String> {
172        self.log.drain(..)
173    }
174
175    /// Advances a wall-clock frame. Negative/non-finite intervals and paused
176    /// frames are skipped. Large intervals are clamped before applying time scale.
177    pub fn tick(&mut self, dt: f32) {
178        self.stats = FrameStats::default();
179        if self.paused || self.time_scale == 0.0 || !dt.is_finite() || dt <= 0.0 {
180            return;
181        }
182        self.advance(dt.min(self.config.max_frame_dt) * self.time_scale);
183    }
184
185    fn advance(&mut self, dt: f32) {
186        self.stats = FrameStats::default();
187        self.time.dt = dt;
188        self.time.elapsed += dt;
189        self.time.frame = self.time.frame.saturating_add(1);
190
191        self.input.begin_frame(dt);
192        self.input.canvas.begin();
193        self.scene.signals.begin_frame();
194        self.scripts.sync(&self.scene, &mut self.log);
195        self.scripts.run(Phase::Update, &mut self.scene, &self.physics, &self.input, self.time, &mut self.log);
196        crate::gameplay::update_lifetimes(&mut self.scene.world, dt);
197
198        let sky = &mut self.scene.settings.atmosphere;
199        if sky.speed != 0.0 {
200            sky.time = (sky.time + sky.speed * dt).rem_euclid(24.0);
201        }
202        self.update_autofocus(dt);
203        crate::interaction::update(&mut self.scene.world, dt);
204        crate::modifiers::update(&mut self.scene.world, self.time.elapsed, dt);
205        crate::tween::update(&mut self.scene.world, dt);
206        crate::animation::update(&mut self.scene.world, dt);
207        crate::neural::update(&mut self.scene.world, dt);
208        crate::procedural::update(&mut self.scene.world, self.time.elapsed, dt, true);
209
210        self.accumulator += dt;
211        while self.accumulator >= self.config.fixed_dt && self.stats.fixed_steps < self.config.max_fixed_steps {
212            self.accumulator -= self.config.fixed_dt;
213            self.scripts.run(
214                Phase::Fixed,
215                &mut self.scene,
216                &self.physics,
217                &self.input,
218                Time { dt: self.config.fixed_dt, ..self.time },
219                &mut self.log,
220            );
221            crate::water::apply_buoyancy(&mut self.scene.world, self.time.elapsed, self.config.fixed_dt);
222            self.physics.step(&mut self.scene, self.config.fixed_dt);
223            self.stats.fixed_steps += 1;
224        }
225        // Characters: clips, then ragdolls and feet planted on the colliders just simulated.
226        let physics = &self.physics;
227        let ground = |from: glam::Vec3, len: f32, me: kvas_ecs::Entity| {
228            let opts = crate::physics::QueryOptions { exclude: Some(me), filter: !crate::ragdoll::RAGDOLL_GROUP, ..Default::default() };
229            physics.raycast_with_options(from, glam::Vec3::NEG_Y, len, opts).map(|h| h.point.y)
230        };
231        crate::skeletal::update_with(&mut self.scene.world, self.time.elapsed, dt, true, Some(&ground));
232        if self.accumulator >= self.config.fixed_dt {
233            let remainder = self.accumulator % self.config.fixed_dt;
234            self.stats.dropped_time = self.accumulator - remainder;
235            self.accumulator = remainder;
236        }
237        self.stats.interpolation_alpha = self.accumulator / self.config.fixed_dt;
238        hierarchy::update_globals(&mut self.scene.world);
239    }
240}
241
242impl Runtime {
243    /// Applies a scene change a script asked for during the last tick: `read` returns the text
244    /// of a scene asset. Returns `None` when nothing was asked, else the outcome (the running
245    /// scene stays as it was on error).
246    pub fn apply_scene_request(&mut self, reg: &crate::registry::Registry, read: impl Fn(&str) -> Result<String, String>) -> Option<Result<String, String>> {
247        let request = self.input.take_scene_request()?;
248        let current = self.input.scene_path();
249        Some(match request {
250            crate::input::SceneRequest::Load(path) => self.load_scene_text(reg, &path, read(&path)),
251            crate::input::SceneRequest::Reload if current.is_empty() => Err("reload_scene: the host did not say which scene is running".into()),
252            crate::input::SceneRequest::Reload => self.load_scene_text(reg, &current, read(&current)),
253            crate::input::SceneRequest::Add(path) => read(&path).and_then(|text| {
254                let warnings = self.scene.add_json(reg, &text)?;
255                self.log.extend(warnings);
256                Ok(path)
257            }),
258        })
259    }
260
261    fn load_scene_text(&mut self, reg: &crate::registry::Registry, path: &str, text: Result<String, String>) -> Result<String, String> {
262        let (scene, warnings) = Scene::from_json(reg, &text.map_err(|e| format!("{path}: {e}"))?).map_err(|e| format!("{path}: {e}"))?;
263        self.replace_scene(scene);
264        self.log.extend(warnings);
265        self.input.set_scene_path(path);
266        Ok(path.to_string())
267    }
268
269    /// Swaps the running scene for another: script instances and the physics world start over,
270    /// time, input bindings and the cursor state carry on.
271    pub fn replace_scene(&mut self, mut scene: Scene) {
272        hierarchy::update_globals(&mut scene.world);
273        let volume = self.scene.audio.master_volume;
274        self.scripts.restart_instances();
275        self.physics = Physics::new();
276        self.scene = scene;
277        self.scene.audio.master_volume = volume;
278        self.accumulator = 0.0;
279    }
280
281    /// Racks the focus distance of the primary camera onto what is in the middle of the screen.
282    fn update_autofocus(&mut self, dt: f32) {
283        let world = &self.scene.world;
284        let Some((entity, camera)) = world.iter::<crate::components::Camera>().find(|(_, c)| c.primary && c.autofocus && c.dof).map(|(e, c)| (e, c.clone())) else { return };
285        let m = hierarchy::global_matrix(world, entity);
286        let (origin, forward) = (m.w_axis.truncate(), -m.z_axis.truncate().normalize_or_zero());
287        let target = self.physics.raycast(origin + forward * camera.near, forward, camera.far.min(300.0)).map_or(camera.far.min(60.0), |h| h.distance + camera.near);
288        if let Some(c) = self.scene.world.get_mut::<crate::components::Camera>(entity) {
289            let k = 1.0 - (-camera.focus_speed.max(0.1) * dt).exp();
290            c.focus_distance += (target - c.focus_distance) * k;
291        }
292    }
293}
294
295impl Drop for Runtime {
296    fn drop(&mut self) {
297        // Script instances reference code in the library; drop them first.
298        self.scripts.set_host(None);
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::input::SceneRequest;
306    use crate::registry::Registry;
307
308    #[test]
309    fn scripts_can_switch_add_and_reload_scenes() {
310        let reg = Registry::default();
311        let mut a = Scene::default();
312        a.spawn("Start");
313        let mut b = Scene::default();
314        b.spawn("Level 2");
315        let files = [("a.scene.json", a.to_json(&reg)), ("b.scene.json", b.to_json(&reg))];
316        let read = |p: &str| files.iter().find(|f| f.0 == p).map(|f| f.1.clone()).ok_or_else(|| format!("missing {p}"));
317        let mut rt = Runtime::new(Scene::from_json(&reg, &files[0].1).unwrap().0, None);
318        assert!(rt.apply_scene_request(&reg, read).is_none(), "nothing asked");
319        rt.input.request_scene(SceneRequest::Reload);
320        assert!(rt.apply_scene_request(&reg, read).unwrap().is_err(), "the host did not say which scene runs");
321        rt.input.set_scene_path("a.scene.json");
322        rt.input.request_scene(SceneRequest::Load("b.scene.json".into()));
323        assert_eq!(rt.apply_scene_request(&reg, read).unwrap().unwrap(), "b.scene.json");
324        assert!(rt.scene.find("Level 2").is_some() && rt.scene.find("Start").is_none());
325        assert_eq!(rt.input.scene_path(), "b.scene.json");
326        rt.input.request_scene(SceneRequest::Add("a.scene.json".into()));
327        rt.apply_scene_request(&reg, read).unwrap().unwrap();
328        assert!(rt.scene.find("Level 2").is_some() && rt.scene.find("Start").is_some());
329        rt.input.request_scene(SceneRequest::Reload);
330        rt.apply_scene_request(&reg, read).unwrap().unwrap();
331        assert!(rt.scene.find("Start").is_none(), "reload restores b only");
332        rt.input.request_scene(SceneRequest::Load("missing.scene.json".into()));
333        assert!(rt.apply_scene_request(&reg, read).unwrap().is_err());
334        assert!(rt.scene.find("Level 2").is_some(), "a failed load keeps the running scene");
335    }
336}