Ease the camera toward the player's interpolated position
The camera target was the sim tile, which jumps 8 px per movement window, and the follow was a constant-speed chase that stopped abruptly. Now the target is the render-side lerp position and the camera closes the gap exponentially, so it accelerates and settles smoothly. Also spawns the first NPC and switches the game to World::blocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+25
-27
@@ -5,7 +5,7 @@ use std::collections::{HashMap, VecDeque};
|
|||||||
|
|
||||||
use crate::assets::{Image, Tile};
|
use crate::assets::{Image, Tile};
|
||||||
use crate::input::{GameAction, InputState};
|
use crate::input::{GameAction, InputState};
|
||||||
use sim::entity::Entity;
|
use sim::entity::{Entity, EntityKind};
|
||||||
use sim::map::TileMap;
|
use sim::map::TileMap;
|
||||||
use sim::{delta_action, load_world, step_allowed, Sim, TICK_HZ, TICKS_PER_MOVE};
|
use sim::{delta_action, load_world, step_allowed, Sim, TICK_HZ, TICKS_PER_MOVE};
|
||||||
|
|
||||||
@@ -36,11 +36,12 @@ const VIEW_TILES: i32 = 30;
|
|||||||
const TILE_PX: i32 = 8;
|
const TILE_PX: i32 = 8;
|
||||||
const VIEW_PX: i32 = VIEW_TILES * TILE_PX;
|
const VIEW_PX: i32 = VIEW_TILES * TILE_PX;
|
||||||
|
|
||||||
/// Camera pan speed. The player walks 8 px per 167 ms ≈ 48 px/s; the camera is a bit
|
/// Camera smoothing time constant: each frame the camera closes this fraction
|
||||||
/// faster, so it trails during movement and settles right after the player stops.
|
/// `1 - exp(-dt/tau)` of the remaining distance to its target, so it eases in and
|
||||||
const CAM_SPEED: f32 = 64.0; // px/s
|
/// out instead of chasing at constant speed. Smaller = snappier, larger = floatier.
|
||||||
|
const CAM_SMOOTH_TAU_MS: f32 = 120.0;
|
||||||
|
|
||||||
/// A position correction farther than this is a teleport — snap instead of panning.
|
/// A position correction farther than this is a teleport — snap instead of easing.
|
||||||
const CAM_SNAP_PX: f32 = 96.0;
|
const CAM_SNAP_PX: f32 = 96.0;
|
||||||
|
|
||||||
/// Render-side smoothing state for one entity: the previous tile and how long ago the
|
/// Render-side smoothing state for one entity: the previous tile and how long ago the
|
||||||
@@ -86,7 +87,8 @@ impl Game {
|
|||||||
println!("loaded {map_path}: {}×{} tiles in {:.1} ms",
|
println!("loaded {map_path}: {}×{} tiles in {:.1} ms",
|
||||||
map.width, map.height, t0.elapsed().as_secs_f64() * 1e3);
|
map.width, map.height, t0.elapsed().as_secs_f64() * 1e3);
|
||||||
let mut sim = Sim::new(world);
|
let mut sim = Sim::new(world);
|
||||||
let player_id = sim.world.spawn_entity(0, (0, 0), 100);
|
let player_id = sim.world.spawn_entity(EntityKind::Player, 0, (0, 0), 100);
|
||||||
|
sim.world.spawn_entity(EntityKind::Npc, 1, (6, 6), 5);
|
||||||
|
|
||||||
let mut game = Game {
|
let mut game = Game {
|
||||||
tileset,
|
tileset,
|
||||||
@@ -109,12 +111,6 @@ impl Game {
|
|||||||
(p.0 as i32, p.1 as i32)
|
(p.0 as i32, p.1 as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the world tile at `(wx, wy)` blocks movement. Outside the loaded world
|
|
||||||
/// counts as blocked — the same rule the sim applies.
|
|
||||||
fn tile_blocked(&self, wx: i32, wy: i32) -> bool {
|
|
||||||
self.sim.world.tile_flags(wx as i16, wy as i16).map_or(true, |f| f.collidable())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(&mut self, render_frame: &mut [u16], dt: usize, input: &InputState)
|
pub fn update(&mut self, render_frame: &mut [u16], dt: usize, input: &InputState)
|
||||||
-> Option<GameSignal>
|
-> Option<GameSignal>
|
||||||
{
|
{
|
||||||
@@ -142,18 +138,21 @@ impl Game {
|
|||||||
return Some(GameSignal::Quit);
|
return Some(GameSignal::Quit);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.step_camera(dt);
|
|
||||||
self.step_lerp(dt);
|
self.step_lerp(dt);
|
||||||
|
self.step_camera(dt);
|
||||||
self.render_viewport(render_frame);
|
self.render_viewport(render_frame);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the camera wants to be: the viewport centered on the player's tile.
|
/// Where the camera wants to be: the viewport centered on the player's rendered
|
||||||
|
/// (interpolated) position, not the coarser sim tile — otherwise the target itself
|
||||||
|
/// jumps in discrete per-tile steps and the easing below has nothing smooth to chase.
|
||||||
fn cam_target(&self) -> (f32, f32) {
|
fn cam_target(&self) -> (f32, f32) {
|
||||||
let (px, py) = self.player_pos();
|
let player = &self.sim.world.entities[&self.player_id];
|
||||||
|
let (px, py) = self.entity_px(player);
|
||||||
(
|
(
|
||||||
(px * TILE_PX - VIEW_PX / 2) as f32,
|
(px - VIEW_PX / 2) as f32,
|
||||||
(py * TILE_PX - VIEW_PX / 2) as f32,
|
(py - VIEW_PX / 2) as f32,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,19 +161,18 @@ impl Game {
|
|||||||
(self.cam.0.round() as i32, self.cam.1.round() as i32)
|
(self.cam.0.round() as i32, self.cam.1.round() as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follow the player linearly at `CAM_SPEED`, axis by axis. Corrections beyond
|
/// Ease toward the target: each frame closes `1 - exp(-dt/tau)` of the remaining
|
||||||
/// `CAM_SNAP_PX` (teleports, respawns) snap outright instead of panning across.
|
/// gap, so the camera eases out of a stop and eases into a catch-up rather than
|
||||||
|
/// tracking at constant speed. Corrections beyond `CAM_SNAP_PX` (teleports,
|
||||||
|
/// respawns) snap outright instead of easing across.
|
||||||
fn step_camera(&mut self, dt: usize) {
|
fn step_camera(&mut self, dt: usize) {
|
||||||
let (tx, ty) = self.cam_target();
|
let (tx, ty) = self.cam_target();
|
||||||
if (tx - self.cam.0).abs().max((ty - self.cam.1).abs()) > CAM_SNAP_PX {
|
if (tx - self.cam.0).abs().max((ty - self.cam.1).abs()) > CAM_SNAP_PX {
|
||||||
self.cam = (tx, ty);
|
self.cam = (tx, ty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let step = CAM_SPEED * dt as f32 / 1000.0;
|
let f = 1.0 - (-(dt as f32) / CAM_SMOOTH_TAU_MS).exp();
|
||||||
let approach = |c: f32, t: f32| {
|
self.cam = (self.cam.0 + (tx - self.cam.0) * f, self.cam.1 + (ty - self.cam.1) * f);
|
||||||
if (t - c).abs() <= step { t } else { c + step * (t - c).signum() }
|
|
||||||
};
|
|
||||||
self.cam = (approach(self.cam.0, tx), approach(self.cam.1, ty));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Click-to-move: translate a framebuffer click into a world tile and adopt it as
|
/// Click-to-move: translate a framebuffer click into a world tile and adopt it as
|
||||||
@@ -198,7 +196,7 @@ impl Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match pathfind::find_path(self.player_pos(), goal, |x, y| self.tile_blocked(x, y)) {
|
match pathfind::find_path(self.player_pos(), goal, |x, y| self.sim.world.blocked(x, y)) {
|
||||||
Some(steps) => {
|
Some(steps) => {
|
||||||
self.route = steps.into();
|
self.route = steps.into();
|
||||||
self.goal = Some(goal);
|
self.goal = Some(goal);
|
||||||
@@ -243,7 +241,7 @@ impl Game {
|
|||||||
};
|
};
|
||||||
let Some((nx, ny)) = step else { return };
|
let Some((nx, ny)) = step else { return };
|
||||||
|
|
||||||
if step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y)) {
|
if step_allowed(from, (nx, ny), |x, y| self.sim.world.blocked(x, y)) {
|
||||||
self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1));
|
self.sim.set_action(self.player_id, delta_action(nx - from.0, ny - from.1));
|
||||||
} else if dir.is_none() {
|
} else if dir.is_none() {
|
||||||
self.replan_route();
|
self.replan_route();
|
||||||
@@ -273,7 +271,7 @@ impl Game {
|
|||||||
self.goal = None;
|
self.goal = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match pathfind::find_path(pos, goal, |x, y| self.tile_blocked(x, y)) {
|
match pathfind::find_path(pos, goal, |x, y| self.sim.world.blocked(x, y)) {
|
||||||
Some(steps) => self.route = steps.into(),
|
Some(steps) => self.route = steps.into(),
|
||||||
None => self.goal = None,
|
None => self.goal = None,
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -54,9 +54,17 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou
|
|||||||
replanning toward the tile under the cursor (only when that tile changes — cursor
|
replanning toward the tile under the cursor (only when that tile changes — cursor
|
||||||
or camera movement), sweeping across blocked tiles keeps the current route.
|
or camera movement), sweeping across blocked tiles keeps the current route.
|
||||||
- [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`)
|
- [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`)
|
||||||
that tracks the player linearly at ~64 px/s, snapping only to whole pixels at
|
that eases toward the player's *interpolated* render position (exponential
|
||||||
render time (and outright on teleport-sized corrections). The viewport renders
|
approach, τ = 120 ms), snapping only to whole pixels at render time (and outright
|
||||||
with sub-tile offsets (31×31 tile pass + right-strip clip).
|
on teleport-sized corrections). The viewport renders with sub-tile offsets
|
||||||
|
(31×31 tile pass + right-strip clip).
|
||||||
|
- [x] Entity blocking: `World::blocked` (solid tile ∨ out of world ∨ occupied) is the
|
||||||
|
one predicate the sim's `entity_tick` and the pathfinder share. Intents execute
|
||||||
|
in id order, so the player (id 1) wins a contested tile deterministically.
|
||||||
|
- [x] First NPC: `EntityKind { Player, Npc }` on `Entity`; `Sim::think` runs before
|
||||||
|
every movement window and schedules a random king move for each NPC one window
|
||||||
|
in four (seeded xorshift in `Sim`). Sprite = tile 1 in `entities.png` (a hen from
|
||||||
|
the Mini-Medieval pack). One is spawned at (6, 6) in `Game::start`.
|
||||||
- [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.rs`) — previous/current
|
- [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.rs`) — previous/current
|
||||||
tile plus a clock, rendered as a pixel lerp over one movement interval. Jumps of
|
tile plus a clock, rendered as a pixel lerp over one movement interval. Jumps of
|
||||||
more than one tile (Chebyshev) snap. Purely cosmetic; game logic keeps using the
|
more than one tile (Chebyshev) snap. Purely cosmetic; game logic keeps using the
|
||||||
@@ -91,13 +99,14 @@ Remaining follow-ups when the need is concrete:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 08 — Basic NPC entity + AI budget
|
### 08 — Basic NPC entity + AI budget *(wanderer done — see Achieved)*
|
||||||
|
|
||||||
One dumb wandering enemy. Validates the simulation architecture before complexity accumulates.
|
The wandering NPC and entity blocking have landed; `Sim::think` is the hook where the
|
||||||
Depends on the action-point model from 06 being reintroduced. NPCs schedule intents through
|
budget model goes once there are enough NPCs to need it. The action-point model from
|
||||||
the same `Sim::set_action` the player uses.
|
06 is still pending and only matters once NPC speeds should differ.
|
||||||
|
|
||||||
- Add `EntityKind::Npc` with a `think() -> u32` method returning budget cost
|
- [x] `EntityKind::Npc`, `Sim::think` scheduling intents through the same intent table
|
||||||
|
- Bump-to-attack: a step onto an occupied tile becomes an attack instead of a refused move
|
||||||
- Per sim-tick: distribute a fixed `think_budget` across entities ordered by player proximity
|
- Per sim-tick: distribute a fixed `think_budget` across entities ordered by player proximity
|
||||||
- Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
|
- Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
|
||||||
- No framework. No trait objects yet. A match on `EntityKind` is fine.
|
- No framework. No trait objects yet. A match on `EntityKind` is fine.
|
||||||
@@ -110,9 +119,6 @@ for entity in entities_by_player_proximity() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Entity-vs-entity blocking: `step_allowed` only checks tiles. Once NPCs exist, occupied
|
|
||||||
tiles need to block too — in the sim *and* in the pathfinder's `blocked` closure.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 09 — Client camera *(done — see Achieved)*
|
### 09 — Client camera *(done — see Achieved)*
|
||||||
|
|||||||
Reference in New Issue
Block a user