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
|
### 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
|
||||||
|
|
|
||||||
|
|
@ -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, …) |
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue