Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
10
crates/domain/Cargo.toml
Normal file
10
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "domain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
libm = { version = "0.2", default-features = false }
|
||||
14
crates/domain/src/errors.rs
Normal file
14
crates/domain/src/errors.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug, PartialEq)]
|
||||
pub enum DomainError {
|
||||
#[error(
|
||||
"Logo dimension ({width}x{height}) exceeds boundary screen size ({bounds_w}x{bounds_h})"
|
||||
)]
|
||||
InvalidDimensions {
|
||||
width: u32,
|
||||
height: u32,
|
||||
bounds_w: u32,
|
||||
bounds_h: u32,
|
||||
},
|
||||
}
|
||||
13
crates/domain/src/lib.rs
Normal file
13
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
#![no_std]
|
||||
|
||||
mod errors;
|
||||
mod logo;
|
||||
mod scene;
|
||||
mod sparkle;
|
||||
mod sprite;
|
||||
mod value_objects;
|
||||
|
||||
pub use errors::DomainError;
|
||||
pub use scene::ScreensaverScene;
|
||||
pub use sprite::{Sprite, SpriteKind};
|
||||
pub use value_objects::{Color, Dimensions, Point2D, Vector2D};
|
||||
125
crates/domain/src/logo.rs
Normal file
125
crates/domain/src/logo.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use crate::{Color, Dimensions, Point2D, Sprite, SpriteKind, Vector2D};
|
||||
|
||||
struct Axis {
|
||||
position: f32,
|
||||
impact: Option<f32>,
|
||||
bounced: bool,
|
||||
}
|
||||
|
||||
fn advance(position: f32, velocity: f32, extent: u32, bound: u32) -> Axis {
|
||||
if extent > bound {
|
||||
return Axis {
|
||||
position: 0.0,
|
||||
impact: None,
|
||||
bounced: false,
|
||||
};
|
||||
}
|
||||
|
||||
let next = position + velocity;
|
||||
|
||||
let impact = if next < 0.0 {
|
||||
Some(0.0)
|
||||
} else if next + extent as f32 >= bound as f32 {
|
||||
Some(bound as f32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match impact {
|
||||
Some(_) => Axis {
|
||||
position: position - velocity,
|
||||
impact,
|
||||
bounced: true,
|
||||
},
|
||||
None => Axis {
|
||||
position: next,
|
||||
impact: None,
|
||||
bounced: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BouncingLogo {
|
||||
position: Point2D,
|
||||
velocity: Vector2D,
|
||||
dimensions: Dimensions,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
impl BouncingLogo {
|
||||
pub(crate) const fn new(
|
||||
position: Point2D,
|
||||
velocity: Vector2D,
|
||||
dimensions: Dimensions,
|
||||
color: Color,
|
||||
) -> Self {
|
||||
Self {
|
||||
position,
|
||||
velocity,
|
||||
dimensions,
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tick(&mut self, bounds: Dimensions) -> Option<Point2D> {
|
||||
let x = advance(
|
||||
self.position.x(),
|
||||
self.velocity.dx(),
|
||||
self.dimensions.width(),
|
||||
bounds.width(),
|
||||
);
|
||||
let y = advance(
|
||||
self.position.y(),
|
||||
self.velocity.dy(),
|
||||
self.dimensions.height(),
|
||||
bounds.height(),
|
||||
);
|
||||
|
||||
if x.bounced {
|
||||
self.velocity = self.velocity.reflected_x();
|
||||
}
|
||||
if y.bounced {
|
||||
self.velocity = self.velocity.reflected_y();
|
||||
}
|
||||
|
||||
if let Some(position) = Point2D::new(x.position, y.position) {
|
||||
self.position = position;
|
||||
}
|
||||
|
||||
if x.bounced || y.bounced {
|
||||
self.mutate_color();
|
||||
}
|
||||
|
||||
match (x.impact, y.impact) {
|
||||
(Some(corner_x), Some(corner_y)) => Point2D::new(corner_x, corner_y),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mutate_color(&mut self) {
|
||||
self.color = match (self.color.r, self.color.g, self.color.b) {
|
||||
(255, 0, 0) => Color {
|
||||
r: 0,
|
||||
g: 255,
|
||||
b: 0,
|
||||
a: 255,
|
||||
},
|
||||
(0, 255, 0) => Color {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 255,
|
||||
a: 255,
|
||||
},
|
||||
_ => Color {
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 255,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) const fn sprite(&self) -> Sprite {
|
||||
Sprite::new(SpriteKind::Logo, self.position, self.dimensions, self.color)
|
||||
}
|
||||
}
|
||||
73
crates/domain/src/scene.rs
Normal file
73
crates/domain/src/scene.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use crate::{
|
||||
Color, Dimensions, DomainError, Point2D, Sprite, Vector2D,
|
||||
logo::BouncingLogo,
|
||||
sparkle::{self, Sparkle},
|
||||
};
|
||||
|
||||
const MAX_SPARKLES: usize = sparkle::BURST as usize * (sparkle::LIFETIME_FRAMES as usize + 1);
|
||||
|
||||
pub struct ScreensaverScene {
|
||||
logo: BouncingLogo,
|
||||
sparkles: [Option<Sparkle>; MAX_SPARKLES],
|
||||
}
|
||||
|
||||
impl ScreensaverScene {
|
||||
pub fn new(
|
||||
logo: Dimensions,
|
||||
speed: Vector2D,
|
||||
color: Color,
|
||||
bounds: Dimensions,
|
||||
) -> Result<Self, DomainError> {
|
||||
if logo.width() > bounds.width() || logo.height() > bounds.height() {
|
||||
return Err(DomainError::InvalidDimensions {
|
||||
width: logo.width(),
|
||||
height: logo.height(),
|
||||
bounds_w: bounds.width(),
|
||||
bounds_h: bounds.height(),
|
||||
});
|
||||
}
|
||||
|
||||
let position = Point2D::new(
|
||||
(bounds.width() - logo.width()) as f32 / 2.0,
|
||||
(bounds.height() - logo.height()) as f32 / 2.0,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Self {
|
||||
logo: BouncingLogo::new(position, speed, logo, color),
|
||||
sparkles: [const { None }; MAX_SPARKLES],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, bounds: Dimensions) {
|
||||
for slot in &mut self.sparkles {
|
||||
if let Some(sparkle) = slot {
|
||||
sparkle.tick();
|
||||
|
||||
if !sparkle.is_alive() {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(corner) = self.logo.tick(bounds) {
|
||||
self.spawn(Sparkle::burst(corner));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sprites(&self) -> impl Iterator<Item = Sprite> + '_ {
|
||||
core::iter::once(self.logo.sprite())
|
||||
.chain(self.sparkles.iter().flatten().map(Sparkle::sprite))
|
||||
}
|
||||
|
||||
fn spawn(&mut self, burst: impl Iterator<Item = Sparkle>) {
|
||||
let mut free = self.sparkles.iter_mut().filter(|slot| slot.is_none());
|
||||
|
||||
for sparkle in burst {
|
||||
match free.next() {
|
||||
Some(slot) => *slot = Some(sparkle),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
crates/domain/src/sparkle.rs
Normal file
60
crates/domain/src/sparkle.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use crate::{Color, Dimensions, Point2D, Sprite, SpriteKind, Vector2D};
|
||||
|
||||
pub(crate) const BURST: u32 = 12;
|
||||
const SPEED: f32 = 3.0;
|
||||
pub(crate) const LIFETIME_FRAMES: u8 = 8;
|
||||
const FADE_PER_FRAME: u8 = u8::MAX / LIFETIME_FRAMES;
|
||||
const SIZE: Dimensions = match Dimensions::new(8, 8) {
|
||||
Some(size) => size,
|
||||
None => panic!("sparkle size must be non-zero"),
|
||||
};
|
||||
const COLOR: Color = Color {
|
||||
r: 255,
|
||||
g: 215,
|
||||
b: 0,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
pub(crate) struct Sparkle {
|
||||
position: Point2D,
|
||||
velocity: Vector2D,
|
||||
dimensions: Dimensions,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
impl Sparkle {
|
||||
pub(crate) fn burst(origin: Point2D) -> impl Iterator<Item = Sparkle> {
|
||||
(0..BURST).filter_map(move |step| {
|
||||
let angle = step as f32 * (core::f32::consts::TAU / BURST as f32);
|
||||
let velocity = Vector2D::new(libm::cosf(angle) * SPEED, libm::sinf(angle) * SPEED)?;
|
||||
|
||||
Some(Sparkle {
|
||||
position: origin,
|
||||
velocity,
|
||||
dimensions: SIZE,
|
||||
color: COLOR,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn tick(&mut self) {
|
||||
if let Some(position) = self.position.translated(self.velocity) {
|
||||
self.position = position;
|
||||
}
|
||||
|
||||
self.color.a = self.color.a.saturating_sub(FADE_PER_FRAME);
|
||||
}
|
||||
|
||||
pub(crate) fn is_alive(&self) -> bool {
|
||||
self.color.a > 0
|
||||
}
|
||||
|
||||
pub(crate) const fn sprite(&self) -> Sprite {
|
||||
Sprite::new(
|
||||
SpriteKind::Sparkle,
|
||||
self.position,
|
||||
self.dimensions,
|
||||
self.color,
|
||||
)
|
||||
}
|
||||
}
|
||||
47
crates/domain/src/sprite.rs
Normal file
47
crates/domain/src/sprite.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::{Color, Dimensions, Point2D};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpriteKind {
|
||||
Logo,
|
||||
Sparkle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Sprite {
|
||||
kind: SpriteKind,
|
||||
position: Point2D,
|
||||
dimensions: Dimensions,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
impl Sprite {
|
||||
pub(crate) const fn new(
|
||||
kind: SpriteKind,
|
||||
position: Point2D,
|
||||
dimensions: Dimensions,
|
||||
color: Color,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
position,
|
||||
dimensions,
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn kind(&self) -> SpriteKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub const fn position(&self) -> Point2D {
|
||||
self.position
|
||||
}
|
||||
|
||||
pub const fn dimensions(&self) -> Dimensions {
|
||||
self.dimensions
|
||||
}
|
||||
|
||||
pub const fn color(&self) -> Color {
|
||||
self.color
|
||||
}
|
||||
}
|
||||
97
crates/domain/src/value_objects.rs
Normal file
97
crates/domain/src/value_objects.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Point2D {
|
||||
x: f32,
|
||||
y: f32,
|
||||
}
|
||||
|
||||
impl Point2D {
|
||||
pub const fn new(x: f32, y: f32) -> Option<Self> {
|
||||
if x.is_finite() && y.is_finite() {
|
||||
Some(Self { x, y })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn x(&self) -> f32 {
|
||||
self.x
|
||||
}
|
||||
|
||||
pub const fn y(&self) -> f32 {
|
||||
self.y
|
||||
}
|
||||
|
||||
pub(crate) fn translated(self, by: Vector2D) -> Option<Self> {
|
||||
Self::new(self.x + by.dx, self.y + by.dy)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Vector2D {
|
||||
dx: f32,
|
||||
dy: f32,
|
||||
}
|
||||
|
||||
impl Vector2D {
|
||||
pub const fn new(dx: f32, dy: f32) -> Option<Self> {
|
||||
if dx.is_finite() && dy.is_finite() {
|
||||
Some(Self { dx, dy })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn dx(&self) -> f32 {
|
||||
self.dx
|
||||
}
|
||||
|
||||
pub const fn dy(&self) -> f32 {
|
||||
self.dy
|
||||
}
|
||||
|
||||
pub(crate) const fn reflected_x(self) -> Self {
|
||||
Self {
|
||||
dx: -self.dx,
|
||||
dy: self.dy,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn reflected_y(self) -> Self {
|
||||
Self {
|
||||
dx: self.dx,
|
||||
dy: -self.dy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Dimensions {
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
impl Dimensions {
|
||||
pub const fn new(width: u32, height: u32) -> Option<Self> {
|
||||
if width == 0 || height == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Self { width, height })
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub const fn height(&self) -> u32 {
|
||||
self.height
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
pub a: u8,
|
||||
}
|
||||
62
crates/domain/tests/common/mod.rs
Normal file
62
crates/domain/tests/common/mod.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use domain::{Color, Dimensions, DomainError, Point2D, ScreensaverScene, Sprite, Vector2D};
|
||||
|
||||
pub const LOGO: Dimensions = size(200, 100);
|
||||
pub const ROOMY: Dimensions = size(800, 600);
|
||||
pub const TINY: Dimensions = size(50, 50);
|
||||
|
||||
pub const SNUG: Dimensions = size(LOGO.width() + 2, LOGO.height() + 2);
|
||||
pub const EDGE_ONLY: Dimensions = size(LOGO.width() + 2, ROOMY.height());
|
||||
|
||||
pub const SPEED: Vector2D = velocity(3.0, 2.0);
|
||||
pub const RED: Color = Color {
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
pub const fn size(width: u32, height: u32) -> Dimensions {
|
||||
match Dimensions::new(width, height) {
|
||||
Some(size) => size,
|
||||
None => panic!("test dimensions must be non-zero"),
|
||||
}
|
||||
}
|
||||
|
||||
const fn velocity(dx: f32, dy: f32) -> Vector2D {
|
||||
match Vector2D::new(dx, dy) {
|
||||
Some(velocity) => velocity,
|
||||
None => panic!("test velocity must be finite"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn point(x: f32, y: f32) -> Point2D {
|
||||
Point2D::new(x, y).expect("test point must be finite")
|
||||
}
|
||||
|
||||
pub fn try_scene(logo: Dimensions, bounds: Dimensions) -> Result<ScreensaverScene, DomainError> {
|
||||
ScreensaverScene::new(logo, SPEED, RED, bounds)
|
||||
}
|
||||
|
||||
pub fn scene_in(bounds: Dimensions) -> ScreensaverScene {
|
||||
try_scene(LOGO, bounds).expect("LOGO must fit the test bounds")
|
||||
}
|
||||
|
||||
pub fn advance(scene: &mut ScreensaverScene, bounds: Dimensions, frames: u32) {
|
||||
for _ in 0..frames {
|
||||
scene.tick(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sprite_count(scene: &ScreensaverScene) -> usize {
|
||||
scene.sprites().count()
|
||||
}
|
||||
|
||||
pub fn logo_sprite(scene: &ScreensaverScene) -> Sprite {
|
||||
scene.sprites().next().expect("the logo is always present")
|
||||
}
|
||||
|
||||
pub fn bottom_right(bounds: Dimensions) -> Point2D {
|
||||
point(bounds.width() as f32, bounds.height() as f32)
|
||||
}
|
||||
37
crates/domain/tests/construction.rs
Normal file
37
crates/domain/tests/construction.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
mod common;
|
||||
|
||||
use common::{LOGO, ROOMY, scene_in, size, sprite_count, try_scene};
|
||||
use domain::{Dimensions, Point2D, Vector2D};
|
||||
|
||||
#[test]
|
||||
fn dimensions_reject_zero_extents() {
|
||||
assert!(Dimensions::new(0, 10).is_none());
|
||||
assert!(Dimensions::new(10, 0).is_none());
|
||||
assert!(Dimensions::new(10, 10).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_reject_non_finite_coordinates() {
|
||||
assert!(Point2D::new(f32::NAN, 0.0).is_none());
|
||||
assert!(Point2D::new(0.0, f32::INFINITY).is_none());
|
||||
assert!(Point2D::new(-1.0, 1.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vectors_reject_non_finite_components() {
|
||||
assert!(Vector2D::new(f32::NAN, 1.0).is_none());
|
||||
assert!(Vector2D::new(1.0, f32::NEG_INFINITY).is_none());
|
||||
assert!(Vector2D::new(0.0, 0.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logo_must_fit_inside_the_bounds() {
|
||||
assert!(try_scene(LOGO, LOGO).is_ok());
|
||||
assert!(try_scene(size(LOGO.width() + 1, LOGO.height()), LOGO).is_err());
|
||||
assert!(try_scene(size(LOGO.width(), LOGO.height() + 1), LOGO).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_scene_holds_only_the_logo() {
|
||||
assert_eq!(sprite_count(&scene_in(ROOMY)), 1);
|
||||
}
|
||||
75
crates/domain/tests/motion.rs
Normal file
75
crates/domain/tests/motion.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
EDGE_ONLY, ROOMY, SNUG, TINY, advance, bottom_right, logo_sprite, scene_in, sprite_count,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn hitting_a_corner_bursts_sparkles_at_that_corner() {
|
||||
let mut scene = scene_in(SNUG);
|
||||
|
||||
advance(&mut scene, SNUG, 1);
|
||||
|
||||
let corner = bottom_right(SNUG);
|
||||
|
||||
assert!(scene.sprites().any(|sprite| sprite.position() == corner));
|
||||
assert!(sprite_count(&scene) > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitting_a_single_edge_recolours_but_does_not_burst() {
|
||||
let mut scene = scene_in(EDGE_ONLY);
|
||||
let before = logo_sprite(&scene).color();
|
||||
|
||||
advance(&mut scene, EDGE_ONLY, 1);
|
||||
|
||||
assert_ne!(logo_sprite(&scene).color(), before);
|
||||
assert_eq!(sprite_count(&scene), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparkles_expire_so_the_scene_reaches_a_steady_size() {
|
||||
let mut scene = scene_in(SNUG);
|
||||
|
||||
advance(&mut scene, SNUG, 64);
|
||||
let steady = sprite_count(&scene);
|
||||
advance(&mut scene, SNUG, 512);
|
||||
|
||||
assert_eq!(sprite_count(&scene), steady);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sparkle_buffer_holds_the_worst_case_without_dropping_any() {
|
||||
let mut scene = scene_in(SNUG);
|
||||
|
||||
advance(&mut scene, SNUG, 256);
|
||||
|
||||
assert_eq!(sprite_count(&scene), 12 * 9 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_shrinking_below_the_logo_neither_recolours_nor_bursts() {
|
||||
let mut scene = scene_in(ROOMY);
|
||||
let before = logo_sprite(&scene).color();
|
||||
|
||||
advance(&mut scene, TINY, 64);
|
||||
|
||||
assert_eq!(logo_sprite(&scene).color(), before);
|
||||
assert_eq!(sprite_count(&scene), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logo_never_leaves_the_bounds() {
|
||||
let mut scene = scene_in(ROOMY);
|
||||
|
||||
for _ in 0..2048 {
|
||||
advance(&mut scene, ROOMY, 1);
|
||||
|
||||
let logo = logo_sprite(&scene);
|
||||
let (position, size) = (logo.position(), logo.dimensions());
|
||||
|
||||
assert!(position.x() >= 0.0 && position.y() >= 0.0);
|
||||
assert!(position.x() + size.width() as f32 <= ROOMY.width() as f32);
|
||||
assert!(position.y() + size.height() as f32 <= ROOMY.height() as f32);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user