feat!: move characters freely instead of tile by tile
Test / Clippy and tests (nextest) (push) Failing after 1m59s Details
Windows package / Build Windows package (push) Failing after 59s Details

Characters have a continuous Position simulated on the server with a
square collision box, sliding along walls and corner assist into
openings. GridPos is derived from the position, so interactions,
building and reach stay tile-based. The exact position is saved.

BREAKING CHANGE: protocol version 4 replaces SetMoveDir with SetMovement
and replicates Position.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-18 23:15:16 +02:00
parent 2d6f52e8a4
commit 0c9136eaae
15 changed files with 345 additions and 98 deletions

View File

@ -49,8 +49,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- **Breaking:** network protocol version 3. Logins carry a player id and
password, so older clients and servers can't connect. Characters saved by name
- Characters move freely instead of tile by tile: diagonal movement, sliding
along walls and around corners. Their exact position is saved.
- **Breaking:** network protocol version 4. Logins carry a player id and
password, and movement is sent as a direction vector, so older clients and
servers can't connect. Characters saved by name
in existing worlds are not carried over to player ids.
### Removed

View File

@ -11,6 +11,8 @@ with [Bevy](https://bevy.org) in Rust.
- **Tiled maps** – maps are regular [Tiled](https://www.mapeditor.org) `.tmx` files.
- **Persistent world** – no rounds. The world and every character (inventory, skills,
vitals, position) are saved and restored across restarts.
- **Free movement** – characters walk anywhere, not tile by tile, and slide along
walls. Interactions still target the tile you face.
- **Singleplayer and multiplayer** – one authoritative server simulation, used locally
in singleplayer and over UDP in multiplayer
([bevy_replicon](https://crates.io/crates/bevy_replicon) + renet).
@ -23,7 +25,7 @@ with [Bevy](https://bevy.org) in Rust.
| `crates/game_server` | Authoritative simulation, Rune scripting, persistence, `game-server` binary |
| `crates/game_client` | Rendering, UI and input (`game` binary); embeds the server for singleplayer/host |
The client never changes game state directly. It sends requests (`Login`, `SetMoveDir`,
The client never changes game state directly. It sends requests (`Login`, `SetMovement`,
`Interact`, `Craft`, …) and the server replicates the resulting state back. In
singleplayer the same server systems run inside the client process, so singleplayer and
multiplayer behave identically.
@ -144,7 +146,7 @@ in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately.
| Key | Action |
|---------------------|------------------------------------------------------|
| WASD / arrows | Move (you face the direction you move) |
| WASD / arrows | Move freely, also diagonally (you face that way) |
| E | Pick up (own tile, otherwise the faced tile) |
| F / left click | Interact with the faced / clicked building; machines open their window |
| U | Use the selected item (eat, heal, …) |

View File

@ -72,44 +72,34 @@ fn window_keys(
}
}
const MOVE_KEYS: [(KeyCode, Dir); 8] = [
(KeyCode::KeyW, Dir::North),
(KeyCode::ArrowUp, Dir::North),
(KeyCode::KeyS, Dir::South),
(KeyCode::ArrowDown, Dir::South),
(KeyCode::KeyA, Dir::West),
(KeyCode::ArrowLeft, Dir::West),
(KeyCode::KeyD, Dir::East),
(KeyCode::ArrowRight, Dir::East),
];
/// Sends the held direction whenever it changes. The most recently pressed key wins.
/// Sends the held movement direction whenever it changes.
fn movement(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
ui: Res<UiState>,
focus: Res<UiFocus>,
mut held: Local<Vec<Dir>>,
mut sent: Local<Option<Dir>>,
mut sent: Local<SetMovement>,
) {
held.retain(|dir| {
MOVE_KEYS
.iter()
.any(|(key, d)| d == dir && keys.pressed(*key))
});
for (key, dir) in MOVE_KEYS {
if keys.just_pressed(key) && !held.contains(&dir) {
held.push(dir);
}
}
let dir = if ui.blocks_game() || ui.chat_open || focus.captures_keyboard() {
None
} else {
held.last().copied()
let axis = |neg: [KeyCode; 2], pos: [KeyCode; 2]| {
i8::from(keys.any_pressed(pos)) - i8::from(keys.any_pressed(neg))
};
if *sent != dir {
*sent = dir;
commands.client_trigger(SetMoveDir(dir));
let movement = if ui.blocks_game() || ui.chat_open || focus.captures_keyboard() {
SetMovement::default()
} else {
SetMovement {
x: axis(
[KeyCode::KeyA, KeyCode::ArrowLeft],
[KeyCode::KeyD, KeyCode::ArrowRight],
),
y: axis(
[KeyCode::KeyW, KeyCode::ArrowUp],
[KeyCode::KeyS, KeyCode::ArrowDown],
),
}
};
if *sent != movement {
*sent = movement;
commands.client_trigger(movement);
}
}

View File

@ -4,7 +4,7 @@ use bevy::prelude::*;
use game_core::{
GameData,
components::*,
grid::{Dir, TILE_SIZE, grid_to_world},
grid::{Dir, TILE_SIZE, grid_to_world, position_to_world},
map::MapLayout,
};
@ -223,7 +223,7 @@ fn character_visuals(
mut commands: Commands,
asset_server: Res<AssetServer>,
local: Res<LocalCharacter>,
added: Query<(Entity, &Character, &GridPos), Added<Character>>,
added: Query<(Entity, &Character, &Position), Added<Character>>,
mut facing: Query<(&Facing, &mut Sprite), (With<Character>, Changed<Facing>)>,
) {
for (entity, character, pos) in &added {
@ -233,7 +233,7 @@ fn character_visuals(
}
commands.entity(entity).insert((
sprite,
Transform::from_translation(grid_to_world(*pos).extend(Z_CHARACTER)),
Transform::from_translation(position_to_world(pos.0).extend(Z_CHARACTER)),
Visibility::default(),
children![(
Text2d::new(character.name.clone()),
@ -247,22 +247,23 @@ fn character_visuals(
}
}
/// Glides characters between tiles; teleports for large jumps (respawn, login).
/// 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<(&GridPos, &mut Transform), With<Character>>,
mut characters: Query<(&Position, &mut Transform), With<Character>>,
) {
let speed = TILE_SIZE / game_server::movement::MOVE_DELAY * 1.2;
let t = 1.0 - (-20.0 * time.delta_secs()).exp();
for (pos, mut transform) in &mut characters {
let target = grid_to_world(*pos);
let target = position_to_world(pos.0);
let current = transform.translation.truncate();
let delta = target - current;
if delta.length() > TILE_SIZE * 3.0 {
transform.translation = target.extend(Z_CHARACTER);
let next = if delta.length() > TILE_SIZE * 3.0 {
target
} else {
let step = delta.clamp_length_max(speed * time.delta_secs());
transform.translation += step.extend(0.0);
}
current + delta * t
};
transform.translation = next.extend(Z_CHARACTER);
}
}

View File

@ -35,12 +35,28 @@ impl GridPos {
}
}
/// Free position of a character in tiles, in Tiled's coordinate system (`y` down).
/// Tile `(x, y)` covers `x..x + 1` and `y..y + 1`. The server keeps the character's
/// [`GridPos`] at the tile containing this point.
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Position(pub Vec2);
impl Position {
pub fn center_of(tile: GridPos) -> Self {
Self(Vec2::new(tile.x as f32 + 0.5, tile.y as f32 + 0.5))
}
pub fn tile(self) -> GridPos {
GridPos::new(self.0.x.floor() as i32, self.0.y.floor() as i32)
}
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Facing(pub Dir);
/// A player character. Names are unique and identify the persistent character.
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[require(GridPos, Facing, Vitals, Inventory, Skills)]
#[require(GridPos, Position, Facing, Vitals, Inventory, Skills)]
pub struct Character {
pub name: String,
}

View File

@ -28,6 +28,11 @@ impl Dir {
}
}
/// Converts a [`Position`](crate::components::Position) in tiles to Bevy world space (y up).
pub fn position_to_world(pos: Vec2) -> Vec2 {
Vec2::new(pos.x * TILE_SIZE, -pos.y * TILE_SIZE)
}
/// Converts a grid position to the center of the tile in Bevy world space (y up).
pub fn grid_to_world(pos: GridPos) -> Vec2 {
Vec2::new(

View File

@ -16,7 +16,7 @@ pub use data::GameData;
pub const DEFAULT_MAP: &str = "maps/station.tmx";
pub const DEFAULT_PORT: u16 = 5000;
/// Bump when the protocol changes in incompatible ways.
pub const PROTOCOL_ID: u64 = 3;
pub const PROTOCOL_ID: u64 = 4;
/// Locates the `assets` directory.
///

View File

@ -7,7 +7,7 @@ use bevy::prelude::*;
use bevy_replicon::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{components::*, grid::Dir};
use crate::components::*;
/// First event a client sends; selects (or creates) the persistent character.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
@ -21,9 +21,13 @@ pub struct Login {
pub password: String,
}
/// Direction the player is holding, `None` to stop.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct SetMoveDir(pub Option<Dir>);
/// Movement keys the player is holding: `x` and `y` are -1, 0 or 1 (`y` down,
/// both 0 to stop). Diagonals move at the same speed as straight lines.
#[derive(Event, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetMovement {
pub x: i8,
pub y: i8,
}
/// Pick up the item on the given tile (own or adjacent).
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
@ -121,6 +125,7 @@ impl Plugin for ProtocolPlugin {
fn build(&self, app: &mut App) {
app.replicate::<WorldMap>()
.replicate::<GridPos>()
.replicate::<Position>()
.replicate::<Facing>()
.replicate::<Character>()
.replicate::<Vitals>()
@ -131,7 +136,7 @@ impl Plugin for ProtocolPlugin {
.replicate::<BuildingState>()
.replicate::<Machine>()
.add_client_event::<Login>(Channel::Ordered)
.add_client_event::<SetMoveDir>(Channel::Ordered)
.add_client_event::<SetMovement>(Channel::Ordered)
.add_client_event::<PickUp>(Channel::Ordered)
.add_client_event::<Interact>(Channel::Ordered)
.add_client_event::<UseItem>(Channel::Ordered)

View File

@ -10,6 +10,7 @@ use game_core::{GameData, components::*, protocol::*};
use crate::{
crafting::{self, item_name},
movement,
players::{Players, tell},
scripting::{ScriptEngine, ScriptState, ScriptTarget},
world::{WorldLayout, spawn_building, spawn_ground_item},
@ -442,7 +443,7 @@ pub fn on_construct(
layout: Res<WorldLayout>,
mut characters: Characters,
buildings: Query<&GridPos, (With<Building>, Without<Character>)>,
occupants: Query<&GridPos, (With<Character>, Without<Building>)>,
occupants: Query<&Position, (With<Character>, Without<Building>)>,
) {
let client = ev.client_id;
let target = ev.message.target;
@ -450,7 +451,9 @@ pub fn on_construct(
return;
};
// Checked before borrowing the actor mutably.
let occupied = occupants.iter().any(|p| *p == target);
let occupied = occupants
.iter()
.any(|p| movement::overlaps_tile(p.0, target));
let Ok((_, pos, mut facing, mut inventory, _, mut skills)) = characters.get_mut(entity) else {
return;
};

View File

@ -78,10 +78,11 @@ impl Plugin for GameServerPlugin {
.add_systems(
FixedUpdate,
(
movement::move_characters,
survival::tick_survival,
(movement::move_characters, survival::tick_survival),
movement::sync_tiles,
machines::tick_machines,
)
.chain()
.run_if(resource_exists::<world::WorldLayout>),
)
.add_systems(
@ -95,7 +96,7 @@ impl Plugin for GameServerPlugin {
.add_observer(players::on_login)
.add_observer(players::on_disconnect)
.add_observer(players::on_chat)
.add_observer(movement::on_set_move_dir)
.add_observer(movement::on_set_movement)
.add_observer(actions::on_pick_up)
.add_observer(actions::on_drop_item)
.add_observer(actions::on_use_item)

View File

@ -1,58 +1,248 @@
//! Tile-based character movement.
//! Free character movement with collision against solid tiles and buildings.
use std::collections::HashSet;
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{components::*, grid::Dir, protocol::SetMoveDir};
use game_core::{components::*, grid::Dir, protocol::SetMovement};
use crate::{
players::Players,
world::{WorldLayout, is_blocked},
};
use crate::{players::Players, world::WorldLayout};
/// Seconds per tile step.
pub const MOVE_DELAY: f32 = 0.16;
/// Walking speed in tiles per second.
pub const MOVE_SPEED: f32 = 4.5;
/// Half the side of a character's square collision box, in tiles. Less than 0.5,
/// so characters fit through one tile wide gaps.
pub const CHARACTER_RADIUS: f32 = 0.3;
/// How far (in tiles) a character blocked by a corner is pushed sideways
/// towards an opening, so doorways don't need pixel-exact aiming.
const CORNER_ASSIST: f32 = 0.45;
/// Gap left between a character and the obstacle it touches.
const CONTACT_GAP: f32 = 1e-3;
/// Longest movement per collision step, below the box size so no tile is skipped.
const MAX_STEP: f32 = 0.25;
/// Normalized walking direction (`y` down), zero when standing still.
#[derive(Component, Default, Debug)]
pub struct MoveIntent {
pub dir: Option<Dir>,
pub cooldown: f32,
}
pub struct MoveIntent(pub Vec2);
pub fn on_set_move_dir(
ev: On<FromClient<SetMoveDir>>,
pub fn on_set_movement(
ev: On<FromClient<SetMovement>>,
players: Res<Players>,
mut intents: Query<(&mut MoveIntent, &mut Facing)>,
) {
let Some(&entity) = players.by_client.get(&ev.client_id) else {
return;
};
if let Ok((mut intent, mut facing)) = intents.get_mut(entity) {
intent.dir = ev.message.0;
if let Some(dir) = intent.dir {
facing.set_if_neq(Facing(dir));
}
let Ok((mut intent, mut facing)) = intents.get_mut(entity) else {
return;
};
let input = Vec2::new(ev.message.x.signum() as f32, ev.message.y.signum() as f32);
intent.0 = input.normalize_or_zero();
if let Some(dir) = facing_for(input, facing.0) {
facing.set_if_neq(Facing(dir));
}
}
/// Facing for a movement input. While moving diagonally, the current facing is kept
/// if it is one of the two directions, so it doesn't flip back and forth.
fn facing_for(input: Vec2, current: Dir) -> Option<Dir> {
let horizontal = match input.x {
x if x > 0.0 => Some(Dir::East),
x if x < 0.0 => Some(Dir::West),
_ => None,
};
let vertical = match input.y {
y if y > 0.0 => Some(Dir::South),
y if y < 0.0 => Some(Dir::North),
_ => None,
};
if horizontal == Some(current) || vertical == Some(current) {
return Some(current);
}
horizontal.or(vertical)
}
pub fn move_characters(
time: Res<Time>,
layout: Option<Res<WorldLayout>>,
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
mut characters: Query<(&mut MoveIntent, &mut GridPos, &mut Facing), With<Character>>,
mut characters: Query<(&MoveIntent, &mut Position), With<Character>>,
) {
let Some(layout) = layout else { return };
let solid: HashSet<GridPos> = buildings
.iter()
.filter(|(_, state)| state.solid)
.map(|(pos, _)| *pos)
.collect();
let blocked = |tile: GridPos| layout.0.is_solid(tile) || solid.contains(&tile);
let dt = time.delta_secs();
for (mut intent, mut pos, mut facing) in &mut characters {
intent.cooldown = (intent.cooldown - dt).max(0.0);
let Some(dir) = intent.dir else { continue };
if intent.cooldown > 0.0 {
for (intent, mut pos) in &mut characters {
if intent.0 == Vec2::ZERO {
continue;
}
facing.set_if_neq(Facing(dir));
let next = pos.step(dir);
if !is_blocked(next, &layout.0, buildings.iter()) {
*pos = next;
intent.cooldown = MOVE_DELAY;
let next = slide(pos.0, intent.0 * MOVE_SPEED * dt, &blocked);
if next != pos.0 {
pos.0 = next;
}
}
}
/// Keeps [`GridPos`] at the tile under each character, for tile-based interactions.
pub fn sync_tiles(mut characters: Query<(&Position, &mut GridPos), Changed<Position>>) {
for (pos, mut tile) in &mut characters {
tile.set_if_neq(pos.tile());
}
}
/// Tiles overlapped by a character's collision box at `pos`.
fn box_tiles(pos: Vec2) -> impl Iterator<Item = GridPos> {
let min = (pos - CHARACTER_RADIUS).floor().as_ivec2();
let max = (pos + CHARACTER_RADIUS).floor().as_ivec2();
(min.y..=max.y).flat_map(move |y| (min.x..=max.x).map(move |x| GridPos::new(x, y)))
}
/// Whether a character at `pos` overlaps `tile`.
pub fn overlaps_tile(pos: Vec2, tile: GridPos) -> bool {
box_tiles(pos).any(|t| t == tile)
}
/// Whether moving from `from` to `to` makes the box overlap a blocked tile. Tiles it
/// already overlaps don't count, so characters can walk out of a door that closed on them.
fn enters_blocked(from: Vec2, to: Vec2, blocked: &impl Fn(GridPos) -> bool) -> bool {
let old: Vec<GridPos> = box_tiles(from).collect();
box_tiles(to).any(|tile| !old.contains(&tile) && blocked(tile))
}
/// Moves a character's box by `delta`, sliding along obstacles and around corners.
pub fn slide(mut pos: Vec2, delta: Vec2, blocked: &impl Fn(GridPos) -> bool) -> Vec2 {
let steps = (delta.abs().max_element() / MAX_STEP).ceil().max(1.0);
let step = delta / steps;
for _ in 0..steps as u32 {
let mut progressed = [false; 2];
for axis in 0..2 {
if step[axis] == 0.0 {
continue;
}
let mut next = pos;
next[axis] += step[axis];
if enters_blocked(pos, next, blocked) {
// Move up to the obstacle instead.
next[axis] = if step[axis] > 0.0 {
(next[axis] + CHARACTER_RADIUS).floor() - CHARACTER_RADIUS - CONTACT_GAP
} else {
(next[axis] - CHARACTER_RADIUS).floor() + 1.0 + CHARACTER_RADIUS + CONTACT_GAP
};
if (next[axis] - pos[axis]) * step[axis] <= 0.0
|| enters_blocked(pos, next, blocked)
{
continue;
}
}
progressed[axis] = next[axis] != pos[axis];
pos = next;
}
for axis in 0..2 {
let other = 1 - axis;
if !progressed[axis] && step[axis] != 0.0 && step[other] == 0.0 {
pos = assist(pos, axis, step[axis], blocked);
}
}
}
pos
}
/// Shifts a character blocked along `axis` sideways towards a nearby opening.
fn assist(pos: Vec2, axis: usize, step: f32, blocked: &impl Fn(GridPos) -> bool) -> Vec2 {
let other = 1 - axis;
let row = pos[other].floor();
let mut lanes = [row - 0.5, row + 0.5, row + 1.5];
lanes.sort_by(|a, b| (a - pos[other]).abs().total_cmp(&(b - pos[other]).abs()));
for lane in lanes {
let offset = lane - pos[other];
if offset.abs() > CORNER_ASSIST {
continue;
}
// Centered in the lane, would the character get ahead?
let mut probe = pos;
probe[other] = lane;
let mut ahead = probe;
ahead[axis] += step.signum() * (CHARACTER_RADIUS + CONTACT_GAP * 2.0).min(0.5);
if box_tiles(ahead).any(blocked) {
continue;
}
let mut next = pos;
next[other] += offset.clamp(-step.abs(), step.abs());
if !enters_blocked(pos, next, blocked) {
return next;
}
}
pos
}
#[cfg(test)]
mod tests {
use super::*;
/// A wall column at x = 2 with a gap at y = 1.
fn wall(tile: GridPos) -> bool {
tile.x == 2 && tile.y != 1
}
fn walk(mut pos: Vec2, dir: Vec2, seconds: f32) -> Vec2 {
let dt = 1.0 / 64.0;
for _ in 0..(seconds / dt) as u32 {
pos = slide(pos, dir.normalize() * MOVE_SPEED * dt, &wall);
}
pos
}
#[test]
fn moves_freely_between_tiles() {
let pos = slide(Vec2::new(0.5, 5.5), Vec2::new(0.3, 0.2), &wall);
assert!((pos - Vec2::new(0.8, 5.7)).length() < 1e-5, "{pos}");
}
#[test]
fn stops_at_walls_and_slides_along_them() {
let pos = walk(Vec2::new(0.5, 5.5), Vec2::new(1.0, 0.0), 1.0);
assert!((pos.x - (2.0 - CHARACTER_RADIUS)).abs() < 0.01, "{pos}");
assert_eq!(pos.y, 5.5);
let pos = walk(Vec2::new(0.5, 5.5), Vec2::new(1.0, 1.0), 1.0);
assert!(
pos.x < 2.0 - CHARACTER_RADIUS + 0.01 && pos.y > 7.0,
"{pos}"
);
}
#[test]
fn corners_guide_into_openings() {
// Slightly below the gap's center: the corner pushes the character into it.
let pos = walk(Vec2::new(0.5, 1.8), Vec2::new(1.0, 0.0), 1.5);
assert!(pos.x > 3.0, "{pos}");
assert_eq!(box_tiles(pos).map(|t| t.y).max(), Some(1), "{pos}");
assert_eq!(box_tiles(pos).map(|t| t.y).min(), Some(1), "{pos}");
// Far from the gap the wall just stops the character.
let pos = walk(Vec2::new(0.5, 3.5), Vec2::new(1.0, 0.0), 1.5);
assert!(pos.x < 2.0 && pos.y == 3.5, "{pos}");
}
#[test]
fn can_leave_blocked_tiles() {
let pos = walk(Vec2::new(2.5, 3.5), Vec2::new(-1.0, 0.0), 0.5);
assert!(pos.x < 2.0, "{pos}");
}
#[test]
fn facing_keeps_diagonal_component() {
assert_eq!(
facing_for(Vec2::new(1.0, 1.0), Dir::South),
Some(Dir::South)
);
assert_eq!(facing_for(Vec2::new(1.0, 1.0), Dir::North), Some(Dir::East));
assert_eq!(
facing_for(Vec2::new(0.0, -1.0), Dir::East),
Some(Dir::North)
);
assert_eq!(facing_for(Vec2::ZERO, Dir::East), None);
}
}

View File

@ -87,6 +87,9 @@ pub enum StorageError {
pub struct SavedCharacter {
pub name: String,
pub pos: GridPos,
/// Exact position within `pos`; missing in older saves.
#[serde(default)]
pub position: Option<Position>,
pub facing: Dir,
pub vitals: Vitals,
pub inventory: Inventory,
@ -629,17 +632,19 @@ pub fn snapshot(world: &mut World) -> SaveGame {
&Character,
&PlayerId,
&GridPos,
&Position,
&Facing,
&Vitals,
&Inventory,
&Skills,
)>();
for (c, id, pos, facing, vitals, inventory, skills) in q.iter(world) {
for (c, id, pos, position, facing, vitals, inventory, skills) in q.iter(world) {
characters.insert(
id.0.clone(),
SavedCharacter {
name: c.name.clone(),
pos: *pos,
position: Some(*position),
facing: facing.0,
vitals: vitals.clone(),
inventory: inventory.clone(),
@ -730,6 +735,7 @@ mod tests {
SavedCharacter {
name: "Alice".into(),
pos: GridPos::new(3, -4),
position: Some(Position(Vec2::new(3.25, -3.5))),
facing: Dir::West,
vitals: Vitals {
health: 42.5,

View File

@ -256,6 +256,11 @@ fn handle_login(world: &mut World, client: ClientId, login: Login) {
&world.resource::<WorldLayout>().0,
&buildings.query(world),
);
// The saved spot is only kept if the tile is still free.
let position = saved
.position
.filter(|p| p.tile() == pos)
.unwrap_or(Position::center_of(pos));
let config = world.resource::<ServerConfig>().clone();
let owner_id = world.resource::<ServerSettings>().owner.clone();
let admin = is_admin(owner_id.as_deref(), &config, client, &player_id);
@ -263,6 +268,7 @@ fn handle_login(world: &mut World, client: ClientId, login: Login) {
Replicated,
Character { name: name.clone() },
pos,
position,
Facing(saved.facing),
saved.vitals,
saved.inventory,
@ -424,6 +430,7 @@ pub fn new_character_data(data: &GameData, name: &str, spawn: GridPos) -> SavedC
SavedCharacter {
name: name.to_owned(),
pos: spawn,
position: None,
facing: Default::default(),
vitals: Vitals::default(),
inventory,
@ -441,6 +448,7 @@ pub fn on_disconnect(
&Character,
&PlayerId,
&GridPos,
&Position,
&Facing,
&Vitals,
&Inventory,
@ -450,13 +458,14 @@ pub fn on_disconnect(
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
return;
};
if let Ok((c, id, pos, facing, vitals, inventory, skills)) = characters.get(entity) {
if let Ok((c, id, pos, position, facing, vitals, inventory, skills)) = characters.get(entity) {
info!("{} logged out", c.name);
store.0.insert(
id.0.clone(),
SavedCharacter {
name: c.name.clone(),
pos: *pos,
position: Some(*position),
facing: facing.0,
vitals: vitals.clone(),
inventory: inventory.clone(),

View File

@ -30,7 +30,8 @@ pub fn tick_survival(
mut characters: Query<(
&Character,
&Controller,
&mut GridPos,
&GridPos,
&mut Position,
&mut Vitals,
&mut Inventory,
)>,
@ -39,7 +40,7 @@ pub fn tick_survival(
return;
}
let dt = timer.0.duration().as_secs_f32();
for (character, controller, mut pos, mut vitals, mut inventory) in &mut characters {
for (character, controller, pos, mut position, mut vitals, mut inventory) in &mut characters {
vitals.nutrition = (vitals.nutrition - HUNGER_RATE * dt).max(0.0);
if vitals.nutrition <= 0.0 {
vitals.heal(-STARVATION_DAMAGE * dt);
@ -56,7 +57,7 @@ pub fn tick_survival(
spawn_ground_item(&mut commands, stack, *pos);
}
if let Some(spawn) = &spawn {
*pos = spawn.0;
*position = Position::center_of(spawn.0);
}
*vitals = Vitals {
nutrition: 50.0,

View File

@ -5,7 +5,7 @@ use std::time::Duration;
use bevy::{prelude::*, state::app::StatesPlugin, time::TimeUpdateStrategy};
use bevy_replicon::prelude::*;
use game_core::{CorePlugin, components::*, grid::Dir, protocol::*};
use game_core::{CorePlugin, components::*, protocol::*};
use game_server::{GameServerPlugin, ServerSettings};
#[derive(Resource, Default)]
@ -108,14 +108,29 @@ fn login_move_and_interact() {
let log = take_log(&mut app);
assert!(log.iter().any(|t| t == "<Tester> hello there"), "{log:?}");
// Walk east one tile.
send(&mut app, SetMoveDir(Some(Dir::East)));
send(&mut app, SetMoveDir(None));
for _ in 0..5 {
// Walk east until the character crosses into the next tile.
let start_x = app.world().get::<Position>(me).unwrap().0.x;
send(&mut app, SetMovement { x: 1, y: 0 });
for _ in 0..20 {
if app.world().get::<GridPos>(me).unwrap().x != start.x {
break;
}
app.update();
}
send(&mut app, SetMovement::default());
let pos = *app.world().get::<GridPos>(me).unwrap();
let position = *app.world().get::<Position>(me).unwrap();
assert_eq!(pos, GridPos::new(start.x + 1, start.y), "start {start:?}");
assert_eq!(position.tile(), pos);
assert!(position.0.x > start_x + 0.5, "{position:?}");
for _ in 0..3 {
app.update();
}
assert_eq!(
app.world().get::<Position>(me).unwrap(),
&position,
"stopped"
);
// Eat an apple through the Rune script.
app.world_mut().get_mut::<Vitals>(me).unwrap().nutrition = 50.0;