use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use bevy_ecs::entity::Entity;
use bevy_ecs::prelude::{
Commands, Component, DetectChanges, Query, Ref, RemovedComponents, Res,
ResMut, Without,
};
use serde::{Deserialize, Serialize};
use super::{
ActionMap, CharacterMove, Collider, ColliderShape, FrameTime, MeshRenderer,
PhysicsBody, PhysicsWorld, RigidBody, RigidBodyKind, RuntimeInput,
PLAYER_JUMP, PLAYER_LEFT, PLAYER_RIGHT,
};
use crate::assets::{
AlphaMode, AssetServer, Handle, MaterialAsset, MaterialModel,
PrimitiveShape,
};
use crate::Transform;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TileKind {
pub color: [f32; 4],
pub texture: Option<PathBuf>,
pub solid: bool,
}
impl Default for TileKind {
fn default() -> Self {
Self {
color: [1.0; 4],
texture: None,
solid: true,
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Sprite {
pub texture: PathBuf,
pub color: [f32; 4],
pub size: [f32; 2],
pub region: [f32; 4],
pub flip_x: bool,
pub flip_y: bool,
pub layer: i32,
#[serde(skip)]
pub handle: Option<Handle<crate::assets::TextureAsset>>,
pub normal_map: PathBuf,
#[serde(skip)]
pub normal_handle: Option<Handle<crate::assets::TextureAsset>>,
pub in_scene: bool,
}
impl Default for Sprite {
fn default() -> Self {
Self {
texture: PathBuf::new(),
color: [1.0; 4],
size: [1.0, 1.0],
region: [0.0, 0.0, 1.0, 1.0],
flip_x: false,
flip_y: false,
layer: 0,
handle: None,
normal_map: PathBuf::new(),
normal_handle: None,
in_scene: false,
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Light2d {
pub color: [f32; 3],
pub intensity: f32,
pub radius: f32,
pub height: f32,
}
impl Default for Light2d {
fn default() -> Self {
Self {
color: [1.0; 3],
intensity: 1.0,
radius: 5.0,
height: 0.5,
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Ambient2d {
pub color: [f32; 3],
}
impl Default for Ambient2d {
fn default() -> Self {
Self { color: [0.2; 3] }
}
}
#[derive(
Component, Clone, Debug, Default, PartialEq, Serialize, Deserialize,
)]
#[serde(default)]
pub struct Particles2d {
pub layer: i32,
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LightOccluder2d {
pub points: Vec<[f32; 2]>,
}
impl Default for LightOccluder2d {
fn default() -> Self {
Self {
points: vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]],
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SpriteAnimation {
pub columns: u32,
pub rows: u32,
pub first: u32,
pub count: u32,
pub fps: f32,
pub looping: bool,
#[serde(skip)]
pub elapsed: f64,
}
impl Default for SpriteAnimation {
fn default() -> Self {
Self {
columns: 1,
rows: 1,
first: 0,
count: 1,
fps: 10.0,
looping: true,
elapsed: 0.0,
}
}
}
impl SpriteAnimation {
#[must_use]
pub fn frame(&self) -> u32 {
let count = self.count.max(1);
let played =
(self.elapsed * f64::from(self.fps.max(0.0)) + 1e-3).floor() as u32;
let step = if self.looping {
played % count
} else {
played.min(count - 1)
};
self.first + step
}
#[must_use]
pub fn region(&self, frame: u32) -> [f32; 4] {
let (columns, rows) = (self.columns.max(1), self.rows.max(1));
let (column, row) = (frame % columns, (frame / columns).min(rows - 1));
let (width, height) = (1.0 / columns as f32, 1.0 / rows as f32);
[
column as f32 * width,
row as f32 * height,
(column + 1) as f32 * width,
(row + 1) as f32 * height,
]
}
}
pub(super) fn animate_sprites(
time: Res<FrameTime>,
mut sprites: Query<(&mut SpriteAnimation, &mut Sprite)>,
) {
let dt = time.fixed_delta.as_secs_f64();
for (mut animation, mut sprite) in &mut sprites {
animation.elapsed += dt;
let region = animation.region(animation.frame());
if sprite.region != region {
sprite.region = region;
}
}
}
pub const MAX_TILE_CELLS: usize = 4096;
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TileMap {
pub tile_size: f32,
pub rows: Vec<String>,
pub tiles: BTreeMap<String, TileKind>,
}
impl Default for TileMap {
fn default() -> Self {
Self {
tile_size: 1.0,
rows: vec!["#...#".into(), "#####".into()],
tiles: BTreeMap::from([("#".into(), TileKind::default())]),
}
}
}
impl TileMap {
#[must_use]
pub fn cell_center(&self, column: usize, row: usize) -> [f32; 2] {
[
(column as f32 + 0.5) * self.tile_size,
-(row as f32 + 0.5) * self.tile_size,
]
}
#[must_use]
pub fn cell_at(&self, local: [f32; 2]) -> Option<(usize, usize)> {
let column = (local[0] / self.tile_size).floor();
let row = (-local[1] / self.tile_size).floor();
let range = 0.0..MAX_TILE_CELLS as f32;
(range.contains(&column) && range.contains(&row))
.then_some((column as usize, row as usize))
}
#[must_use]
pub fn cell(&self, column: usize, row: usize) -> char {
self.rows
.get(row)
.and_then(|text| text.chars().nth(column))
.unwrap_or('.')
}
pub fn set_cell(
&mut self,
column: usize,
row: usize,
character: char,
) -> bool {
if self.cell(column, row) == character {
return false;
}
if self.rows.len() <= row {
self.rows.resize(row + 1, String::new());
}
let mut cells: Vec<char> = self.rows[row].chars().collect();
if cells.len() <= column {
cells.resize(column + 1, '.');
}
cells[column] = character;
self.rows[row] = cells.into_iter().collect();
true
}
pub fn fill_rect(
&mut self,
from: (usize, usize),
to: (usize, usize),
character: char,
) -> bool {
let columns = from.0.min(to.0)..=from.0.max(to.0);
let mut changed = false;
for row in from.1.min(to.1)..=from.1.max(to.1) {
if columns
.clone()
.all(|column| self.cell(column, row) == character)
{
continue;
}
if self.rows.len() <= row {
self.rows.resize(row + 1, String::new());
}
let mut cells: Vec<char> = self.rows[row].chars().collect();
if cells.len() <= *columns.end() {
cells.resize(columns.end() + 1, '.');
}
cells[columns.clone()].fill(character);
self.rows[row] = cells.into_iter().collect();
changed = true;
}
changed
}
pub fn fill_line(
&mut self,
from: (usize, usize),
to: (usize, usize),
character: char,
) -> bool {
let mut changed = false;
for (column, row) in Self::line_cells(from, to) {
changed |= self.set_cell(column, row, character);
}
changed
}
#[must_use]
pub fn line_cells(
from: (usize, usize),
to: (usize, usize),
) -> Vec<(usize, usize)> {
let (mut x, mut y) = (from.0 as i64, from.1 as i64);
let (end_x, end_y) = (to.0 as i64, to.1 as i64);
let (dx, dy) = ((end_x - x).abs(), -(end_y - y).abs());
let (step_x, step_y) = ((end_x - x).signum(), (end_y - y).signum());
let mut error = dx + dy;
let mut cells = vec![(from.0, from.1)];
while (x, y) != (end_x, end_y) {
let doubled = 2 * error;
if doubled >= dy {
error += dy;
x += step_x;
}
if doubled <= dx {
error += dx;
y += step_y;
}
cells.push((x as usize, y as usize));
}
cells
}
pub fn fill(&mut self, column: usize, row: usize, character: char) -> bool {
let target = self.cell(column, row);
let width = self
.rows
.iter()
.map(|text| text.chars().count())
.max()
.unwrap_or(0);
let height = self.rows.len();
if target == character || column >= width || row >= height {
return false;
}
let mut open = vec![(column, row)];
let mut changed = false;
while let Some((column, row)) = open.pop() {
if column >= width
|| row >= height
|| self.cell(column, row) != target
{
continue;
}
changed |= self.set_cell(column, row, character);
open.extend([(column + 1, row), (column, row + 1)]);
open.extend(column.checked_sub(1).map(|left| (left, row)));
open.extend(row.checked_sub(1).map(|up| (column, up)));
}
changed
}
#[must_use]
pub fn kind_at(&self, column: usize, row: usize) -> Option<&TileKind> {
let character = self.rows.get(row)?.chars().nth(column)?;
self.tiles.get(character.encode_utf8(&mut [0; 4]) as &str)
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
pub struct TileOf(pub Entity);
pub(super) fn build_tile_maps(
mut commands: Commands,
assets: Option<ResMut<AssetServer>>,
maps: Query<(Entity, Ref<TileMap>, Ref<Transform>)>,
mut removed: RemovedComponents<TileMap>,
tiles: Query<(Entity, &TileOf)>,
) {
let mut stale: BTreeSet<Entity> = removed.read().collect();
let changed: Vec<_> = maps
.iter()
.filter(|(_, map, transform)| {
map.is_changed() || transform.is_changed()
})
.collect();
stale.extend(changed.iter().map(|(entity, ..)| *entity));
stale.extend(
tiles
.iter()
.map(|(_, owner)| owner.0)
.filter(|owner| !maps.contains(*owner)),
);
if stale.is_empty() {
return;
}
for (tile, owner) in &tiles {
if stale.contains(&owner.0) {
commands.entity(tile).despawn();
}
}
let Some(mut assets) = assets else {
return;
};
let quad = assets.builtin_primitive(PrimitiveShape::Quad);
for (entity, map, transform) in changed {
let origin = transform.position;
let size = map.tile_size;
let mut materials = BTreeMap::new();
for (row, text) in map.rows.iter().enumerate() {
let mut run: Option<usize> = None;
let columns = text.chars().count();
for column in 0..=columns {
let kind = map.kind_at(column, row);
if let Some(kind) = kind {
let key = text.chars().nth(column).unwrap_or(' ');
let material = *materials
.entry(key)
.or_insert_with(|| tile_material(&mut assets, kind));
let [x, y] = map.cell_center(column, row);
commands.spawn((
Transform {
position: [origin[0] + x, origin[1] + y, origin[2]],
scale: [size, size, 1.0],
..Transform::default()
},
MeshRenderer {
mesh: quad,
material,
cast_shadows: false,
receive_shadows: false,
},
TileOf(entity),
));
}
let solid = kind.is_some_and(|kind| kind.solid);
match (solid, run) {
(true, None) => run = Some(column),
(false, Some(start)) => {
run = None;
let width = (column - start) as f32 * size;
let [x, y] = map.cell_center(start, row);
commands.spawn((
Transform::new([
origin[0] + x - size / 2.0 + width / 2.0,
origin[1] + y,
origin[2],
]),
PhysicsBody::default(),
RigidBody {
kind: RigidBodyKind::Fixed,
..RigidBody::default()
},
Collider {
shape: ColliderShape::Box {
half_extents: [
width / 2.0,
size / 2.0,
size / 2.0,
],
},
..Collider::default()
},
TileOf(entity),
));
}
_ => {}
}
}
}
}
}
fn tile_material(
assets: &mut AssetServer,
kind: &TileKind,
) -> Handle<MaterialAsset> {
let texture = kind.texture.as_ref().and_then(|path| {
assets
.textures
.handle_for_path(path)
.map(Ok)
.unwrap_or_else(|| assets.load_texture(path))
.map_err(|error| eprintln!("tile texture: {error}"))
.ok()
});
let material = MaterialAsset {
model: MaterialModel::Unlit,
alpha_mode: if texture.is_some() {
AlphaMode::Mask { cutoff: 0.5 }
} else {
AlphaMode::Opaque
},
base_color: kind.color,
base_color_texture: texture,
..MaterialAsset::default()
};
let existing = assets.materials.iter().find_map(|(handle, existing)| {
(*existing == material).then_some(handle)
});
existing.unwrap_or_else(|| assets.materials.insert(material))
}
pub const DEFAULT_PLATFORMER_SHAPE: ColliderShape = ColliderShape::Capsule {
half_height: 0.2,
radius: 0.3,
};
#[derive(Component, Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlatformerController {
pub run_speed: f32,
pub jump_speed: f32,
pub gravity: f32,
pub collision_mask: u32,
pub air_jumps: u32,
#[serde(skip)]
pub air_jumps_used: u32,
#[serde(skip)]
pub vertical_speed: f32,
#[serde(skip)]
pub grounded: bool,
#[serde(skip)]
pub jump_buffer: f32,
#[serde(skip)]
pub air_time: f32,
#[serde(skip)]
pub floor: Option<(Entity, [f32; 3])>,
#[serde(skip)]
pub dash_velocity: [f32; 3],
#[serde(skip)]
pub dash_left: f32,
}
pub const JUMP_GRACE: f32 = 0.1;
impl Default for PlatformerController {
fn default() -> Self {
Self {
run_speed: 5.0,
jump_speed: 8.0,
gravity: 20.0,
collision_mask: u32::MAX,
air_jumps: 0,
air_jumps_used: 0,
vertical_speed: 0.0,
grounded: false,
jump_buffer: 0.0,
air_time: 0.0,
floor: None,
dash_velocity: [0.0; 3],
dash_left: 0.0,
}
}
}
pub(super) fn platformer_jump(
input: Res<RuntimeInput>,
actions: Res<ActionMap>,
mut players: Query<&mut PlatformerController>,
) {
if actions.just_pressed(&input, PLAYER_JUMP) {
for mut player in &mut players {
player.jump_buffer = JUMP_GRACE;
}
}
}
#[allow(clippy::type_complexity)]
pub(super) fn platformer_move(
time: Res<FrameTime>,
input: Res<RuntimeInput>,
actions: Res<ActionMap>,
physics: Res<PhysicsWorld>,
mut players: Query<(
Entity,
&mut PlatformerController,
&mut Transform,
Option<&Collider>,
)>,
floors: Query<
(
&Transform,
Option<&super::Parent>,
Option<&super::GlobalTransform>,
),
Without<PlatformerController>,
>,
) {
let floor_at = |floor: Entity| {
let (t, parent, global) = floors.get(floor).ok()?;
Some(super::cpu_physics::world_position(t, parent, global))
};
let dt = time.fixed_delta.as_secs_f32();
let run = f32::from(u8::from(actions.held(&input, PLAYER_RIGHT)))
- f32::from(u8::from(actions.held(&input, PLAYER_LEFT)));
for (entity, mut player, mut transform, collider) in &mut players {
if player.jump_buffer > 0.0 && player.air_time <= JUMP_GRACE {
player.vertical_speed = player.jump_speed;
player.jump_buffer = 0.0;
player.air_time = f32::INFINITY;
} else if player.jump_buffer > 0.0
&& player.air_jumps_used < player.air_jumps
{
player.air_jumps_used += 1;
player.vertical_speed = player.jump_speed;
player.jump_buffer = 0.0;
}
player.jump_buffer = (player.jump_buffer - dt).max(0.0);
player.vertical_speed -= player.gravity * dt;
let shape = collider
.map_or(DEFAULT_PLATFORMER_SHAPE, |collider| collider.shape);
let (start, _) = CharacterMove::ride(
&physics,
shape,
transform.position,
player.floor.map(|(floor, at)| {
(floor, at, super::cpu_physics::NO_ROTATION)
}),
player.collision_mask,
entity,
|floor| Some((floor_at(floor)?, super::cpu_physics::NO_ROTATION)),
);
let mut motion =
[run * player.run_speed * dt, player.vertical_speed * dt, 0.0];
if player.dash_left > 0.0 {
motion = [
player.dash_velocity[0] * dt,
player.dash_velocity[1] * dt,
0.0,
];
player.vertical_speed = 0.0;
player.dash_left = (player.dash_left - dt).max(0.0);
}
let moved = physics.move_character(
shape,
start,
motion,
player.collision_mask,
Some(entity),
);
player.grounded = moved.grounded;
if moved.grounded && player.vertical_speed < 0.0 {
player.vertical_speed = 0.0;
player.air_time = 0.0;
player.air_jumps_used = 0;
} else {
player.air_time += dt;
}
if moved.ceiling && player.vertical_speed > 0.0 {
player.vertical_speed = 0.0;
}
player.floor = moved
.floor
.and_then(|floor| Some((floor, floor_at(floor)?)));
transform.position = moved.position;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cameras_2d_follow_smoothly_stay_in_limits_and_fit_pixels() {
use super::super::{Camera, GlobalTransform, Name, Projection};
use std::time::Duration;
let mut app = super::super::App::new();
app.world_mut()
.resource_mut::<RuntimeInput>()
.record_viewport_size([800.0, 600.0]);
let player = app
.spawn((Name("Player".into()), Transform::new([10.0, 0.0, 0.0])));
let ortho = |vertical_size| Camera {
projection: Projection::Orthographic {
vertical_size,
near: 0.1,
far: 100.0,
},
..Camera::default()
};
let camera = app.spawn((
ortho(6.0),
Transform::new([0.0, 0.0, 10.0]),
Camera2d {
target: "Player".into(),
smoothing: 0.1,
..Camera2d::default()
},
));
let at = |app: &super::super::App| {
let transform = app.world().get::<Transform>(camera).unwrap();
let global = app.world().get::<GlobalTransform>(camera).unwrap();
assert_eq!(global.matrix[3][..2], transform.position[..2]);
assert_eq!(transform.position[2], 10.0);
[transform.position[0], transform.position[1]]
};
app.update(Duration::from_secs_f32(0.1)).unwrap();
let [x, y] = at(&app);
assert!((x - 10.0 * (1.0 - (-1.0_f32).exp())).abs() < 1e-3, "{x}");
assert_eq!(y, 0.0);
let set = |app: &mut super::super::App,
edit: &dyn Fn(&mut Camera2d)| {
edit(&mut app.world_mut().get_mut::<Camera2d>(camera).unwrap());
app.update(Duration::from_secs_f32(0.1)).unwrap();
};
set(&mut app, &|follow| {
follow.smoothing = 0.0;
follow.offset = [0.0, 100.0];
follow.limits = Some([-5.0, -5.0, 5.0, 5.0]);
});
assert_eq!(at(&app), [1.0, 2.0]);
set(&mut app, &|follow| {
follow.limits = Some([0.0, -5.0, 4.0, 5.0])
});
assert_eq!(at(&app), [2.0, 2.0]);
set(&mut app, &|follow| {
follow.limits = None;
follow.offset = [0.004 - 10.0, 0.3];
follow.pixels_per_unit = 16.0;
});
let camera_component = app.world().get::<Camera>(camera).unwrap();
assert_eq!(camera_component.projection, ortho(6.25).projection);
assert_eq!(at(&app), [0.0, (0.3_f32 * 96.0).round() / 96.0]);
set(&mut app, &|follow| {
follow.offset = [1.0 - 10.0, 0.0];
follow.smoothing = 30.0;
});
for _ in 0..20 {
app.update(Duration::from_secs_f32(0.1)).unwrap();
}
assert!(at(&app)[0] > 0.05, "{:?}", at(&app));
let _ = player;
}
#[test]
fn sprite_animations_step_through_sheet_frames_on_the_fixed_tick() {
let mut app = super::super::App::new();
let step = app.world().resource::<FrameTime>().fixed_delta;
let walk = app.spawn((
Sprite::default(),
SpriteAnimation {
columns: 4,
rows: 2,
first: 2,
count: 4,
fps: 1.0 / step.as_secs_f32(),
..SpriteAnimation::default()
},
));
let once = app.spawn((
Sprite::default(),
SpriteAnimation {
columns: 4,
rows: 2,
count: 2,
fps: 1.0 / step.as_secs_f32(),
looping: false,
..SpriteAnimation::default()
},
));
let slow = app.spawn((
Sprite::default(),
SpriteAnimation {
count: 8,
columns: 8,
fps: 10.0,
..SpriteAnimation::default()
},
));
let mut frames = Vec::new();
let mut slow_frames = Vec::new();
for _ in 0..6 {
app.update(step).unwrap();
let animation =
|entity| app.world().get::<SpriteAnimation>(entity).unwrap();
frames.push((animation(walk).frame(), animation(once).frame()));
slow_frames.push(animation(slow).frame());
}
let region = |entity| app.world().get::<Sprite>(entity).unwrap().region;
assert_eq!(region(walk), [0.0, 0.5, 0.25, 1.0], "frame 4 is row two");
assert_eq!(
frames,
[(3, 1), (4, 1), (5, 1), (2, 1), (3, 1), (4, 1)],
"one frame per fixed step; the walk loops, the other holds"
);
assert_eq!(slow_frames, [0, 0, 0, 0, 0, 1]);
}
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Camera2d {
pub target: String,
pub offset: [f32; 2],
pub smoothing: f32,
pub limits: Option<[f32; 4]>,
pub pixels_per_unit: f32,
#[serde(skip)]
pub center: Option<[f32; 2]>,
}
impl Default for Camera2d {
fn default() -> Self {
Self {
target: String::new(),
offset: [0.0; 2],
smoothing: 0.15,
limits: None,
pixels_per_unit: 0.0,
center: None,
}
}
}
pub(super) fn follow_cameras_2d(
time: Res<FrameTime>,
input: Res<RuntimeInput>,
targets: Query<(&super::Name, &super::GlobalTransform), Without<Camera2d>>,
mut cameras: Query<(
&mut Camera2d,
&mut super::Camera,
&mut Transform,
&mut super::GlobalTransform,
)>,
) {
let dt = time.delta.as_secs_f32();
let [width, height] = input.viewport_size();
for (mut follow, mut camera, mut transform, mut global) in &mut cameras {
let mut center = follow
.center
.unwrap_or([transform.position[0], transform.position[1]]);
let goal = targets
.iter()
.find(|(name, _)| {
!follow.target.is_empty() && name.as_str() == follow.target
})
.map(|(_, target)| {
let at = target.matrix[3];
[at[0] + follow.offset[0], at[1] + follow.offset[1]]
});
if let Some(goal) = goal {
let share = if follow.smoothing > 0.0 {
1.0 - (-dt / follow.smoothing).exp()
} else {
1.0
};
for axis in 0..2 {
center[axis] += (goal[axis] - center[axis]) * share;
}
}
follow.center = Some(center);
let mut drawn = center;
if let super::Projection::Orthographic { vertical_size, .. } =
&mut camera.projection
{
let pixels = follow.pixels_per_unit;
if pixels > 0.0 && height > 0.0 && *vertical_size > 0.0 {
let zoom =
(height / (*vertical_size * pixels)).round().max(1.0);
*vertical_size = height / (zoom * pixels);
}
if let Some([min_x, min_y, max_x, max_y]) = follow.limits {
let aspect = if height > 0.0 { width / height } else { 1.0 };
let half =
[*vertical_size * aspect * 0.5, *vertical_size * 0.5];
for (axis, (min, max)) in
[(min_x, max_x), (min_y, max_y)].into_iter().enumerate()
{
drawn[axis] = if max - min <= half[axis] * 2.0 {
(min + max) * 0.5
} else {
drawn[axis].clamp(min + half[axis], max - half[axis])
};
}
}
if pixels > 0.0 && height > 0.0 {
let step = *vertical_size / height;
drawn = drawn.map(|value| (value / step).round() * step);
}
}
transform.position[0] = drawn[0];
transform.position[1] = drawn[1];
global.matrix[3][0] = drawn[0];
global.matrix[3][1] = drawn[1];
}
}