feat!: move characters freely instead of tile by tile
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:
parent
2d6f52e8a4
commit
0c9136eaae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, …) |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue