298 lines
9.3 KiB
Rust
298 lines
9.3 KiB
Rust
|
|
//! World rendering: Tiled map layers, sprites for replicated entities and the camera.
|
||
|
|
|
||
|
|
use bevy::prelude::*;
|
||
|
|
use game_core::{
|
||
|
|
GameData,
|
||
|
|
components::*,
|
||
|
|
grid::{Dir, TILE_SIZE, grid_to_world},
|
||
|
|
map::MapLayout,
|
||
|
|
};
|
||
|
|
|
||
|
|
use crate::LocalPlayer;
|
||
|
|
|
||
|
|
pub struct RenderPlugin;
|
||
|
|
|
||
|
|
impl Plugin for RenderPlugin {
|
||
|
|
fn build(&self, app: &mut App) {
|
||
|
|
app.init_resource::<LocalCharacter>()
|
||
|
|
.add_systems(Startup, setup)
|
||
|
|
.add_observer(spawn_map)
|
||
|
|
.add_systems(
|
||
|
|
Update,
|
||
|
|
(
|
||
|
|
find_local_character,
|
||
|
|
building_visuals,
|
||
|
|
item_visuals,
|
||
|
|
character_visuals,
|
||
|
|
move_characters,
|
||
|
|
update_marker,
|
||
|
|
follow_camera,
|
||
|
|
)
|
||
|
|
.chain(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const Z_BUILDING: f32 = 1.0;
|
||
|
|
const Z_ITEM: f32 = 2.0;
|
||
|
|
const Z_CHARACTER: f32 = 3.0;
|
||
|
|
const Z_MARKER: f32 = 4.0;
|
||
|
|
|
||
|
|
/// Our own character, once it has been replicated.
|
||
|
|
#[derive(Resource, Default)]
|
||
|
|
pub struct LocalCharacter(pub Option<Entity>);
|
||
|
|
|
||
|
|
/// Highlights the tile in front of the player (target of interactions).
|
||
|
|
#[derive(Component)]
|
||
|
|
struct FacingMarker;
|
||
|
|
|
||
|
|
fn setup(mut commands: Commands) {
|
||
|
|
commands.spawn(Camera2d);
|
||
|
|
commands.spawn((
|
||
|
|
FacingMarker,
|
||
|
|
Sprite::from_color(Color::srgba(1.0, 0.9, 0.2, 0.18), Vec2::splat(TILE_SIZE)),
|
||
|
|
Transform::from_xyz(0.0, 0.0, Z_MARKER),
|
||
|
|
Visibility::Hidden,
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Builds sprites for every tile when the world map arrives. Tiles are children
|
||
|
|
/// of the map entity, so they disappear with it (e.g. on disconnect).
|
||
|
|
fn spawn_map(
|
||
|
|
add: On<Add, WorldMap>,
|
||
|
|
mut commands: Commands,
|
||
|
|
maps: Query<&WorldMap>,
|
||
|
|
asset_server: Res<AssetServer>,
|
||
|
|
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||
|
|
) {
|
||
|
|
let Ok(map) = maps.get(add.entity) else {
|
||
|
|
return;
|
||
|
|
};
|
||
|
|
let layout = match MapLayout::load(&game_core::assets_dir(), &map.path) {
|
||
|
|
Ok(layout) => layout,
|
||
|
|
Err(e) => {
|
||
|
|
error!("can't display map: {e}");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
let atlases: Vec<_> = layout
|
||
|
|
.tilesets
|
||
|
|
.iter()
|
||
|
|
.map(|ts| {
|
||
|
|
let rows = ts.tile_count.div_ceil(ts.columns.max(1));
|
||
|
|
let atlas = TextureAtlasLayout::from_grid(
|
||
|
|
UVec2::new(ts.tile_width, ts.tile_height),
|
||
|
|
ts.columns,
|
||
|
|
rows,
|
||
|
|
Some(UVec2::splat(ts.spacing)),
|
||
|
|
Some(UVec2::splat(ts.margin)),
|
||
|
|
);
|
||
|
|
(asset_server.load(ts.image.clone()), layouts.add(atlas))
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
commands
|
||
|
|
.entity(add.entity)
|
||
|
|
.insert((Transform::default(), Visibility::default()));
|
||
|
|
for (layer_index, layer) in layout.layers.iter().enumerate() {
|
||
|
|
let z = layer_index as f32 * 0.01;
|
||
|
|
for (i, cell) in layer.cells.iter().enumerate() {
|
||
|
|
let Some(cell) = cell else { continue };
|
||
|
|
let Some((image, atlas)) = atlases.get(cell.tileset) else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
let pos = GridPos::new(
|
||
|
|
(i as u32 % layout.width) as i32,
|
||
|
|
(i as u32 / layout.width) as i32,
|
||
|
|
);
|
||
|
|
commands.spawn((
|
||
|
|
ChildOf(add.entity),
|
||
|
|
Sprite {
|
||
|
|
image: image.clone(),
|
||
|
|
texture_atlas: Some(TextureAtlas {
|
||
|
|
layout: atlas.clone(),
|
||
|
|
index: cell.id as usize,
|
||
|
|
}),
|
||
|
|
flip_x: cell.flip_h,
|
||
|
|
flip_y: cell.flip_v,
|
||
|
|
custom_size: Some(Vec2::splat(TILE_SIZE)),
|
||
|
|
..default()
|
||
|
|
},
|
||
|
|
Transform::from_translation(grid_to_world(pos).extend(z)),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
info!(
|
||
|
|
"map `{}` displayed ({}x{})",
|
||
|
|
map.path, layout.width, layout.height
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
fn find_local_character(
|
||
|
|
mut local: ResMut<LocalCharacter>,
|
||
|
|
player: Res<LocalPlayer>,
|
||
|
|
characters: Query<(Entity, &Character)>,
|
||
|
|
) {
|
||
|
|
if local.0.is_some_and(|e| characters.contains(e)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
local.0 = characters
|
||
|
|
.iter()
|
||
|
|
.find(|(_, c)| c.name == player.name)
|
||
|
|
.map(|(e, _)| e);
|
||
|
|
}
|
||
|
|
|
||
|
|
fn sprite(asset_server: &AssetServer, path: &str, size: f32) -> Sprite {
|
||
|
|
Sprite {
|
||
|
|
image: asset_server.load(path.to_owned()),
|
||
|
|
custom_size: Some(Vec2::splat(size)),
|
||
|
|
..default()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn building_visuals(
|
||
|
|
mut commands: Commands,
|
||
|
|
data: Res<GameData>,
|
||
|
|
asset_server: Res<AssetServer>,
|
||
|
|
buildings: Query<
|
||
|
|
(Entity, &Building, &BuildingState, &GridPos, Has<Transform>),
|
||
|
|
Or<(Changed<BuildingState>, Changed<GridPos>, Without<Sprite>)>,
|
||
|
|
>,
|
||
|
|
) {
|
||
|
|
for (entity, building, state, pos, has_transform) in &buildings {
|
||
|
|
let Some(def) = data.buildings.get(&building.proto) else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
let mut entity = commands.entity(entity);
|
||
|
|
entity.insert(sprite(
|
||
|
|
&asset_server,
|
||
|
|
def.sprite_for(state.variant.as_deref()),
|
||
|
|
TILE_SIZE,
|
||
|
|
));
|
||
|
|
let transform = Transform::from_translation(grid_to_world(*pos).extend(Z_BUILDING));
|
||
|
|
if has_transform {
|
||
|
|
entity.insert(transform);
|
||
|
|
} else {
|
||
|
|
entity.insert((transform, Visibility::default()));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Component)]
|
||
|
|
struct StackLabel;
|
||
|
|
|
||
|
|
fn item_visuals(
|
||
|
|
mut commands: Commands,
|
||
|
|
data: Res<GameData>,
|
||
|
|
asset_server: Res<AssetServer>,
|
||
|
|
items: Query<(Entity, &GroundItem, &GridPos), Or<(Changed<GroundItem>, Changed<GridPos>)>>,
|
||
|
|
children: Query<&Children>,
|
||
|
|
labels: Query<(), With<StackLabel>>,
|
||
|
|
) {
|
||
|
|
for (entity, item, pos) in &items {
|
||
|
|
let Some(def) = data.items.get(&item.0.item) else {
|
||
|
|
continue;
|
||
|
|
};
|
||
|
|
for child in children.get(entity).into_iter().flatten() {
|
||
|
|
if labels.contains(*child) {
|
||
|
|
commands.entity(*child).despawn();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
commands.entity(entity).insert((
|
||
|
|
sprite(&asset_server, &def.sprite, TILE_SIZE * 0.75),
|
||
|
|
Transform::from_translation(grid_to_world(*pos).extend(Z_ITEM)),
|
||
|
|
Visibility::default(),
|
||
|
|
));
|
||
|
|
if item.0.count > 1 {
|
||
|
|
commands.spawn((
|
||
|
|
ChildOf(entity),
|
||
|
|
StackLabel,
|
||
|
|
Text2d::new(item.0.count.to_string()),
|
||
|
|
TextFont::from_font_size(10.0),
|
||
|
|
Transform::from_xyz(9.0, -9.0, 0.1),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn character_visuals(
|
||
|
|
mut commands: Commands,
|
||
|
|
asset_server: Res<AssetServer>,
|
||
|
|
local: Res<LocalCharacter>,
|
||
|
|
added: Query<(Entity, &Character, &GridPos), Added<Character>>,
|
||
|
|
mut facing: Query<(&Facing, &mut Sprite), (With<Character>, Changed<Facing>)>,
|
||
|
|
) {
|
||
|
|
for (entity, character, pos) in &added {
|
||
|
|
let mut sprite = sprite(&asset_server, "sprites/characters/player.png", TILE_SIZE);
|
||
|
|
if local.0 != Some(entity) {
|
||
|
|
sprite.color = Color::srgb(0.8, 0.9, 1.0);
|
||
|
|
}
|
||
|
|
commands.entity(entity).insert((
|
||
|
|
sprite,
|
||
|
|
Transform::from_translation(grid_to_world(*pos).extend(Z_CHARACTER)),
|
||
|
|
Visibility::default(),
|
||
|
|
children![(
|
||
|
|
Text2d::new(character.name.clone()),
|
||
|
|
TextFont::from_font_size(11.0),
|
||
|
|
Transform::from_xyz(0.0, TILE_SIZE * 0.7, 0.1),
|
||
|
|
)],
|
||
|
|
));
|
||
|
|
}
|
||
|
|
for (facing, mut sprite) in &mut facing {
|
||
|
|
sprite.flip_x = facing.0 == Dir::West;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Glides characters between tiles; teleports for large jumps (respawn, login).
|
||
|
|
fn move_characters(
|
||
|
|
time: Res<Time>,
|
||
|
|
mut characters: Query<(&GridPos, &mut Transform), With<Character>>,
|
||
|
|
) {
|
||
|
|
let speed = TILE_SIZE / game_server::movement::MOVE_DELAY * 1.2;
|
||
|
|
for (pos, mut transform) in &mut characters {
|
||
|
|
let target = grid_to_world(*pos);
|
||
|
|
let current = transform.translation.truncate();
|
||
|
|
let delta = target - current;
|
||
|
|
if delta.length() > TILE_SIZE * 3.0 {
|
||
|
|
transform.translation = target.extend(Z_CHARACTER);
|
||
|
|
} else {
|
||
|
|
let step = delta.clamp_length_max(speed * time.delta_secs());
|
||
|
|
transform.translation += step.extend(0.0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn update_marker(
|
||
|
|
local: Res<LocalCharacter>,
|
||
|
|
characters: Query<(&GridPos, &Facing)>,
|
||
|
|
mut marker: Query<(&mut Transform, &mut Visibility), With<FacingMarker>>,
|
||
|
|
) {
|
||
|
|
let Ok((mut transform, mut visibility)) = marker.single_mut() else {
|
||
|
|
return;
|
||
|
|
};
|
||
|
|
match local.0.and_then(|e| characters.get(e).ok()) {
|
||
|
|
Some((pos, facing)) => {
|
||
|
|
transform.translation = grid_to_world(pos.step(facing.0)).extend(Z_MARKER);
|
||
|
|
*visibility = Visibility::Visible;
|
||
|
|
}
|
||
|
|
None => *visibility = Visibility::Hidden,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn follow_camera(
|
||
|
|
local: Res<LocalCharacter>,
|
||
|
|
time: Res<Time>,
|
||
|
|
targets: Query<&Transform, (With<Character>, Without<Camera2d>)>,
|
||
|
|
mut camera: Query<&mut Transform, With<Camera2d>>,
|
||
|
|
) {
|
||
|
|
let Some(target) = local.0.and_then(|e| targets.get(e).ok()) else {
|
||
|
|
return;
|
||
|
|
};
|
||
|
|
let Ok(mut camera) = camera.single_mut() else {
|
||
|
|
return;
|
||
|
|
};
|
||
|
|
let goal = target.translation.truncate().extend(camera.translation.z);
|
||
|
|
let t = 1.0 - (-8.0 * time.delta_secs()).exp();
|
||
|
|
camera.translation = camera.translation.lerp(goal, t);
|
||
|
|
}
|