use std::time::SystemTime;
use pixtuoid_core::sprite::blit::blit_frame;
use pixtuoid_core::sprite::format::Pack;
use pixtuoid_core::sprite::{Rgb, RgbBuffer};
use pixtuoid_core::state::{DaemonPresence, DaemonState, FloorLocalDeskIndex};
use pixtuoid_core::AgentSlot;
use super::effects::{
paint_coffee_steam, paint_pet_hearts, paint_screen_glow, paint_sleep_z, paint_waiting_bubble,
paint_walking_dust,
};
use super::epoch_ms;
use super::furniture::{
paint_area_rug, paint_meeting_table, paint_pantry_chair, paint_pantry_table, paint_side_table,
};
use super::paint_character_at;
use crate::tui::frame_cache::FrameCache;
use crate::tui::layout::{Layout, Point, Size, DESK_H, DESK_W};
use crate::tui::pathfind::{find_path, snap_point_to_walkable};
use crate::tui::pet::PetKind;
use pixtuoid_core::walkable::OccupancyOverlay;
pub(super) struct Drawable<'a> {
pub(super) anchor_y: u16,
pub(super) kind: DrawableKind<'a>,
}
pub(super) enum DrawableKind<'a> {
DeskCubicle {
desk: Point,
is_last_col: bool,
has_cabinet: bool,
screen_glow: Option<Rgb>,
has_coffee: bool,
coffee_steam: bool,
},
Character {
agent: &'a AgentSlot,
anim_name: &'static str,
frame_idx: usize,
anchor: Point,
flip_x: bool,
glow_tint: Option<Rgb>,
sleep_z_seed: Option<u64>,
waiting_bubble: bool,
walking_dust_frame: Option<usize>,
},
WaypointCouch {
pos: Point,
},
WaypointPantry {
pos: Point,
use_large: bool,
},
MeetingSofa {
pos: Point,
mirrored: bool,
},
MeetingTable {
pos: Point,
},
AreaRug {
pos: Point,
width: u16,
height: u16,
},
LoungeSideTable {
pos: Point,
},
PantryTable {
pos: Point,
},
PantryChair {
pos: Point,
},
Plant {
kind: crate::tui::layout::PlantKind,
pos: Point,
},
PodDecorItem {
kind: crate::tui::layout::PodDecor,
pos: Point,
},
FloorLamp {
pos: Point,
},
Door {
pos: Point,
frame_idx: usize,
},
WallDecor {
kind: crate::tui::layout::WallDecor,
pos: Point,
},
VendingMachine {
pos: Point,
},
Printer {
pos: Point,
},
Pet {
kind: PetKind,
pos: Point,
flip: bool,
anim_name: &'static str,
frame_idx: usize,
pet_elapsed_ms: Option<u64>,
},
GatewayMascot {
pos: Point,
anim_name: &'static str,
frame_idx: usize,
run_count: u32,
degraded: bool,
},
RoomWallH {
x0: u16,
x1: u16,
y_top: u16,
},
CoatRack {
pos: Point,
},
}
pub(super) fn pet_position(
kind: PetKind,
layout: &Layout,
pack: &Pack,
now: SystemTime,
idle_desk_indices: &[FloorLocalDeskIndex],
all_idle: bool,
pet_seed: u64,
) -> Option<(Point, bool, &'static str, usize)> {
pack.animation(kind.walk_anim())?;
layout.corridor?;
let elapsed_ms = epoch_ms(now);
const CYCLE_MS: u64 = 40_000;
let cycle_n = (elapsed_ms / CYCLE_MS).wrapping_add(pet_seed);
let frac = (elapsed_ms % CYCLE_MS) as f32 / CYCLE_MS as f32;
let mut spots: Vec<(Point, bool)> = Vec::new();
for (i, desk) in layout.home_desks.iter().enumerate() {
spots.push((
Point {
x: desk.x + DESK_W + 1,
y: desk.y + DESK_H + 2,
},
idle_desk_indices.contains(&FloorLocalDeskIndex(i)),
));
}
if let Some(wp) = layout
.waypoints
.iter()
.find(|w| matches!(w.kind, crate::tui::layout::WaypointKind::Pantry))
{
spots.push((
Point {
x: wp.pos.x + 4,
y: wp.pos.y + 6,
},
false,
));
}
for room in &layout.meeting_furniture {
for sofa in room.sofas {
spots.push((
Point {
x: sofa.x + 4,
y: sofa.y + 4,
},
false,
));
}
}
if let Some(wp) = layout
.waypoints
.iter()
.find(|w| matches!(w.kind, crate::tui::layout::WaypointKind::Couch))
{
spots.push((
Point {
x: wp.pos.x + 4,
y: wp.pos.y + 6,
},
false,
));
}
if let Some(corridor) = layout.corridor {
spots.push((
Point {
x: corridor.x + corridor.width / 2,
y: corridor.y + corridor.height / 2,
},
false,
));
}
if spots.is_empty() {
return None;
}
let pick = |n: u64| -> (Point, bool) {
let h = n.wrapping_mul(0x9e37_79b9_7f4a_7c15) as usize;
spots[h % spots.len()]
};
let (dest, is_idle_spot) = pick(cycle_n);
let (prev, _) = pick(cycle_n.wrapping_sub(1));
let frame_idx = (elapsed_ms / 220) as usize % 2;
if frac < 0.35 {
let t = (frac / 0.35).clamp(0.0, 1.0);
let flip = dest.x < prev.x;
let src_anchor = snap_point_to_walkable(&layout.walkable, prev).unwrap_or(prev);
let dst_anchor = snap_point_to_walkable(&layout.walkable, dest).unwrap_or(dest);
let empty_overlay = OccupancyOverlay::new();
let pos = if let Some(mut pts) = find_path(
&layout.walkable,
&empty_overlay,
layout.corridor,
prev,
dest,
) {
if let Some(first) = pts.first_mut() {
*first = src_anchor;
}
if let Some(last) = pts.last_mut() {
*last = dst_anchor;
}
sample_polyline(&pts, t, dst_anchor)
} else {
Point {
x: (src_anchor.x as f32 + (dst_anchor.x as f32 - src_anchor.x as f32) * t) as u16,
y: (src_anchor.y as f32 + (dst_anchor.y as f32 - src_anchor.y as f32) * t) as u16,
}
};
return Some((pos, flip, kind.walk_anim(), frame_idx));
}
let rest_pos = snap_point_to_walkable(&layout.walkable, dest).unwrap_or(dest);
let anim = if all_idle || (kind.sleeps_near_idle() && is_idle_spot) {
kind.sleep_anim()
} else {
kind.sit_anim()
};
Some((rest_pos, false, anim, 0))
}
fn sample_polyline(pts: &[Point], t: f32, fallback: Point) -> Point {
let Some(&last_pt) = pts.last() else {
return fallback;
};
if pts.len() == 1 || t >= 1.0 {
return last_pt;
}
let mut seg_lens: Vec<f32> = Vec::with_capacity(pts.len() - 1);
let mut total = 0.0_f32;
for w in pts.windows(2) {
let dx = (w[1].x as i32 - w[0].x as i32).unsigned_abs() as f32;
let dy = (w[1].y as i32 - w[0].y as i32).unsigned_abs() as f32;
let len = dx.max(dy) + dx.min(dy) * (std::f32::consts::SQRT_2 - 1.0);
seg_lens.push(len);
total += len;
}
if total < 1e-3 {
return last_pt;
}
let target = (t * total).min(total);
let mut cumul = 0.0_f32;
for (i, &slen) in seg_lens.iter().enumerate() {
let is_last_seg = i == seg_lens.len() - 1;
if cumul + slen >= target || is_last_seg {
let local_t = if slen < 1e-3 {
0.0
} else {
((target - cumul) / slen).clamp(0.0, 1.0)
};
let a = pts[i];
let b = pts[i + 1];
return Point {
x: (a.x as f32 + (b.x as f32 - a.x as f32) * local_t) as u16,
y: (a.y as f32 + (b.y as f32 - a.y as f32) * local_t) as u16,
};
}
cumul += slen;
}
last_pt
}
const MASCOT_ENTER_MS: u64 = 2200;
const MASCOT_LEAVE_MS: u64 = 2200;
const MASCOT_IDLE_CYCLE_MS: u64 = 9000;
const MASCOT_BUSY_CYCLE_MS: u64 = 4500;
const MASCOT_DEGRADED_CYCLE_MS: u64 = 14000;
const MASCOT_WALK_FRAC: f32 = 0.45;
pub(super) struct GatewayMascotDef {
pub walk: &'static str,
pub rest: &'static str,
pub display_name: &'static str,
}
pub(super) fn gateway_mascot_def(source: &str) -> Option<GatewayMascotDef> {
match source {
s if s == pixtuoid_core::source::openclaw::SOURCE_NAME => Some(GatewayMascotDef {
walk: "lobster_walk",
rest: "lobster_rest",
display_name: "OpenClaw",
}),
_ => None,
}
}
fn hash_pick(spots: &[Point], n: u64) -> Point {
let h = n.wrapping_mul(0x9e37_79b9_7f4a_7c15) as usize;
spots[h % spots.len()]
}
fn mascot_walk_between(layout: &Layout, from: Point, to: Point, t: f32) -> Point {
let src = snap_point_to_walkable(&layout.walkable, from).unwrap_or(from);
let dst = snap_point_to_walkable(&layout.walkable, to).unwrap_or(to);
let empty = OccupancyOverlay::new();
if let Some(mut pts) = find_path(&layout.walkable, &empty, layout.corridor, from, to) {
if let Some(first) = pts.first_mut() {
*first = src;
}
if let Some(last) = pts.last_mut() {
*last = dst;
}
sample_polyline(&pts, t, dst)
} else {
Point {
x: (src.x as f32 + (dst.x as f32 - src.x as f32) * t) as u16,
y: (src.y as f32 + (dst.y as f32 - src.y as f32) * t) as u16,
}
}
}
fn mascot_elevator(layout: &Layout) -> Option<Point> {
let raw = layout.door_threshold.or(layout.door).or_else(|| {
layout.corridor.map(|c| Point {
x: c.x + c.width / 2,
y: c.y,
})
})?;
snap_point_to_walkable(&layout.walkable, raw)
}
fn mascot_home(layout: &Layout) -> Option<Point> {
let c = layout.corridor?;
snap_point_to_walkable(
&layout.walkable,
Point {
x: c.x + c.width / 2,
y: c.y + c.height / 2,
},
)
}
fn mascot_spots(layout: &Layout, state: DaemonState, home: Point) -> Vec<Point> {
let mut spots = vec![home];
if state == DaemonState::Busy {
for desk in &layout.home_desks {
spots.push(Point {
x: desk.x + DESK_W + 1,
y: desk.y + DESK_H + 2,
});
}
} else {
if let Some(wp) = layout
.waypoints
.iter()
.find(|w| matches!(w.kind, crate::tui::layout::WaypointKind::Pantry))
{
spots.push(Point {
x: wp.pos.x + 4,
y: wp.pos.y + 6,
});
}
for room in &layout.meeting_furniture {
for sofa in room.sofas {
spots.push(Point {
x: sofa.x + 4,
y: sofa.y + 4,
});
}
}
if let Some(wp) = layout
.waypoints
.iter()
.find(|w| matches!(w.kind, crate::tui::layout::WaypointKind::Couch))
{
spots.push(Point {
x: wp.pos.x + 4,
y: wp.pos.y + 6,
});
}
}
spots
}
fn mascot_wander(
layout: &Layout,
we_ms: u64,
seed: u64,
spots: &[Point],
home: Point,
cycle_ms: u64,
) -> (Point, bool) {
if spots.is_empty() {
return (home, false);
}
let cycle = we_ms / cycle_ms;
let frac = (we_ms % cycle_ms) as f32 / cycle_ms as f32;
let dest = hash_pick(spots, seed.wrapping_add(cycle).wrapping_add(1));
let prev = if cycle == 0 {
home
} else {
hash_pick(spots, seed.wrapping_add(cycle))
};
if frac < MASCOT_WALK_FRAC {
let t = (frac / MASCOT_WALK_FRAC).clamp(0.0, 1.0);
(mascot_walk_between(layout, prev, dest, t), true)
} else {
(
snap_point_to_walkable(&layout.walkable, dest).unwrap_or(dest),
false,
)
}
}
pub(super) fn mascot_position(
layout: &Layout,
presence: &DaemonPresence,
walk_anim: &'static str,
rest_anim: &'static str,
now: SystemTime,
seed: u64,
) -> Option<(Point, &'static str, usize)> {
let elevator = mascot_elevator(layout)?;
let home = mascot_home(layout)?;
let frame = ((epoch_ms(now) / 200) % 2) as usize;
if presence.state == DaemonState::Down {
let down_age = now.duration_since(presence.last_seen).ok()?.as_millis() as u64;
if down_age >= MASCOT_LEAVE_MS {
return None; }
let spots = mascot_spots(layout, DaemonState::Idle, home);
let down_we = presence
.last_seen
.duration_since(presence.entered_at)
.ok()
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
.saturating_sub(MASCOT_ENTER_MS);
let (from, _) = mascot_wander(layout, down_we, seed, &spots, home, MASCOT_IDLE_CYCLE_MS);
let t = down_age as f32 / MASCOT_LEAVE_MS as f32;
return Some((
mascot_walk_between(layout, from, elevator, t),
walk_anim,
frame,
));
}
let age = now.duration_since(presence.entered_at).ok()?.as_millis() as u64;
if age < MASCOT_ENTER_MS {
let t = age as f32 / MASCOT_ENTER_MS as f32;
return Some((
mascot_walk_between(layout, elevator, home, t),
walk_anim,
frame,
));
}
let cycle_ms = match presence.state {
DaemonState::Busy => MASCOT_BUSY_CYCLE_MS,
DaemonState::Degraded => MASCOT_DEGRADED_CYCLE_MS,
_ => MASCOT_IDLE_CYCLE_MS,
};
let spots = mascot_spots(layout, presence.state, home);
let (pos, walking) = mascot_wander(layout, age - MASCOT_ENTER_MS, seed, &spots, home, cycle_ms);
if walking {
Some((pos, walk_anim, frame))
} else {
Some((pos, rest_anim, 0))
}
}
fn paint_mascot_bubbles(buf: &mut RgbBuffer, pos: Point, frame_h: u16, runs: u32, now: SystemTime) {
let now_ms = epoch_ms(now);
let bubble = Rgb {
r: 0xd6,
g: 0xf2,
b: 0xf8,
};
let top = pos.y.saturating_sub(frame_h / 2 + 1);
let n = (runs + 1).min(4) as u16;
for i in 0..n {
let phase = ((now_ms / 110) + i as u64 * 7) % 6;
let by = top.saturating_sub(phase as u16);
let bx = (pos.x + i * 2).saturating_sub(n);
if bx < buf.width && by < buf.height {
buf.put(bx, by, bubble);
}
}
}
pub(super) fn paint_drawable(
d: &Drawable<'_>,
buf: &mut RgbBuffer,
pack: &Pack,
cache: &mut FrameCache,
now: SystemTime,
theme: &crate::tui::theme::Theme,
) {
match &d.kind {
DrawableKind::DeskCubicle {
desk,
is_last_col,
has_cabinet,
screen_glow,
has_coffee,
coffee_steam,
} => {
let divider = theme.office.cubicle_divider;
if !is_last_col {
let div_x = desk.x + DESK_W + 3;
for dy in 0..(DESK_H + 1) {
let py = desk.y.saturating_sub(1) + dy;
if div_x < buf.width && py < buf.height {
buf.put(div_x, py, divider);
}
}
}
if *has_cabinet {
if let Some(cab) = pack
.animation("filing_cabinet")
.and_then(|a| a.frames.first())
{
let cab_x = desk.x.saturating_sub(cab.width + 1);
let cab_y = desk.y;
if cab_y + cab.height <= buf.height {
blit_frame(cab, cab_x, cab_y, buf);
}
}
}
if let Some(frame) = pack.animation("desk").and_then(|a| a.frames.first()) {
blit_frame(frame, desk.x, desk.y.saturating_sub(1), buf);
}
if let Some(bin) = pack.animation("trash_bin").and_then(|a| a.frames.first()) {
let bin_x = desk.x + DESK_W;
let bin_y = desk.y + 4;
if bin_x + bin.width <= buf.width && bin_y + bin.height <= buf.height {
blit_frame(bin, bin_x, bin_y, buf);
}
}
paint_desk_coffee(buf, *desk, *has_coffee, *coffee_steam, now, theme);
if let Some(tint) = screen_glow {
paint_screen_glow(buf, desk.x, desk.y, now, *tint, theme);
}
}
DrawableKind::Character {
agent,
anim_name,
frame_idx,
anchor,
flip_x,
glow_tint,
sleep_z_seed,
waiting_bubble,
walking_dust_frame,
} => {
if let Some(dust_frame) = walking_dust_frame {
paint_walking_dust(buf, *anchor, *dust_frame, theme);
}
paint_character_at(
buf, anim_name, *frame_idx, *anchor, agent, pack, *flip_x, *glow_tint, cache,
);
if let Some(seed) = sleep_z_seed {
paint_sleep_z(buf, *anchor, now, *seed, theme);
}
if *waiting_bubble {
paint_waiting_bubble(buf, *anchor, theme);
}
}
DrawableKind::WaypointCouch { pos } => {
if let Some(f) = pack
.animation("meeting_sofa")
.and_then(|a| a.frames.first())
{
let cx = pos.x.saturating_sub(f.width / 2);
let cy = pos.y.saturating_sub(f.height / 2);
let flipped = f.mirror_vertical();
blit_frame(&flipped, cx, cy, buf);
}
}
DrawableKind::WaypointPantry { pos, use_large } => {
let anim_name = if *use_large { "pantry" } else { "pantry_small" };
if let Some(f) = pack.animation(anim_name).and_then(|a| a.frames.first()) {
let cx = pos.x.saturating_sub(f.width / 2);
let cy = pos.y.saturating_sub(f.height / 2);
blit_frame(f, cx, cy, buf);
}
let steam_dx: i16 = if *use_large { -2 } else { 1 };
let steam_x = (pos.x as i32 + steam_dx as i32).max(0) as u16;
paint_coffee_steam(
buf,
Point {
x: steam_x,
y: pos.y.saturating_sub(2),
},
now,
theme,
);
}
DrawableKind::MeetingSofa { pos, mirrored } => {
if let Some(f) = pack
.animation("meeting_sofa")
.and_then(|a| a.frames.first())
{
let sx = pos.x.saturating_sub(f.width / 2);
let sy = pos.y.saturating_sub(f.height / 2);
if *mirrored {
let flipped = f.mirror_vertical();
blit_frame(&flipped, sx, sy, buf);
} else {
blit_frame(f, sx, sy, buf);
}
}
}
DrawableKind::MeetingTable { pos } => {
let Size { w, h } =
crate::tui::layout::furniture_def(crate::tui::layout::Furniture::MeetingTable)
.visual;
paint_meeting_table(buf, pos.x, pos.y, w, h, theme);
}
DrawableKind::AreaRug { pos, width, height } => {
paint_area_rug(buf, pos.x, pos.y, *width, *height, theme);
}
DrawableKind::LoungeSideTable { pos } => {
paint_side_table(buf, pos.x, pos.y, theme);
}
DrawableKind::PantryTable { pos } => {
paint_pantry_table(buf, pos.x, pos.y, theme);
}
DrawableKind::PantryChair { pos } => {
paint_pantry_chair(buf, pos.x, pos.y, theme);
}
DrawableKind::Plant { kind, pos } => {
let anim_name = kind.sprite_name();
if let Some(f) = pack.animation(anim_name).and_then(|a| a.frames.first()) {
let px = pos.x.saturating_sub(f.width / 2);
let py = pos.y.saturating_sub(f.height / 2);
blit_frame(f, px, py, buf);
}
}
DrawableKind::PodDecorItem { kind, pos } => {
let anim_name = kind.sprite_name();
if let Some(f) = pack.animation(anim_name).and_then(|a| a.frames.first()) {
let px = pos.x.saturating_sub(f.width / 2);
let py = pos.y.saturating_sub(f.height / 2);
blit_frame(f, px, py, buf);
}
}
DrawableKind::FloorLamp { pos } => {
if let Some(f) = pack.animation("floor_lamp").and_then(|a| a.frames.first()) {
let px = pos.x.saturating_sub(f.width / 2);
let py = pos.y.saturating_sub(f.height / 2);
blit_frame(f, px, py, buf);
}
}
DrawableKind::Door { pos, frame_idx } => {
if let Some(f) = pack
.animation("door")
.and_then(|a| a.frames.get(*frame_idx).or_else(|| a.frames.first()))
{
blit_frame(f, pos.x, pos.y, buf);
}
}
DrawableKind::WallDecor { kind, pos } => {
let anim_name = kind.sprite_name();
if let Some(f) = pack.animation(anim_name).and_then(|a| a.frames.first()) {
blit_frame(f, pos.x, pos.y, buf);
}
}
DrawableKind::VendingMachine { pos } => {
let body = theme.appliance.vending_body;
let panel = theme.appliance.vending_panel;
let drinks = theme.appliance.vending_drinks;
let vx = pos.x.saturating_sub(2);
let vy = pos.y.saturating_sub(3);
for dy in 0..6u16 {
for dx in 0..4u16 {
let px = vx + dx;
let py = vy + dy;
if px < buf.width && py < buf.height {
let color = if dy == 0 {
panel
} else if (1..=3).contains(&dy) && (1..=2).contains(&dx) {
let idx = ((dy - 1) * 2 + (dx - 1)) as usize;
if idx < drinks.len() {
drinks[idx]
} else {
body
}
} else if dy == 4 && dx == 2 {
theme.appliance.vending_trim
} else if dy == 5 {
theme.appliance.vending_dark
} else {
body
};
buf.put(px, py, color);
}
}
}
}
DrawableKind::Printer { pos } => {
let body_white = theme.appliance.printer_body;
let top_dark = theme.appliance.printer_top;
let glass = theme.appliance.printer_glass;
let paper = theme.appliance.printer_paper;
let tray = theme.appliance.printer_tray;
let px0 = pos.x.saturating_sub(2);
let py0 = pos.y.saturating_sub(2);
for dy in 0..4u16 {
for dx in 0..5u16 {
let px = px0 + dx;
let py = py0 + dy;
if px < buf.width && py < buf.height {
let color = if dy == 0 {
if (1..=3).contains(&dx) {
glass
} else {
top_dark
}
} else if dy == 3 {
if (1..=3).contains(&dx) {
paper
} else {
tray
}
} else if dx == 0 || dx == 4 {
tray
} else {
body_white
};
buf.put(px, py, color);
}
}
}
}
DrawableKind::Pet {
kind,
pos,
flip,
anim_name,
frame_idx,
pet_elapsed_ms,
} => {
let Some(anim) = pack.animation(anim_name) else {
return;
};
let Some(frame) = anim.frames.get(*frame_idx).or(anim.frames.first()) else {
return;
};
let final_frame = if *flip {
frame.mirror_horizontal()
} else {
frame.clone()
};
let px = pos.x.saturating_sub(final_frame.width / 2);
let py = pos.y.saturating_sub(final_frame.height / 2);
blit_frame(&final_frame, px, py, buf);
if let Some(elapsed) = pet_elapsed_ms {
paint_pet_hearts(buf, *pos, *elapsed);
} else if *anim_name == kind.sleep_anim() {
paint_sleep_z(buf, *pos, now, 0xCAFE, theme);
}
}
DrawableKind::GatewayMascot {
pos,
anim_name,
frame_idx,
run_count,
degraded,
} => {
let Some(anim) = pack.animation(anim_name) else {
return;
};
let Some(frame) = anim.frames.get(*frame_idx).or(anim.frames.first()) else {
return;
};
let px = pos.x.saturating_sub(frame.width / 2);
let py = pos.y.saturating_sub(frame.height / 2);
if *degraded {
blit_frame(&super::palette::degraded_frame(frame), px, py, buf);
} else {
blit_frame(frame, px, py, buf);
}
if *run_count > 0 {
paint_mascot_bubbles(buf, *pos, frame.height, *run_count, now);
}
}
DrawableKind::RoomWallH { x0, x1, y_top } => {
super::paint_glass_wall_h(buf, theme, *x0, *x1, *y_top);
}
DrawableKind::CoatRack { pos } => {
let (cx, cy) = (pos.x, pos.y);
let pole = theme.furniture.wood_trim;
let base = theme.furniture.wood_top;
let coats = theme.appliance.coats;
for dy in 0..8u16 {
let py = cy + dy;
if py < buf.height && cx < buf.width {
buf.put(cx, py, pole);
}
}
let by = cy + 7;
for dx in 0..3u16 {
let px = cx.saturating_sub(1) + dx;
if px < buf.width && by < buf.height {
buf.put(px, by, base);
}
}
for (i, &coat_color) in coats.iter().enumerate() {
let hook_y = cy + 1 + (i as u16) * 2;
let side: i16 = if i % 2 == 0 { -1 } else { 1 };
let hx = (cx as i16 + side) as u16;
for dy in 0..2u16 {
for dx in 0..2u16 {
let px = hx.wrapping_add(if side < 0 { dx.wrapping_sub(1) } else { dx });
let py = hook_y + dy;
if px < buf.width && py < buf.height {
buf.put(px, py, coat_color);
}
}
}
}
}
}
}
fn paint_desk_coffee(
buf: &mut RgbBuffer,
desk: Point,
has_coffee: bool,
coffee_steam: bool,
now: SystemTime,
theme: &crate::tui::theme::Theme,
) {
if !has_coffee {
return;
}
let put = |buf: &mut RgbBuffer, x: u16, y: u16, c: Rgb| {
if x < buf.width && y < buf.height {
buf.put(x, y, c);
}
};
let cx = desk.x + 2;
let cy = desk.y + 2;
put(buf, cx, cy, theme.furniture.coffee_cup);
put(buf, cx + 1, cy, theme.furniture.coffee_cup);
put(buf, cx, cy + 1, theme.furniture.coffee_cup_shadow);
put(buf, cx + 1, cy + 1, theme.furniture.coffee_cup_shadow);
if coffee_steam {
paint_coffee_steam(buf, Point { x: cx, y: cy }, now, theme);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(x: u16, y: u16) -> Point {
Point { x, y }
}
#[test]
fn sample_polyline_empty_returns_fallback() {
assert_eq!(sample_polyline(&[], 0.5, p(9, 9)), p(9, 9));
}
#[test]
fn sample_polyline_single_point_returns_it() {
assert_eq!(sample_polyline(&[p(3, 4)], 0.5, p(9, 9)), p(3, 4));
}
#[test]
fn sample_polyline_t_at_or_past_one_returns_last() {
let pts = [p(0, 0), p(10, 0)];
assert_eq!(sample_polyline(&pts, 1.0, p(9, 9)), p(10, 0));
assert_eq!(sample_polyline(&pts, 2.5, p(9, 9)), p(10, 0));
}
#[test]
fn sample_polyline_t_zero_returns_first() {
assert_eq!(sample_polyline(&[p(0, 0), p(10, 0)], 0.0, p(9, 9)), p(0, 0));
}
#[test]
fn sample_polyline_midpoint_on_straight_segment() {
assert_eq!(sample_polyline(&[p(0, 0), p(10, 0)], 0.5, p(9, 9)), p(5, 0));
}
#[test]
fn sample_polyline_arc_length_hits_corner_of_l() {
let pts = [p(0, 0), p(10, 0), p(10, 10)];
assert_eq!(sample_polyline(&pts, 0.5, p(9, 9)), p(10, 0));
}
#[test]
fn sample_polyline_octile_weights_diagonal() {
let pts = [p(0, 0), p(10, 0), p(20, 10)];
let total = 10.0 + 10.0 * std::f32::consts::SQRT_2;
assert_eq!(sample_polyline(&pts, 10.0 / total, p(9, 9)), p(10, 0));
}
#[test]
fn sample_polyline_zero_length_leading_segment_no_div_by_zero() {
let pts = [p(5, 5), p(5, 5), p(15, 5)];
assert_eq!(sample_polyline(&pts, 0.5, p(0, 0)), p(10, 5));
}
#[test]
fn sample_polyline_target_on_zero_length_segment_uses_local_t_zero() {
let pts = [p(0, 0), p(0, 0), p(10, 0)];
assert_eq!(sample_polyline(&pts, 0.0, p(9, 9)), p(0, 0));
}
fn test_pack() -> Pack {
crate::tui::embedded_pack::load_sprite_pack(None).expect("embedded pack")
}
#[test]
fn pet_rest_picks_sleep_anim_when_all_idle() {
let layout = crate::tui::layout::Layout::compute(160, 200, 4).expect("layout fits");
let pack = test_pack();
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(20_000);
let (_, _, anim, frame) =
pet_position(PetKind::Cat, &layout, &pack, now, &[], true, 0).expect("a pet position");
assert_eq!(anim, PetKind::Cat.sleep_anim(), "all_idle → sleep anim");
assert_eq!(frame, 0, "rest pose uses frame 0");
}
#[test]
fn pet_no_route_falls_back_to_straight_lerp() {
use pixtuoid_core::layout::{Bounds, ReachSet};
use pixtuoid_core::walkable::WalkableMask;
let (w, h) = (200u16, 120u16);
let mut mask = WalkableMask::new_open(w, h);
mask.mark_blocked(80, 0, 40, h, 0);
let reachable = ReachSet::from_mask(&mask, Point { x: 20, y: 20 });
let mut layout = crate::tui::layout::Layout::compute(w, h, 4).expect("layout fits");
layout.home_desks = vec![Point { x: 20, y: 30 }];
layout.waypoints.clear();
layout.meeting_furniture.clear();
layout.corridor = Some(Bounds {
x: 150,
y: 40,
width: 20,
height: 20,
});
layout.walkable = mask;
layout.reachable = reachable;
let pack = test_pack();
let spots = [
Point {
x: 20 + DESK_W + 1,
y: 30 + DESK_H + 2,
},
Point { x: 160, y: 50 },
];
let now = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(5_000);
let seed = 0u64;
let pick = |n: u64| spots[(n.wrapping_mul(0x9e37_79b9_7f4a_7c15) as usize) % spots.len()];
let dest = pick(seed);
let prev = pick(seed.wrapping_sub(1));
assert_ne!(prev, dest, "seed must make the leg cross the wall");
let src_anchor = snap_point_to_walkable(&layout.walkable, prev).expect("prev snaps");
let dst_anchor = snap_point_to_walkable(&layout.walkable, dest).expect("dest snaps");
assert!(
find_path(
&layout.walkable,
&OccupancyOverlay::new(),
layout.corridor,
prev,
dest
)
.is_none(),
"the two pockets must be disconnected so the straight-lerp fallback is the only path"
);
let t = (0.125_f32 / 0.35).clamp(0.0, 1.0);
let lerp = |a: u16, b: u16| (a as f32 + (b as f32 - a as f32) * t) as u16;
let expected = Point {
x: lerp(src_anchor.x, dst_anchor.x),
y: lerp(src_anchor.y, dst_anchor.y),
};
let (pos, _, anim, _) =
pet_position(PetKind::Cat, &layout, &pack, now, &[], false, seed).expect("walk pos");
assert_eq!(anim, PetKind::Cat.walk_anim(), "walk phase");
assert_eq!(
pos, expected,
"no-route leg must be the straight lerp between snapped anchors"
);
}
fn theme() -> &'static crate::tui::theme::Theme {
crate::tui::theme::theme_by_name("normal").expect("theme")
}
#[test]
fn desk_cubicle_with_cabinet_blits_cabinet_and_trash_bin() {
let pack = test_pack();
let mut cache = FrameCache::new();
let now = SystemTime::UNIX_EPOCH;
let desk = Point { x: 40, y: 30 };
let cab = pack
.animation("filing_cabinet")
.and_then(|a| a.frames.first())
.expect("filing_cabinet anim");
let bin = pack
.animation("trash_bin")
.and_then(|a| a.frames.first())
.expect("trash_bin anim");
let bg = Rgb { r: 1, g: 2, b: 3 };
let mut buf = RgbBuffer::filled(120, 80, bg);
let d = Drawable {
anchor_y: desk.y + 8,
kind: DrawableKind::DeskCubicle {
desk,
is_last_col: true,
has_cabinet: true,
screen_glow: None,
has_coffee: false,
coffee_steam: false,
},
};
paint_drawable(&d, &mut buf, &pack, &mut cache, now, theme());
let cab_x = desk.x.saturating_sub(cab.width + 1);
let mut cab_painted = false;
for dy in 0..cab.height {
for dx in 0..cab.width {
if buf.get(cab_x + dx, desk.y + dy) != bg {
cab_painted = true;
}
}
}
assert!(cab_painted, "filing cabinet should paint west of the desk");
let bin_x = desk.x + DESK_W;
let mut bin_painted = false;
for dy in 0..bin.height {
for dx in 0..bin.width {
if buf.get(bin_x + dx, desk.y + 4 + dy) != bg {
bin_painted = true;
}
}
}
assert!(bin_painted, "trash bin should paint at the desk east edge");
}
#[test]
fn meeting_sofa_mirrored_flips_vertically() {
let pack = test_pack();
let mut cache = FrameCache::new();
let now = SystemTime::UNIX_EPOCH;
let pos = Point { x: 30, y: 30 };
let mut render = |mirrored: bool| {
let mut buf = RgbBuffer::filled(80, 80, Rgb { r: 0, g: 0, b: 0 });
let d = Drawable {
anchor_y: pos.y,
kind: DrawableKind::MeetingSofa { pos, mirrored },
};
paint_drawable(&d, &mut buf, &pack, &mut cache, now, theme());
buf
};
let plain = render(false);
let flipped = render(true);
let mut differs = false;
for y in 0..80u16 {
for x in 0..80u16 {
if plain.get(x, y) != flipped.get(x, y) {
differs = true;
}
}
}
assert!(differs, "mirrored sofa must render distinct pixels");
}
#[test]
fn pet_drawable_missing_anim_is_a_noop() {
let pack = test_pack();
let mut cache = FrameCache::new();
let now = SystemTime::UNIX_EPOCH;
let bg = Rgb { r: 7, g: 8, b: 9 };
let mut buf = RgbBuffer::filled(60, 60, bg);
let d = Drawable {
anchor_y: 30,
kind: DrawableKind::Pet {
kind: PetKind::Cat,
pos: Point { x: 30, y: 30 },
flip: false,
anim_name: "nonexistent_anim",
frame_idx: 0,
pet_elapsed_ms: None,
},
};
paint_drawable(&d, &mut buf, &pack, &mut cache, now, theme());
for y in 0..buf.height {
for x in 0..buf.width {
assert_eq!(buf.get(x, y), bg, "missing pet anim must paint nothing");
}
}
}
#[test]
fn pet_drawable_sleep_anim_paints_sleep_z() {
let pack = test_pack();
let mut cache = FrameCache::new();
let now = SystemTime::UNIX_EPOCH;
let pos = Point { x: 30, y: 40 };
let mut render = |anim_name: &'static str| {
let mut buf = RgbBuffer::filled(60, 60, Rgb { r: 0, g: 0, b: 0 });
let d = Drawable {
anchor_y: pos.y,
kind: DrawableKind::Pet {
kind: PetKind::Cat,
pos,
flip: false,
anim_name,
frame_idx: 0,
pet_elapsed_ms: None,
},
};
paint_drawable(&d, &mut buf, &pack, &mut cache, now, theme());
buf
};
let count_above = |buf: &RgbBuffer| {
let mut n = 0u32;
for y in 0..pos.y.saturating_sub(4) {
for x in 0..60u16 {
if buf.get(x, y) != (Rgb { r: 0, g: 0, b: 0 }) {
n += 1;
}
}
}
n
};
let sit = count_above(&render(PetKind::Cat.sit_anim()));
let sleep = count_above(&render(PetKind::Cat.sleep_anim()));
assert!(
sleep > sit,
"sleep anim must add floating z's above the pet (sleep={sleep}, sit={sit})"
);
}
}