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,
};
use serde::{Deserialize, Serialize};
use super::{
ActionMap, 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 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 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));
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,
#[serde(skip)]
pub vertical_speed: f32,
#[serde(skip)]
pub grounded: bool,
#[serde(skip)]
pub jump_buffer: f32,
#[serde(skip)]
pub air_time: 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,
vertical_speed: 0.0,
grounded: false,
jump_buffer: 0.0,
air_time: 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;
}
}
}
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>,
)>,
) {
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;
}
player.jump_buffer = (player.jump_buffer - dt).max(0.0);
player.vertical_speed -= player.gravity * dt;
let moved = physics.move_character(
collider
.map_or(DEFAULT_PLATFORMER_SHAPE, |collider| collider.shape),
transform.position,
[run * player.run_speed * dt, player.vertical_speed * dt, 0.0],
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;
} else {
player.air_time += dt;
}
transform.position = moved.position;
}
}