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 ### Changed
- **Breaking:** network protocol version 3. Logins carry a player id and - Characters move freely instead of tile by tile: diagonal movement, sliding
password, so older clients and servers can't connect. Characters saved by name 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. in existing worlds are not carried over to player ids.
### Removed ### 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. - **Tiled maps** – maps are regular [Tiled](https://www.mapeditor.org) `.tmx` files.
- **Persistent world** – no rounds. The world and every character (inventory, skills, - **Persistent world** – no rounds. The world and every character (inventory, skills,
vitals, position) are saved and restored across restarts. 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 - **Singleplayer and multiplayer** – one authoritative server simulation, used locally
in singleplayer and over UDP in multiplayer in singleplayer and over UDP in multiplayer
([bevy_replicon](https://crates.io/crates/bevy_replicon) + renet). ([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_server` | Authoritative simulation, Rune scripting, persistence, `game-server` binary |
| `crates/game_client` | Rendering, UI and input (`game` binary); embeds the server for singleplayer/host | | `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 `Interact`, `Craft`, …) and the server replicates the resulting state back. In
singleplayer the same server systems run inside the client process, so singleplayer and singleplayer the same server systems run inside the client process, so singleplayer and
multiplayer behave identically. multiplayer behave identically.
@ -144,7 +146,7 @@ in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately.
| Key | Action | | 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) | | E | Pick up (own tile, otherwise the faced tile) |
| F / left click | Interact with the faced / clicked building; machines open their window | | F / left click | Interact with the faced / clicked building; machines open their window |
| U | Use the selected item (eat, heal, …) | | U | Use the selected item (eat, heal, …) |

View File

@ -72,44 +72,34 @@ fn window_keys(
} }
} }
const MOVE_KEYS: [(KeyCode, Dir); 8] = [ /// Sends the held movement direction whenever it changes.
(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.
fn movement( fn movement(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
ui: Res<UiState>, ui: Res<UiState>,
focus: Res<UiFocus>, focus: Res<UiFocus>,
mut held: Local<Vec<Dir>>, mut sent: Local<SetMovement>,
mut sent: Local<Option<Dir>>,
) { ) {
held.retain(|dir| { let axis = |neg: [KeyCode; 2], pos: [KeyCode; 2]| {
MOVE_KEYS i8::from(keys.any_pressed(pos)) - i8::from(keys.any_pressed(neg))
.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()
}; };
if *sent != dir { let movement = if ui.blocks_game() || ui.chat_open || focus.captures_keyboard() {
*sent = dir; SetMovement::default()
commands.client_trigger(SetMoveDir(dir)); } 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::{ use game_core::{
GameData, GameData,
components::*, components::*,
grid::{Dir, TILE_SIZE, grid_to_world}, grid::{Dir, TILE_SIZE, grid_to_world, position_to_world},
map::MapLayout, map::MapLayout,
}; };
@ -223,7 +223,7 @@ fn character_visuals(
mut commands: Commands, mut commands: Commands,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
local: Res<LocalCharacter>, 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>)>, mut facing: Query<(&Facing, &mut Sprite), (With<Character>, Changed<Facing>)>,
) { ) {
for (entity, character, pos) in &added { for (entity, character, pos) in &added {
@ -233,7 +233,7 @@ fn character_visuals(
} }
commands.entity(entity).insert(( commands.entity(entity).insert((
sprite, sprite,
Transform::from_translation(grid_to_world(*pos).extend(Z_CHARACTER)), Transform::from_translation(position_to_world(pos.0).extend(Z_CHARACTER)),
Visibility::default(), Visibility::default(),
children![( children![(
Text2d::new(character.name.clone()), 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( fn move_characters(
time: Res<Time>, 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 { 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 current = transform.translation.truncate();
let delta = target - current; let delta = target - current;
if delta.length() > TILE_SIZE * 3.0 { let next = if delta.length() > TILE_SIZE * 3.0 {
transform.translation = target.extend(Z_CHARACTER); target
} else { } else {
let step = delta.clamp_length_max(speed * time.delta_secs()); current + delta * t
transform.translation += step.extend(0.0); };
} 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)] #[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Facing(pub Dir); pub struct Facing(pub Dir);
/// A player character. Names are unique and identify the persistent character. /// A player character. Names are unique and identify the persistent character.
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[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 struct Character {
pub name: String, 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). /// Converts a grid position to the center of the tile in Bevy world space (y up).
pub fn grid_to_world(pos: GridPos) -> Vec2 { pub fn grid_to_world(pos: GridPos) -> Vec2 {
Vec2::new( Vec2::new(

View File

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

View File

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

View File

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

View File

@ -78,10 +78,11 @@ impl Plugin for GameServerPlugin {
.add_systems( .add_systems(
FixedUpdate, FixedUpdate,
( (
movement::move_characters, (movement::move_characters, survival::tick_survival),
survival::tick_survival, movement::sync_tiles,
machines::tick_machines, machines::tick_machines,
) )
.chain()
.run_if(resource_exists::<world::WorldLayout>), .run_if(resource_exists::<world::WorldLayout>),
) )
.add_systems( .add_systems(
@ -95,7 +96,7 @@ impl Plugin for GameServerPlugin {
.add_observer(players::on_login) .add_observer(players::on_login)
.add_observer(players::on_disconnect) .add_observer(players::on_disconnect)
.add_observer(players::on_chat) .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_pick_up)
.add_observer(actions::on_drop_item) .add_observer(actions::on_drop_item)
.add_observer(actions::on_use_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::prelude::*;
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use game_core::{components::*, grid::Dir, protocol::SetMoveDir}; use game_core::{components::*, grid::Dir, protocol::SetMovement};
use crate::{ use crate::{players::Players, world::WorldLayout};
players::Players,
world::{WorldLayout, is_blocked},
};
/// Seconds per tile step. /// Walking speed in tiles per second.
pub const MOVE_DELAY: f32 = 0.16; 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)] #[derive(Component, Default, Debug)]
pub struct MoveIntent { pub struct MoveIntent(pub Vec2);
pub dir: Option<Dir>,
pub cooldown: f32,
}
pub fn on_set_move_dir( pub fn on_set_movement(
ev: On<FromClient<SetMoveDir>>, ev: On<FromClient<SetMovement>>,
players: Res<Players>, players: Res<Players>,
mut intents: Query<(&mut MoveIntent, &mut Facing)>, mut intents: Query<(&mut MoveIntent, &mut Facing)>,
) { ) {
let Some(&entity) = players.by_client.get(&ev.client_id) else { let Some(&entity) = players.by_client.get(&ev.client_id) else {
return; return;
}; };
if let Ok((mut intent, mut facing)) = intents.get_mut(entity) { let Ok((mut intent, mut facing)) = intents.get_mut(entity) else {
intent.dir = ev.message.0; return;
if let Some(dir) = intent.dir { };
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.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( pub fn move_characters(
time: Res<Time>, time: Res<Time>,
layout: Option<Res<WorldLayout>>, layout: Option<Res<WorldLayout>>,
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>, 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 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(); let dt = time.delta_secs();
for (mut intent, mut pos, mut facing) in &mut characters { for (intent, mut pos) in &mut characters {
intent.cooldown = (intent.cooldown - dt).max(0.0); if intent.0 == Vec2::ZERO {
let Some(dir) = intent.dir else { continue };
if intent.cooldown > 0.0 {
continue; continue;
} }
facing.set_if_neq(Facing(dir)); let next = slide(pos.0, intent.0 * MOVE_SPEED * dt, &blocked);
let next = pos.step(dir); if next != pos.0 {
if !is_blocked(next, &layout.0, buildings.iter()) { pos.0 = next;
*pos = next;
intent.cooldown = MOVE_DELAY;
} }
} }
} }
/// 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 struct SavedCharacter {
pub name: String, pub name: String,
pub pos: GridPos, pub pos: GridPos,
/// Exact position within `pos`; missing in older saves.
#[serde(default)]
pub position: Option<Position>,
pub facing: Dir, pub facing: Dir,
pub vitals: Vitals, pub vitals: Vitals,
pub inventory: Inventory, pub inventory: Inventory,
@ -629,17 +632,19 @@ pub fn snapshot(world: &mut World) -> SaveGame {
&Character, &Character,
&PlayerId, &PlayerId,
&GridPos, &GridPos,
&Position,
&Facing, &Facing,
&Vitals, &Vitals,
&Inventory, &Inventory,
&Skills, &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( characters.insert(
id.0.clone(), id.0.clone(),
SavedCharacter { SavedCharacter {
name: c.name.clone(), name: c.name.clone(),
pos: *pos, pos: *pos,
position: Some(*position),
facing: facing.0, facing: facing.0,
vitals: vitals.clone(), vitals: vitals.clone(),
inventory: inventory.clone(), inventory: inventory.clone(),
@ -730,6 +735,7 @@ mod tests {
SavedCharacter { SavedCharacter {
name: "Alice".into(), name: "Alice".into(),
pos: GridPos::new(3, -4), pos: GridPos::new(3, -4),
position: Some(Position(Vec2::new(3.25, -3.5))),
facing: Dir::West, facing: Dir::West,
vitals: Vitals { vitals: Vitals {
health: 42.5, health: 42.5,

View File

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

View File

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

View File

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