Extract headless sim crate from server
Move chunk, entity, world, map, sim and load_world into a new sim library crate together with the game rules from shared (player_action, action_delta, delta_action, step_allowed, tile_collidable, tick constants). shared keeps only the wire types and re-exports the rules, so server and client compile unchanged. First step toward a local single-player build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5610c2c343
commit
303c587aee
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "sim"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
|
||||
pub struct TileFlags(pub u8);
|
||||
|
||||
impl TileFlags {
|
||||
pub const COLLIDABLE: u8 = 1 << 0;
|
||||
pub const OPAQUE: u8 = 1 << 1;
|
||||
pub const LIQUID: u8 = 1 << 2;
|
||||
|
||||
pub fn collidable(self) -> bool { self.0 & Self::COLLIDABLE != 0 }
|
||||
pub fn opaque(self) -> bool { self.0 & Self::OPAQUE != 0 }
|
||||
pub fn liquid(self) -> bool { self.0 & Self::LIQUID != 0 }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
|
||||
pub struct TileDef { pub tile_id: u16, pub flags: TileFlags }
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PaletteFullError;
|
||||
|
||||
pub struct Chunk {
|
||||
pub palette: [TileDef; 64],
|
||||
pub pal_count: u8,
|
||||
pub tiles: Box<[u8; 1024]>,
|
||||
pub version: u16,
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
/// Runtime use: all tiles empty, pal_count=1, version=1.
|
||||
/// Clients with version=0 will always request this chunk.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
palette: [TileDef::default(); 64],
|
||||
pal_count: 1,
|
||||
tiles: Box::new([0u8; 1024]),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find existing palette entry or insert a new one.
|
||||
/// Index 0 is always the null sentinel; this short-circuits for default TileDef.
|
||||
/// Returns (palette_index, was_new). Does NOT bump version.
|
||||
fn find_or_insert_raw(&mut self, def: TileDef) -> Result<(u8, bool), PaletteFullError> {
|
||||
if def == TileDef::default() {
|
||||
return Ok((0, false));
|
||||
}
|
||||
for i in 1..self.pal_count as usize {
|
||||
if self.palette[i] == def {
|
||||
return Ok((i as u8, false));
|
||||
}
|
||||
}
|
||||
if self.pal_count >= 64 {
|
||||
return Err(PaletteFullError);
|
||||
}
|
||||
let idx = self.pal_count;
|
||||
self.palette[idx as usize] = def;
|
||||
self.pal_count += 1;
|
||||
Ok((idx, true))
|
||||
}
|
||||
|
||||
/// World gen path. Calls the closure for every tile coordinate.
|
||||
/// version=0 so the first StatePacket causes all clients to request it.
|
||||
pub fn generate(f: impl Fn(u8, u8) -> TileDef) -> Result<Self, PaletteFullError> {
|
||||
let mut chunk = Self {
|
||||
palette: [TileDef::default(); 64],
|
||||
pal_count: 1,
|
||||
tiles: Box::new([0u8; 1024]),
|
||||
version: 0,
|
||||
};
|
||||
for ly in 0u8..32 {
|
||||
for lx in 0u8..32 {
|
||||
let def = f(lx, ly);
|
||||
let (idx, _) = chunk.find_or_insert_raw(def)?;
|
||||
chunk.tiles[ly as usize * 32 + lx as usize] = idx;
|
||||
}
|
||||
}
|
||||
chunk.version = 1;
|
||||
Ok(chunk)
|
||||
}
|
||||
|
||||
/// Set a tile at local coords. Bumps version if tile index or palette changed.
|
||||
pub fn set_tile(&mut self, lx: u8, ly: u8, def: TileDef) -> Result<(), PaletteFullError> {
|
||||
let (idx, was_new) = self.find_or_insert_raw(def)?;
|
||||
let slot = &mut self.tiles[ly as usize * 32 + lx as usize];
|
||||
let changed = *slot != idx;
|
||||
*slot = idx;
|
||||
if changed || was_new {
|
||||
self.version = self.version.wrapping_add(1);
|
||||
if self.version == 0 {
|
||||
self.version = 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tile(&self, lx: u8, ly: u8) -> TileDef {
|
||||
self.palette[self.tiles[ly as usize * 32 + lx as usize] as usize]
|
||||
}
|
||||
|
||||
pub fn tile_flags(&self, lx: u8, ly: u8) -> TileFlags {
|
||||
self.get_tile(lx, ly).flags
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
pub struct Entity {
|
||||
pub id: u32,
|
||||
pub type_id: u16, // 0 = player
|
||||
pub pos: (i16, i16),
|
||||
pub hp: u16,
|
||||
pub hp_max: u16,
|
||||
}
|
||||
|
||||
impl Entity {
|
||||
pub fn new(id: u32, type_id: u16, pos: (i16, i16), hp: u16) -> Self {
|
||||
Self {
|
||||
id,
|
||||
type_id,
|
||||
pos,
|
||||
hp,
|
||||
hp_max: hp,
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
//! 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::Sim;
|
||||
|
||||
use chunk::{Chunk, TileDef, TileFlags};
|
||||
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;
|
||||
|
||||
/// How many *future* movement windows an actor may address. Actions targeted beyond
|
||||
/// this horizon are dropped.
|
||||
pub const ACTION_WINDOW_HORIZON: usize = 3;
|
||||
|
||||
/// 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
|
||||
/// become an invisible solid border (id 0, 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);
|
||||
if wx < 0 || wy < 0 || wx >= map.width as i16 || wy >= map.height as i16 {
|
||||
return TileDef { tile_id: 0, flags: TileFlags(TileFlags::COLLIDABLE) };
|
||||
}
|
||||
let id = map.tile(wx, wy);
|
||||
TileDef { tile_id: id, flags: map::tile_flags(id) }
|
||||
}).expect("palette overflow during world gen");
|
||||
w.set_chunk(cx, cy, chunk);
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Minimal Tiled-CSV map loader.
|
||||
//!
|
||||
//! Reads a bare comma-separated tile grid (Tiled's "CSV" layer export, stripped of the
|
||||
//! surrounding TMX/XML). Dimensions are derived from the content: columns from the first
|
||||
//! row, height from the row count. Tiled encodes per-tile flip/rotation in the top bits of
|
||||
//! each 32-bit GID, which makes flipped tiles show up as large negative decimals — those
|
||||
//! bits are masked off here, leaving the bare tile_id. Flip orientation is discarded for now.
|
||||
|
||||
use crate::chunk::TileFlags;
|
||||
|
||||
/// Top three bits hold horizontal / vertical / diagonal flip flags; the rest is the tile id.
|
||||
const GID_FLIP_MASK: u32 = 0x1FFF_FFFF;
|
||||
|
||||
pub struct TileMap {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub tiles: Vec<u16>, // row-major, len == width * height
|
||||
}
|
||||
|
||||
impl TileMap {
|
||||
pub fn from_csv(path: &str) -> Self {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("failed to read map {path}: {e}"));
|
||||
|
||||
let mut tiles = Vec::new();
|
||||
let mut width = 0usize;
|
||||
let mut height = 0usize;
|
||||
|
||||
for line in text.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut cols = 0usize;
|
||||
for field in line.split(',') {
|
||||
let field = field.trim();
|
||||
if field.is_empty() {
|
||||
continue; // tolerate a trailing comma
|
||||
}
|
||||
// Parse as i64 so the "negative" flipped GIDs fit, then reinterpret the low
|
||||
// 32 bits as u32 and strip the flip flags.
|
||||
let raw = field.parse::<i64>()
|
||||
.unwrap_or_else(|_| panic!("invalid tile value {field:?} in {path}"));
|
||||
let gid = (raw as u32) & GID_FLIP_MASK;
|
||||
tiles.push(gid as u16);
|
||||
cols += 1;
|
||||
}
|
||||
if width == 0 {
|
||||
width = cols;
|
||||
} else if cols != width {
|
||||
panic!("ragged map row in {path}: expected {width} cols, got {cols}");
|
||||
}
|
||||
height += 1;
|
||||
}
|
||||
|
||||
TileMap { width: width as u16, height: height as u16, tiles }
|
||||
}
|
||||
|
||||
/// Tile id at map coordinates, or 0 (empty) when outside the map bounds.
|
||||
pub fn tile(&self, x: i16, y: i16) -> u16 {
|
||||
if x < 0 || y < 0 || x >= self.width as i16 || y >= self.height as i16 {
|
||||
return 0;
|
||||
}
|
||||
self.tiles[y as usize * self.width as usize + x as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Gameplay flags for a global tile id. Collision comes from the shared movement
|
||||
/// vocabulary (`crate::tile_collidable`) so the client can predict it identically;
|
||||
/// other flags (opaque, …) stay sim-side. Tile ids index into `overworld.tga`.
|
||||
pub fn tile_flags(tile_id: u16) -> TileFlags {
|
||||
let mut bits = 0u8;
|
||||
if crate::tile_collidable(tile_id) {
|
||||
bits |= TileFlags::COLLIDABLE;
|
||||
}
|
||||
TileFlags(bits)
|
||||
}
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use crate::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE};
|
||||
use crate::world::World;
|
||||
|
||||
pub struct Sim {
|
||||
pub world: World,
|
||||
/// Scheduled actions per entity, keyed by movement window (`tick / TICKS_PER_MOVE`).
|
||||
/// The window slot is the whole ordering model: an action addressed to an occupied
|
||||
/// window *replaces* it (retraction and rescheduling by the client, dedup of
|
||||
/// retransmits), a late action only fills the next window if it is empty, and
|
||||
/// anything past `ACTION_WINDOW_HORIZON` is dropped — so no client can grow server
|
||||
/// memory or move faster than one action per window.
|
||||
pending: HashMap<u32, BTreeMap<u32, u16>>,
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
pub fn new(world: World) -> Self {
|
||||
Self { world, pending: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, tick: u32, actions: &[(u32, u32, u16)]) {
|
||||
// The earliest window still addressable at this tick. On a movement tick that is
|
||||
// the window executing *this call* — actions arriving the same tick still count.
|
||||
let min_window = tick.div_ceil(TICKS_PER_MOVE);
|
||||
|
||||
for &(entity_id, target_tick, action) in actions {
|
||||
let slots = self.pending.entry(entity_id).or_default();
|
||||
let window = target_tick.div_ceil(TICKS_PER_MOVE);
|
||||
if window < min_window {
|
||||
// Late. A movement action keeps its *order* instead of its time: it
|
||||
// fills the first still-empty upcoming window, so bunched late arrivals
|
||||
// don't collapse onto one slot and eat each other. A late NOOP is
|
||||
// dropped — once its window has passed, its cancellation intent is
|
||||
// ambiguous, and as a gap-filler it would block real steps (worst case
|
||||
// one stale step executes; the client's reconciliation handles that).
|
||||
if crate::action_delta(action).is_some() {
|
||||
let gap = (min_window..min_window + ACTION_WINDOW_HORIZON as u32)
|
||||
.find(|w| !slots.contains_key(w));
|
||||
if let Some(w) = gap {
|
||||
slots.insert(w, action);
|
||||
}
|
||||
}
|
||||
} else if window - min_window < ACTION_WINDOW_HORIZON as u32 {
|
||||
slots.insert(window, action); // newest addressing wins
|
||||
}
|
||||
}
|
||||
|
||||
if tick.is_multiple_of(TICKS_PER_MOVE) {
|
||||
self.entity_tick(tick / TICKS_PER_MOVE);
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_tick(&mut self, window: u32) {
|
||||
// Exactly one action per entity per movement window, then the window is gone.
|
||||
let actions: Vec<(u32, u16)> = self.pending.iter_mut()
|
||||
.filter_map(|(&id, slots)| {
|
||||
let action = slots.remove(&window);
|
||||
slots.retain(|&w, _| w > window); // drop anything the timeline passed by
|
||||
action.map(|a| (id, a))
|
||||
})
|
||||
.collect();
|
||||
self.pending.retain(|_, slots| !slots.is_empty());
|
||||
|
||||
for (entity_id, action) in actions {
|
||||
let delta = match crate::action_delta(action) {
|
||||
Some(d) => d,
|
||||
None => continue, // NOOP or garbage
|
||||
};
|
||||
let pos = match self.world.entities.get(&entity_id) {
|
||||
Some(e) => e.pos,
|
||||
None => continue,
|
||||
};
|
||||
let from = (pos.0 as i32, pos.1 as i32);
|
||||
let to = (from.0 + delta.0, from.1 + delta.1);
|
||||
// `step_allowed` is the shared movement rule (chessboard geometry, no corner
|
||||
// cutting) — the client predicts with the exact same function.
|
||||
let allowed = crate::step_allowed(from, to, |x, y| {
|
||||
self.world.tile_flags(x as i16, y as i16).map_or(true, |f| f.collidable())
|
||||
});
|
||||
if allowed {
|
||||
self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::chunk::{Chunk, TileDef, TileFlags, PaletteFullError};
|
||||
use crate::entity::Entity;
|
||||
|
||||
pub fn tile_to_chunk(tx: i16, ty: i16) -> (i16, i16) {
|
||||
(tx >> 5, ty >> 5)
|
||||
}
|
||||
|
||||
pub fn tile_to_local(tx: i16, ty: i16) -> (u8, u8) {
|
||||
((tx & 31) as u8, (ty & 31) as u8)
|
||||
}
|
||||
|
||||
pub fn local_to_tile(cx: i16, cy: i16, lx: u8, ly: u8) -> (i16, i16) {
|
||||
((cx << 5) | lx as i16, (cy << 5) | ly as i16)
|
||||
}
|
||||
|
||||
pub struct World {
|
||||
chunks: HashMap<(i16, i16), Chunk>,
|
||||
pub entities: HashMap<u32, Entity>,
|
||||
chunk_entities: HashMap<(i16, i16), Vec<u32>>,
|
||||
next_entity_id: u32,
|
||||
}
|
||||
|
||||
impl World {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
chunks: HashMap::new(),
|
||||
entities: HashMap::new(),
|
||||
chunk_entities: HashMap::new(),
|
||||
next_entity_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chunk(&self, cx: i16, cy: i16) -> Option<&Chunk> {
|
||||
self.chunks.get(&(cx, cy))
|
||||
}
|
||||
|
||||
pub fn set_chunk(&mut self, cx: i16, cy: i16, chunk: Chunk) {
|
||||
self.chunks.insert((cx, cy), chunk);
|
||||
}
|
||||
|
||||
pub fn tile_flags(&self, wx: i16, wy: i16) -> Option<TileFlags> {
|
||||
let (cx, cy) = tile_to_chunk(wx, wy);
|
||||
let (lx, ly) = tile_to_local(wx, wy);
|
||||
self.chunks.get(&(cx, cy)).map(|c| c.tile_flags(lx, ly))
|
||||
}
|
||||
|
||||
pub fn set_tile(&mut self, wx: i16, wy: i16, def: TileDef) -> Result<(), PaletteFullError> {
|
||||
let (cx, cy) = tile_to_chunk(wx, wy);
|
||||
let (lx, ly) = tile_to_local(wx, wy);
|
||||
let chunk = self.chunks.entry((cx, cy)).or_insert_with(Chunk::new);
|
||||
chunk.set_tile(lx, ly, def)
|
||||
}
|
||||
|
||||
pub fn spawn_entity(&mut self, type_id: u16, pos: (i16, i16), hp: u16) -> u32 {
|
||||
let id = self.next_entity_id;
|
||||
self.next_entity_id += 1;
|
||||
let entity = Entity::new(id, type_id, pos, hp);
|
||||
self.add_entity(entity);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn add_entity(&mut self, entity: Entity) {
|
||||
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
|
||||
let id = entity.id;
|
||||
self.entities.insert(id, entity);
|
||||
self.chunk_entities.entry(chunk_key).or_default().push(id);
|
||||
}
|
||||
|
||||
pub fn remove_entity(&mut self, id: u32) {
|
||||
if let Some(entity) = self.entities.remove(&id) {
|
||||
let chunk_key = tile_to_chunk(entity.pos.0, entity.pos.1);
|
||||
if let Some(list) = self.chunk_entities.get_mut(&chunk_key) {
|
||||
list.retain(|&eid| eid != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_entity(&mut self, id: u32, new_pos: (i16, i16)) {
|
||||
if let Some(entity) = self.entities.get_mut(&id) {
|
||||
let old_chunk = tile_to_chunk(entity.pos.0, entity.pos.1);
|
||||
let new_chunk = tile_to_chunk(new_pos.0, new_pos.1);
|
||||
entity.pos = new_pos;
|
||||
if old_chunk != new_chunk {
|
||||
if let Some(list) = self.chunk_entities.get_mut(&old_chunk) {
|
||||
list.retain(|&eid| eid != id);
|
||||
}
|
||||
self.chunk_entities.entry(new_chunk).or_default().push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entities_in_chunk(&self, cx: i16, cy: i16) -> &[u32] {
|
||||
self.chunk_entities.get(&(cx, cy)).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Returns all entities within a 30×30 tile viewport centered on `center`.
|
||||
pub fn entities_in_viewport(&self, center: (i16, i16)) -> Vec<&Entity> {
|
||||
let half: i16 = 15;
|
||||
let min_x = center.0 - half;
|
||||
let max_x = center.0 + half - 1;
|
||||
let min_y = center.1 - half;
|
||||
let max_y = center.1 + half - 1;
|
||||
|
||||
let min_cx = tile_to_chunk(min_x, min_y).0;
|
||||
let max_cx = tile_to_chunk(max_x, max_y).0;
|
||||
let min_cy = tile_to_chunk(min_x, min_y).1;
|
||||
let max_cy = tile_to_chunk(max_x, max_y).1;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for cy in min_cy..=max_cy {
|
||||
for cx in min_cx..=max_cx {
|
||||
for &eid in self.entities_in_chunk(cx, cy) {
|
||||
if let Some(e) = self.entities.get(&eid) {
|
||||
if e.pos.0 >= min_x && e.pos.0 <= max_x
|
||||
&& e.pos.1 >= min_y && e.pos.1 <= max_y
|
||||
{
|
||||
result.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user