Skip to main content

concinnity_core/sky/
system.rs

1use alloc::vec::Vec;
2
3use crate::components::{SkyRotation, Transform};
4use crate::ecs::{Entity, MenuActive, PipelineContext, SimTiming, StepResult, System};
5use crate::sky::SkyOrientation;
6
7const FULL_TURN_DEG: f32 = 360.0;
8
9/// Advances the world's celestial-sphere rotation on the fixed timestep.
10///
11/// Publishes the tick's [`SkyOrientation`] and writes it onto the
12/// [`SkyRotation`] entity's [`Transform`], so the renderer, the lights and the
13/// transform hierarchy all read one rotation. Frozen while a world-pausing
14/// screen is open, like the rest of the simulation.
15#[derive(Debug)]
16pub struct SkyRotationSystem {
17    axis: [f32; 3],
18    degrees_per_second: f32,
19    angle_deg: f32,
20    pivots: Vec<Entity>,
21}
22
23impl SkyRotationSystem {
24    /// The system for a world's authored rotation.
25    pub fn new(rotation: &SkyRotation) -> Self {
26        Self {
27            axis: rotation.axis,
28            degrees_per_second: rotation.degrees_per_second,
29            angle_deg: rotation.angle_deg,
30            pivots: Vec::new(),
31        }
32    }
33
34    // Publish the current orientation and carry it onto every pivot entity.
35    fn publish(&self, ctx: &mut PipelineContext) {
36        let sky = SkyOrientation::new(self.axis, self.angle_deg);
37        let rotation_deg = sky.euler_deg();
38        for &pivot in &self.pivots {
39            match ctx.get_mut::<Transform>(pivot) {
40                Some(transform) => transform.rotation_deg = rotation_deg,
41                None => ctx.insert(
42                    pivot,
43                    Transform {
44                        rotation_deg,
45                        ..Default::default()
46                    },
47                ),
48            }
49        }
50        ctx.insert_resource(sky);
51    }
52}
53
54impl System for SkyRotationSystem {
55    fn init(&mut self, ctx: &mut PipelineContext) {
56        self.pivots = ctx
57            .query_with_entity::<SkyRotation>()
58            .map(|(entity, _)| entity)
59            .collect();
60        self.publish(ctx);
61    }
62
63    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
64        if ctx.resource::<MenuActive>().is_some_and(|m| m.0) {
65            return StepResult::Continue;
66        }
67        let timing = ctx.resource::<SimTiming>().copied().unwrap_or_default();
68        let turned = self.degrees_per_second * timing.ticks as f32 * timing.tick_dt;
69        // Kept within one turn, so a long session never loses precision to a
70        // large angle.
71        let wrapped = (self.angle_deg + turned) % FULL_TURN_DEG;
72        self.angle_deg = if wrapped < 0.0 {
73            wrapped + FULL_TURN_DEG
74        } else {
75            wrapped
76        };
77        self.publish(ctx);
78        StepResult::Continue
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::ecs::World;
86
87    fn world_with(rotation: SkyRotation) -> (World, SkyRotationSystem) {
88        let system = SkyRotationSystem::new(&rotation);
89        let mut world = World::new();
90        world.add_component(rotation);
91        (world, system)
92    }
93
94    // One second of a 90-degree-per-second sky is a quarter turn, arrived at
95    // through 60 fixed ticks rather than a wall clock.
96    #[test]
97    fn the_angle_integrates_on_the_fixed_timestep() {
98        let (mut world, mut system) = world_with(SkyRotation {
99            degrees_per_second: 90.0,
100            ..Default::default()
101        });
102        system.init(&mut world.context());
103        for _ in 0..60 {
104            system.step(&mut world.context());
105        }
106        let sky = world
107            .resource::<SkyOrientation>()
108            .copied()
109            .expect("published");
110        assert!((sky.angle_deg - 90.0).abs() < 1e-3, "{}", sky.angle_deg);
111        let up = sky.rotate([0.0, 0.0, 1.0]);
112        assert!(up[1] > 0.99, "a quarter turn puts +Z overhead: {up:?}");
113    }
114
115    // The authored start angle is where the world opens, before any tick.
116    #[test]
117    fn the_authored_angle_is_live_from_init() {
118        let (mut world, mut system) = world_with(SkyRotation {
119            degrees_per_second: 0.0,
120            angle_deg: 180.0,
121            ..Default::default()
122        });
123        system.init(&mut world.context());
124        let sky = world
125            .resource::<SkyOrientation>()
126            .copied()
127            .expect("published");
128        assert_eq!(sky.angle_deg, 180.0);
129    }
130
131    // The pivot entity carries the rotation as its own transform, which is what
132    // a parented prop orbits on.
133    #[test]
134    fn the_pivot_entity_carries_the_rotation() {
135        let (mut world, mut system) = world_with(SkyRotation {
136            degrees_per_second: 60.0,
137            ..Default::default()
138        });
139        system.init(&mut world.context());
140        {
141            let ctx = world.context();
142            let mut transforms = ctx.query::<Transform>();
143            let t = transforms.next().expect("the pivot gained a transform");
144            assert_eq!(t.rotation_deg, [0.0, 0.0, 0.0]);
145            assert!(transforms.next().is_none(), "one pivot, one transform");
146        }
147        for _ in 0..30 {
148            system.step(&mut world.context());
149        }
150        let ctx = world.context();
151        let t = ctx
152            .query::<Transform>()
153            .next()
154            .copied()
155            .expect("still there");
156        // Half a second at 60 deg/s is 30 degrees of pitch, in the sense that
157        // lifts +Z toward +Y (a negative right-handed turn about +X).
158        assert!(
159            (t.rotation_deg[0] + 30.0).abs() < 1e-2,
160            "{:?}",
161            t.rotation_deg
162        );
163    }
164
165    // A full turn lands back where it started, and a negative rate counts
166    // down from the top of the turn rather than below zero.
167    #[test]
168    fn the_angle_stays_within_one_turn() {
169        let (mut world, mut system) = world_with(SkyRotation {
170            degrees_per_second: -90.0,
171            angle_deg: 30.0,
172            ..Default::default()
173        });
174        system.init(&mut world.context());
175        for _ in 0..60 {
176            system.step(&mut world.context());
177        }
178        let angle = world
179            .resource::<SkyOrientation>()
180            .expect("published")
181            .angle_deg;
182        assert!((angle - 300.0).abs() < 1e-2, "{angle}");
183        let orientation = SkyOrientation::new([1.0, 0.0, 0.0], -60.0).rotate([0.0, 0.0, 1.0]);
184        let published = world
185            .resource::<SkyOrientation>()
186            .expect("published")
187            .rotate([0.0, 0.0, 1.0]);
188        assert!(
189            (0..3).all(|i| (orientation[i] - published[i]).abs() < 1e-3),
190            "{orientation:?} {published:?}"
191        );
192    }
193
194    // A paused world holds still: the sky is simulation, not presentation.
195    #[test]
196    fn an_open_menu_freezes_the_sky() {
197        let (mut world, mut system) = world_with(SkyRotation {
198            degrees_per_second: 90.0,
199            ..Default::default()
200        });
201        system.init(&mut world.context());
202        world.insert_resource(MenuActive(true));
203        for _ in 0..60 {
204            system.step(&mut world.context());
205        }
206        assert_eq!(
207            world
208                .resource::<SkyOrientation>()
209                .expect("published")
210                .angle_deg,
211            0.0
212        );
213    }
214}