Skip to main content

lunar_core/
app.rs

1//! app builder and time resource
2//!
3//! the app builder provides a fluent interface for configuring the engine.
4//! game plugins register their systems, resources, and sub-plugins through the app.
5
6use bevy_ecs::prelude::*;
7use bevy_ecs::schedule::IntoScheduleConfigs;
8use bevy_ecs::system::ScheduleSystem;
9
10use crate::engine::Engine;
11use crate::game_loop::{GameLoop, TickRate};
12use crate::schedule::UpdateStage;
13use crate::state::EngineState;
14
15/// runtime-switchable logic tick rate.
16///
17/// write `rate` to change the tick rate at any time (e.g. from a settings menu).
18/// the game loop detects the change each frame and calls `GameLoop::set_tick_rate`.
19#[derive(Resource, Clone, Copy, PartialEq, Eq)]
20pub struct TickRateConfig {
21	pub rate: TickRate,
22}
23
24/// timing parameters for the game loop, passed to [`App::run`].
25///
26/// the one typed representation of loop timing — render-side configs
27/// (`RenderConfig`, `RenderConfig3d`) expose a `loop_config()` that produces this,
28/// so authoring stays in one place and `run` takes a single self-documenting value.
29#[derive(Clone, Copy, PartialEq, Eq)]
30pub struct LoopConfig {
31	/// render frame cap in fps. `0` = uncapped (vsync-limited).
32	pub frame_cap: u32,
33	/// fixed logic tick rate, independent of the render frame rate.
34	pub tick_rate: TickRate,
35}
36
37impl Default for LoopConfig {
38	fn default() -> Self {
39		Self {
40			frame_cap: 0,
41			tick_rate: TickRate::Hz60,
42		}
43	}
44}
45
46/// time resource updated each frame
47///
48/// provides delta time for framerate-independent movement and elapsed time.
49#[derive(Resource)]
50pub struct Time {
51	/// fixed logic delta in seconds (scaled by time_scale).
52	/// always exactly 1/tick_hz — use this for all game logic and physics.
53	delta_seconds: f32,
54	/// fixed logic delta in seconds (unscaled).
55	raw_delta_seconds: f32,
56	/// wall-clock seconds since the last render frame (unscaled).
57	/// use this for animation blending and rendering interpolation only.
58	real_delta_seconds: f32,
59	/// total simulated time in seconds (sum of fixed deltas, scaled)
60	elapsed_seconds: f32,
61	/// time multiplier (1.0 = normal, 0.5 = half speed, 2.0 = double speed)
62	scale: f32,
63	/// total logic tick count since engine start
64	frame_count: u64,
65	/// render interpolation alpha: how far we are between the last tick and the next.
66	/// 0.0 = just ticked, 1.0 = about to tick. use for lerping render-side transforms.
67	interp_alpha: f32,
68}
69
70impl Time {
71	/// create a new time resource
72	#[must_use]
73	pub fn new() -> Self {
74		Self {
75			delta_seconds: 0.0,
76			raw_delta_seconds: 0.0,
77			real_delta_seconds: 0.0,
78			elapsed_seconds: 0.0,
79			scale: 1.0,
80			frame_count: 0,
81			interp_alpha: 0.0,
82		}
83	}
84
85	/// get delta time in seconds (scaled)
86	#[must_use]
87	pub const fn delta_seconds(&self) -> f32 {
88		self.delta_seconds
89	}
90
91	/// get raw delta time in seconds (unscaled)
92	/// unscaled fixed tick delta — same value as `delta_seconds / time_scale`
93	#[must_use]
94	pub const fn raw_delta_seconds(&self) -> f32 {
95		self.raw_delta_seconds
96	}
97
98	/// wall-clock seconds since the last render frame.
99	/// use only for rendering/animation interpolation — NOT for game logic.
100	#[must_use]
101	pub const fn real_delta_seconds(&self) -> f32 {
102		self.real_delta_seconds
103	}
104
105	/// total simulated time in seconds (sum of fixed deltas, scaled)
106	#[must_use]
107	pub const fn elapsed_seconds(&self) -> f32 {
108		self.elapsed_seconds
109	}
110
111	/// current time scale multiplier
112	#[must_use]
113	pub const fn time_scale(&self) -> f32 {
114		self.scale
115	}
116
117	/// set the time scale multiplier (0.0+ range; 0 = frozen)
118	pub fn set_time_scale(&mut self, scale: f32) {
119		self.scale = scale.max(0.0);
120	}
121
122	/// total logic tick count since engine start
123	#[must_use]
124	pub const fn frame_count(&self) -> u64 {
125		self.frame_count
126	}
127
128	/// set delta directly — for unit tests only
129	pub fn set_delta_seconds(&mut self, delta: f32) {
130		self.delta_seconds = delta;
131		self.raw_delta_seconds = delta;
132	}
133
134	/// advance by one logic tick using the fixed delta from the tick rate.
135	///
136	/// `fixed_delta` must be `tick_rate.delta_seconds()`. never pass wall-clock
137	/// time here — the whole point is that this is always exactly 1/tick_hz.
138	pub fn advance(&mut self, fixed_delta: f32) {
139		self.raw_delta_seconds = fixed_delta;
140		self.delta_seconds = fixed_delta * self.scale;
141		self.elapsed_seconds += self.delta_seconds;
142		self.frame_count += 1;
143	}
144
145	/// update the wall-clock render delta — called once per render frame, not per tick.
146	pub fn set_real_delta(&mut self, real_delta: f32) {
147		self.real_delta_seconds = real_delta;
148	}
149
150	/// render interpolation alpha: 0.0 = just ticked, 1.0 = about to tick.
151	/// use this to lerp entity transforms on the render side for smooth motion.
152	#[must_use]
153	pub const fn interp_alpha(&self) -> f32 {
154		self.interp_alpha
155	}
156
157	/// set the interpolation alpha — called by the game loop once per render frame.
158	pub fn set_interp_alpha(&mut self, alpha: f32) {
159		self.interp_alpha = alpha;
160	}
161}
162
163impl Default for Time {
164	fn default() -> Self {
165		Self::new()
166	}
167}
168
169/// app builder for configuring the engine
170///
171/// use the app to register systems, resources, and plugins before calling `run()`.
172pub struct App {
173	/// the engine instance
174	engine: Engine,
175	/// plugins registered but not yet built
176	pending_plugins: Vec<Box<dyn GamePlugin>>,
177	/// names of plugins already built (for cycle detection)
178	built_plugins: Vec<String>,
179	/// whether startup systems have been run
180	startup_run: bool,
181}
182
183impl App {
184	/// create a new app with default setup
185	#[must_use]
186	pub fn new() -> Self {
187		let mut engine = Engine::new();
188		// insert the time resource
189		engine.world_mut().insert_resource(Time::new());
190		// insert the engine state resource
191		engine.world_mut().insert_resource(EngineState::Running);
192		Self {
193			engine,
194			pending_plugins: Vec::new(),
195			built_plugins: Vec::new(),
196			startup_run: false,
197		}
198	}
199
200	/// get mutable access to the world for direct manipulation
201	pub const fn world_mut(&mut self) -> &mut World {
202		self.engine.world_mut()
203	}
204
205	/// insert a resource into the world
206	pub fn insert_resource<R: Resource>(&mut self, resource: R) -> &mut Self {
207		self.engine.world_mut().insert_resource(resource);
208		self
209	}
210
211	/// add one or more systems to the default Update stage.
212	/// accepts a single system or a tuple — use `(a, b, c).chain()` to
213	/// enforce ordering when systems share `ResMut` borrows.
214	pub fn add_system<M>(
215		&mut self,
216		systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
217	) -> &mut Self {
218		self.add_system_to_stage(UpdateStage::Update, systems)
219	}
220
221	/// add one or more systems to a specific update stage.
222	/// systems are grouped by stage and run in order each frame:
223	/// Input → Physics → Update → Render.
224	/// pass a tuple with `.chain()` to enforce intra-stage ordering.
225	pub fn add_system_to_stage<M>(
226		&mut self,
227		stage: UpdateStage,
228		systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
229	) -> &mut Self {
230		self.engine.stage_schedule_mut(stage).add_systems(systems);
231		self
232	}
233
234	/// add systems to the default Update stage, enforcing sequential execution order.
235	/// equivalent to `add_system((a, b, c).chain())` but without needing to import
236	/// `IntoScheduleConfigs` in game code.
237	pub fn add_ordered_systems<M>(
238		&mut self,
239		systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
240	) -> &mut Self {
241		self.add_system(systems.chain())
242	}
243
244	/// add systems to a specific stage, enforcing sequential execution order.
245	/// equivalent to `add_system_to_stage(stage, (a, b, c).chain())` but without
246	/// needing to import `IntoScheduleConfigs` in game code.
247	pub fn add_ordered_systems_to_stage<M>(
248		&mut self,
249		stage: UpdateStage,
250		systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
251	) -> &mut Self {
252		self.add_system_to_stage(stage, systems.chain())
253	}
254
255	/// add one or more startup systems that run once before the main loop
256	pub fn add_startup_system<M>(
257		&mut self,
258		systems: impl IntoScheduleConfigs<ScheduleSystem, M>,
259	) -> &mut Self {
260		self.engine.startup_schedule_mut().add_systems(systems);
261		self
262	}
263
264	/// add a plugin to the app
265	/// plugins are built in dependency order using topological sort.
266	/// each plugin's dependencies must be built before the plugin itself.
267	pub fn add_plugin(&mut self, plugin: impl GamePlugin + 'static) -> &mut Self {
268		self.pending_plugins.push(Box::new(plugin));
269		self
270	}
271
272	/// build all pending plugins in dependency order
273	fn build_plugins(&mut self) {
274		// name-keyed topological build: each round drains every plugin whose
275		// declared dependencies are already built and defers the rest. plugins
276		// registered during build() are absorbed before the next round. the loop
277		// ends once a full round builds nothing new — any leftovers have missing
278		// or circular dependencies. `built` accumulates across calls.
279		let mut built = std::mem::take(&mut self.built_plugins);
280		let mut pending = std::mem::take(&mut self.pending_plugins);
281		let mut ready: Vec<Box<dyn GamePlugin>> = Vec::new();
282
283		loop {
284			// absorb anything registered by a previous round's build() calls
285			pending.append(&mut self.pending_plugins);
286			if pending.is_empty() {
287				break;
288			}
289
290			let mut progressed = false;
291			for mut plugin in std::mem::take(&mut pending) {
292				let deps_met = plugin
293					.dependencies()
294					.iter()
295					.all(|dep| built.iter().any(|name| name.as_str() == *dep));
296				if deps_met {
297					plugin.build(self);
298					built.push(plugin.name().to_string());
299					ready.push(plugin);
300					progressed = true;
301				} else {
302					pending.push(plugin);
303				}
304			}
305
306			// nothing became buildable and nothing new was registered → stuck
307			if !progressed && self.pending_plugins.is_empty() {
308				break;
309			}
310		}
311
312		// put back any plugins that couldn't be built (circular deps or missing deps)
313		self.pending_plugins = pending;
314		self.built_plugins = built;
315
316		if !self.pending_plugins.is_empty() {
317			log::warn!(
318				"{} plugins could not be built (missing dependencies or circular deps)",
319				self.pending_plugins.len()
320			);
321		}
322
323		// second pass: finish all successfully built plugins
324		for mut plugin in ready {
325			plugin.finish(self);
326		}
327	}
328
329	/// get a reference to the engine
330	pub const fn engine(&self) -> &Engine {
331		&self.engine
332	}
333
334	/// get mutable access to the engine
335	pub const fn engine_mut(&mut self) -> &mut Engine {
336		&mut self.engine
337	}
338
339	/// start the game loop with the given timing ([`LoopConfig`]).
340	pub fn run(&mut self, config: LoopConfig) {
341		self.run_with_events(config, |_| {});
342	}
343
344	/// start the game loop with per-frame event processing.
345	///
346	/// `time.delta_seconds()` inside systems is always exactly `1 / tick_hz`.
347	/// `time.real_delta_seconds()` is wall-clock render frame time for interpolation.
348	pub fn run_with_events<F>(&mut self, config: LoopConfig, mut process_events: F)
349	where
350		F: FnMut(&mut World),
351	{
352		self.build_plugins();
353		if !self.startup_run {
354			self.engine.run_startup();
355			self.startup_run = true;
356		}
357
358		// insert TickRateConfig so game code can change tick rate at runtime
359		self.engine.world_mut().insert_resource(TickRateConfig {
360			rate: config.tick_rate,
361		});
362
363		let mut fixed_delta = config.tick_rate.delta_seconds();
364		let mut game_loop = GameLoop::new(config.frame_cap, config.tick_rate);
365
366		while game_loop.is_running() {
367			// check if game code changed the tick rate via TickRateConfig
368			if let Some(cfg) = self.engine.world().get_resource::<TickRateConfig>()
369				&& cfg.rate != game_loop.tick_rate()
370			{
371				game_loop.set_tick_rate(cfg.rate);
372				fixed_delta = cfg.rate.delta_seconds();
373			}
374
375			let (ticks, frame_delta) = game_loop.tick();
376			let alpha = game_loop.interpolation_alpha();
377
378			if let Some(mut time) = self.engine.world_mut().get_resource_mut::<Time>() {
379				time.set_real_delta(frame_delta);
380				time.set_interp_alpha(alpha);
381			}
382
383			// run 0-5 logic ticks for this frame (fixed timestep accumulator)
384			for _ in 0..ticks {
385				if let Some(mut time) = self.engine.world_mut().get_resource_mut::<Time>() {
386					time.advance(fixed_delta);
387				}
388				self.engine.run_logic_tick();
389			}
390			// render + post-update always fire exactly once per display frame,
391			// even when ticks == 0 (frame ran faster than the tick interval).
392			// this decouples render rate from logic rate so uncapped framerates work.
393			self.engine.run_render_and_post();
394
395			if let Some(state) = self.engine.world().get_resource::<EngineState>()
396				&& state.is_stopping()
397			{
398				break;
399			}
400
401			game_loop.apply_frame_cap();
402			process_events(self.engine.world_mut());
403		}
404	}
405
406	/// run a single frame tick (for external loops like requestAnimationFrame).
407	/// `fixed_delta` should be `tick_rate.delta_seconds()` for your chosen rate.
408	pub fn tick(&mut self, fixed_delta: f32) {
409		if !self.pending_plugins.is_empty() {
410			self.build_plugins();
411		}
412		if !self.startup_run {
413			self.engine.run_startup();
414			self.startup_run = true;
415		}
416		if let Some(mut time) = self.engine.world_mut().get_resource_mut::<Time>() {
417			time.advance(fixed_delta);
418		}
419		self.engine.run_stages();
420	}
421}
422
423impl Default for App {
424	fn default() -> Self {
425		Self::new()
426	}
427}
428
429/// trait for game plugins
430///
431/// plugins configure the app by adding systems, resources, and other plugins.
432pub trait GamePlugin: Send {
433	/// get the plugin name for dependency resolution
434	fn name(&self) -> &str;
435
436	/// get the list of plugin names this plugin depends on
437	fn dependencies(&self) -> &[&str] {
438		&[]
439	}
440
441	/// build the plugin, adding systems and resources to the app
442	fn build(&mut self, _app: &mut App) {}
443
444	/// finish the plugin, called after all plugins have been built
445	fn finish(&mut self, _app: &mut App) {}
446}
447
448#[cfg(test)]
449mod tests {
450	use super::*;
451	use std::sync::{Arc, Mutex};
452
453	type Log = Arc<Mutex<Vec<String>>>;
454	/// callback that registers more plugins mid-build()
455	type Spawn = Box<dyn FnOnce(&mut App) + Send>;
456
457	/// test plugin that records its build/finish calls into a shared log.
458	/// `spawn` optionally registers another plugin during build() to exercise
459	/// mid-build registration absorption.
460	struct Recorder {
461		name: &'static str,
462		deps: Vec<&'static str>,
463		log: Log,
464		spawn: Option<Spawn>,
465	}
466
467	impl GamePlugin for Recorder {
468		fn name(&self) -> &str {
469			self.name
470		}
471		fn dependencies(&self) -> &[&str] {
472			&self.deps
473		}
474		fn build(&mut self, app: &mut App) {
475			self.log
476				.lock()
477				.unwrap()
478				.push(format!("build:{}", self.name));
479			if let Some(spawn) = self.spawn.take() {
480				spawn(app);
481			}
482		}
483		fn finish(&mut self, _app: &mut App) {
484			self.log
485				.lock()
486				.unwrap()
487				.push(format!("finish:{}", self.name));
488		}
489	}
490
491	fn calls(log: &Log) -> Vec<String> {
492		log.lock().unwrap().clone()
493	}
494
495	#[test]
496	fn builds_dependencies_before_dependents() {
497		let log: Log = Arc::new(Mutex::new(Vec::new()));
498		let mut app = App::new();
499		// register the dependent first to prove ordering isn't just insertion order
500		app.add_plugin(Recorder {
501			name: "b",
502			deps: vec!["a"],
503			log: log.clone(),
504			spawn: None,
505		});
506		app.add_plugin(Recorder {
507			name: "a",
508			deps: vec![],
509			log: log.clone(),
510			spawn: None,
511		});
512		app.build_plugins();
513
514		let c = calls(&log);
515		let build_a = c.iter().position(|x| x == "build:a").expect("a built");
516		let build_b = c.iter().position(|x| x == "build:b").expect("b built");
517		assert!(build_a < build_b, "a must build before b: {c:?}");
518		// every build runs before any finish
519		let last_build = c.iter().rposition(|x| x.starts_with("build:")).unwrap();
520		let first_finish = c.iter().position(|x| x.starts_with("finish:")).unwrap();
521		assert!(
522			last_build < first_finish,
523			"all builds precede finishes: {c:?}"
524		);
525	}
526
527	#[test]
528	fn absorbs_plugin_registered_during_build() {
529		let log: Log = Arc::new(Mutex::new(Vec::new()));
530		let log_for_child = log.clone();
531		let mut app = App::new();
532		app.add_plugin(Recorder {
533			name: "parent",
534			deps: vec![],
535			log: log.clone(),
536			spawn: Some(Box::new(move |app| {
537				app.add_plugin(Recorder {
538					name: "child",
539					deps: vec!["parent"],
540					log: log_for_child.clone(),
541					spawn: None,
542				});
543			})),
544		});
545		app.build_plugins();
546
547		let c = calls(&log);
548		assert!(
549			c.contains(&"build:parent".to_string()),
550			"parent built: {c:?}"
551		);
552		assert!(
553			c.contains(&"build:child".to_string()),
554			"child registered during build must be built: {c:?}"
555		);
556		assert!(app.pending_plugins.is_empty(), "no plugins left pending");
557	}
558
559	#[test]
560	fn leaves_unresolved_dependency_unbuilt() {
561		let log: Log = Arc::new(Mutex::new(Vec::new()));
562		let mut app = App::new();
563		app.add_plugin(Recorder {
564			name: "needy",
565			deps: vec!["missing"],
566			log: log.clone(),
567			spawn: None,
568		});
569		app.build_plugins();
570
571		assert!(
572			!calls(&log).iter().any(|x| x == "build:needy"),
573			"plugin with a missing dep must not build"
574		);
575		assert_eq!(
576			app.pending_plugins.len(),
577			1,
578			"the unresolved plugin stays pending"
579		);
580	}
581}