Skip to main content

concinnity_engine/ecs/
schedule.rs

1//! Gate builders for the system table (`define_systems!` in `registry`). Each
2//! gate inspects the world's content and returns the constructed system when
3//! its gating components are present, or `None` to leave it out of the
4//! schedule. `World::start` and `World::system_manifest` both run these same
5//! gates, so what the manifest reports and what `start` builds cannot drift.
6//!
7//! Gates construct their system, so every system constructor must stay cheap
8//! and side-effect-free: the manifest probe discards the value, and anything
9//! heavy (device acquisition, payload reads) belongs in `System::init`.
10
11use crate::ecs::World;
12
13// OverlaySystem: paired with GraphicsSystem (same gate) -- it shapes the
14// overlay draw list graphics submits. Scheduled first so the menu state it
15// publishes gates every later system this same tick.
16pub(crate) fn overlay(world: &World) -> Option<crate::gfx::overlay::OverlaySystem> {
17    world
18        .query::<crate::components::GraphicsConfig>()
19        .next()
20        .map(|_| crate::gfx::overlay::OverlaySystem::new())
21}
22
23// BehaviorSystem: present whenever the world declares any `Behavior`.
24// Scheduled before SpawnSystem / SettingsSystem / StorySystem / AudioSystem so
25// the requests its firing bodies emit are drained the same tick. Built with
26// what this host lends it: the job pool and a file-backed state store.
27pub(crate) fn behavior(world: &World) -> Option<concinnity_core::behavior::BehaviorSystem> {
28    world
29        .query::<crate::components::Behavior>()
30        .next()
31        .map(|_| crate::behavior::build(crate::ecs::state_tree(world)))
32}
33
34// SpawnSystem: paired with GraphicsSystem (same gate) -- its churn retires and
35// clones the GPU draw slots graphics owns. Scheduled immediately before it so
36// a despawn is applied before the transform push and a spawn reuses slots
37// freed this same frame.
38pub(crate) fn spawn(world: &World) -> Option<crate::spawn::SpawnSystem> {
39    world
40        .query::<crate::components::GraphicsConfig>()
41        .next()
42        .map(|_| crate::spawn::SpawnSystem::new())
43}
44
45// SettingsSystem: paired with GraphicsSystem (same gate) -- it applies the
46// settings/scene command batches against the backend graphics owns and holds
47// the settings snapshot GraphicsSystem's init resolves. Scheduled just before
48// GraphicsSystem so a change lands for this frame's submit.
49pub(crate) fn settings(world: &World) -> Option<crate::gfx::settings_system::SettingsSystem> {
50    world
51        .query::<crate::components::GraphicsConfig>()
52        .next()
53        .map(|_| crate::gfx::settings_system::SettingsSystem::new())
54}
55
56// StreamingSystem: paired with GraphicsSystem (same gate) -- it drives the
57// streaming pools and publishes the camera-relative screen graphics draws.
58// Scheduled immediately before GraphicsSystem so a chunk world's screen rebase is
59// ready for this frame's submit and any texture/mesh upload lands before it.
60pub(crate) fn streaming(world: &World) -> Option<crate::gfx::streaming_system::StreamingSystem> {
61    world
62        .query::<crate::components::GraphicsConfig>()
63        .next()
64        .map(|_| crate::gfx::streaming_system::StreamingSystem::new())
65}
66
67// GraphicsSystem: present whenever the world declares a `GraphicsConfig`
68// (the render marker).
69pub(crate) fn graphics(world: &World) -> Option<crate::gfx::graphics_system::GraphicsSystem> {
70    world
71        .query::<crate::components::GraphicsConfig>()
72        .next()
73        .map(|_| crate::gfx::graphics_system::GraphicsSystem::new(crate::ecs::state_tree(world)))
74}
75
76// InputSystem: paired with GraphicsSystem (same gate) -- it samples the window
77// backend graphics drives. Scheduled immediately after it so the snapshot is
78// taken right after the draw (the OS event pump on Metal runs inside
79// draw_frame) and is fresh for every consumer below.
80pub(crate) fn input(world: &World) -> Option<crate::gfx::input_system::InputSystem> {
81    world
82        .query::<crate::components::GraphicsConfig>()
83        .next()
84        .map(|_| crate::gfx::input_system::InputSystem::new())
85}
86
87// StatHud: present whenever the world declares a `StatHud`; built from that
88// component (the HUD's TextLabel refs).
89pub(crate) fn stat_hud(world: &World) -> Option<crate::hud::stat_hud::StatHudSystem> {
90    world
91        .query::<crate::components::StatHud>()
92        .next()
93        .cloned()
94        .map(crate::hud::stat_hud::StatHudSystem::new)
95}
96
97// DebugHud: present whenever the world declares a `DebugHud`, but only in
98// developer contexts. Blobs are profile-agnostic (the build injects a
99// DebugHud into every rendering world), so the running binary is the one
100// place its own profile is knowable: a debug build or a `cn debug` session
101// activates the HUD, a release `cn run` leaves it inert.
102pub(crate) fn debug_hud(world: &World) -> Option<crate::hud::debug_hud::DebugHudSystem> {
103    if !(cfg!(debug_assertions) || crate::app::dev_flags::enabled()) {
104        return None;
105    }
106    world
107        .query::<crate::components::DebugHud>()
108        .next()
109        .cloned()
110        .map(crate::hud::debug_hud::DebugHudSystem::new)
111}
112
113// LoadingOverlaySystem: present whenever the world declares a `LoadingOverlay`;
114// built from that component (its screen + element refs).
115pub(crate) fn loading_overlay(
116    world: &World,
117) -> Option<crate::hud::loading_overlay::LoadingOverlaySystem> {
118    world
119        .query::<crate::components::LoadingOverlay>()
120        .next()
121        .cloned()
122        .map(crate::hud::loading_overlay::LoadingOverlaySystem::new)
123}
124
125// PhysicsSystem: present whenever the world has physics content, namely a
126// `PhysicsConfig` (optional floor / terrain tuning), a `RigidBody` (character
127// capsule), a `PropBody` (dynamic prop), or a `TriggerVolume` (sensor
128// region). Reads the `PhysicsConfig` if present, otherwise a flat-floor
129// default.
130pub(crate) fn physics(world: &World) -> Option<concinnity_core::physics::PhysicsSystem> {
131    let needs = world
132        .query::<crate::components::PhysicsConfig>()
133        .next()
134        .is_some()
135        || world.query::<crate::components::RigidBody>().next().is_some()
136        || world.query::<crate::components::PropBody>().next().is_some()
137        || world
138            .query::<crate::components::TriggerVolume>()
139            .next()
140            .is_some()
141        // A skinned mesh with a character capsule needs the rig drive
142        // (the CharacterRig itself is published later, by GraphicsSystem
143        // init, so gate on the baked resource data).
144        || world
145            .resource::<crate::resource::SkinnedMeshTable>()
146            .is_some_and(|t| t.has_capsule());
147    if !needs {
148        return None;
149    }
150    // Cook injects the config into every shipped world with physics content,
151    // so the fallback covers worlds built directly (tests, the editor's
152    // in-memory path).
153    let config = world
154        .query::<crate::components::PhysicsConfig>()
155        .next()
156        .cloned()
157        .unwrap_or_default();
158    Some(crate::physics::build(config))
159}
160
161// The first controlled `Camera3D` picks the controller flavor: no `follow`
162// block selects this first-person / fly controller, a `follow` block selects
163// the adjacent ThirdPersonSystem entry instead (a camera never gets both). A
164// `controller: null` camera opts out entirely (cutscene cameras).
165pub(crate) fn camera3d(world: &World) -> Option<crate::gfx::camera_controller::Camera3DSystem> {
166    let ctrl = controlled_camera(world)?;
167    ctrl.follow
168        .is_none()
169        .then(|| crate::gfx::camera_controller::Camera3DSystem::new(ctrl))
170}
171
172// Counterpart of `camera3d`: the first controlled camera declares a `follow`
173// block, so the third-person controller drives it.
174pub(crate) fn third_person(world: &World) -> Option<crate::gfx::third_person::ThirdPersonSystem> {
175    let ctrl = controlled_camera(world)?;
176    ctrl.follow
177        .is_some()
178        .then(|| crate::gfx::third_person::ThirdPersonSystem::new(&ctrl))
179}
180
181fn controlled_camera(world: &World) -> Option<crate::components::CameraController> {
182    world
183        .query::<crate::components::Camera3D>()
184        .find_map(|c| c.controller.clone())
185}
186
187// FpsCounter: present whenever the world declares an `FpsCounter`; built from
188// that component (its optional TextLabel ref).
189pub(crate) fn fps_counter(world: &World) -> Option<crate::hud::fps_counter::FpsCounterSystem> {
190    world
191        .query::<crate::components::FpsCounter>()
192        .next()
193        .cloned()
194        .map(crate::hud::fps_counter::FpsCounterSystem::new)
195}
196
197// AnimationSystem: present whenever the world declares any `Animation` or
198// `AnimationGraph`. It drains both at init and writes `SkeletonPose` each
199// frame. (A graph without clips is a build error, so the second check
200// only matters for hand-assembled worlds.)
201pub(crate) fn animation(world: &World) -> Option<crate::gfx::animation::AnimationSystem> {
202    let declared = world
203        .query::<crate::components::Animation>()
204        .next()
205        .is_some()
206        || world
207            .query::<crate::components::AnimationGraph>()
208            .next()
209            .is_some();
210    declared.then(crate::gfx::animation::AnimationSystem::new)
211}
212
213// StorySystem: present whenever the world declares a `Story` (a compiled
214// story graph). It runs before AudioSystem so its page-audio requests are
215// heard the same tick, and before UiInputSystem like every other event
216// producer (its screen commands apply next frame).
217pub(crate) fn story(world: &World) -> Option<crate::story::StorySystem> {
218    world
219        .query::<crate::components::Story>()
220        .next()
221        .cloned()
222        .map(|story| crate::story::StorySystem::new(story, crate::ecs::state_tree(world)))
223}
224
225// AudioSystem: present whenever the world declares any `AudioEmitter`
226// (positional sound), `AudioCue` (screen-triggered sound), `Story`
227// (page-triggered sound), or `Behavior` with a sound node. Its init opens
228// an audio device, so a world with none of them stays silent and device-free.
229pub(crate) fn audio(world: &World) -> Option<crate::audio::AudioSystem> {
230    let needs = world
231        .query::<crate::components::AudioEmitter>()
232        .next()
233        .is_some()
234        || world
235            .query::<crate::components::AudioCue>()
236            .next()
237            .is_some()
238        || world
239            .query::<crate::components::Story>()
240            .next()
241            .is_some_and(|s| {
242                s.nodes.iter().any(|n| {
243                    n.choice_music.is_some()
244                        || !n.choice_sounds.is_empty()
245                        || n.pages
246                            .iter()
247                            .any(|p| p.music.is_some() || !p.sounds.is_empty())
248                })
249            })
250        || world
251            .query::<crate::components::Behavior>()
252            .any(crate::components::Behavior::plays_sound);
253    if !needs {
254        return None;
255    }
256    // The persisted volumes live in the engine's settings store; resolve them
257    // here and hand them to the system so the audio crate stays free of the
258    // engine's `Settings` type.
259    let audio = crate::config::Settings::load(crate::ecs::state_tree(world)).audio;
260    Some(crate::audio::AudioSystem::new(crate::audio::AudioVolumes {
261        master: audio.master_volume,
262        music: audio.music_volume,
263        sfx: audio.sfx_volume,
264        voice: audio.voice_volume,
265    }))
266}
267
268// UiInputSystem: present whenever the world declares any `HitRegion`, `Screen`,
269// or `KeyBinding`. It drains all three at init.
270pub(crate) fn ui_input(world: &World) -> Option<crate::ui::UiInputSystem> {
271    let needs = world
272        .query::<crate::components::HitRegion>()
273        .next()
274        .is_some()
275        || world.query::<crate::components::Screen>().next().is_some()
276        || world
277            .query::<crate::components::KeyBinding>()
278            .next()
279            .is_some();
280    needs.then(crate::ui::UiInputSystem::new)
281}
282
283// TextInputSystem: present whenever the world declares any `TextInput`. It
284// edits the focused field in place from the frame's typed character and
285// caret keys, so it runs after GraphicsSystem deposits `FrameInput`.
286pub(crate) fn text_input(world: &World) -> Option<crate::text_input_system::TextInputSystem> {
287    world
288        .query::<crate::components::TextInput>()
289        .next()
290        .map(|_| crate::text_input_system::TextInputSystem::new())
291}
292
293#[cfg(test)]
294mod tests {
295    use crate::components::{Camera3D, CameraController, PhysicsConfig, RigidBody};
296    use crate::ecs::{SYSTEMS, World};
297
298    fn controlled_camera() -> Camera3D {
299        Camera3D {
300            fov_y_degrees: 75.0,
301            near: 0.05,
302            far: 200.0,
303            view_matrix: [[0.0; 4]; 4],
304            position: [0.0, 1.0, 0.0],
305            yaw: 0.0,
306            pitch: 0.0,
307            desired_move: [0.0; 3],
308            jump_requested: false,
309            interact_requested: false,
310            controller: Some(CameraController::default()),
311        }
312    }
313
314    // A PhysicsConfig gates the internal physics system on.
315    #[test]
316    fn physics_config_spawns_internal_system() {
317        let mut world = World::new();
318        world.add_component(PhysicsConfig::default());
319        world.start(SYSTEMS).unwrap();
320        let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
321        assert_eq!(names, ["PhysicsSystem"]);
322    }
323
324    // A RigidBody (character capsule) gates physics on, even with no config.
325    #[test]
326    fn rigid_body_spawns_internal_system() {
327        let mut world = World::new();
328        world.add_component(RigidBody::default());
329        world.start(SYSTEMS).unwrap();
330        let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
331        assert_eq!(names, ["PhysicsSystem"]);
332    }
333
334    // No physics content (no PhysicsConfig / RigidBody / PropBody) → no system.
335    #[test]
336    fn no_physics_content_no_system() {
337        let mut world = World::new();
338        world.start(SYSTEMS).unwrap();
339        assert!(world.systems().is_empty());
340    }
341
342    // PhysicsSystem runs before Camera3DSystem: it consumes the camera's
343    // previous-frame movement intent.
344    #[test]
345    fn physics_runs_before_camera_controller() {
346        let mut world = World::new();
347        world.add_component(PhysicsConfig::default());
348        world.add_component(controlled_camera());
349        world.start(SYSTEMS).unwrap();
350        let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
351        assert_eq!(names, ["PhysicsSystem", "Camera3DSystem"]);
352    }
353
354    // An `AudioEmitter` in the world spawns the internal AudioSystem; without
355    // one, no audio device is opened.
356    #[test]
357    fn audio_emitter_spawns_internal_system() {
358        let mut world = World::new();
359        world.add_component(crate::components::AudioEmitter::default());
360        world.start(SYSTEMS).unwrap();
361
362        let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
363        assert_eq!(names, ["AudioSystem"]);
364    }
365
366    // No audio content means no AudioSystem (no audio device is opened).
367    #[test]
368    fn no_audio_emitter_means_no_system() {
369        let mut world = World::new();
370        world.start(SYSTEMS).unwrap();
371        assert!(world.systems().is_empty());
372    }
373
374    // An `AudioCue` alone (no emitter) also spawns the audio system: a UI-only
375    // world can play screen-triggered audio.
376    #[test]
377    fn audio_cue_spawns_internal_system() {
378        let mut world = World::new();
379        world.add_component(crate::components::AudioCue::default());
380        world.start(SYSTEMS).unwrap();
381
382        let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
383        assert!(names.contains(&"AudioSystem"), "{names:?}");
384    }
385
386    // The full trigger chain: the initial screen's activation (announced by
387    // UiInputSystem at init) reaches the audio system, which matches the
388    // screen's cue on the first step. Playback itself needs a device and a
389    // compiled payload, so the test observes the match counter.
390    #[test]
391    fn initial_view_fires_its_cue() {
392        use crate::components::{AudioCue, Screen};
393        use crate::ecs::AudioClipHandle;
394        use crate::ecs::asset_id::AssetId;
395
396        let mut world = World::new();
397        let screen = AssetId(90);
398        // The cue references its clip by handle. Matching (screen + clip present)
399        // is independent of the clip payload, so no `AudioClipTable` is needed
400        // here -- the counter observes the match, not playback.
401        world.add_component(Screen {
402            asset_id: screen,
403            initial: true,
404            fade_in_secs: 0.0,
405            ..Default::default()
406        });
407        world.add_component(AudioCue {
408            screen: Some(screen),
409            clip: Some(AudioClipHandle(0)),
410            ..Default::default()
411        });
412        world.start(SYSTEMS).unwrap();
413        world.step();
414
415        let matched = world
416            .systems()
417            .iter()
418            .find_map(|s| s.downcast_ref::<crate::audio::AudioSystem>())
419            .map(|a| a.cues_matched())
420            .expect("world has an AudioSystem");
421        assert_eq!(matched, 1, "the initial screen's cue should have matched");
422    }
423}