all_is_cubes/block/modifier/
move.rs

1use crate::block::TickAction;
2use crate::block::{
3    self, AIR, Block, BlockAttributes, Evoxel, Evoxels, MinEval, Modifier, Resolution::R16,
4};
5use crate::math::{Face6, GridAab, GridCoordinate, GridRotation, GridVector, Vol};
6use crate::op::Operation;
7use crate::time;
8use crate::universe;
9
10/// Data for [`Modifier::Move`]; displaces the block out of the grid, cropping it.
11/// A pair of `Move`s can depict a block moving between two cubes.
12///
13/// # Animation
14///
15/// * If the `distance` is zero then the modifier will remove itself, if possible,
16///   on the next tick.
17/// * If the `distance` and `velocity` are such that the block is out of view and will
18///   never start being in view, the block will be replaced with [`AIR`].
19///
20/// (TODO: Define the conditions for “if possible”.)
21#[derive(Clone, Debug, Eq, Hash, PartialEq)]
22#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
23#[non_exhaustive]
24pub struct Move {
25    /// The direction in which the block is displaced.
26    pub direction: Face6,
27
28    /// The distance, in 1/256ths, by which it is displaced.
29    pub distance: u16,
30
31    /// The amount by which `self.distance` is changing every time
32    /// `self.schedule` fires.
33    pub velocity: i16,
34
35    /// When to apply the velocity.
36    ///
37    /// If `self.velocity` is zero, this is ignored.
38    pub schedule: time::Schedule,
39}
40
41impl Move {
42    /// TODO: make a cleaner, less internals-ish constructor
43    pub fn new(direction: Face6, distance: u16, velocity: i16) -> Self {
44        Self {
45            direction,
46            distance,
47            velocity,
48            schedule: time::Schedule::EVERY_TICK,
49        }
50    }
51
52    /// Create a pair of [`Move`]s to displace a block.
53    /// The first goes on the block being moved, and the second on the air
54    /// it's moving into.
55    //---
56    // TODO: This is going to need to change again in order to support
57    // moving one block in and another out at the same time.
58    //
59    // TODO: This would be a good candidate for an example
60    #[must_use]
61    pub fn into_paired(self) -> [Self; 2] {
62        let complement = self.complement();
63        [self, complement]
64    }
65
66    /// Calculate the modifier which should be paired with this one, and located at the adjacent
67    /// cube pointed to by [`Self::direction`], to produce a complete moving block across two
68    /// cubes.
69    #[must_use]
70    pub fn complement(&self) -> Self {
71        Move {
72            direction: self.direction.opposite(),
73            distance: 256 - self.distance,
74            velocity: -self.velocity,
75            schedule: self.schedule,
76        }
77    }
78
79    /// Rotate the movement direction as specified.
80    #[must_use]
81    pub fn rotate(mut self, rotation: GridRotation) -> Self {
82        self.direction = rotation.transform(self.direction);
83        self
84    }
85
86    /// Note that `Modifier::Move` does some preprocessing to keep this simpler.
87    pub(super) fn evaluate(
88        &self,
89        block: &Block,
90        this_modifier_index: usize,
91        mut input: MinEval,
92        filter: &block::EvalFilter<'_>,
93    ) -> Result<MinEval, block::InEvalError> {
94        let Move {
95            direction,
96            distance,
97            velocity,
98            schedule,
99        } = *self;
100
101        // Apply Quote to ensure that the block's own `tick_action` and other effects
102        // don't interfere with movement or cause duplication.
103        // (In the future we may want a more nuanced policy that allows internal changes,
104        // but that will involve some sort of predicate and transformation on tick actions.)
105        input = block::Quote::default().evaluate(input, filter)?;
106
107        // TODO: short-circuit case when distance is 0
108
109        let (input_attributes, input_voxels) = input.into_parts();
110        let (original_bounds, effective_resolution) = match input_voxels.single_voxel() {
111            None => (input_voxels.bounds(), input_voxels.resolution()),
112            // Treat atom blocks as having a resolution of 16. TODO: Improve on this hardcoded constant
113            Some(_) => (GridAab::for_block(R16), R16),
114        };
115
116        // For now, our strategy is to work in units of the block's resolution.
117        // TODO: Generalize to being able to increase resolution to a chosen minimum.
118        let distance_in_res =
119            GridCoordinate::from(distance) * GridCoordinate::from(effective_resolution) / 256;
120        let translation_in_res = direction.vector(distance_in_res);
121
122        // This will be None if the displacement puts the block entirely out of view.
123        let displaced_bounds: Option<GridAab> = original_bounds
124            .translate(translation_in_res)
125            .intersection_cubes(GridAab::for_block(effective_resolution));
126
127        let animation_op: Option<Operation> = if displaced_bounds.is_none() && velocity >= 0 {
128            // Displaced to invisibility; turn into just plain air.
129            Some(Operation::Become(AIR))
130        } else if translation_in_res == GridVector::zero() && velocity == 0
131            || distance == 0 && velocity < 0
132        {
133            // Either a stationary displacement which is invisible, or an animated one which has finished its work.
134            assert!(
135                matches!(&block.modifiers()[this_modifier_index], Modifier::Move(m) if m == self)
136            );
137            let mut new_block = block.clone();
138            new_block.modifiers_mut().remove(this_modifier_index); // TODO: What if other modifiers want to do things?
139            Some(Operation::Become(new_block))
140        } else if velocity != 0 {
141            // Movement in progress.
142            assert!(
143                matches!(&block.modifiers()[this_modifier_index], Modifier::Move(m) if m == self)
144            );
145            let mut new_block = block.clone();
146            {
147                let modifiers = new_block.modifiers_mut();
148
149                // Update the distance for the next step.
150                #[expect(clippy::shadow_unrelated)]
151                if let Modifier::Move(Move {
152                    distance, velocity, ..
153                }) = &mut modifiers[this_modifier_index]
154                {
155                    *distance = i32::from(*distance)
156                            .saturating_add(i32::from(*velocity))
157                            .clamp(0, i32::from(u16::MAX))
158                            .try_into()
159                            .unwrap(/* clamped to range */);
160                }
161
162                // Do not include any other modifiers.
163                // The modifiers themselves are responsible for doing so.
164                modifiers.truncate(this_modifier_index + 1);
165            }
166            Some(Operation::Become(new_block))
167        } else {
168            // Stationary displacement; take no action
169            None
170        };
171
172        let animation_hint = if animation_op.is_some() {
173            input_attributes.animation_hint
174                | block::AnimationHint::replacement(block::AnimationChange::Shape)
175        } else {
176            input_attributes.animation_hint
177        };
178
179        let attributes = BlockAttributes {
180            animation_hint,
181            tick_action: animation_op.map(|operation| TickAction {
182                operation,
183                schedule,
184            }),
185            ..input_attributes
186        };
187
188        Ok(match displaced_bounds {
189            Some(displaced_bounds) => {
190                block::Budget::decrement_voxels(
191                    &filter.budget,
192                    displaced_bounds.volume().unwrap(),
193                )?;
194
195                let displaced_voxels = match input_voxels.single_voxel() {
196                    None => {
197                        let voxels = input_voxels.as_vol_ref();
198                        Evoxels::from_many(
199                            effective_resolution,
200                            Vol::from_fn(displaced_bounds, |cube| {
201                                voxels[cube - translation_in_res]
202                            }),
203                        )
204                    }
205                    Some(voxel) => {
206                        // Input block is a solid color; synthesize voxels.
207                        // TODO: Also synthesize if the resolution is merely low
208                        // compared to the velocity.
209                        Evoxels::from_many(
210                            effective_resolution,
211                            Vol::from_fn(displaced_bounds, |_| voxel),
212                        )
213                    }
214                };
215                MinEval::new(attributes, displaced_voxels)
216            }
217            None => MinEval::new(attributes, Evoxels::from_one(Evoxel::AIR)),
218        })
219    }
220}
221
222impl From<Move> for Modifier {
223    fn from(value: Move) -> Self {
224        Modifier::Move(value)
225    }
226}
227
228impl universe::VisitHandles for Move {
229    fn visit_handles(&self, _visitor: &mut dyn universe::HandleVisitor) {
230        let Move {
231            direction: _,
232            distance: _,
233            velocity: _,
234            schedule: _,
235        } = self;
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::block::{Composite, EvaluatedBlock, Resolution::*, VoxelOpacityMask};
243    use crate::content::make_some_blocks;
244    use crate::math::{FaceMap, GridPoint, OpacityCategory, Rgb, Rgba, rgba_const, zo32};
245    use crate::space::{self, Space};
246    use crate::universe::{ReadTicket, Universe};
247    use pretty_assertions::assert_eq;
248
249    #[test]
250    fn move_atom_block_evaluation() {
251        let color = rgba_const!(1.0, 0.0, 0.0, 1.0);
252        let original = Block::from(color);
253        let moved = original.clone().with_modifier(Move {
254            direction: Face6::PY,
255            distance: 128, // distance 1/2 block × scale factor of 256
256            velocity: 0,
257            schedule: time::Schedule::EVERY_TICK,
258        });
259
260        let expected_bounds = GridAab::from_lower_size([0, 8, 0], [16, 8, 16]);
261
262        let ev_original = original.evaluate(ReadTicket::stub()).unwrap();
263        assert_eq!(
264            moved.evaluate(ReadTicket::stub()).unwrap(),
265            EvaluatedBlock {
266                block: moved,
267                attributes: ev_original.attributes.clone(),
268                voxels: Evoxels::from_many(
269                    R16,
270                    Vol::repeat(expected_bounds, Evoxel::from_block(&ev_original))
271                ),
272                cost: block::Cost {
273                    components: ev_original.cost.components + 1,
274                    voxels: expected_bounds.volume_f64() as u32,
275                    recursion: 0
276                },
277                derived: block::Derived {
278                    color: color.to_rgb().with_alpha(zo32(2. / 3.)),
279                    face_colors: FaceMap {
280                        nx: color.to_rgb().with_alpha(zo32(0.5)),
281                        ny: color.to_rgb().with_alpha(zo32(1.0)),
282                        nz: color.to_rgb().with_alpha(zo32(0.5)),
283                        px: color.to_rgb().with_alpha(zo32(0.5)),
284                        py: color.to_rgb().with_alpha(zo32(1.0)),
285                        pz: color.to_rgb().with_alpha(zo32(0.5)),
286                    },
287                    light_emission: Rgb::ZERO,
288                    opaque: FaceMap::splat(false).with(Face6::PY, true),
289                    visible: true,
290                    uniform_collision: None,
291                    voxel_opacity_mask: VoxelOpacityMask::new_raw(
292                        R16,
293                        Vol::repeat(expected_bounds, OpacityCategory::Opaque)
294                    ),
295                }
296            }
297        );
298    }
299
300    #[test]
301    fn move_voxel_block_evaluation() {
302        let mut universe = Universe::new();
303        let resolution = R2;
304        let color = rgba_const!(1.0, 0.0, 0.0, 1.0);
305        let original = Block::builder()
306            .voxels_fn(resolution, |_| Block::from(color))
307            .unwrap()
308            .build_into(&mut universe);
309
310        let moved = original.clone().with_modifier(Move {
311            direction: Face6::PY,
312            distance: 128, // distance 1/2 block × scale factor of 256
313            velocity: 0,
314            schedule: time::Schedule::EVERY_TICK,
315        });
316
317        let expected_bounds = GridAab::from_lower_size([0, 1, 0], [2, 1, 2]);
318
319        let ev_original = original.evaluate(universe.read_ticket()).unwrap();
320        assert_eq!(
321            moved.evaluate(universe.read_ticket()).unwrap(),
322            EvaluatedBlock {
323                block: moved,
324                attributes: ev_original.attributes.clone(),
325                cost: block::Cost {
326                    components: ev_original.cost.components + 1,
327                    voxels: 2u32.pow(3) * 3 / 2, // original recur + 1/2 block of Move
328                    recursion: 0
329                },
330                voxels: Evoxels::from_many(
331                    resolution,
332                    Vol::repeat(expected_bounds, Evoxel::from_block(&ev_original))
333                ),
334                derived: block::Derived {
335                    color: color.to_rgb().with_alpha(zo32(2. / 3.)),
336                    face_colors: FaceMap {
337                        nx: color.to_rgb().with_alpha(zo32(0.5)),
338                        ny: color.to_rgb().with_alpha(zo32(1.0)),
339                        nz: color.to_rgb().with_alpha(zo32(0.5)),
340                        px: color.to_rgb().with_alpha(zo32(0.5)),
341                        py: color.to_rgb().with_alpha(zo32(1.0)),
342                        pz: color.to_rgb().with_alpha(zo32(0.5)),
343                    },
344                    light_emission: Rgb::ZERO,
345                    opaque: FaceMap::splat(false).with(Face6::PY, true),
346                    visible: true,
347                    uniform_collision: None,
348                    voxel_opacity_mask: VoxelOpacityMask::new_raw(
349                        resolution,
350                        Vol::repeat(expected_bounds, OpacityCategory::Opaque)
351                    ),
352                }
353            }
354        );
355    }
356
357    /// [`Modifier::Move`] incorporates [`Modifier::Quote`] to ensure that no conflicting
358    /// effects happen.
359    #[test]
360    fn move_also_quotes() {
361        let universe = Universe::new();
362        let original = Block::builder()
363            .color(Rgba::WHITE)
364            .tick_action(Some(TickAction::from(Operation::Become(AIR))))
365            .build();
366        let moved = original.with_modifier(Move {
367            direction: Face6::PY,
368            distance: 128,
369            velocity: 0,
370            schedule: time::Schedule::EVERY_TICK,
371        });
372
373        assert_eq!(
374            moved.evaluate(universe.read_ticket()).unwrap().attributes.tick_action,
375            None
376        );
377    }
378
379    /// Set up a `Modifier::Move`, let it run, and then allow assertions to be made about the result.
380    fn move_block_test(
381        direction: Face6,
382        velocity: i16,
383        checker: impl FnOnce(space::Read<'_>, &Block),
384    ) {
385        let [block] = make_some_blocks();
386        let [move_out, move_in] = Move::new(direction, 0, velocity).into_paired();
387        let space = Space::builder(GridAab::from_lower_upper([-1, -1, -1], [2, 2, 2]))
388            .build_and_mutate(|m| {
389                m.set([0, 0, 0], block.clone().with_modifier(move_out))?;
390                m.set(
391                    GridPoint::origin() + direction.normal_vector(),
392                    block.clone().with_modifier(move_in),
393                )?;
394                Ok(())
395            })
396            .unwrap();
397        let mut universe = Universe::new();
398        let space = universe.insert("space".into(), space).unwrap();
399        // TODO: We need a "step until idle" function, or for the UniverseStepInfo to convey how many blocks were updated / are waiting
400        // TODO: Some tests will want to look at the partial results
401        for _ in 0..257 {
402            universe.step(false, time::Deadline::Whenever);
403        }
404        checker(space.read(universe.read_ticket()).unwrap(), &block);
405    }
406
407    #[test]
408    fn velocity_zero() {
409        move_block_test(Face6::PX, 0, |space, block| {
410            assert_eq!(&space[[0, 0, 0]], block);
411            assert_eq!(&space[[1, 0, 0]], &AIR);
412        });
413    }
414
415    #[test]
416    fn velocity_slow() {
417        move_block_test(Face6::PX, 1, |space, block| {
418            assert_eq!(&space[[0, 0, 0]], &AIR);
419            assert_eq!(&space[[1, 0, 0]], block);
420        });
421    }
422
423    #[test]
424    fn velocity_whole_cube_in_one_tick() {
425        move_block_test(Face6::PX, 256, |space, block| {
426            assert_eq!(&space[[0, 0, 0]], &AIR);
427            assert_eq!(&space[[1, 0, 0]], block);
428        });
429    }
430
431    /// Check the behavior of a `Move` modifier under a `Rotate` modifier.
432    /// In particular, we want to make sure the outcome doesn’t end up doubly-rotated.
433    #[test]
434    fn move_inside_rotation() {
435        let universe = Universe::new();
436        let [base] = make_some_blocks();
437        const R: Modifier = Modifier::Rotate(Face6::PY.clockwise());
438
439        let block = base
440            .clone()
441            .with_modifier(Move {
442                direction: Face6::PX,
443                distance: 10,
444                velocity: 10,
445                schedule: time::Schedule::EVERY_TICK,
446            })
447            .with_modifier(R);
448
449        let expected_after_tick = base
450            .with_modifier(Move {
451                direction: Face6::PX,
452                distance: 20,
453                velocity: 10,
454                schedule: time::Schedule::EVERY_TICK,
455            })
456            .with_modifier(R);
457
458        assert_eq!(
459            block.evaluate(universe.read_ticket()).unwrap().attributes.tick_action,
460            Some(TickAction::from(Operation::Become(expected_after_tick)))
461        );
462    }
463
464    /// Test [`Move`] acting within another modifier ([`Composite`]).
465    #[test]
466    fn move_inside_composite_destination() {
467        let universe = Universe::new();
468        let [base, extra] = make_some_blocks();
469        let composite = Composite::new(extra, block::CompositeOperator::Over);
470
471        let block = base
472            .clone()
473            .with_modifier(Move {
474                direction: Face6::PX,
475                distance: 10,
476                velocity: 10,
477                schedule: time::Schedule::EVERY_TICK,
478            })
479            .with_modifier(composite.clone());
480
481        let expected_after_tick = base
482            .with_modifier(Move {
483                direction: Face6::PX,
484                distance: 20,
485                velocity: 10,
486                schedule: time::Schedule::EVERY_TICK,
487            })
488            .with_modifier(composite);
489
490        assert_eq!(
491            block.evaluate(universe.read_ticket()).unwrap().attributes.tick_action,
492            Some(TickAction::from(Operation::Become(expected_after_tick)))
493        );
494    }
495
496    /// Test [`Move`] acting within the `source` position of a [`Modifier::Composite`].
497    #[test]
498    fn move_inside_composite_source() {
499        let universe = Universe::new();
500        let [base, extra] = make_some_blocks();
501
502        let block = extra.clone().with_modifier(Composite::new(
503            base.clone().with_modifier(Move {
504                direction: Face6::PX,
505                distance: 10,
506                velocity: 10,
507                schedule: time::Schedule::EVERY_TICK,
508            }),
509            block::CompositeOperator::Over,
510        ));
511
512        let expected_after_tick = extra.with_modifier(Composite::new(
513            base.with_modifier(Move {
514                direction: Face6::PX,
515                distance: 20,
516                velocity: 10,
517                schedule: time::Schedule::EVERY_TICK,
518            }),
519            block::CompositeOperator::Over,
520        ));
521
522        assert_eq!(
523            block.evaluate(universe.read_ticket()).unwrap().attributes.tick_action,
524            Some(TickAction::from(Operation::Become(expected_after_tick)))
525        );
526    }
527}