A step onto an occupied tile is now an attack: the geometry rule is checked against terrain only and the target tile decides between move and bump. Entities carry an attack stat; at zero hp they are removed. The sim reports Attacked/Died through an event queue the game drains after every movement window — the seam for sound, log, camera effects and spawns. Click-to-move treats an occupied goal as a target but detours around entities in the way; the player's death ends the game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
4.2 KiB
Rust
119 lines
4.2 KiB
Rust
//! Headless world simulation: tiles, entities, movement rules and the tick loop.
|
||
//! No platform or rendering dependencies — the game binary drives `Sim::tick`.
|
||
|
||
pub mod chunk;
|
||
pub mod entity;
|
||
pub mod map;
|
||
pub mod world;
|
||
mod sim;
|
||
|
||
pub use sim::{Event, Sim};
|
||
|
||
use chunk::Chunk;
|
||
use map::TileMap;
|
||
use world::World;
|
||
|
||
pub mod player_action {
|
||
pub const NOOP: u16 = 0;
|
||
pub const NORTH: u16 = 1;
|
||
pub const EAST: u16 = 2;
|
||
pub const SOUTH: u16 = 3;
|
||
pub const WEST: u16 = 4;
|
||
pub const NORTH_EAST: u16 = 5;
|
||
pub const SOUTH_EAST: u16 = 6;
|
||
pub const SOUTH_WEST: u16 = 7;
|
||
pub const NORTH_WEST: u16 = 8;
|
||
}
|
||
|
||
/// The movement delta of an action, or `None` for `NOOP` and unknown values.
|
||
pub fn action_delta(action: u16) -> Option<(i32, i32)> {
|
||
match action {
|
||
player_action::NORTH => Some((0, -1)),
|
||
player_action::EAST => Some((1, 0)),
|
||
player_action::SOUTH => Some((0, 1)),
|
||
player_action::WEST => Some((-1, 0)),
|
||
player_action::NORTH_EAST => Some((1, -1)),
|
||
player_action::SOUTH_EAST => Some((1, 1)),
|
||
player_action::SOUTH_WEST => Some((-1, 1)),
|
||
player_action::NORTH_WEST => Some((-1, -1)),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The action for a single-step delta. Inverse of [`action_delta`]; panics on anything
|
||
/// that is not a king move.
|
||
pub fn delta_action(dx: i32, dy: i32) -> u16 {
|
||
match (dx, dy) {
|
||
(0, -1) => player_action::NORTH,
|
||
(1, 0) => player_action::EAST,
|
||
(0, 1) => player_action::SOUTH,
|
||
(-1, 0) => player_action::WEST,
|
||
(1, -1) => player_action::NORTH_EAST,
|
||
(1, 1) => player_action::SOUTH_EAST,
|
||
(-1, 1) => player_action::SOUTH_WEST,
|
||
(-1, -1) => player_action::NORTH_WEST,
|
||
d => panic!("non-step delta {d:?}"),
|
||
}
|
||
}
|
||
|
||
/// The single-step movement rule, shared by the sim and the pathfinder so both agree
|
||
/// on the same physics.
|
||
///
|
||
/// World geometry is chessboard (Chebyshev): diagonal and cardinal steps are the same
|
||
/// distance, so a step is any king move onto a free tile. A diagonal step additionally
|
||
/// requires *both* orthogonal neighbor tiles to be free — no squeezing between two
|
||
/// diagonally touching blockers (corner cutting).
|
||
pub fn step_allowed(
|
||
from: (i32, i32),
|
||
to: (i32, i32),
|
||
blocked: impl Fn(i32, i32) -> bool,
|
||
) -> bool {
|
||
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
|
||
if dx.abs() > 1 || dy.abs() > 1 || (dx == 0 && dy == 0) {
|
||
return false;
|
||
}
|
||
if blocked(to.0, to.1) {
|
||
return false;
|
||
}
|
||
dx == 0 || dy == 0 || (!blocked(from.0 + dx, from.1) && !blocked(from.0, from.1 + dy))
|
||
}
|
||
|
||
/// Base tick rate. The timeline all action scheduling is expressed in.
|
||
pub const TICK_HZ: u32 = 24;
|
||
|
||
/// Movement resolves on every `TICKS_PER_MOVE`-th tick — one "movement window" per
|
||
/// `TICKS_PER_MOVE` ticks (6 Hz). Window `w` executes at tick `w * TICKS_PER_MOVE`.
|
||
pub const TICKS_PER_MOVE: u32 = 4;
|
||
|
||
/// Movement vocabulary: whether a tile id blocks movement. Keep this in lockstep with
|
||
/// the tileset in `overworld.tga`.
|
||
///
|
||
/// Test values for now — a proper tile-data file format replaces this table later.
|
||
pub fn tile_collidable(tile_id: u16) -> bool {
|
||
match tile_id {
|
||
0 => true, // empty / world border (chunks past the map rim are padded with id 0)
|
||
146 => true, // trees / rocks
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Build a world from a loaded tile map. The map's top-left tile sits at world (0, 0); the
|
||
/// map is sliced into 32×32 chunks. Tiles inside a loaded chunk but outside the authored map
|
||
/// get id 0 (solid, see `tile_collidable`), so the walkable world edge sits flush with the
|
||
/// visible map rim instead of with the larger chunk boundary.
|
||
pub fn load_world(map: &TileMap) -> World {
|
||
let mut w = World::new();
|
||
let max_cx = (map.width.saturating_sub(1) >> 5) as i16;
|
||
let max_cy = (map.height.saturating_sub(1) >> 5) as i16;
|
||
for cy in 0..=max_cy {
|
||
for cx in 0..=max_cx {
|
||
let chunk = Chunk::generate(|lx, ly| {
|
||
let (wx, wy) = world::local_to_tile(cx, cy, lx, ly);
|
||
map.tile(wx, wy) // 0 outside the map
|
||
});
|
||
w.set_chunk(cx, cy, chunk);
|
||
}
|
||
}
|
||
w
|
||
}
|