concinnity_core/sky/
system.rs1use 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#[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 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 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 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 #[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 #[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 #[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 assert!(
159 (t.rotation_deg[0] + 30.0).abs() < 1e-2,
160 "{:?}",
161 t.rotation_deg
162 );
163 }
164
165 #[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 #[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}