use std::sync::Mutex;
use crate::gfx::graphics_system::WorldReloadState;
#[derive(Debug, Clone)]
pub(crate) struct DecalSpawnArgs {
pub texture: Option<String>,
pub position: [f32; 3],
pub rotation_deg: [f32; 3],
pub size: [f32; 3],
pub tint: [f32; 4],
}
impl Default for DecalSpawnArgs {
fn default() -> Self {
Self {
texture: None,
position: [0.0, 0.0, 0.0],
rotation_deg: [0.0, 0.0, 0.0],
size: [1.0, 1.0, 1.0],
tint: [1.0, 1.0, 1.0, 1.0],
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct EmitterSpawnArgs {
pub texture: Option<String>,
pub position: [f32; 3],
pub direction: [f32; 3],
pub spread_deg: f32,
pub speed_min: f32,
pub speed_max: f32,
pub lifetime_min: f32,
pub lifetime_max: f32,
pub gravity: [f32; 3],
pub spawn_rate: f32,
pub max_particles: u32,
pub size_start: f32,
pub size_end: f32,
pub color_start: [f32; 4],
pub color_end: [f32; 4],
}
impl Default for EmitterSpawnArgs {
fn default() -> Self {
Self {
texture: None,
position: [0.0, 0.0, 0.0],
direction: [0.0, 1.0, 0.0],
spread_deg: 15.0,
speed_min: 1.0,
speed_max: 2.0,
lifetime_min: 1.0,
lifetime_max: 2.0,
gravity: [0.0, -9.8, 0.0],
spawn_rate: 32.0,
max_particles: 256,
size_start: 0.2,
size_end: 0.05,
color_start: [1.0, 1.0, 1.0, 1.0],
color_end: [1.0, 1.0, 1.0, 0.0],
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct CameraSetArgs {
pub position: [f32; 3],
pub yaw: f32,
pub pitch: f32,
pub fov_y_degrees: Option<f32>,
}
impl Default for CameraSetArgs {
fn default() -> Self {
Self {
position: [0.0, 0.0, 0.0],
yaw: 0.0,
pitch: 0.0,
fov_y_degrees: None,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct CameraMoveArgs {
pub forward: f32,
pub right: f32,
pub up: f32,
pub yaw: f32,
pub pitch: f32,
pub frames: u32,
}
impl Default for CameraMoveArgs {
fn default() -> Self {
Self {
forward: 0.0,
right: 0.0,
up: 0.0,
yaw: 0.0,
pitch: 0.0,
frames: 0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CameraMotion {
pub forward: f32,
pub right: f32,
pub up: f32,
pub yaw: f32,
pub pitch: f32,
pub frames_left: Option<u32>,
}
impl CameraMotion {
pub(crate) fn from_args(args: &CameraMoveArgs) -> Self {
Self {
forward: args.forward,
right: args.right,
up: args.up,
yaw: args.yaw,
pitch: args.pitch,
frames_left: if args.frames == 0 {
None
} else {
Some(args.frames)
},
}
}
pub(crate) fn advanced(self) -> Option<Self> {
match self.frames_left {
None => Some(self),
Some(n) if n > 1 => Some(Self {
frames_left: Some(n - 1),
..self
}),
Some(_) => None,
}
}
}
pub(crate) fn advance_pose(
position: [f32; 3],
yaw: f32,
pitch: f32,
motion: &CameraMotion,
) -> ([f32; 3], f32, f32) {
let cp = pitch.cos();
let fwd = [-yaw.sin() * cp, pitch.sin(), -yaw.cos() * cp];
let right = [yaw.cos(), 0.0, -yaw.sin()];
let new_pos = [
position[0] + fwd[0] * motion.forward + right[0] * motion.right,
position[1] + fwd[1] * motion.forward + motion.up,
position[2] + fwd[2] * motion.forward + right[2] * motion.right,
];
let new_yaw = yaw + motion.yaw;
let new_pitch = (pitch + motion.pitch).clamp(
-std::f32::consts::FRAC_PI_2 + 0.01,
std::f32::consts::FRAC_PI_2 - 0.01,
);
(new_pos, new_yaw, new_pitch)
}
pub(crate) enum RuntimeCommand {
DecalAdd {
args: DecalSpawnArgs,
reply: std::sync::mpsc::SyncSender<Result<usize, String>>,
},
DecalRemove {
id: usize,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
EmitterAdd {
args: EmitterSpawnArgs,
reply: std::sync::mpsc::SyncSender<Result<usize, String>>,
},
EmitterRemove {
id: usize,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
Screenshot {
path: String,
reply: std::sync::mpsc::SyncSender<Result<String, String>>,
},
CameraSet {
args: CameraSetArgs,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
CameraMove {
args: CameraMoveArgs,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
CameraStop {
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
QualitySet {
setting: String,
op: crate::components::SettingOp,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
Rebind {
setting: String,
key: crate::components::InputKey,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
Despawn {
name: String,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
Reparent {
child: String,
parent: Option<String>,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
Spawn {
template: String,
name: String,
position: [f32; 3],
rotation_deg: [f32; 3],
scale: [f32; 3],
lifetime: Option<f32>,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
Story {
command: crate::components::StoryCommand,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
}
static QUEUE: Mutex<Vec<RuntimeCommand>> = Mutex::new(Vec::new());
pub(crate) fn enqueue(cmd: RuntimeCommand) {
let mut q = match QUEUE.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
q.push(cmd);
}
pub(crate) fn drain() -> Vec<RuntimeCommand> {
let mut q = match QUEUE.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
std::mem::take(&mut *q)
}
pub(crate) fn dispatch_runtime_spawn(
cmd: RuntimeCommand,
world_reload: Option<&WorldReloadState>,
backend: &mut dyn crate::gfx::backend::RenderBackend,
) {
match cmd {
RuntimeCommand::DecalAdd { args, reply } => {
let result = resolve_texture_slot(args.texture.as_deref(), world_reload)
.and_then(|slot| {
let model = crate::gfx::decal::decal_model_matrix(
args.position,
args.rotation_deg,
args.size,
);
let inv_model = crate::gfx::decal::invert_decal_model(model)
.ok_or_else(|| "decal-add: degenerate size".to_string())?;
Ok(crate::gfx::decal::DecalRecord {
model,
inv_model,
texture_slot: slot,
tint: args.tint,
})
})
.and_then(|rec| backend.add_decal(rec));
let _ = reply.send(result);
}
RuntimeCommand::DecalRemove { id, reply } => {
let _ = reply.send(backend.remove_decal(id));
}
RuntimeCommand::EmitterAdd { args, reply } => {
let result = resolve_texture_slot(args.texture.as_deref(), world_reload)
.map(|slot| {
let dir = {
let d = args.direction;
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if !len.is_finite() || len < 1e-6 {
[0.0, 1.0, 0.0]
} else {
[d[0] / len, d[1] / len, d[2] / len]
}
};
let spread_cos = args.spread_deg.clamp(0.0, 180.0).to_radians().cos();
let lifetime_min = args.lifetime_min.max(0.001);
let lifetime_max = args.lifetime_max.max(lifetime_min);
let speed_min = args.speed_min.max(0.0);
let speed_max = args.speed_max.max(speed_min);
let max_particles = args
.max_particles
.clamp(1, crate::gfx::particles::MAX_PARTICLES_PER_EMITTER);
crate::gfx::particles::ParticleEmitterRecord {
texture_slot: slot,
position: args.position,
direction: dir,
spread_cos,
speed_min,
speed_max,
lifetime_min,
lifetime_max,
gravity: args.gravity,
spawn_rate: args.spawn_rate.max(0.0),
max_particles,
size_start: args.size_start.max(0.0),
size_end: args.size_end.max(0.0),
color_start: args.color_start,
color_end: args.color_end,
}
})
.and_then(|rec| backend.add_emitter(rec));
let _ = reply.send(result);
}
RuntimeCommand::EmitterRemove { id, reply } => {
let _ = reply.send(backend.remove_emitter(id));
}
RuntimeCommand::Screenshot { path, reply } => {
let _ = reply.send(backend.screenshot(&path));
}
RuntimeCommand::CameraSet { reply, .. } => {
let _ = reply.send(Err("camera-set: misrouted to backend dispatch".to_string()));
}
RuntimeCommand::CameraMove { reply, .. } => {
let _ = reply.send(Err("camera-move: misrouted to backend dispatch".to_string()));
}
RuntimeCommand::CameraStop { reply } => {
let _ = reply.send(Err("camera-stop: misrouted to backend dispatch".to_string()));
}
RuntimeCommand::QualitySet { reply, .. } => {
let _ = reply.send(Err("quality-set: misrouted to backend dispatch".to_string()));
}
RuntimeCommand::Rebind { reply, .. } => {
let _ = reply.send(Err("rebind: misrouted to backend dispatch".to_string()));
}
RuntimeCommand::Despawn { reply, .. } => {
let _ = reply.send(Err("despawn: misrouted to backend dispatch".to_string()));
}
RuntimeCommand::Reparent { reply, .. } => {
let _ = reply.send(Err("reparent: misrouted to backend dispatch".to_string()));
}
RuntimeCommand::Spawn { reply, .. } => {
let _ = reply.send(Err("spawn: misrouted to backend dispatch".to_string()));
}
RuntimeCommand::Story { reply, .. } => {
let _ = reply.send(Err("story: misrouted to backend dispatch".to_string()));
}
}
}
pub(crate) fn dispatch_camera_set(cmd: RuntimeCommand, world: &mut crate::ecs::World) {
let RuntimeCommand::CameraSet { args, reply } = cmd else {
return;
};
let _ = reply.send(apply_camera_set(&args, world));
}
pub(crate) fn dispatch_quality_set(cmd: RuntimeCommand, world: &mut crate::ecs::World) {
let RuntimeCommand::QualitySet { setting, op, reply } = cmd else {
return;
};
world
.events_mut::<crate::components::SettingCommand>()
.send(crate::components::SettingCommand {
setting,
op,
value_label: None,
persist: true,
});
let _ = reply.send(Ok(()));
}
pub(crate) fn dispatch_rebind(cmd: RuntimeCommand, world: &mut crate::ecs::World) {
let RuntimeCommand::Rebind {
setting,
key,
reply,
} = cmd
else {
return;
};
world
.events_mut::<crate::components::SettingCommand>()
.send(crate::components::SettingCommand {
setting,
op: crate::components::SettingOp::Rebind(key),
value_label: None,
persist: true,
});
let _ = reply.send(Ok(()));
}
pub(crate) fn dispatch_despawn(cmd: RuntimeCommand, world: &mut crate::ecs::World) {
let RuntimeCommand::Despawn { name, reply } = cmd else {
return;
};
let Some(id) = crate::ecs::asset_id::lookup(&name) else {
let _ = reply.send(Err(format!("despawn: name '{name}' not found")));
return;
};
world
.events_mut::<crate::components::DespawnRequest>()
.send(crate::components::DespawnRequest { target: id.into() });
let _ = reply.send(Ok(()));
}
pub(crate) fn dispatch_reparent(cmd: RuntimeCommand, world: &mut crate::ecs::World) {
let RuntimeCommand::Reparent {
child,
parent,
reply,
} = cmd
else {
return;
};
let resolve = crate::ecs::asset_id::lookup;
let Some(child_id) = resolve(&child) else {
let _ = reply.send(Err(format!("reparent: child '{child}' not found")));
return;
};
let parent_id = match &parent {
Some(p) => match resolve(p) {
Some(id) => Some(id),
None => {
let _ = reply.send(Err(format!("reparent: parent '{p}' not found")));
return;
}
},
None => None,
};
world
.events_mut::<crate::components::ReparentRequest>()
.send(crate::components::ReparentRequest {
child: child_id.into(),
parent: parent_id.map(Into::into),
});
let _ = reply.send(Ok(()));
}
pub(crate) fn dispatch_spawn(cmd: RuntimeCommand, world: &mut crate::ecs::World) {
let RuntimeCommand::Spawn {
template,
name,
position,
rotation_deg,
scale,
lifetime,
reply,
} = cmd
else {
return;
};
let Some(template_id) = crate::ecs::asset_id::lookup(&template) else {
let _ = reply.send(Err(format!("spawn: template '{template}' not found")));
return;
};
let scale = if scale == [0.0; 3] { [1.0; 3] } else { scale };
let name_id = crate::ecs::asset_id::intern(&name);
world
.events_mut::<crate::components::SpawnRequest>()
.send(crate::components::SpawnRequest {
template: template_id,
name: Some(name_id),
transform: crate::components::Transform {
position,
rotation_deg,
scale,
},
lifetime_secs: lifetime,
});
let _ = reply.send(Ok(()));
}
pub(crate) fn dispatch_story(cmd: RuntimeCommand, world: &mut crate::ecs::World) {
let RuntimeCommand::Story { command, reply } = cmd else {
return;
};
world
.events_mut::<crate::components::StoryCommand>()
.send(command);
let _ = reply.send(Ok(()));
}
pub(crate) fn apply_camera_set(
args: &CameraSetArgs,
world: &mut crate::ecs::World,
) -> Result<(), String> {
use crate::components::Camera3D;
let Some(camera) = world.query_mut::<Camera3D>().next() else {
return Err("camera-set: no Camera3D in world".to_string());
};
camera.position = args.position;
camera.yaw = args.yaw;
camera.pitch = args.pitch;
if let Some(fov) = args.fov_y_degrees {
camera.fov_y_degrees = fov;
}
camera.view_matrix = crate::gfx::camera::view_matrix(camera.position, camera.yaw, camera.pitch);
for system in world.systems_mut() {
if let Some(c) = system.downcast_mut::<crate::gfx::camera_controller::Camera3DSystem>() {
c.reset_velocity();
}
}
Ok(())
}
pub(crate) fn apply_camera_move_step(motion: &CameraMotion, world: &mut crate::ecs::World) -> bool {
use crate::components::Camera3D;
let Some(camera) = world.query_mut::<Camera3D>().next() else {
return false;
};
let (pos, yaw, pitch) = advance_pose(camera.position, camera.yaw, camera.pitch, motion);
camera.position = pos;
camera.yaw = yaw;
camera.pitch = pitch;
camera.view_matrix = crate::gfx::camera::view_matrix(pos, yaw, pitch);
for system in world.systems_mut() {
if let Some(c) = system.downcast_mut::<crate::gfx::camera_controller::Camera3DSystem>() {
c.reset_velocity();
}
}
true
}
fn resolve_texture_slot(
texture: Option<&str>,
world_reload: Option<&WorldReloadState>,
) -> Result<usize, String> {
let Some(name) = texture else {
return Ok(0);
};
let id = crate::ecs::asset_id::lookup(name)
.ok_or_else(|| format!("texture '{}' not found in interner", name))?;
let reload = world_reload.ok_or_else(|| {
"texture-name resolution requires cn debug (world_reload missing)".to_string()
})?;
reload
.texture_name_to_slot
.get(&id)
.copied()
.ok_or_else(|| format!("texture '{}' is not in the live texture pool", name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support;
#[test]
fn enqueue_drain_round_trip() {
let _guard = test_support::lock();
let _ = drain();
let (tx, _rx) = std::sync::mpsc::sync_channel(1);
enqueue(RuntimeCommand::DecalRemove { id: 7, reply: tx });
let cmds = drain();
assert_eq!(cmds.len(), 1);
match cmds.into_iter().next().unwrap() {
RuntimeCommand::DecalRemove { id, .. } => assert_eq!(id, 7),
_ => panic!("wrong variant"),
}
assert!(drain().is_empty());
}
#[test]
fn despawn_enqueue_drain_round_trip() {
let _guard = test_support::lock();
let _ = drain();
let (tx, _rx) = std::sync::mpsc::sync_channel(1);
enqueue(RuntimeCommand::Despawn {
name: "crate_a".to_string(),
reply: tx,
});
let cmds = drain();
assert_eq!(cmds.len(), 1);
match cmds.into_iter().next().unwrap() {
RuntimeCommand::Despawn { name, .. } => assert_eq!(name, "crate_a"),
_ => panic!("wrong variant"),
}
assert!(drain().is_empty());
}
#[test]
fn reparent_enqueue_drain_round_trip() {
let _guard = test_support::lock();
let _ = drain();
let (tx, _rx) = std::sync::mpsc::sync_channel(1);
enqueue(RuntimeCommand::Reparent {
child: "box_a".to_string(),
parent: Some("frame".to_string()),
reply: tx,
});
let cmds = drain();
assert_eq!(cmds.len(), 1);
match cmds.into_iter().next().unwrap() {
RuntimeCommand::Reparent { child, parent, .. } => {
assert_eq!(child, "box_a");
assert_eq!(parent.as_deref(), Some("frame"));
}
_ => panic!("wrong variant"),
}
assert!(drain().is_empty());
}
#[test]
fn decal_spawn_args_defaults() {
let a = DecalSpawnArgs::default();
assert_eq!(a.size, [1.0, 1.0, 1.0]);
assert_eq!(a.tint, [1.0, 1.0, 1.0, 1.0]);
assert!(a.texture.is_none());
}
#[test]
fn emitter_spawn_args_defaults() {
let a = EmitterSpawnArgs::default();
assert_eq!(a.direction, [0.0, 1.0, 0.0]);
assert_eq!(a.max_particles, 256);
assert!((a.spread_deg - 15.0).abs() < 1e-6);
}
#[test]
fn camera_set_args_defaults() {
let a = CameraSetArgs::default();
assert_eq!(a.position, [0.0, 0.0, 0.0]);
assert_eq!(a.yaw, 0.0);
assert_eq!(a.pitch, 0.0);
assert!(a.fov_y_degrees.is_none());
}
#[test]
fn camera_set_enqueue_drain_round_trip() {
let _guard = test_support::lock();
let _ = drain();
let (tx, _rx) = std::sync::mpsc::sync_channel(1);
enqueue(RuntimeCommand::CameraSet {
args: CameraSetArgs {
position: [1.0, 2.0, 3.0],
yaw: 0.5,
pitch: -0.25,
fov_y_degrees: Some(60.0),
},
reply: tx,
});
let cmds = drain();
assert_eq!(cmds.len(), 1);
match cmds.into_iter().next().unwrap() {
RuntimeCommand::CameraSet { args, .. } => {
assert_eq!(args.position, [1.0, 2.0, 3.0]);
assert_eq!(args.fov_y_degrees, Some(60.0));
}
_ => panic!("wrong variant"),
}
assert!(drain().is_empty());
}
#[test]
fn apply_camera_set_writes_active_camera() {
use crate::components::{Camera3D, CameraController};
use crate::ecs::World;
let mut world = World::new();
world.add_component(Camera3D {
fov_y_degrees: 75.0,
near: 0.05,
far: 200.0,
view_matrix: [[0.0; 4]; 4],
position: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
desired_move: [0.0; 3],
jump_requested: false,
interact_requested: false,
controller: Some(CameraController::default()),
});
world.start(concinnity_engine::ecs::SYSTEMS).unwrap();
let args = CameraSetArgs {
position: [10.0, 20.0, 30.0],
yaw: 1.0,
pitch: -0.5,
fov_y_degrees: Some(50.0),
};
assert!(apply_camera_set(&args, &mut world).is_ok());
let cam = world.query::<Camera3D>().next().expect("camera present");
assert_eq!(cam.position, [10.0, 20.0, 30.0]);
assert_eq!(cam.yaw, 1.0);
assert_eq!(cam.pitch, -0.5);
assert_eq!(cam.fov_y_degrees, 50.0);
assert_ne!(cam.view_matrix, [[0.0; 4]; 4]);
}
#[test]
fn apply_camera_set_keeps_fov_when_none() {
use crate::components::{Camera3D, CameraController};
use crate::ecs::World;
let mut world = World::new();
world.add_component(Camera3D {
fov_y_degrees: 75.0,
near: 0.05,
far: 200.0,
view_matrix: [[0.0; 4]; 4],
position: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
desired_move: [0.0; 3],
jump_requested: false,
interact_requested: false,
controller: Some(CameraController::default()),
});
world.start(concinnity_engine::ecs::SYSTEMS).unwrap();
let args = CameraSetArgs {
position: [1.0, 1.0, 1.0],
yaw: 0.0,
pitch: 0.0,
fov_y_degrees: None,
};
assert!(apply_camera_set(&args, &mut world).is_ok());
let cam = world.query::<Camera3D>().next().expect("camera present");
assert_eq!(cam.fov_y_degrees, 75.0);
}
#[test]
fn apply_camera_set_errors_without_camera() {
let mut world = crate::ecs::World::new();
let args = CameraSetArgs::default();
assert!(apply_camera_set(&args, &mut world).is_err());
}
#[test]
fn camera_move_args_defaults_are_zero_hold() {
let a = CameraMoveArgs::default();
assert_eq!(a.forward, 0.0);
assert_eq!(a.right, 0.0);
assert_eq!(a.up, 0.0);
assert_eq!(a.yaw, 0.0);
assert_eq!(a.pitch, 0.0);
assert_eq!(a.frames, 0);
}
#[test]
fn camera_motion_from_args_maps_zero_frames_to_indefinite_hold() {
let hold = CameraMotion::from_args(&CameraMoveArgs {
forward: 1.0,
frames: 0,
..CameraMoveArgs::default()
});
assert_eq!(hold.frames_left, None);
let finite = CameraMotion::from_args(&CameraMoveArgs {
forward: 1.0,
frames: 5,
..CameraMoveArgs::default()
});
assert_eq!(finite.frames_left, Some(5));
}
#[test]
fn camera_motion_advanced_counts_down_then_stops() {
let m = CameraMotion {
forward: 1.0,
right: 0.0,
up: 0.0,
yaw: 0.0,
pitch: 0.0,
frames_left: Some(3),
};
let m = m.advanced().expect("2 frames remain");
assert_eq!(m.frames_left, Some(2));
let m = m.advanced().expect("1 frame remains");
assert_eq!(m.frames_left, Some(1));
assert!(m.advanced().is_none(), "last frame exhausts the motion");
}
#[test]
fn camera_motion_advanced_holds_indefinitely() {
let m = CameraMotion {
forward: 0.0,
right: 0.0,
up: 0.0,
yaw: 0.1,
pitch: 0.0,
frames_left: None,
};
let next = m.clone().advanced().expect("hold never exhausts");
assert_eq!(next, m);
}
#[test]
fn advance_pose_moves_along_look_basis() {
let motion = CameraMotion {
forward: 2.0,
right: 3.0,
up: 4.0,
yaw: 0.0,
pitch: 0.0,
frames_left: Some(1),
};
let (pos, yaw, pitch) = advance_pose([0.0, 0.0, 0.0], 0.0, 0.0, &motion);
assert!((pos[0] - 3.0).abs() < 1e-5, "right -> +X");
assert!((pos[1] - 4.0).abs() < 1e-5, "up -> +Y");
assert!((pos[2] + 2.0).abs() < 1e-5, "forward -> -Z");
assert_eq!(yaw, 0.0);
assert_eq!(pitch, 0.0);
}
#[test]
fn advance_pose_accumulates_yaw_and_clamps_pitch() {
let motion = CameraMotion {
forward: 0.0,
right: 0.0,
up: 0.0,
yaw: 0.5,
pitch: 100.0,
frames_left: Some(1),
};
let (_, yaw, pitch) = advance_pose([0.0, 0.0, 0.0], 1.0, 0.0, &motion);
assert!((yaw - 1.5).abs() < 1e-6);
let limit = std::f32::consts::FRAC_PI_2 - 0.01;
assert!(
(pitch - limit).abs() < 1e-5,
"pitch clamps to near-vertical"
);
}
#[test]
fn camera_move_enqueue_drain_round_trip() {
let _guard = test_support::lock();
let _ = drain();
let (tx, _rx) = std::sync::mpsc::sync_channel(1);
enqueue(RuntimeCommand::CameraMove {
args: CameraMoveArgs {
forward: 1.5,
frames: 30,
..CameraMoveArgs::default()
},
reply: tx,
});
let (stx, _srx) = std::sync::mpsc::sync_channel(1);
enqueue(RuntimeCommand::CameraStop { reply: stx });
let cmds = drain();
assert_eq!(cmds.len(), 2);
let mut it = cmds.into_iter();
match it.next().unwrap() {
RuntimeCommand::CameraMove { args, .. } => {
assert_eq!(args.forward, 1.5);
assert_eq!(args.frames, 30);
}
_ => panic!("wrong variant"),
}
assert!(matches!(
it.next().unwrap(),
RuntimeCommand::CameraStop { .. }
));
assert!(drain().is_empty());
}
#[test]
fn apply_camera_move_step_advances_active_camera() {
use crate::components::{Camera3D, CameraController};
use crate::ecs::World;
let mut world = World::new();
world.add_component(Camera3D {
fov_y_degrees: 75.0,
near: 0.05,
far: 200.0,
view_matrix: [[0.0; 4]; 4],
position: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
desired_move: [0.0; 3],
jump_requested: false,
interact_requested: false,
controller: Some(CameraController::default()),
});
world.start(concinnity_engine::ecs::SYSTEMS).unwrap();
let motion = CameraMotion {
forward: 1.0,
right: 0.0,
up: 0.0,
yaw: 0.0,
pitch: 0.0,
frames_left: Some(2),
};
assert!(apply_camera_move_step(&motion, &mut world));
assert!(apply_camera_move_step(&motion, &mut world));
let cam = world.query::<Camera3D>().next().expect("camera present");
assert!((cam.position[2] + 2.0).abs() < 1e-5);
assert_ne!(cam.view_matrix, [[0.0; 4]; 4]);
}
#[test]
fn apply_camera_move_step_false_without_camera() {
let mut world = crate::ecs::World::new();
let motion = CameraMotion {
forward: 1.0,
right: 0.0,
up: 0.0,
yaw: 0.0,
pitch: 0.0,
frames_left: None,
};
assert!(!apply_camera_move_step(&motion, &mut world));
}
struct StubBackend;
impl crate::gfx::scene_flow::SceneControl for StubBackend {
fn update_visibility(&mut self, _draw_idx: usize, _visible: bool) {}
fn set_fade(&mut self, _fade: f32) {}
}
impl crate::gfx::backend::RenderBackend for StubBackend {
fn window_closed(&mut self) -> bool {
false
}
fn capture_cursor(&mut self) {}
fn take_input(&mut self) -> crate::gfx::input::RenderInput {
crate::gfx::input::RenderInput::default()
}
fn wait_idle(&self) {}
fn draw_frame(
&mut self,
_params: crate::gfx::backend::FrameParams<'_>,
) -> crate::gfx::error::RenderResult<()> {
Ok(())
}
fn update_view(&mut self, _matrix: [[f32; 4]; 4]) {}
fn update_models(&mut self, _updates: &[(u32, [[f32; 4]; 4])]) {}
fn retire_draw_object(&mut self, _draw_idx: usize) {}
fn upload_skinned(
&mut self,
_vertices: &[crate::gfx::mesh_payload::SkinnedVertex],
_indices: &[u32],
_draw_objects: Vec<crate::gfx::render_types::SkinnedDrawObject>,
_vert_bytes: &[u8],
_frag_bytes: &[u8],
_shadow_bytes: &[u8],
) -> crate::gfx::error::RenderResult<()> {
Ok(())
}
fn update_skinned_pose(&mut self, _skinned_index: usize, _matrices: &[[[f32; 4]; 4]]) {}
fn evict_texture_slot(&mut self, _slot: usize) -> Result<(), String> {
Ok(())
}
fn update_texture_slot(
&mut self,
_slot: usize,
_image: &concinnity_core::build::texture::TextureImage,
) -> crate::gfx::error::RenderResult<()> {
Ok(())
}
fn evict_mesh(&mut self, _draw_idx: usize, _retire_frame: u64) -> Result<(), String> {
Ok(())
}
fn upload_mesh(
&mut self,
_draw_idx: usize,
_verts: &[crate::gfx::mesh_payload::Vertex],
_idxs: &[u16],
_frame: u64,
) -> crate::gfx::error::RenderResult<()> {
Ok(())
}
fn setup_chunk_streaming(
&mut self,
_chunk_vtx_bytes: usize,
_chunk_idx_bytes: usize,
_texture_slot: usize,
_normal_map_slot: usize,
) -> crate::gfx::error::RenderResult<()> {
Ok(())
}
fn add_chunk_mesh(
&mut self,
_: crate::gfx::backend::ChunkMesh<'_>,
_: crate::gfx::draw_slot::SlotAlloc,
) -> crate::gfx::error::RenderResult<()> {
Ok(())
}
fn remove_chunk_mesh(
&mut self,
_draw_idx: usize,
_retire_frame: u64,
) -> Result<(), String> {
Ok(())
}
fn set_chunk_model(
&mut self,
_draw_idx: usize,
_model: [[f32; 4]; 4],
) -> Result<(), String> {
Ok(())
}
}
fn dispatch_spawn_unit(
build: impl FnOnce(std::sync::mpsc::SyncSender<Result<(), String>>) -> RuntimeCommand,
) -> Result<(), String> {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let mut backend = StubBackend;
dispatch_runtime_spawn(build(tx), None, &mut backend);
rx.recv().expect("dispatch replied")
}
#[test]
fn backend_dispatch_reports_misroute_for_ecs_commands() {
let cases: [(&str, Result<(), String>); 9] = [
(
"camera-set",
dispatch_spawn_unit(|reply| RuntimeCommand::CameraSet {
args: CameraSetArgs::default(),
reply,
}),
),
(
"camera-move",
dispatch_spawn_unit(|reply| RuntimeCommand::CameraMove {
args: CameraMoveArgs::default(),
reply,
}),
),
(
"camera-stop",
dispatch_spawn_unit(|reply| RuntimeCommand::CameraStop { reply }),
),
(
"quality-set",
dispatch_spawn_unit(|reply| RuntimeCommand::QualitySet {
setting: "ssao".to_string(),
op: crate::components::SettingOp::Next,
reply,
}),
),
(
"rebind",
dispatch_spawn_unit(|reply| RuntimeCommand::Rebind {
setting: "key_forward".to_string(),
key: crate::components::InputKey::Space,
reply,
}),
),
(
"despawn",
dispatch_spawn_unit(|reply| RuntimeCommand::Despawn {
name: "x".to_string(),
reply,
}),
),
(
"reparent",
dispatch_spawn_unit(|reply| RuntimeCommand::Reparent {
child: "x".to_string(),
parent: None,
reply,
}),
),
(
"spawn",
dispatch_spawn_unit(|reply| RuntimeCommand::Spawn {
template: "x".to_string(),
name: "y".to_string(),
position: [0.0; 3],
rotation_deg: [0.0; 3],
scale: [1.0; 3],
lifetime: None,
reply,
}),
),
(
"story",
dispatch_spawn_unit(|reply| RuntimeCommand::Story {
command: crate::components::StoryCommand::Advance,
reply,
}),
),
];
for (label, result) in cases {
let err = result.expect_err("misroute should be an error");
assert!(err.contains(label), "expected '{label}' in: {err}");
assert!(err.contains("misrouted to backend dispatch"), "got: {err}");
}
}
#[test]
fn backend_dispatch_decal_add_builds_record_then_surfaces_backend_err() {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let mut backend = StubBackend;
dispatch_runtime_spawn(
RuntimeCommand::DecalAdd {
args: DecalSpawnArgs::default(),
reply: tx,
},
None,
&mut backend,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("add_decal"), "got: {err}");
}
#[test]
fn backend_dispatch_decal_add_rejects_degenerate_size() {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let mut backend = StubBackend;
dispatch_runtime_spawn(
RuntimeCommand::DecalAdd {
args: DecalSpawnArgs {
size: [0.0, 0.0, 0.0],
..DecalSpawnArgs::default()
},
reply: tx,
},
None,
&mut backend,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("degenerate size"), "got: {err}");
}
#[test]
fn backend_dispatch_decal_remove_surfaces_backend_err() {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let mut backend = StubBackend;
dispatch_runtime_spawn(
RuntimeCommand::DecalRemove { id: 3, reply: tx },
None,
&mut backend,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("remove_decal"), "got: {err}");
}
#[test]
fn backend_dispatch_emitter_add_normalizes_then_surfaces_backend_err() {
let configs = [
EmitterSpawnArgs::default(),
EmitterSpawnArgs {
direction: [0.0, 0.0, 0.0],
spread_deg: 500.0,
speed_min: -5.0,
speed_max: -10.0,
lifetime_min: -1.0,
lifetime_max: -2.0,
max_particles: 0,
spawn_rate: -1.0,
size_start: -1.0,
size_end: -1.0,
..EmitterSpawnArgs::default()
},
];
for args in configs {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let mut backend = StubBackend;
dispatch_runtime_spawn(
RuntimeCommand::EmitterAdd { args, reply: tx },
None,
&mut backend,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("add_emitter"), "got: {err}");
}
}
#[test]
fn backend_dispatch_emitter_remove_surfaces_backend_err() {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let mut backend = StubBackend;
dispatch_runtime_spawn(
RuntimeCommand::EmitterRemove { id: 5, reply: tx },
None,
&mut backend,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("remove_emitter"), "got: {err}");
}
#[test]
fn backend_dispatch_screenshot_surfaces_backend_err() {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let mut backend = StubBackend;
dispatch_runtime_spawn(
RuntimeCommand::Screenshot {
path: "shot.png".to_string(),
reply: tx,
},
None,
&mut backend,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(
err.contains("screenshot capture not supported"),
"got: {err}"
);
}
#[test]
fn resolve_texture_slot_covers_every_case() {
let _guard = test_support::lock();
crate::ecs::asset_id::reset_interner();
assert_eq!(resolve_texture_slot(None, None).unwrap(), 0);
let err = resolve_texture_slot(Some("ghost"), None).unwrap_err();
assert!(err.contains("not found in interner"), "got: {err}");
crate::ecs::asset_id::intern_all(&["grid"]);
let err = resolve_texture_slot(Some("grid"), None).unwrap_err();
assert!(err.contains("world_reload missing"), "got: {err}");
let empty = WorldReloadState {
texture_name_to_slot: std::collections::HashMap::new(),
};
let err = resolve_texture_slot(Some("grid"), Some(&empty)).unwrap_err();
assert!(err.contains("not in the live texture pool"), "got: {err}");
let mut map = std::collections::HashMap::new();
map.insert(crate::ecs::asset_id::AssetId(0), 5usize);
let reload = WorldReloadState {
texture_name_to_slot: map,
};
assert_eq!(
resolve_texture_slot(Some("grid"), Some(&reload)).unwrap(),
5
);
}
#[test]
fn ecs_dispatchers_ignore_mismatched_variants() {
fn dropped(rx: std::sync::mpsc::Receiver<Result<(), String>>) -> bool {
rx.recv().is_err()
}
let mut world = crate::ecs::World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_camera_set(RuntimeCommand::CameraStop { reply: tx }, &mut world);
assert!(dropped(rx));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_quality_set(RuntimeCommand::CameraStop { reply: tx }, &mut world);
assert!(dropped(rx));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_rebind(RuntimeCommand::CameraStop { reply: tx }, &mut world);
assert!(dropped(rx));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_despawn(RuntimeCommand::CameraStop { reply: tx }, &mut world);
assert!(dropped(rx));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_reparent(RuntimeCommand::CameraStop { reply: tx }, &mut world);
assert!(dropped(rx));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_spawn(RuntimeCommand::CameraStop { reply: tx }, &mut world);
assert!(dropped(rx));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_story(RuntimeCommand::CameraStop { reply: tx }, &mut world);
assert!(dropped(rx));
}
fn controlled_camera() -> crate::components::Camera3D {
use crate::components::{Camera3D, CameraController};
Camera3D {
fov_y_degrees: 75.0,
near: 0.05,
far: 200.0,
view_matrix: [[0.0; 4]; 4],
position: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
desired_move: [0.0; 3],
jump_requested: false,
interact_requested: false,
controller: Some(CameraController::default()),
}
}
#[test]
fn dispatch_camera_set_applies_pose_and_replies_ok() {
use crate::components::Camera3D;
use crate::ecs::World;
let mut world = World::new();
world.add_component(controlled_camera());
world.start(concinnity_engine::ecs::SYSTEMS).unwrap();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_camera_set(
RuntimeCommand::CameraSet {
args: CameraSetArgs {
position: [1.0, 2.0, 3.0],
yaw: 0.5,
pitch: -0.25,
fov_y_degrees: Some(50.0),
},
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
let cam = world.query::<Camera3D>().next().expect("camera present");
assert_eq!(cam.position, [1.0, 2.0, 3.0]);
assert_eq!(cam.fov_y_degrees, 50.0);
}
#[test]
fn dispatch_quality_set_sends_setting_command() {
let mut world = crate::ecs::World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_quality_set(
RuntimeCommand::QualitySet {
setting: "ssao".to_string(),
op: crate::components::SettingOp::Next,
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
let events = world
.events::<crate::components::SettingCommand>()
.expect("setting command queued");
let mut cursor = crate::ecs::EventCursor::default();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].setting, "ssao");
assert_eq!(seen[0].op, crate::components::SettingOp::Next);
assert!(seen[0].persist);
assert!(seen[0].value_label.is_none());
}
#[test]
fn dispatch_rebind_sends_rebind_setting_command() {
let mut world = crate::ecs::World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_rebind(
RuntimeCommand::Rebind {
setting: "key_forward".to_string(),
key: crate::components::InputKey::Space,
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
let events = world
.events::<crate::components::SettingCommand>()
.expect("setting command queued");
let mut cursor = crate::ecs::EventCursor::default();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].setting, "key_forward");
assert_eq!(
seen[0].op,
crate::components::SettingOp::Rebind(crate::components::InputKey::Space)
);
}
#[test]
fn dispatch_story_forwards_the_command() {
let mut world = crate::ecs::World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_story(
RuntimeCommand::Story {
command: crate::components::StoryCommand::Choose(2),
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
let events = world
.events::<crate::components::StoryCommand>()
.expect("story command queued");
let mut cursor = crate::ecs::EventCursor::default();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(*seen[0], crate::components::StoryCommand::Choose(2));
}
#[test]
fn dispatch_despawn_resolves_name_and_reports_unknown() {
let _guard = test_support::lock();
crate::ecs::asset_id::reset_interner();
crate::ecs::asset_id::intern_all(&["crate_a", "crate_b"]);
let mut world = crate::ecs::World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_despawn(
RuntimeCommand::Despawn {
name: "crate_b".to_string(),
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
let events = world
.events::<crate::components::DespawnRequest>()
.expect("despawn request queued");
let mut cursor = crate::ecs::EventCursor::default();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(
seen[0].target.name().unwrap(),
crate::ecs::asset_id::AssetId(1)
);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_despawn(
RuntimeCommand::Despawn {
name: "ghost".to_string(),
reply: tx,
},
&mut world,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("'ghost' not found"), "got: {err}");
}
#[test]
fn dispatch_reparent_resolves_names_and_reports_errors() {
let _guard = test_support::lock();
crate::ecs::asset_id::reset_interner();
crate::ecs::asset_id::intern_all(&["box_a", "frame"]);
let mut world = crate::ecs::World::new();
let mut cursor = crate::ecs::EventCursor::default();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_reparent(
RuntimeCommand::Reparent {
child: "box_a".to_string(),
parent: Some("frame".to_string()),
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
{
let events = world
.events::<crate::components::ReparentRequest>()
.expect("reparent request queued");
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(
seen[0].child.name().unwrap(),
crate::ecs::asset_id::AssetId(0)
);
assert_eq!(
seen[0].parent.and_then(|p| p.name()),
Some(crate::ecs::asset_id::AssetId(1))
);
}
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_reparent(
RuntimeCommand::Reparent {
child: "box_a".to_string(),
parent: None,
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
{
let events = world
.events::<crate::components::ReparentRequest>()
.unwrap();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert!(seen[0].parent.is_none());
}
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_reparent(
RuntimeCommand::Reparent {
child: "ghost".to_string(),
parent: Some("frame".to_string()),
reply: tx,
},
&mut world,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("child 'ghost' not found"), "got: {err}");
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_reparent(
RuntimeCommand::Reparent {
child: "box_a".to_string(),
parent: Some("void".to_string()),
reply: tx,
},
&mut world,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("parent 'void' not found"), "got: {err}");
}
#[test]
fn dispatch_spawn_resolves_template_interns_name_and_defaults_scale() {
let _guard = test_support::lock();
crate::ecs::asset_id::reset_interner();
crate::ecs::asset_id::intern_all(&["template_a"]);
let mut world = crate::ecs::World::new();
let mut cursor = crate::ecs::EventCursor::default();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_spawn(
RuntimeCommand::Spawn {
template: "template_a".to_string(),
name: "instance_1".to_string(),
position: [1.0, 2.0, 3.0],
rotation_deg: [0.0, 90.0, 0.0],
scale: [2.0, 2.0, 2.0],
lifetime: Some(5.0),
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
{
let events = world
.events::<crate::components::SpawnRequest>()
.expect("spawn request queued");
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].template, crate::ecs::asset_id::AssetId(0));
assert_eq!(seen[0].name, Some(crate::ecs::asset_id::AssetId(1)));
assert_eq!(seen[0].transform.position, [1.0, 2.0, 3.0]);
assert_eq!(seen[0].transform.scale, [2.0, 2.0, 2.0]);
assert_eq!(seen[0].lifetime_secs, Some(5.0));
}
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_spawn(
RuntimeCommand::Spawn {
template: "template_a".to_string(),
name: "instance_2".to_string(),
position: [0.0; 3],
rotation_deg: [0.0; 3],
scale: [0.0; 3],
lifetime: None,
reply: tx,
},
&mut world,
);
assert!(rx.recv().unwrap().is_ok());
{
let events = world.events::<crate::components::SpawnRequest>().unwrap();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].transform.scale, [1.0, 1.0, 1.0]);
}
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_spawn(
RuntimeCommand::Spawn {
template: "missing".to_string(),
name: "instance_3".to_string(),
position: [0.0; 3],
rotation_deg: [0.0; 3],
scale: [1.0; 3],
lifetime: None,
reply: tx,
},
&mut world,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("template 'missing' not found"), "got: {err}");
}
}