use concinnity_core::components::Camera3D;
use concinnity_core::components::DespawnRequest;
use concinnity_core::components::InputKey;
use concinnity_core::components::ReparentRequest;
use concinnity_core::components::SettingCommand;
use concinnity_core::components::SettingOp;
use concinnity_core::components::SpawnRequest;
use concinnity_core::components::StoryCommand;
use concinnity_core::components::Transform;
use concinnity_core::ecs::World;
use concinnity_core::ecs::asset_id::AssetId;
use concinnity_core::gfx::camera;
use concinnity_core::input::keymap::Bindable;
use concinnity_core::render::backend;
use concinnity_core::render::decal;
use concinnity_core::render::particles;
use concinnity_core::settings::SettingKey;
use concinnity_engine::controller::camera::Camera3DSystem;
use concinnity_engine::gfx::system::parked::TextureNameSlots;
use concinnity_host::thread::asset_id;
use std::sync::Mutex;
#[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 BackendCommand {
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>>,
},
CullStatus {
reply: std::sync::mpsc::SyncSender<Result<Vec<u32>, String>>,
},
}
pub(crate) enum WorldCommand {
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: SettingKey,
op: SettingOp,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
Rebind {
action: Bindable,
key: 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: StoryCommand,
reply: std::sync::mpsc::SyncSender<Result<(), String>>,
},
}
pub(crate) enum RuntimeCommand {
Backend(BackendCommand),
World(WorldCommand),
}
impl From<BackendCommand> for RuntimeCommand {
fn from(cmd: BackendCommand) -> Self {
Self::Backend(cmd)
}
}
impl From<WorldCommand> for RuntimeCommand {
fn from(cmd: WorldCommand) -> Self {
Self::World(cmd)
}
}
static QUEUE: Mutex<Vec<RuntimeCommand>> = Mutex::new(Vec::new());
pub(crate) fn enqueue(cmd: impl Into<RuntimeCommand>) {
let mut q = match QUEUE.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
q.push(cmd.into());
}
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: BackendCommand,
texture_slots: Option<&TextureNameSlots>,
backend: &mut dyn backend::RenderBackend,
) {
match cmd {
BackendCommand::DecalAdd { args, reply } => {
let result = resolve_texture_slot(args.texture.as_deref(), texture_slots)
.and_then(|slot| {
let model =
decal::decal_model_matrix(args.position, args.rotation_deg, args.size);
let inv_model = decal::invert_decal_model(model)
.ok_or_else(|| "decal-add: degenerate size".to_string())?;
Ok(decal::DecalRecord {
model,
inv_model,
texture_slot: slot,
tint: args.tint,
})
})
.and_then(|rec| backend.add_decal(rec).map_err(|e| e.to_string()));
let _ = reply.send(result);
}
BackendCommand::DecalRemove { id, reply } => {
let _ = reply.send(backend.remove_decal(id).map_err(|e| e.to_string()));
}
BackendCommand::EmitterAdd { args, reply } => {
let result = resolve_texture_slot(args.texture.as_deref(), texture_slots)
.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, particles::MAX_PARTICLES_PER_EMITTER);
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).map_err(|e| e.to_string()));
let _ = reply.send(result);
}
BackendCommand::EmitterRemove { id, reply } => {
let _ = reply.send(backend.remove_emitter(id).map_err(|e| e.to_string()));
}
BackendCommand::Screenshot { path, reply } => {
let _ = reply.send(backend.screenshot(&path).map_err(|e| e.to_string()));
}
BackendCommand::CullStatus { reply } => {
let _ = reply.send(backend.read_cull_status().map_err(|e| e.to_string()));
}
}
}
pub(crate) fn dispatch_world_command(
cmd: WorldCommand,
world: &mut World,
motion: &mut Option<CameraMotion>,
) {
match cmd {
WorldCommand::CameraSet { args, reply } => {
let _ = reply.send(apply_camera_set(&args, world));
}
WorldCommand::CameraMove { args, reply } => {
let result = if world.query::<Camera3D>().next().is_some() {
*motion = Some(CameraMotion::from_args(&args));
Ok(())
} else {
Err("camera-move: no Camera3D in world".to_string())
};
let _ = reply.send(result);
}
WorldCommand::CameraStop { reply } => {
*motion = None;
let _ = reply.send(Ok(()));
}
WorldCommand::QualitySet { setting, op, reply } => {
send_setting(world, setting, op);
let _ = reply.send(Ok(()));
}
WorldCommand::Rebind { action, key, reply } => {
send_setting(world, SettingKey::KeyRebind(action), SettingOp::Rebind(key));
let _ = reply.send(Ok(()));
}
WorldCommand::Despawn { name, reply } => {
let result = asset_id::lookup(&name)
.ok_or_else(|| format!("despawn: name '{name}' not found"))
.map(|id| {
world
.events_mut::<DespawnRequest>()
.send(DespawnRequest { target: id.into() });
});
let _ = reply.send(result);
}
WorldCommand::Reparent {
child,
parent,
reply,
} => {
let _ = reply.send(reparent_by_name(world, &child, parent.as_deref()));
}
WorldCommand::Spawn {
template,
name,
position,
rotation_deg,
scale,
lifetime,
reply,
} => {
let result = spawn_ids(&template, &name).map(|template_id| {
let scale = if scale == [0.0; 3] { [1.0; 3] } else { scale };
world.events_mut::<SpawnRequest>().send(SpawnRequest {
template: template_id,
name: Some(asset_id::intern(&name)),
transform: Transform {
position,
rotation_deg,
scale,
},
lifetime_secs: lifetime,
});
});
let _ = reply.send(result);
}
WorldCommand::Story { command, reply } => {
world.events_mut::<StoryCommand>().send(command);
let _ = reply.send(Ok(()));
}
}
}
fn spawn_ids(template: &str, name: &str) -> Result<AssetId, String> {
let template_id = asset_id::lookup(template)
.ok_or_else(|| format!("spawn: template '{template}' not found"))?;
if asset_id::lookup(name).is_some() {
return Err(format!("spawn: '{name}' already names an asset"));
}
Ok(template_id)
}
fn send_setting(world: &mut World, setting: SettingKey, op: SettingOp) {
world.events_mut::<SettingCommand>().send(SettingCommand {
setting,
op,
value_label: None,
persist: true,
});
}
fn reparent_by_name(world: &mut World, child: &str, parent: Option<&str>) -> Result<(), String> {
let child_id =
asset_id::lookup(child).ok_or_else(|| format!("reparent: child '{child}' not found"))?;
let parent_id = parent
.map(|p| asset_id::lookup(p).ok_or_else(|| format!("reparent: parent '{p}' not found")))
.transpose()?;
world.events_mut::<ReparentRequest>().send(ReparentRequest {
child: child_id.into(),
parent: parent_id.map(Into::into),
});
Ok(())
}
pub(crate) fn apply_camera_set(args: &CameraSetArgs, world: &mut World) -> Result<(), String> {
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 = camera::view_matrix(camera.position, camera.yaw, camera.pitch);
for system in world.systems_mut() {
if let Some(c) = system.downcast_mut::<Camera3DSystem>() {
c.reset_velocity();
}
}
Ok(())
}
pub(crate) fn apply_camera_move_step(motion: &CameraMotion, world: &mut World) -> bool {
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 = camera::view_matrix(pos, yaw, pitch);
for system in world.systems_mut() {
if let Some(c) = system.downcast_mut::<Camera3DSystem>() {
c.reset_velocity();
}
}
true
}
fn resolve_texture_slot(
texture: Option<&str>,
texture_slots: Option<&TextureNameSlots>,
) -> Result<usize, String> {
let Some(name) = texture else {
return Ok(0);
};
let id = asset_id::lookup(name)
.ok_or_else(|| format!("texture '{}' not found in interner", name))?;
let slots = texture_slots.ok_or_else(|| {
"texture-name resolution requires cn debug (texture-name slots not captured)".to_string()
})?;
slots
.0
.get(&id)
.copied()
.ok_or_else(|| format!("texture '{}' is not in the live texture pool", name))
}
#[cfg(test)]
mod tests {
#[test]
fn spawn_ids_resolve_the_template_and_refuse_a_taken_name() {
asset_id::reset_interner();
asset_id::prime_name_table(&[(0, "crate".to_string()), (1, "Prop#0".to_string())]);
assert_eq!(spawn_ids("Prop#0", "crate_copy"), Ok(AssetId(1)));
assert!(spawn_ids("ghost", "x").unwrap_err().contains("not found"));
assert!(
spawn_ids("crate", "Prop#0")
.unwrap_err()
.contains("already names")
);
}
use super::*;
use crate::debug::test_backend::StubBackend;
use crate::test_support;
use concinnity_core::components::Camera3D;
use concinnity_core::ecs::EventCursor;
use concinnity_core::ecs::asset_id::AssetId;
#[test]
fn enqueue_drain_round_trip() {
let _guard = test_support::lock();
let _ = drain();
let (tx, _rx) = std::sync::mpsc::sync_channel(1);
enqueue(BackendCommand::DecalRemove { id: 7, reply: tx });
let cmds = drain();
assert_eq!(cmds.len(), 1);
match cmds.into_iter().next().unwrap() {
RuntimeCommand::Backend(BackendCommand::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(WorldCommand::Despawn {
name: "crate_a".to_string(),
reply: tx,
});
let cmds = drain();
assert_eq!(cmds.len(), 1);
match cmds.into_iter().next().unwrap() {
RuntimeCommand::World(WorldCommand::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(WorldCommand::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::World(WorldCommand::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(WorldCommand::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::World(WorldCommand::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 concinnity_core::components::{Camera3D, CameraController};
use concinnity_core::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 concinnity_core::components::{Camera3D, CameraController};
use concinnity_core::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 = 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(WorldCommand::CameraMove {
args: CameraMoveArgs {
forward: 1.5,
frames: 30,
..CameraMoveArgs::default()
},
reply: tx,
});
let (stx, _srx) = std::sync::mpsc::sync_channel(1);
enqueue(WorldCommand::CameraStop { reply: stx });
let cmds = drain();
assert_eq!(cmds.len(), 2);
let mut it = cmds.into_iter();
match it.next().unwrap() {
RuntimeCommand::World(WorldCommand::CameraMove { args, .. }) => {
assert_eq!(args.forward, 1.5);
assert_eq!(args.frames, 30);
}
_ => panic!("wrong variant"),
}
assert!(matches!(
it.next().unwrap(),
RuntimeCommand::World(WorldCommand::CameraStop { .. })
));
assert!(drain().is_empty());
}
#[test]
fn apply_camera_move_step_advances_active_camera() {
use concinnity_core::components::{Camera3D, CameraController};
use concinnity_core::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 = 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));
}
#[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(
BackendCommand::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(
BackendCommand::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(
BackendCommand::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(
BackendCommand::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(
BackendCommand::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(
BackendCommand::Screenshot {
path: "shot.png".to_string(),
reply: tx,
},
None,
&mut backend,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("screenshot: not supported"), "got: {err}");
}
#[test]
fn backend_dispatch_cull_status_surfaces_backend_err() {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let mut backend = StubBackend;
dispatch_runtime_spawn(BackendCommand::CullStatus { reply: tx }, None, &mut backend);
let err = rx.recv().unwrap().unwrap_err();
assert!(
err.contains("read_cull_status: not supported"),
"got: {err}"
);
}
#[test]
fn resolve_texture_slot_covers_every_case() {
let _guard = test_support::lock();
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}");
asset_id::intern_all(&["grid"]);
let err = resolve_texture_slot(Some("grid"), None).unwrap_err();
assert!(err.contains("slots not captured"), "got: {err}");
let empty = TextureNameSlots::default();
let err = resolve_texture_slot(Some("grid"), Some(&empty)).unwrap_err();
assert!(err.contains("not in the live texture pool"), "got: {err}");
let slots = TextureNameSlots([(AssetId(0), 5usize)].into());
assert_eq!(resolve_texture_slot(Some("grid"), Some(&slots)).unwrap(), 5);
}
fn controlled_camera() -> Camera3D {
use concinnity_core::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 world_command_camera_set_applies_pose_and_replies_ok() {
use concinnity_core::components::Camera3D;
use concinnity_core::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_world_command(
WorldCommand::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,
&mut None,
);
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 world_command_quality_set_sends_setting_command() {
let mut world = World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::QualitySet {
setting: SettingKey::Ssao,
op: SettingOp::Next,
reply: tx,
},
&mut world,
&mut None,
);
assert!(rx.recv().unwrap().is_ok());
let events = world
.events::<SettingCommand>()
.expect("setting command queued");
let mut cursor = EventCursor::default();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].setting, SettingKey::Ssao);
assert_eq!(seen[0].op, SettingOp::Next);
assert!(seen[0].persist);
assert!(seen[0].value_label.is_none());
}
#[test]
fn world_command_rebind_sends_rebind_setting_command() {
let mut world = World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::Rebind {
action: Bindable::Forward,
key: InputKey::Space,
reply: tx,
},
&mut world,
&mut None,
);
assert!(rx.recv().unwrap().is_ok());
let events = world
.events::<SettingCommand>()
.expect("setting command queued");
let mut cursor = EventCursor::default();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].setting, SettingKey::KeyRebind(Bindable::Forward));
assert_eq!(seen[0].op, SettingOp::Rebind(InputKey::Space));
}
#[test]
fn world_command_story_forwards_the_command() {
let mut world = World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::Story {
command: StoryCommand::Choose(2),
reply: tx,
},
&mut world,
&mut None,
);
assert!(rx.recv().unwrap().is_ok());
let events = world
.events::<StoryCommand>()
.expect("story command queued");
let mut cursor = EventCursor::default();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(*seen[0], StoryCommand::Choose(2));
}
#[test]
fn world_command_despawn_resolves_name_and_reports_unknown() {
let _guard = test_support::lock();
asset_id::reset_interner();
asset_id::intern_all(&["crate_a", "crate_b"]);
let mut world = World::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::Despawn {
name: "crate_b".to_string(),
reply: tx,
},
&mut world,
&mut None,
);
assert!(rx.recv().unwrap().is_ok());
let events = world
.events::<DespawnRequest>()
.expect("despawn request queued");
let mut cursor = EventCursor::default();
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].target.name().unwrap(), AssetId(1));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::Despawn {
name: "ghost".to_string(),
reply: tx,
},
&mut world,
&mut None,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("'ghost' not found"), "got: {err}");
}
#[test]
fn world_command_reparent_resolves_names_and_reports_errors() {
let _guard = test_support::lock();
asset_id::reset_interner();
asset_id::intern_all(&["box_a", "frame"]);
let mut world = World::new();
let mut cursor = EventCursor::default();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::Reparent {
child: "box_a".to_string(),
parent: Some("frame".to_string()),
reply: tx,
},
&mut world,
&mut None,
);
assert!(rx.recv().unwrap().is_ok());
{
let events = world
.events::<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(), AssetId(0));
assert_eq!(seen[0].parent.and_then(|p| p.name()), Some(AssetId(1)));
}
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::Reparent {
child: "box_a".to_string(),
parent: None,
reply: tx,
},
&mut world,
&mut None,
);
assert!(rx.recv().unwrap().is_ok());
{
let events = world.events::<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_world_command(
WorldCommand::Reparent {
child: "ghost".to_string(),
parent: Some("frame".to_string()),
reply: tx,
},
&mut world,
&mut None,
);
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_world_command(
WorldCommand::Reparent {
child: "box_a".to_string(),
parent: Some("void".to_string()),
reply: tx,
},
&mut world,
&mut None,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("parent 'void' not found"), "got: {err}");
}
#[test]
fn world_command_spawn_resolves_template_interns_name_and_defaults_scale() {
let _guard = test_support::lock();
asset_id::reset_interner();
asset_id::intern_all(&["template_a"]);
let mut world = World::new();
let mut cursor = EventCursor::default();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::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,
&mut None,
);
assert!(rx.recv().unwrap().is_ok());
{
let events = world
.events::<SpawnRequest>()
.expect("spawn request queued");
let seen: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].template, AssetId(0));
assert_eq!(seen[0].name, Some(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_world_command(
WorldCommand::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,
&mut None,
);
assert!(rx.recv().unwrap().is_ok());
{
let events = world.events::<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_world_command(
WorldCommand::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,
&mut None,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("template 'missing' not found"), "got: {err}");
}
#[test]
fn world_command_camera_move_without_a_camera_errs_and_installs_no_motion() {
let mut world = World::new();
let mut motion = None;
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::CameraMove {
args: CameraMoveArgs {
forward: 1.0,
..CameraMoveArgs::default()
},
reply: tx,
},
&mut world,
&mut motion,
);
let err = rx.recv().unwrap().unwrap_err();
assert!(err.contains("no Camera3D"), "got: {err}");
assert!(motion.is_none());
}
#[test]
fn world_command_camera_move_installs_the_motion_when_a_camera_exists() {
let mut world = World::new();
world.add_component(controlled_camera());
let mut motion = None;
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::CameraMove {
args: CameraMoveArgs {
forward: 1.0,
frames: 4,
..CameraMoveArgs::default()
},
reply: tx,
},
&mut world,
&mut motion,
);
assert!(rx.recv().unwrap().is_ok());
assert_eq!(motion.map(|m| m.frames_left), Some(Some(4)));
}
#[test]
fn world_command_camera_stop_clears_the_motion_and_replies_ok() {
let mut world = World::new();
let mut motion = Some(CameraMotion::from_args(&CameraMoveArgs::default()));
let (tx, rx) = std::sync::mpsc::sync_channel(1);
dispatch_world_command(
WorldCommand::CameraStop { reply: tx },
&mut world,
&mut motion,
);
assert!(rx.recv().unwrap().is_ok());
assert!(motion.is_none());
}
}