use std::collections::HashMap;
use std::time::{Duration, SystemTime};
use pixtuoid_core::physics::{walk_arrived, walk_profile, walk_progress, WalkIntent};
use pixtuoid_core::state::AgentSlot;
use pixtuoid_core::walkable::OccupancyOverlay;
use pixtuoid_core::AgentId;
use crate::tui::motion::{
advance_wander, octile_path_len, settle_len, MotionState, WalkLeg, WalkPathSnapshot,
WanderPhase,
};
pub use pixtuoid_core::pose::{
aimless_wander_seed, cycle_ms_for, derive, derive_state_only, dwell_ms, est_wander_cycle_ms,
is_aimless_cycle, personality_for, pick_aimless_dest, seated_dwell_ms, takes_trip,
waypoint_index_for_cycle, Personality, Pose, ENTRY_ANIMATION_MS, THINKING_WINDOW_SECS,
TYPING_FRAMES, TYPING_FRAME_MS, WALKING_FRAMES, WALKING_FRAME_MS, WANDER_CYCLE_BASE_MS,
WANDER_CYCLE_RANGE_MS, WANDER_DWELL_EST_MS, WANDER_WALK_EST_MS,
};
use crate::tui::layout::{desk_walk_anchor, Layout, Point, WaypointKind};
use crate::tui::pathfind::Router;
pub struct RouteCtx<'a> {
pub router: &'a mut dyn Router,
pub overlay: &'a OccupancyOverlay,
pub history: &'a mut PoseHistory,
pub motion: &'a mut HashMap<AgentId, MotionState>,
}
#[derive(Debug, Default, Clone)]
pub struct PoseHistory {
last: std::collections::HashMap<AgentId, (Point, SystemTime)>,
}
impl PoseHistory {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, agent_id: AgentId, anchor: Point, now: SystemTime) {
self.last.insert(agent_id, (anchor, now));
}
pub fn evict_missing(&mut self, scene: &pixtuoid_core::state::SceneState) {
self.last.retain(|id, _| scene.agents.contains_key(id));
}
#[cfg(test)]
pub fn contains(&self, agent_id: AgentId) -> bool {
self.last.contains_key(&agent_id)
}
pub fn recent(&self, agent_id: AgentId, max_age_ms: u64, now: SystemTime) -> Option<Point> {
let (pt, when) = self.last.get(&agent_id).copied()?;
let age = now.duration_since(when).ok()?.as_millis() as u64;
if age <= max_age_ms {
Some(pt)
} else {
None
}
}
}
const SNAP_BACK_MS: u64 = 900;
const SNAP_BACK_MIN_DIST: i32 = 8;
pub(in crate::tui) fn desk_approach_cell(desk: Point, layout: &Layout) -> Option<Point> {
use pixtuoid_core::layout::{approach_point, desk_walk_anchor, Facing, Furniture};
let chair = desk_walk_anchor(desk);
let cell = approach_point(
Furniture::Desk,
chair,
Facing::South,
layout.pantry_counter_size,
&layout.walkable,
chair,
&layout.reachable,
);
(cell != chair).then_some(cell)
}
pub(in crate::tui) fn desk_leg_endpoint(desk: Point, layout: &Layout) -> (Point, Option<Point>) {
let chair = pixtuoid_core::layout::desk_walk_anchor(desk);
match desk_approach_cell(desk, layout) {
Some(approach) => (approach, Some(chair)),
None => (chair, None),
}
}
pub fn derive_with_routing(
slot: &AgentSlot,
now: SystemTime,
layout: &Layout,
rctx: &mut RouteCtx<'_>,
) -> Option<Pose> {
let router = &mut *rctx.router;
let overlay = rctx.overlay;
let history = &mut *rctx.history;
let motion = &mut *rctx.motion;
let desk = layout.home_desk(slot.desk_index.single_floor_local())?;
if let Some(exit_time) = slot.exiting_at {
let Some(door_target) = layout.door_threshold else {
let raw = derive_state_only(slot, now, layout)?;
return match raw {
Pose::Walking { .. } => route_walking_pose(
slot,
now,
layout,
&mut RouteCtx {
router,
overlay,
history,
motion,
},
raw,
Settle::None,
),
other => Some(other),
};
};
let mstate = motion
.entry(slot.agent_id)
.or_insert_with(|| MotionState::new(slot.agent_id));
if mstate.exit.is_none() {
let desk_anchor = desk_walk_anchor(desk);
let from = history
.recent(slot.agent_id, 300, now)
.unwrap_or(desk_anchor);
let (route_from, chair_rise) = if from == desk_anchor {
desk_leg_endpoint(desk, layout)
} else {
(from, None)
};
let to_jittered = jitter_dest(slot.agent_id, door_target);
let path = router.route(&layout.walkable, overlay, route_from, to_jittered);
let glide = settle_len(route_from, chair_rise);
let path_len = (octile_path_len(&path) + glide).max(1);
let profile = walk_profile(path_len, WalkIntent::Exit, slot.agent_id);
mstate.exit = Some(WalkLeg {
started_at: exit_time,
profile,
from,
});
}
let e = mstate.exit.as_ref()?;
let started_at = e.started_at;
let profile = &e.profile;
let stored_from = e.from;
let elapsed_ms = now
.duration_since(started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
let exit_budget = (pixtuoid_core::state::reducer::EXIT_GRACE_WINDOW.as_millis() as u64)
.saturating_sub(300);
let eff_elapsed = if profile.duration_ms.saturating_add(profile.pause_ms) > exit_budget {
(elapsed_ms.saturating_mul(profile.duration_ms) / exit_budget.max(1)).max(elapsed_ms)
} else {
elapsed_ms
};
if walk_arrived(profile, eff_elapsed) {
return None;
}
let t_x1000 = walk_progress(profile, eff_elapsed);
let frame = ((eff_elapsed / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
let (from, exit_settle) = if stored_from == desk_walk_anchor(desk) {
let (approach, chair) = desk_leg_endpoint(desk, layout);
(approach, chair.map_or(Settle::None, Settle::Start))
} else {
(stored_from, Settle::None)
};
return route_walking_pose(
slot,
now,
layout,
&mut RouteCtx {
router,
overlay,
history,
motion,
},
Pose::Walking {
from,
to: door_target,
t_x1000,
frame,
carrying_coffee: false,
},
exit_settle,
);
}
let since_spawn = now
.duration_since(slot.created_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
if let Some(door) = layout.door_threshold {
let (approach, chair_settle) = desk_leg_endpoint(desk, layout);
let settle = chair_settle.map_or(Settle::None, Settle::End);
let settle_px = settle_len(approach, chair_settle);
let mstate = motion
.entry(slot.agent_id)
.or_insert_with(|| MotionState::new(slot.agent_id));
if mstate.entry.is_none() && since_spawn < ENTRY_ANIMATION_MS {
let to_jittered = jitter_dest(slot.agent_id, approach);
let path = router.route(&layout.walkable, overlay, door, to_jittered);
let path_len = (octile_path_len(&path) + settle_px).max(1);
let profile = walk_profile(path_len, WalkIntent::Entry, slot.agent_id);
mstate.entry = Some((slot.created_at, profile));
}
if let Some((started_at, ref profile)) = mstate.entry.clone() {
let elapsed_ms = now
.duration_since(started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
if !walk_arrived(profile, elapsed_ms) {
let t_x1000 = walk_progress(profile, elapsed_ms);
let frame = ((elapsed_ms / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
return route_walking_pose(
slot,
now,
layout,
&mut RouteCtx {
router,
overlay,
history,
motion,
},
Pose::Walking {
from: door,
to: approach,
t_x1000,
frame,
carrying_coffee: false,
},
settle,
);
}
}
}
let is_idle = matches!(slot.state, pixtuoid_core::state::ActivityState::Idle);
if is_idle && slot.exiting_at.is_none() {
let was_active = slot.last_event_at > slot.created_at;
let since_last_event = now
.duration_since(slot.last_event_at)
.unwrap_or(Duration::ZERO)
.as_secs();
if was_active && since_last_event < THINKING_WINDOW_SECS {
return Some(Pose::SeatedThinking);
}
let (wander_phase, t_phys) = advance_wander(slot, now, layout, router, overlay, motion);
match wander_phase {
WanderPhase::WalkingOut => {
let ms = motion.get(&slot.agent_id)?;
let desk_point = layout.home_desk(slot.desk_index.single_floor_local())?;
let dest = ms.wander_dest;
let seat = ms.wander_seat;
let (from, chair_settle) = desk_leg_endpoint(desk_point, layout);
let settle = match (chair_settle, seat) {
(Some(chair), Some(s)) => Settle::Both {
start: chair,
end: s,
},
(Some(chair), None) => Settle::Start(chair),
(None, Some(s)) => Settle::End(s),
(None, None) => Settle::None,
};
let elapsed_phase = now
.duration_since(ms.wander_phase_started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
let frame = (elapsed_phase / WALKING_FRAME_MS) as usize % WALKING_FRAMES;
return route_walking_pose(
slot,
now,
layout,
&mut RouteCtx {
router,
overlay,
history,
motion,
},
Pose::Walking {
from,
to: dest,
t_x1000: t_phys,
frame,
carrying_coffee: false,
},
settle,
);
}
WanderPhase::AtWaypoint => {
let ms = motion.get(&slot.agent_id)?;
let pose = if let (Some(wp_idx), Some(kind)) =
(ms.wander_dest_wp_idx, ms.wander_dest_kind)
{
Pose::AtWaypoint { wp: wp_idx, kind }
} else {
Pose::AimlessAt {
dest: ms.wander_dest,
}
};
let pt = ms.wander_dest;
history.record(slot.agent_id, pt, now);
return Some(pose);
}
WanderPhase::WalkingBack => {
let ms = motion.get(&slot.agent_id)?;
let desk_point = layout.home_desk(slot.desk_index.single_floor_local())?;
let wander_dest = ms.wander_dest;
let wander_phase_started_at = ms.wander_phase_started_at;
let carrying_coffee = ms.wander_dest_kind == Some(WaypointKind::Pantry);
let seat = ms.wander_seat;
let (snap_target, chair_settle) = desk_leg_endpoint(desk_point, layout);
let settle = match (seat, chair_settle) {
(Some(s), Some(chair)) => Settle::Both {
start: s,
end: chair,
},
(Some(s), None) => Settle::Start(s),
(None, Some(chair)) => Settle::End(chair),
(None, None) => Settle::None,
};
let elapsed_phase = now
.duration_since(wander_phase_started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
let frame = (elapsed_phase / WALKING_FRAME_MS) as usize % WALKING_FRAMES;
return route_walking_pose(
slot,
now,
layout,
&mut RouteCtx {
router,
overlay,
history,
motion,
},
Pose::Walking {
from: wander_dest,
to: snap_target,
t_x1000: t_phys,
frame,
carrying_coffee,
},
settle,
);
}
WanderPhase::Seated => {
return Some(Pose::SeatedIdle);
}
}
}
let raw = derive_state_only(slot, now, layout)?;
let desk_pose = matches!(
raw,
Pose::SeatedIdle | Pose::SeatedThinking | Pose::SeatedTyping { .. } | Pose::StandingAtDesk
);
let since_state = now
.duration_since(slot.state_started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
let mut final_settle = Settle::None;
let pose = if desk_pose {
let ms_entry = motion
.entry(slot.agent_id)
.or_insert_with(|| MotionState::new(slot.agent_id));
let already_armed =
matches!(&ms_entry.snap_back, Some(leg) if leg.started_at == slot.state_started_at);
if !already_armed {
ms_entry.snap_back = None;
if since_state < SNAP_BACK_MS {
if let Some(prev) = history.recent(slot.agent_id, 300, now) {
let chair = desk_walk_anchor(desk);
let dist = (prev.x as i32 - chair.x as i32).abs()
+ (prev.y as i32 - chair.y as i32).abs();
if dist >= SNAP_BACK_MIN_DIST {
let (snap_target, chair_settle) = desk_leg_endpoint(desk, layout);
let to_jittered = jitter_dest(slot.agent_id, snap_target);
let path = router.route(&layout.walkable, overlay, prev, to_jittered);
let len =
(octile_path_len(&path) + settle_len(snap_target, chair_settle)).max(1);
let p = walk_profile(len, WalkIntent::SnapBack, slot.agent_id);
ms_entry.snap_back = Some(WalkLeg {
started_at: slot.state_started_at,
profile: p,
from: prev,
});
}
}
}
}
match ms_entry.snap_back.clone() {
Some(WalkLeg {
started_at,
profile,
from: snap_prev,
}) if started_at == slot.state_started_at => {
let elapsed_ms = now
.duration_since(started_at)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
if walk_arrived(&profile, elapsed_ms) {
ms_entry.snap_back = None;
raw
} else {
let t_x1000 = walk_progress(&profile, elapsed_ms);
let frame = ((elapsed_ms / WALKING_FRAME_MS) as usize) % WALKING_FRAMES;
let (snap_target, chair_settle) = desk_leg_endpoint(desk, layout);
final_settle = chair_settle.map_or(Settle::None, Settle::End);
Pose::Walking {
from: snap_prev,
to: snap_target,
t_x1000,
frame,
carrying_coffee: false,
}
}
}
_ => raw,
}
} else {
if let Some(ms) = motion.get_mut(&slot.agent_id) {
if ms.snap_back.is_some() {
ms.snap_back = None;
}
}
raw
};
route_walking_pose(
slot,
now,
layout,
&mut RouteCtx {
router,
overlay,
history,
motion,
},
pose,
final_settle,
)
}
#[derive(Clone, Copy)]
enum Settle {
None,
End(Point),
Start(Point),
Both { start: Point, end: Point },
}
fn route_walking_pose(
slot: &AgentSlot,
now: SystemTime,
layout: &Layout,
rctx: &mut RouteCtx<'_>,
pose: Pose,
settle: Settle,
) -> Option<Pose> {
let router = &mut *rctx.router;
let overlay = rctx.overlay;
let history = &mut *rctx.history;
let motion = &mut *rctx.motion;
let Pose::Walking {
from,
to,
t_x1000,
frame,
carrying_coffee,
} = pose
else {
if let Some(ms) = motion.get_mut(&slot.agent_id) {
ms.walk_path = None;
}
let pt = match &pose {
Pose::AtWaypoint { wp, .. } => layout.waypoints.get(*wp).map(|w| w.pos),
Pose::AimlessAt { dest } => Some(*dest),
_ => None,
};
if let Some(p) = pt {
history.record(slot.agent_id, p, now);
}
return Some(pose);
};
let path = {
let ms = motion
.entry(slot.agent_id)
.or_insert_with(|| MotionState::new(slot.agent_id));
match &ms.walk_path {
Some(wp) if wp.from == from && wp.to == to => wp.path.clone(),
_ => {
let to_jittered = jitter_dest(slot.agent_id, to);
let mut p = router.route(&layout.walkable, overlay, from, to_jittered);
if let Some(last) = p.last_mut() {
*last = to;
}
match settle {
Settle::End(s) if p.last() != Some(&s) => p.push(s),
Settle::Start(s) if p.first() != Some(&s) => p.insert(0, s),
Settle::Both { start, end } => {
if p.last() != Some(&end) {
p.push(end);
}
if p.first() != Some(&start) {
p.insert(0, start);
}
}
_ => {}
}
if p.len() > 2 {
ms.walk_path = Some(WalkPathSnapshot {
from,
to,
path: p.clone(),
});
} else {
ms.walk_path = None;
}
p
}
}
};
if path.len() <= 2 {
history.record(slot.agent_id, walking_position(from, to, t_x1000), now);
return Some(Pose::Walking {
from,
to,
t_x1000,
frame,
carrying_coffee,
});
}
let mut leg_lens: Vec<u32> = Vec::with_capacity(path.len() - 1);
for w in path.windows(2) {
leg_lens.push(octile_distance(w[0], w[1]));
}
let total: u32 = leg_lens.iter().sum();
if total == 0 {
return Some(pose);
}
let traveled = (t_x1000 as u32 * total) / 1000;
let mut acc: u32 = 0;
for (i, &leg) in leg_lens.iter().enumerate() {
if acc + leg >= traveled {
let into_leg = traveled - acc;
let seg_t = (into_leg * 1000)
.checked_div(leg)
.map(|t| t.min(1000) as u16)
.unwrap_or(1000);
let cur_pos = walking_position(path[i], path[i + 1], seg_t);
history.record(slot.agent_id, cur_pos, now);
return Some(Pose::Walking {
from: path[i],
to: path[i + 1],
t_x1000: seg_t,
frame,
carrying_coffee,
});
}
acc += leg;
}
let last = path.len() - 1;
history.record(slot.agent_id, path[last], now);
Some(Pose::Walking {
from: path[last - 1],
to: path[last],
t_x1000: 1000,
frame,
carrying_coffee,
})
}
use crate::tui::pixel_painter::walking_position;
pub(in crate::tui) fn octile_distance(a: Point, b: Point) -> u32 {
let dx = (a.x as i32 - b.x as i32).unsigned_abs();
let dy = (a.y as i32 - b.y as i32).unsigned_abs();
14 * dx.min(dy) + 10 * (dx.max(dy) - dx.min(dy))
}
fn jitter_dest(id: AgentId, p: Point) -> Point {
let h = id.raw();
let jx = ((h % 9) as i32 - 4) as i16;
let jy = (((h >> 16) % 9) as i32 - 4) as i16;
Point {
x: p.x.saturating_add_signed(jx),
y: p.y.saturating_add_signed(jy),
}
}
#[cfg(test)]
mod tests;