station/crates/game_client/src/render.rs

338 lines
11 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, position_to_world},
};
use crate::session::SessionInfo;
pub struct RenderPlugin;
impl Plugin for RenderPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LocalCharacter>()
.add_systems(Startup, setup)
.add_systems(
Update,
(
find_local_character,
spawn_map,
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(
mut commands: Commands,
maps: Query<(Entity, &WorldMap), Changed<WorldMap>>,
asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
for (entity, map) in &maps {
let layout = &map.layout;
commands.entity(entity).despawn_children();
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(entity)
.insert((Transform::default(), Visibility::default()));
let default_layer = layout
.default_tile
.map(|cell| game_core::map::TileLayerData {
name: "Default terrain".into(),
solid: false,
cells: vec![Some(cell); (layout.width * layout.height) as usize],
});
for (layer_index, layer) in default_layer.iter().chain(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(entity),
Sprite {
image: image.clone(),
texture_atlas: Some(TextureAtlas {
layout: atlas.clone(),
index: cell.id as usize,
}),
flip_x: if cell.flip_d {
!cell.flip_v
} else {
cell.flip_h
},
flip_y: if cell.flip_d {
cell.flip_h
} else {
cell.flip_v
},
custom_size: Some(Vec2::splat(TILE_SIZE)),
..default()
},
Transform::from_translation(grid_to_world(pos).extend(z)).with_rotation(
Quat::from_rotation_z(if cell.flip_d {
-std::f32::consts::FRAC_PI_2
} else {
0.0
}),
),
));
}
}
info!(
"map `{}` displayed ({}x{})",
map.path, layout.width, layout.height
);
}
}
fn find_local_character(
mut local: ResMut<LocalCharacter>,
session: Res<SessionInfo>,
characters: Query<(Entity, &Character)>,
) {
if local.0.is_some_and(|e| characters.contains(e)) {
return;
}
if session.character.is_empty() {
local.0 = None;
return;
}
local.0 = characters
.iter()
.find(|(_, c)| c.name == session.character)
.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);
let mut visual = sprite(
&asset_server,
def.sprite_for(state.variant.as_deref()),
TILE_SIZE,
);
visual.flip_x = if state.flip_d {
!state.flip_v
} else {
state.flip_h
};
visual.flip_y = if state.flip_d {
state.flip_h
} else {
state.flip_v
};
entity.insert(visual);
let z = if state.layer == game_core::data::BuildingLayer::Floor {
0.5
} else {
Z_BUILDING
};
let transform = Transform::from_translation(grid_to_world(*pos).extend(z)).with_rotation(
Quat::from_rotation_z(if state.flip_d {
-std::f32::consts::FRAC_PI_2
} else {
0.0
}),
);
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, &Position), 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(position_to_world(pos.0).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;
}
}
/// Smooths replicated positions, which arrive at the server tick rate; teleports for large
/// jumps (respawn, login).
fn move_characters(
time: Res<Time>,
mut characters: Query<(&Position, &mut Transform), With<Character>>,
) {
let t = 1.0 - (-20.0 * time.delta_secs()).exp();
for (pos, mut transform) in &mut characters {
let target = position_to_world(pos.0);
let current = transform.translation.truncate();
let delta = target - current;
let next = if delta.length() > TILE_SIZE * 3.0 {
target
} else {
current + delta * t
};
transform.translation = next.extend(Z_CHARACTER);
}
}
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);
}