338 lines
11 KiB
Rust
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);
|
|
}
|