1use std::cell::{Cell, RefCell};
2use std::collections::{HashMap, HashSet};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::rc::Rc;
6use std::sync::Arc;
7
8use glam::{Quat, Vec3};
9use rhai::{CustomType, Dynamic, Engine, FnPtr, Scope, TypeBuilder, AST};
10use serde::{Deserialize, Serialize};
11
12use crate::ecs::{ComponentStore, Entity};
13use crate::input::{InputState, MouseState};
14use crate::renderer::{Model, Renderer, Sprite, Texture};
15use crate::types::KeyCode;
16use crate::world::{Name, Renderable, Transform, World};
17
18#[derive(Clone, Serialize, Deserialize)]
21pub struct ScriptAttachment {
22 pub path: PathBuf,
23 #[serde(default = "default_enabled")]
24 pub enabled: bool,
25}
26
27fn default_enabled() -> bool {
28 true
29}
30
31#[derive(Clone, Default, Serialize, Deserialize)]
35pub struct ScriptList(pub Vec<ScriptAttachment>);
36
37#[derive(Clone, Copy)]
42enum ScriptEntityId {
43 Real(Entity),
44 Pending(u64),
45}
46
47enum WorldCommand {
57 SetTransform(ScriptEntityId, Transform),
58 Rename(ScriptEntityId, String),
59 Despawn(ScriptEntityId),
60 Spawn { pending_id: u64, name: String, transform: Transform },
61 AttachScript(ScriptEntityId, PathBuf),
62 SetScriptEnabled(ScriptEntityId, usize, bool),
63 SetSprite(ScriptEntityId, PathBuf),
69 SetModel(ScriptEntityId, PathBuf),
72 ClearRenderable(ScriptEntityId),
75 SetPersistent(ScriptEntityId, bool),
79 ChangeScene(PathBuf),
86}
87
88#[derive(Default)]
97struct FrameSnapshot {
98 transforms: ComponentStore<Transform>,
99 names: ComponentStore<Name>,
100 by_name: HashMap<String, Entity>,
102}
103
104impl FrameSnapshot {
105 fn capture(world: &World) -> Self {
106 let mut by_name = HashMap::new();
107 for (entity, name) in world.names.iter() {
108 by_name.entry(name.0.clone()).or_insert(entity);
109 }
110 Self { transforms: world.transforms.clone(), names: world.names.clone(), by_name }
111 }
112}
113
114#[derive(Clone, CustomType)]
134#[rhai_type(name = "Entity", extra = Self::register_extra)]
135struct EntityHandle {
136 #[rhai_type(skip)]
137 x: f64,
138 #[rhai_type(skip)]
139 y: f64,
140 #[rhai_type(skip)]
141 z: f64,
142 #[rhai_type(skip)]
151 rotation: Quat,
152 #[rhai_type(skip)]
153 scale: Vec3,
154 #[rhai_type(skip)]
155 name: String,
156 #[rhai_type(skip)]
157 id: ScriptEntityId,
158 #[rhai_type(skip)]
159 commands: Rc<RefCell<Vec<WorldCommand>>>,
160}
161
162impl EntityHandle {
163 fn register_extra(builder: &mut TypeBuilder<Self>) {
169 builder
170 .with_get_set("x", |e: &mut Self| e.x, |e: &mut Self, v: f64| {
171 e.x = v;
172 e.queue_transform_update();
173 })
174 .with_get_set("y", |e: &mut Self| e.y, |e: &mut Self, v: f64| {
175 e.y = v;
176 e.queue_transform_update();
177 })
178 .with_get_set("z", |e: &mut Self| e.z, |e: &mut Self, v: f64| {
179 e.z = v;
180 e.queue_transform_update();
181 })
182 .with_fn("translate", |e: &mut Self, x: f64, y: f64, z: f64| {
183 e.x += x;
184 e.y += y;
185 e.z += z;
186 e.queue_transform_update();
187 })
188 .with_fn("rotate", |e: &mut Self, x: f64, y: f64, z: f64| {
189 let delta = Quat::from_euler(glam::EulerRot::XYZ, x as f32, y as f32, z as f32);
190 e.rotation = (e.rotation * delta).normalize();
191 e.queue_transform_update();
192 })
193 .with_fn("look_at", |e: &mut Self, x: f64, y: f64, z: f64| {
194 let position = Vec3::new(e.x as f32, e.y as f32, e.z as f32);
195 let direction = Vec3::new(x as f32, y as f32, z as f32) - position;
196 if direction.length_squared() > 1e-12 {
200 e.rotation = Quat::from_rotation_arc(Vec3::NEG_Z, direction.normalize());
205 e.queue_transform_update();
206 }
207 })
208 .with_fn("scale", |e: &mut Self, x: f64, y: f64, z: f64| {
209 e.scale *= Vec3::new(x as f32, y as f32, z as f32);
210 e.queue_transform_update();
211 })
212 .with_fn("name", |e: &mut Self| e.name.clone())
213 .with_fn("set_name", |e: &mut Self, name: &str| {
214 e.name = name.to_string();
215 e.commands.borrow_mut().push(WorldCommand::Rename(e.id, e.name.clone()));
216 })
217 .with_fn("despawn", |e: &mut Self| {
218 e.commands.borrow_mut().push(WorldCommand::Despawn(e.id));
219 })
220 .with_fn("set_persistent", |e: &mut Self, persistent: bool| {
221 e.commands.borrow_mut().push(WorldCommand::SetPersistent(e.id, persistent));
222 })
223 .with_fn("attach_script", |e: &mut Self, path: &str| {
224 e.commands.borrow_mut().push(WorldCommand::AttachScript(e.id, PathBuf::from(path)));
225 })
226 .with_fn("set_script_enabled", |e: &mut Self, index: i64, enabled: bool| {
227 e.commands.borrow_mut().push(WorldCommand::SetScriptEnabled(e.id, index.max(0) as usize, enabled));
228 })
229 .with_fn("set_sprite", |e: &mut Self, path: &str| {
230 e.commands.borrow_mut().push(WorldCommand::SetSprite(e.id, PathBuf::from(path)));
231 })
232 .with_fn("set_model", |e: &mut Self, path: &str| {
233 e.commands.borrow_mut().push(WorldCommand::SetModel(e.id, PathBuf::from(path)));
234 })
235 .with_fn("detach_renderable", |e: &mut Self| {
236 e.commands.borrow_mut().push(WorldCommand::ClearRenderable(e.id));
237 });
238 }
239
240 fn new(id: ScriptEntityId, transform: &Transform, name: String, commands: Rc<RefCell<Vec<WorldCommand>>>) -> Self {
244 Self {
245 x: transform.position.x as f64,
246 y: transform.position.y as f64,
247 z: transform.position.z as f64,
248 rotation: transform.rotation,
249 scale: transform.scale,
250 name,
251 id,
252 commands,
253 }
254 }
255
256 fn sync_from_world(&mut self, transform: &Transform, name: &str) {
262 self.x = transform.position.x as f64;
263 self.y = transform.position.y as f64;
264 self.z = transform.position.z as f64;
265 self.rotation = transform.rotation;
266 self.scale = transform.scale;
267 self.name = name.to_string();
268 }
269
270 fn queue_transform_update(&self) {
271 let transform = Transform {
272 position: Vec3::new(self.x as f32, self.y as f32, self.z as f32),
273 rotation: self.rotation,
274 scale: self.scale,
275 };
276 self.commands.borrow_mut().push(WorldCommand::SetTransform(self.id, transform));
277 }
278}
279
280#[derive(Clone, CustomType)]
294#[rhai_type(name = "World", extra = Self::register_extra)]
295struct ScriptWorld {
296 #[rhai_type(skip)]
297 frame: Rc<RefCell<FrameSnapshot>>,
298 #[rhai_type(skip)]
299 commands: Rc<RefCell<Vec<WorldCommand>>>,
300 #[rhai_type(skip)]
301 next_pending_id: Rc<Cell<u64>>,
302}
303
304impl ScriptWorld {
305 fn register_extra(builder: &mut TypeBuilder<Self>) {
306 builder
307 .with_fn("find", |w: &mut Self, name: &str| -> Dynamic {
308 let frame = w.frame.borrow();
309 let Some(&entity) = frame.by_name.get(name) else { return Dynamic::UNIT };
310 let transform = frame.transforms.get(entity).copied().unwrap_or_default();
311 let entity_name = frame.names.get(entity).map(|n| n.0.clone()).unwrap_or_default();
312 Dynamic::from(EntityHandle::new(ScriptEntityId::Real(entity), &transform, entity_name, w.commands.clone()))
313 })
314 .with_fn("spawn_entity", |w: &mut Self, name: &str, x: f64, y: f64, z: f64| -> EntityHandle {
315 let pending_id = w.next_pending_id.get();
316 w.next_pending_id.set(pending_id + 1);
317 let transform = Transform { position: Vec3::new(x as f32, y as f32, z as f32), ..Transform::default() };
318 w.commands.borrow_mut().push(WorldCommand::Spawn { pending_id, name: name.to_string(), transform });
319 EntityHandle::new(ScriptEntityId::Pending(pending_id), &transform, name.to_string(), w.commands.clone())
320 });
321 }
322}
323
324#[derive(Clone, CustomType)]
329#[rhai_type(name = "Scene", extra = Self::register_extra)]
330struct ScriptScene {
331 #[rhai_type(skip)]
332 commands: Rc<RefCell<Vec<WorldCommand>>>,
333}
334
335impl ScriptScene {
336 fn register_extra(builder: &mut TypeBuilder<Self>) {
337 builder.with_fn("change", |s: &mut Self, path: &str| {
338 s.commands.borrow_mut().push(WorldCommand::ChangeScene(PathBuf::from(path)));
339 });
340 }
341}
342
343#[derive(Clone, CustomType)]
356#[rhai_type(name = "Input", extra = Self::register_extra)]
357struct ScriptInput {
358 #[rhai_type(skip)]
359 held: HashSet<KeyCode>,
360 #[rhai_type(skip)]
361 pressed: HashSet<KeyCode>,
362 #[rhai_type(skip)]
363 mouse_held: HashSet<winit::event::MouseButton>,
364 #[rhai_type(skip)]
365 mouse_pressed: HashSet<winit::event::MouseButton>,
366 #[rhai_type(skip)]
367 mouse_x: f64,
368 #[rhai_type(skip)]
369 mouse_y: f64,
370 #[rhai_type(skip)]
372 mouse_dx: f64,
373 #[rhai_type(skip)]
374 mouse_dy: f64,
375 #[rhai_type(skip)]
376 mouse_wheel: f64,
377}
378
379impl ScriptInput {
380 fn from_state(input: &InputState, mouse: &MouseState, mouse_delta: (f32, f32)) -> Self {
381 let (mouse_x, mouse_y) = mouse.position();
382 Self {
383 held: input.keys_held().collect(),
384 pressed: input.keys_pressed().collect(),
385 mouse_held: mouse.buttons_held().collect(),
386 mouse_pressed: mouse.buttons_pressed().collect(),
387 mouse_x: mouse_x as f64,
388 mouse_y: mouse_y as f64,
389 mouse_dx: mouse_delta.0 as f64,
390 mouse_dy: mouse_delta.1 as f64,
391 mouse_wheel: mouse.wheel_delta() as f64,
392 }
393 }
394
395 fn register_extra(builder: &mut TypeBuilder<Self>) {
396 builder
397 .with_fn("is_held", |i: &mut Self, name: &str| {
398 KeyCode::from_name(name).is_some_and(|key| i.held.contains(&key))
399 })
400 .with_fn("is_pressed", |i: &mut Self, name: &str| {
401 KeyCode::from_name(name).is_some_and(|key| i.pressed.contains(&key))
402 })
403 .with_fn("is_mouse_held", |i: &mut Self, name: &str| {
404 mouse_button_from_name(name).is_some_and(|button| i.mouse_held.contains(&button))
405 })
406 .with_fn("is_mouse_pressed", |i: &mut Self, name: &str| {
407 mouse_button_from_name(name).is_some_and(|button| i.mouse_pressed.contains(&button))
408 })
409 .with_fn("mouse_x", |i: &mut Self| i.mouse_x)
410 .with_fn("mouse_y", |i: &mut Self| i.mouse_y)
411 .with_fn("mouse_dx", |i: &mut Self| i.mouse_dx)
412 .with_fn("mouse_dy", |i: &mut Self| i.mouse_dy)
413 .with_fn("mouse_wheel", |i: &mut Self| i.mouse_wheel);
414 }
415}
416
417fn mouse_button_from_name(name: &str) -> Option<winit::event::MouseButton> {
423 Some(match name {
424 "Left" => winit::event::MouseButton::Left,
425 "Right" => winit::event::MouseButton::Right,
426 "Middle" => winit::event::MouseButton::Middle,
427 "Back" => winit::event::MouseButton::Back,
428 "Forward" => winit::event::MouseButton::Forward,
429 _ => return None,
430 })
431}
432
433#[derive(Debug)]
436pub struct ScriptError {
437 pub entity: Entity,
438 pub script: PathBuf,
439 pub message: String,
440}
441
442const MAX_CONSECUTIVE_FAILURES: u32 = 5;
446
447struct ScriptInstance {
448 ast: Rc<AST>,
449 scope: Scope<'static>,
450 on_update: Option<FnPtr>,
455 consecutive_failures: u32,
456}
457
458pub struct ScriptRuntime {
464 engine: Engine,
465 compiled: HashMap<PathBuf, Rc<AST>>,
466 instances: HashMap<(Entity, usize), ScriptInstance>,
467 commands: Rc<RefCell<Vec<WorldCommand>>>,
469 frame: Rc<RefCell<FrameSnapshot>>,
471 next_pending_id: Rc<Cell<u64>>,
474 project_root: PathBuf,
478}
479
480impl ScriptRuntime {
481 pub fn new(project_root: PathBuf) -> Self {
482 Self {
483 engine: Self::build_engine(),
484 compiled: HashMap::new(),
485 instances: HashMap::new(),
486 commands: Rc::new(RefCell::new(Vec::new())),
487 frame: Rc::new(RefCell::new(FrameSnapshot::default())),
488 next_pending_id: Rc::new(Cell::new(0)),
489 project_root,
490 }
491 }
492
493 fn build_engine() -> Engine {
494 let mut engine = Engine::new();
495 engine.build_type::<EntityHandle>();
496 engine.build_type::<ScriptInput>();
497 engine.build_type::<ScriptWorld>();
498 engine.build_type::<ScriptScene>();
499 engine
500 }
501
502 pub fn begin_frame(&mut self, world: &World) {
506 *self.frame.borrow_mut() = FrameSnapshot::capture(world);
507 }
508
509 fn script_world(&self) -> ScriptWorld {
510 ScriptWorld { frame: self.frame.clone(), commands: self.commands.clone(), next_pending_id: self.next_pending_id.clone() }
511 }
512
513 fn script_scene(&self) -> ScriptScene {
514 ScriptScene { commands: self.commands.clone() }
515 }
516
517 pub fn start_script(&mut self, world: &mut World, entity: Entity, index: usize, script_path: &Path, renderer: Option<&Renderer>) -> Vec<ScriptError> {
536 let err = |message: String| ScriptError { entity, script: script_path.to_path_buf(), message };
537
538 let ast = match self.compiled.get(script_path) {
539 Some(ast) => ast.clone(),
540 None => {
541 let source = match fs::read_to_string(script_path) {
542 Ok(source) => source,
543 Err(e) => return vec![err(format!("failed to read script: {e}"))],
544 };
545 let ast = match self.engine.compile(&source) {
546 Ok(ast) => Rc::new(ast),
547 Err(e) => return vec![err(format!("compile error: {e}"))],
548 };
549 self.compiled.insert(script_path.to_path_buf(), ast.clone());
550 ast
551 }
552 };
553
554 let mut scope = Scope::new();
555 let transform = world.transforms.get(entity).copied().unwrap_or_default();
556 let name = world.names.get(entity).map(|n| n.0.clone()).unwrap_or_default();
557 scope.push("entity", EntityHandle::new(ScriptEntityId::Real(entity), &transform, name, self.commands.clone()));
558 scope.push("world", self.script_world());
559 scope.push("scene", self.script_scene());
560
561 if let Err(e) = self.engine.run_ast_with_scope(&mut scope, &ast) {
562 return vec![err(format!("script error: {e}"))];
563 }
564
565 let on_update = scope.get_value::<FnPtr>("on_update");
566 let on_start = scope.get_value::<FnPtr>("on_start");
567
568 let mut errors: Vec<ScriptError> = on_start
569 .as_ref()
570 .and_then(|on_start| {
571 on_start.call::<rhai::Dynamic>(&self.engine, &ast, ()).err().map(|e| err(format!("on_start error: {e}")))
572 })
573 .into_iter()
574 .collect();
575
576 self.instances.insert((entity, index), ScriptInstance { ast, scope, on_update, consecutive_failures: 0 });
577
578 errors.extend(self.drain_commands(world, entity, renderer, script_path));
579 errors
580 }
581
582 pub fn start_all_scripts(&mut self, world: &mut World, base_dir: &Path, renderer: Option<&Renderer>) -> Vec<ScriptError> {
598 let mut errors = Vec::new();
599 for entity in world.iter_entities().collect::<Vec<_>>() {
600 let Some(list) = world.scripts.get(entity) else { continue };
601 let starts: Vec<(usize, PathBuf)> = list
607 .0
608 .iter()
609 .enumerate()
610 .filter(|(index, attachment)| attachment.enabled && !self.instances.contains_key(&(entity, *index)))
611 .map(|(index, attachment)| (index, base_dir.join(&attachment.path)))
612 .collect();
613
614 for (index, script_path) in starts {
615 errors.extend(self.start_script(world, entity, index, &script_path, renderer));
616 }
617 }
618 errors
619 }
620
621 pub fn update_entity(
640 &mut self,
641 world: &mut World,
642 entity: Entity,
643 dt: f32,
644 input: &InputState,
645 mouse: &MouseState,
646 mouse_delta: (f32, f32),
647 renderer: Option<&Renderer>,
648 ) -> Vec<ScriptError> {
649 let mut errors = Vec::new();
650 let Some(attachments) = world.scripts.get(entity).cloned() else { return errors };
651 let input = ScriptInput::from_state(input, mouse, mouse_delta);
652
653 for (index, attachment) in attachments.0.iter().enumerate() {
654 if !attachment.enabled {
655 continue;
656 }
657 let Some(instance) = self.instances.get_mut(&(entity, index)) else { continue };
658 let Some(on_update) = instance.on_update.clone() else { continue };
659
660 if let Some(transform) = world.transforms.get(entity).copied() {
662 let name = world.names.get(entity).map(|n| n.0.as_str()).unwrap_or("");
663 if let Some(mut handle) = scope_entity_mut(&mut instance.scope) {
664 handle.sync_from_world(&transform, name);
665 }
666 }
667
668 match on_update.call::<rhai::Dynamic>(&self.engine, &instance.ast, (dt as f64, input.clone())) {
671 Ok(_) => instance.consecutive_failures = 0,
672 Err(e) => {
673 instance.consecutive_failures += 1;
674 errors.push(ScriptError { entity, script: attachment.path.clone(), message: e.to_string() });
675
676 if instance.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
677 instance.consecutive_failures = 0;
678 if let Some(list) = world.scripts.get_mut(entity) {
679 if let Some(attachment) = list.0.get_mut(index) {
680 attachment.enabled = false;
681 }
682 }
683 }
684 }
685 }
686
687 errors.extend(self.drain_commands(world, entity, renderer, &attachment.path));
690
691 if !world.is_alive(entity) {
692 break;
693 }
694 }
695
696 errors
697 }
698
699 fn drain_commands(&mut self, world: &mut World, entity: Entity, renderer: Option<&Renderer>, source_script: &Path) -> Vec<ScriptError> {
709 let commands: Vec<WorldCommand> = self.commands.borrow_mut().drain(..).collect();
710 let mut pending: HashMap<u64, Entity> = HashMap::new();
711 let mut errors = Vec::new();
712
713 let resolve = |id: ScriptEntityId, pending: &HashMap<u64, Entity>| match id {
714 ScriptEntityId::Real(entity) => Some(entity),
715 ScriptEntityId::Pending(pending_id) => pending.get(&pending_id).copied(),
716 };
717
718 for command in commands {
719 match command {
720 WorldCommand::Spawn { pending_id, name, transform } => {
721 let entity = world.spawn_empty(name, transform);
722 pending.insert(pending_id, entity);
723 }
724 WorldCommand::SetTransform(id, transform) => {
725 if let Some(entity) = resolve(id, &pending) {
726 if let Some(slot) = world.transforms.get_mut(entity) {
727 *slot = transform;
728 }
729 }
730 }
731 WorldCommand::Rename(id, name) => {
732 if let Some(entity) = resolve(id, &pending) {
733 if let Some(slot) = world.names.get_mut(entity) {
734 slot.0 = name;
735 }
736 }
737 }
738 WorldCommand::Despawn(id) => {
739 if let Some(entity) = resolve(id, &pending) {
740 world.despawn(entity);
741 self.instances.retain(|(e, _), _| *e != entity);
742 }
743 }
744 WorldCommand::AttachScript(id, path) => {
745 if let Some(entity) = resolve(id, &pending) {
746 let full_path = self.project_root.join(&path);
747 let new_index = match world.scripts.get_mut(entity) {
748 Some(list) => {
749 list.0.push(ScriptAttachment { path, enabled: true });
750 list.0.len() - 1
751 }
752 None => {
753 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path, enabled: true }]));
754 0
755 }
756 };
757 errors.extend(self.start_script(world, entity, new_index, &full_path, renderer));
761 }
762 }
763 WorldCommand::SetScriptEnabled(id, index, enabled) => {
764 if let Some(entity) = resolve(id, &pending) {
765 if let Some(list) = world.scripts.get_mut(entity) {
766 if let Some(attachment) = list.0.get_mut(index) {
767 attachment.enabled = enabled;
768 }
769 }
770 }
771 }
772 WorldCommand::SetSprite(id, path) => {
773 if let Some(entity) = resolve(id, &pending) {
774 let Some(renderer) = renderer else {
775 errors.push(no_renderer_error(entity, source_script, &path));
776 continue;
777 };
778 let full_path = self.project_root.join(&path);
779 match Texture::from_path(renderer, &full_path) {
780 Ok(texture) => {
781 let mut sprite = Sprite::new(Arc::new(texture));
782 sprite.fit_within_unit_square();
783 world.set_renderable(entity, Renderable::Sprite(sprite));
784 }
785 Err(e) => errors.push(ScriptError {
786 entity,
787 script: source_script.to_path_buf(),
788 message: format!("failed to load sprite {}: {e}", path.display()),
789 }),
790 }
791 }
792 }
793 WorldCommand::SetModel(id, path) => {
794 if let Some(entity) = resolve(id, &pending) {
795 let Some(renderer) = renderer else {
796 errors.push(no_renderer_error(entity, source_script, &path));
797 continue;
798 };
799 let full_path = self.project_root.join(&path);
800 match Model::load(renderer, &full_path) {
801 Ok(model) => world.set_renderable(entity, Renderable::Model(model)),
802 Err(e) => errors.push(ScriptError {
803 entity,
804 script: source_script.to_path_buf(),
805 message: format!("failed to load model {}: {e}", path.display()),
806 }),
807 }
808 }
809 }
810 WorldCommand::ClearRenderable(id) => {
811 if let Some(entity) = resolve(id, &pending) {
812 world.clear_renderable(entity);
813 }
814 }
815 WorldCommand::SetPersistent(id, persistent) => {
816 if let Some(target) = resolve(id, &pending) {
817 world.set_persistent(target, persistent);
818 }
819 }
820 WorldCommand::ChangeScene(path) => {
821 let full_path = self.project_root.join(&path);
822 match crate::scene_file::SceneFile::load(&full_path) {
823 Ok(scene_file) => {
824 world.despawn_non_persistent();
825 self.instances.retain(|(e, _), _| world.is_alive(*e));
826
827 let base_dir = self.project_root.clone();
831 for record in scene_file.entities {
832 record.spawn_into(world, renderer, &base_dir);
833 }
834 self.begin_frame(world);
835 errors.extend(self.start_all_scripts(world, &base_dir, renderer));
836 }
837 Err(e) => errors.push(ScriptError {
838 entity,
839 script: source_script.to_path_buf(),
840 message: format!("failed to load scene {}: {e}", path.display()),
841 }),
842 }
843 }
844 }
845 }
846
847 errors
848 }
849}
850
851fn no_renderer_error(entity: Entity, source_script: &Path, asset_path: &Path) -> ScriptError {
852 ScriptError {
853 entity,
854 script: source_script.to_path_buf(),
855 message: format!("cannot load {} — no renderer available", asset_path.display()),
856 }
857}
858
859fn scope_entity_mut<'a>(scope: &'a mut Scope<'static>) -> Option<rhai::DynamicWriteLock<'a, EntityHandle>> {
863 scope.get_mut("entity")?.write_lock::<EntityHandle>()
864}
865
866#[cfg(test)]
867mod closure_state_spike {
868 use rhai::{Engine, FnPtr, Scope};
875
876 #[derive(Clone)]
877 struct SpikeEntity {
878 x: f64,
879 }
880
881 #[test]
882 fn closure_captures_scope_variables_by_reference() {
883 let mut engine = Engine::new();
884 engine
885 .register_type_with_name::<SpikeEntity>("Entity")
886 .register_get_set("x", |e: &mut SpikeEntity| e.x, |e: &mut SpikeEntity, v: f64| e.x = v);
887
888 let mut scope = Scope::new();
889 scope.push("entity", SpikeEntity { x: 0.0 });
890
891 let ast = engine
892 .compile(
893 r#"
894 let counter = 0;
895 let on_update = |dt| {
896 counter += 1;
897 entity.x += dt;
898 counter
899 };
900 "#,
901 )
902 .expect("script should compile");
903
904 engine.run_ast_with_scope(&mut scope, &ast).expect("script body should run");
905
906 let on_update: FnPtr = scope.get_value("on_update").expect("on_update closure should be in scope");
907
908 let first: i64 = on_update.call(&engine, &ast, (1.5_f64,)).expect("first call should succeed");
909 let second: i64 = on_update.call(&engine, &ast, (1.5_f64,)).expect("second call should succeed");
910
911 assert_eq!(first, 1, "counter should persist across calls (closure captures by reference)");
912 assert_eq!(second, 2);
913
914 let entity: SpikeEntity = scope.get_value("entity").expect("entity should still be in scope");
915 assert_eq!(entity.x, 3.0, "mutations inside the closure should be visible via the original Scope variable");
916 }
917}
918
919#[cfg(test)]
920mod runtime_tests {
921 use super::*;
922 use crate::camera::Camera;
923 use crate::world::Transform;
924
925 fn test_camera() -> Camera {
926 Camera { position: Vec3::ZERO, yaw: 0.0, pitch: 0.0, aspect: 1.0, fov: 45.0, znear: 0.1, zfar: 100.0 }
927 }
928
929 fn no_input() -> InputState {
931 InputState::for_test([], [])
932 }
933
934 fn no_mouse() -> MouseState {
937 MouseState::new()
938 }
939
940 fn test_runtime() -> ScriptRuntime {
941 ScriptRuntime::new(PathBuf::new())
942 }
943
944 fn write_script(name: &str, source: &str) -> PathBuf {
948 let dir = std::env::temp_dir().join("libdqg_scripting_tests");
949 fs::create_dir_all(&dir).unwrap();
950 let path = dir.join(name);
951 fs::write(&path, source).unwrap();
952 path
953 }
954
955 fn write_scene(name: &str, scene_file: &crate::scene_file::SceneFile) -> PathBuf {
959 let dir = std::env::temp_dir().join("libdqg_scripting_tests");
960 fs::create_dir_all(&dir).unwrap();
961 let path = dir.join(name);
962 scene_file.save(&path).unwrap();
963 path
964 }
965
966 #[test]
967 fn on_update_translates_entity_and_persists_state_across_frames() {
968 let mut world = World::new(test_camera());
969 let entity = world.spawn_empty("Mover", Transform::default());
970 let script_path = write_script(
971 "mover.rhai",
972 r#"
973 let speed = 1.0;
974 let on_update = |dt, input| {
975 entity.translate(speed * dt, 0.0, 0.0);
976 };
977 "#,
978 );
979 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
980
981 let mut runtime = test_runtime();
982 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
983
984 let input = no_input();
985 assert!(runtime.update_entity(&mut world, entity, 0.5, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
986 assert!(runtime.update_entity(&mut world, entity, 0.5, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
987
988 let position = world.transforms.get(entity).unwrap().position;
989 assert!((position.x - 1.0).abs() < 1e-5, "expected x\u{2248}1.0 after two 0.5s updates, got {position:?}");
990 }
991
992 #[test]
993 fn repeated_runtime_errors_disable_the_attachment() {
994 let mut world = World::new(test_camera());
995 let entity = world.spawn_empty("Broken", Transform::default());
996 let script_path = write_script("broken.rhai", "let on_update = |dt, input| { throw \"boom\"; };");
997 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
998
999 let mut runtime = test_runtime();
1000 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1001
1002 let input = no_input();
1003 for _ in 0..MAX_CONSECUTIVE_FAILURES {
1004 let errors = runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None);
1005 assert_eq!(errors.len(), 1);
1006 }
1007
1008 let attachment_enabled = world.scripts.get(entity).unwrap().0[0].enabled;
1009 assert!(!attachment_enabled, "attachment should auto-disable after MAX_CONSECUTIVE_FAILURES errors");
1010
1011 assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1013 }
1014
1015 #[test]
1016 fn on_update_sees_held_and_pressed_keys() {
1017 let mut world = World::new(test_camera());
1018 let entity = world.spawn_empty("Listener", Transform::default());
1019 let script_path = write_script(
1020 "input.rhai",
1021 r#"
1022 let on_update = |dt, input| {
1023 if input.is_held("KeyW") {
1024 entity.translate(0.0, 0.0, -1.0);
1025 }
1026 if input.is_pressed("Space") {
1027 entity.translate(1.0, 0.0, 0.0);
1028 }
1029 // An unrecognized key name should just read as not held/pressed, not error.
1030 if input.is_held("NotAKey") {
1031 entity.translate(0.0, 99.0, 0.0);
1032 }
1033 };
1034 "#,
1035 );
1036 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1037
1038 let mut runtime = test_runtime();
1039 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1040
1041 let input = InputState::for_test([KeyCode::KeyW], [KeyCode::Space]);
1042 assert!(runtime.update_entity(&mut world, entity, 1.0, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1043
1044 let position = world.transforms.get(entity).unwrap().position;
1045 assert!(
1046 position.abs_diff_eq(Vec3::new(1.0, 0.0, -1.0), 1e-5),
1047 "expected held KeyW and pressed Space to both apply, got {position:?}"
1048 );
1049 }
1050
1051 #[test]
1052 fn on_update_sees_mouse_state() {
1053 let mut world = World::new(test_camera());
1054 let entity = world.spawn_empty("MouseListener", Transform::default());
1055 let script_path = write_script(
1056 "mouse.rhai",
1057 r#"
1058 let on_update = |dt, input| {
1059 if input.is_mouse_held("Left") {
1060 entity.translate(0.0, 0.0, -1.0);
1061 }
1062 if input.is_mouse_pressed("Right") {
1063 entity.translate(1.0, 0.0, 0.0);
1064 }
1065 entity.translate(input.mouse_dx() * 0.1, input.mouse_dy() * 0.1, 0.0);
1066 // An unrecognized button name should just read as not held/pressed, not error.
1067 if input.is_mouse_held("NotAButton") {
1068 entity.translate(0.0, 99.0, 0.0);
1069 }
1070 };
1071 "#,
1072 );
1073 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1074
1075 let mut runtime = test_runtime();
1076 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1077
1078 let mouse = MouseState::for_test(
1079 (0.0, 0.0),
1080 [winit::event::MouseButton::Left],
1081 [winit::event::MouseButton::Right],
1082 0.0,
1083 );
1084 assert!(runtime.update_entity(&mut world, entity, 1.0, &no_input(), &mouse, (5.0, 2.0), None).is_empty());
1085
1086 let position = world.transforms.get(entity).unwrap().position;
1087 assert!(
1088 position.abs_diff_eq(Vec3::new(1.5, 0.2, -1.0), 1e-5),
1089 "expected held Left, pressed Right, and mouse delta to all apply, got {position:?}"
1090 );
1091 }
1092
1093 #[test]
1101 fn on_update_rotation_does_not_stall_past_ninety_degrees() {
1102 let mut world = World::new(test_camera());
1103 let entity = world.spawn_empty("Spinner", Transform::default());
1104 let script_path = write_script(
1105 "spin.rhai",
1106 r#"
1107 let speed = 1.0;
1108 let on_update = |dt, input| {
1109 entity.rotate(0.0, speed * dt, 0.0);
1110 };
1111 "#,
1112 );
1113 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1114
1115 let mut runtime = test_runtime();
1116 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1117
1118 let input = no_input();
1120 let steps = 200;
1121 let dt = std::f32::consts::PI / steps as f32;
1122 for _ in 0..steps {
1123 assert!(runtime.update_entity(&mut world, entity, dt, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1124 }
1125
1126 let rotation = world.transforms.get(entity).unwrap().rotation;
1127 let rotated_x = rotation * Vec3::X;
1128 assert!(
1129 rotated_x.abs_diff_eq(-Vec3::X, 1e-3),
1130 "expected a 180\u{b0} Y rotation to flip +X to -X, got {rotated_x:?} (rotation stalled around 90\u{b0}?)"
1131 );
1132 }
1133
1134 #[test]
1135 fn look_at_faces_the_target_along_local_negative_z() {
1136 let mut world = World::new(test_camera());
1137 let looker = world.spawn_empty("Looker", Transform { position: Vec3::new(5.0, 0.0, 0.0), ..Transform::default() });
1138 let script_path = write_script(
1139 "look_at.rhai",
1140 r#"
1141 let on_update = |dt, input| {
1142 entity.look_at(0.0, 0.0, 0.0);
1143 };
1144 "#,
1145 );
1146 world.scripts.insert(looker, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1147
1148 let mut runtime = test_runtime();
1149 assert!(runtime.start_script(&mut world, looker, 0, &script_path, None).is_empty(), "script should start");
1150 assert!(runtime.update_entity(&mut world, looker, 0.1, &no_input(), &no_mouse(), (0.0, 0.0), None).is_empty());
1151
1152 let rotation = world.transforms.get(looker).unwrap().rotation;
1153 let forward = rotation * Vec3::NEG_Z;
1154 assert!(
1155 forward.abs_diff_eq(Vec3::NEG_X, 1e-4),
1156 "expected the entity at (5,0,0) to face back toward the origin along -X, got forward={forward:?}"
1157 );
1158 }
1159
1160 #[test]
1168 fn on_start_can_read_other_entities_via_find_when_begin_frame_ran_first() {
1169 let mut world = World::new(test_camera());
1170 world.spawn_empty("Target", Transform { position: Vec3::new(3.0, 0.0, 0.0), ..Transform::default() });
1171 let reader = world.spawn_empty("Reader", Transform::default());
1172 let script_path = write_script(
1173 "read_other_on_start.rhai",
1174 r#"
1175 let on_start = || {
1176 // If `world.find` saw the default-empty snapshot (the bug this test guards
1177 // against), `other` would be `()` and `.x` would throw here, failing this
1178 // `on_start` call.
1179 let other = world.find("Target");
1180 let x = other.x;
1181 };
1182 "#,
1183 );
1184 world.scripts.insert(reader, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1185
1186 let mut runtime = test_runtime();
1187 runtime.begin_frame(&world);
1188
1189 assert!(runtime.start_script(&mut world, reader, 0, &script_path, None).is_empty());
1190 }
1191
1192 #[test]
1193 fn on_update_reads_another_entitys_position_via_find() {
1194 let mut world = World::new(test_camera());
1195 world.spawn_empty("Target", Transform { position: Vec3::new(3.0, 0.0, 0.0), ..Transform::default() });
1196 let reader = world.spawn_empty("Reader", Transform::default());
1197 let script_path = write_script(
1198 "read_other.rhai",
1199 r#"
1200 let on_update = |dt, input| {
1201 let other = world.find("Target");
1202 entity.x = other.x;
1203 };
1204 "#,
1205 );
1206 world.scripts.insert(reader, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1207
1208 let mut runtime = test_runtime();
1209 assert!(runtime.start_script(&mut world, reader, 0, &script_path, None).is_empty(), "script should start");
1210 runtime.begin_frame(&world);
1211
1212 let input = no_input();
1213 assert!(runtime.update_entity(&mut world, reader, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1214
1215 assert_eq!(world.transforms.get(reader).unwrap().position.x, 3.0);
1216 }
1217
1218 #[test]
1219 fn on_update_writes_another_entitys_position_via_find() {
1220 let mut world = World::new(test_camera());
1221 let target = world.spawn_empty("Target", Transform::default());
1222 let mover = world.spawn_empty("Mover", Transform::default());
1223 let script_path = write_script(
1224 "move_other.rhai",
1225 r#"
1226 let on_update = |dt, input| {
1227 world.find("Target").translate(1.0, 0.0, 0.0);
1228 };
1229 "#,
1230 );
1231 world.scripts.insert(mover, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1232
1233 let mut runtime = test_runtime();
1234 assert!(runtime.start_script(&mut world, mover, 0, &script_path, None).is_empty(), "script should start");
1235 runtime.begin_frame(&world);
1236
1237 let input = no_input();
1238 assert!(runtime.update_entity(&mut world, mover, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1239
1240 assert_eq!(world.transforms.get(target).unwrap().position.x, 1.0);
1241 }
1242
1243 #[test]
1244 fn find_on_a_missing_name_returns_unit_rather_than_erroring() {
1245 let mut world = World::new(test_camera());
1246 let entity = world.spawn_empty("Lonely", Transform::default());
1247 let script_path = write_script(
1248 "find_missing.rhai",
1249 r#"
1250 let on_update = |dt, input| {
1251 let missing = world.find("NoSuchEntity");
1252 if missing == () {
1253 entity.x = 42.0;
1254 }
1255 };
1256 "#,
1257 );
1258 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1259
1260 let mut runtime = test_runtime();
1261 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1262 runtime.begin_frame(&world);
1263
1264 let input = no_input();
1265 assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1266
1267 assert_eq!(world.transforms.get(entity).unwrap().position.x, 42.0);
1268 }
1269
1270 #[test]
1271 fn cross_entity_reads_are_frozen_at_frame_start() {
1272 let mut world = World::new(test_camera());
1275 let a = world.spawn_empty("A", Transform::default());
1276 let b = world.spawn_empty("B", Transform::default());
1277 let a_script = write_script(
1278 "a_moves.rhai",
1279 r#"let on_update = |dt, input| { entity.translate(10.0, 0.0, 0.0); };"#,
1280 );
1281 let b_script = write_script(
1282 "b_reads_a.rhai",
1283 r#"let on_update = |dt, input| { entity.x = world.find("A").x; };"#,
1284 );
1285 world.scripts.insert(a, ScriptList(vec![ScriptAttachment { path: a_script.clone(), enabled: true }]));
1286 world.scripts.insert(b, ScriptList(vec![ScriptAttachment { path: b_script.clone(), enabled: true }]));
1287
1288 let mut runtime = test_runtime();
1289 assert!(runtime.start_script(&mut world, a, 0, &a_script, None).is_empty(), "a should start");
1290 assert!(runtime.start_script(&mut world, b, 0, &b_script, None).is_empty(), "b should start");
1291 runtime.begin_frame(&world);
1292
1293 let input = no_input();
1294 assert!(runtime.update_entity(&mut world, a, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1295 assert!(runtime.update_entity(&mut world, b, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1296
1297 assert_eq!(world.transforms.get(a).unwrap().position.x, 10.0, "A should have moved");
1298 assert_eq!(
1299 world.transforms.get(b).unwrap().position.x,
1300 0.0,
1301 "B's read of A via find() should reflect A's position at the start of the frame, not A's move this same frame"
1302 );
1303 }
1304
1305 #[test]
1306 fn on_update_despawns_another_entity() {
1307 let mut world = World::new(test_camera());
1308 let target = world.spawn_empty("Target", Transform::default());
1309 let killer = world.spawn_empty("Killer", Transform::default());
1310 let script_path = write_script(
1311 "despawn_other.rhai",
1312 r#"let on_update = |dt, input| { world.find("Target").despawn(); };"#,
1313 );
1314 world.scripts.insert(killer, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1315
1316 let mut runtime = test_runtime();
1317 assert!(runtime.start_script(&mut world, killer, 0, &script_path, None).is_empty(), "script should start");
1318 runtime.begin_frame(&world);
1319
1320 let input = no_input();
1321 assert!(runtime.update_entity(&mut world, killer, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1322
1323 assert!(!world.is_alive(target));
1324 }
1325
1326 #[test]
1327 fn self_despawn_stops_remaining_attachments_this_frame() {
1328 let mut world = World::new(test_camera());
1329 let entity = world.spawn_empty("SelfDestruct", Transform::default());
1330 let despawn_script = write_script("despawn_self.rhai", r#"let on_update = |dt, input| { entity.despawn(); };"#);
1331 let mover_script = write_script(
1332 "mover_after_despawn.rhai",
1333 r#"let on_update = |dt, input| { entity.translate(1.0, 0.0, 0.0); };"#,
1334 );
1335 world.scripts.insert(
1336 entity,
1337 ScriptList(vec![
1338 ScriptAttachment { path: despawn_script.clone(), enabled: true },
1339 ScriptAttachment { path: mover_script.clone(), enabled: true },
1340 ]),
1341 );
1342
1343 let mut runtime = test_runtime();
1344 assert!(runtime.start_script(&mut world, entity, 0, &despawn_script, None).is_empty(), "script 0 should start");
1345 assert!(runtime.start_script(&mut world, entity, 1, &mover_script, None).is_empty(), "script 1 should start");
1346 runtime.begin_frame(&world);
1347
1348 let input = no_input();
1349 assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1350
1351 assert!(!world.is_alive(entity), "entity should be despawned");
1352 }
1353
1354 #[test]
1355 fn world_spawn_returns_a_handle_usable_in_the_same_call() {
1356 let mut world = World::new(test_camera());
1357 let spawner = world.spawn_empty("Spawner", Transform::default());
1358 let script_path = write_script(
1359 "spawn_and_move.rhai",
1360 r#"
1361 let on_update = |dt, input| {
1362 let bullet = world.spawn_entity("Bullet", 1.0, 2.0, 3.0);
1363 bullet.translate(1.0, 0.0, 0.0);
1364 };
1365 "#,
1366 );
1367 world.scripts.insert(spawner, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1368
1369 let mut runtime = test_runtime();
1370 assert!(runtime.start_script(&mut world, spawner, 0, &script_path, None).is_empty(), "script should start");
1371 runtime.begin_frame(&world);
1372
1373 let input = no_input();
1374 assert!(runtime.update_entity(&mut world, spawner, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1375
1376 let spawned = world
1377 .iter_entities()
1378 .find(|&e| e != spawner && world.names.get(e).is_some_and(|n| n.0 == "Bullet"))
1379 .expect("a Bullet entity should have been spawned");
1380 let position = world.transforms.get(spawned).unwrap().position;
1381 assert!(
1382 position.abs_diff_eq(Vec3::new(2.0, 2.0, 3.0), 1e-5),
1383 "expected spawn position (1,2,3) plus a same-call translate(1,0,0), got {position:?}"
1384 );
1385 }
1386
1387 #[test]
1388 fn on_update_renames_self_and_another_entity() {
1389 let mut world = World::new(test_camera());
1390 let other = world.spawn_empty("Old Name", Transform::default());
1391 let renamer = world.spawn_empty("Renamer", Transform::default());
1392 let script_path = write_script(
1393 "rename.rhai",
1394 r#"
1395 let on_update = |dt, input| {
1396 entity.set_name("New Renamer Name");
1397 world.find("Old Name").set_name("New Name");
1398 };
1399 "#,
1400 );
1401 world.scripts.insert(renamer, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1402
1403 let mut runtime = test_runtime();
1404 assert!(runtime.start_script(&mut world, renamer, 0, &script_path, None).is_empty(), "script should start");
1405 runtime.begin_frame(&world);
1406
1407 let input = no_input();
1408 assert!(runtime.update_entity(&mut world, renamer, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1409
1410 assert_eq!(world.names.get(renamer).unwrap().0, "New Renamer Name");
1411 assert_eq!(world.names.get(other).unwrap().0, "New Name");
1412 }
1413
1414 #[test]
1415 fn attach_script_starts_running_within_the_same_session() {
1416 let mut world = World::new(test_camera());
1417 let entity = world.spawn_empty("LateBloomer", Transform::default());
1418 let mover_script = write_script("late_mover.rhai", r#"let on_update = |dt, input| { entity.translate(1.0, 0.0, 0.0); };"#);
1419 let attacher_script = write_script(
1420 "attacher.rhai",
1421 &format!(
1422 r#"let on_update = |dt, input| {{ entity.attach_script("{}"); }};"#,
1423 mover_script.display().to_string().replace('\\', "\\\\")
1424 ),
1425 );
1426 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: attacher_script.clone(), enabled: true }]));
1427
1428 let mut runtime = test_runtime();
1429 assert!(runtime.start_script(&mut world, entity, 0, &attacher_script, None).is_empty(), "attacher should start");
1430 runtime.begin_frame(&world);
1431
1432 let input = no_input();
1433 assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1436 assert_eq!(
1437 world.scripts.get(entity).unwrap().0.len(),
1438 2,
1439 "the attached mover script should now be in the entity's ScriptList"
1440 );
1441
1442 runtime.begin_frame(&world);
1445 assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1446 assert!(
1447 world.transforms.get(entity).unwrap().position.x > 0.0,
1448 "the attached script should have moved the entity by its second frame"
1449 );
1450 }
1451
1452 #[test]
1460 fn set_sprite_without_a_renderer_reports_an_error_instead_of_panicking() {
1461 let mut world = World::new(test_camera());
1462 let entity = world.spawn_empty("NeedsASprite", Transform::default());
1463 let script_path = write_script(
1464 "set_sprite.rhai",
1465 r#"let on_update = |dt, input| { entity.set_sprite("assets/textures/missing.png"); };"#,
1466 );
1467 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1468
1469 let mut runtime = test_runtime();
1470 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1471 runtime.begin_frame(&world);
1472
1473 let errors = runtime.update_entity(&mut world, entity, 0.1, &no_input(), &no_mouse(), (0.0, 0.0), None);
1474 assert_eq!(errors.len(), 1, "expected one error reporting the missing renderer, got {errors:?}");
1475 assert!(world.renderables.get(entity).is_none(), "nothing should have been attached");
1476 }
1477
1478 #[test]
1479 fn set_model_without_a_renderer_reports_an_error_instead_of_panicking() {
1480 let mut world = World::new(test_camera());
1481 let entity = world.spawn_empty("NeedsAModel", Transform::default());
1482 let script_path = write_script(
1483 "set_model.rhai",
1484 r#"let on_update = |dt, input| { entity.set_model("assets/models/missing.obj"); };"#,
1485 );
1486 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1487
1488 let mut runtime = test_runtime();
1489 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1490 runtime.begin_frame(&world);
1491
1492 let errors = runtime.update_entity(&mut world, entity, 0.1, &no_input(), &no_mouse(), (0.0, 0.0), None);
1493 assert_eq!(errors.len(), 1, "expected one error reporting the missing renderer, got {errors:?}");
1494 assert!(world.renderables.get(entity).is_none(), "nothing should have been attached");
1495 }
1496
1497 #[test]
1498 fn detach_renderable_needs_no_renderer() {
1499 let mut world = World::new(test_camera());
1500 let entity = world.spawn_empty("MaybeVisible", Transform::default());
1501 let script_path = write_script("detach.rhai", r#"let on_update = |dt, input| { entity.detach_renderable(); };"#);
1502 world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1503
1504 let mut runtime = test_runtime();
1505 assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1506 runtime.begin_frame(&world);
1507
1508 assert!(runtime.update_entity(&mut world, entity, 0.1, &no_input(), &no_mouse(), (0.0, 0.0), None).is_empty());
1512 assert!(world.renderables.get(entity).is_none());
1513 }
1514
1515 #[test]
1516 fn scene_change_tears_down_non_persistent_entities_but_keeps_persistent_ones_running() {
1517 use crate::scene_file::{EntityRecord, SceneFile};
1518
1519 let mut world = World::new(test_camera());
1520 let persistent_entity = world.spawn_empty("Persistent", Transform::default());
1521 let transient_entity = world.spawn_empty("Transient", Transform::default());
1522 let trigger_entity = world.spawn_empty("Trigger", Transform::default());
1523
1524 let persistent_script = write_script(
1528 "persistent.rhai",
1529 r#"
1530 let counter = 0.0;
1531 let on_start = || { entity.set_persistent(true); };
1532 let on_update = |dt, input| {
1533 counter += 1.0;
1534 entity.x = counter;
1535 };
1536 "#,
1537 );
1538 world.scripts.insert(
1539 persistent_entity,
1540 ScriptList(vec![ScriptAttachment { path: persistent_script.clone(), enabled: true }]),
1541 );
1542
1543 let new_entity_script = write_script(
1546 "new_entity.rhai",
1547 r#"let on_start = || { entity.set_name("Started"); };"#,
1548 );
1549 let target_scene = write_scene(
1550 "target.ron",
1551 &SceneFile {
1552 entities: vec![EntityRecord {
1553 name: "NewEntity".to_string(),
1554 transform: Transform::default(),
1555 renderable: None,
1556 scripts: vec![ScriptAttachment { path: new_entity_script.clone(), enabled: true }],
1557 camera: None,
1558 }],
1559 },
1560 );
1561
1562 let trigger_script = write_script(
1563 "trigger.rhai",
1564 &format!(
1565 r#"let on_update = |dt, input| {{ scene.change("{}"); }};"#,
1566 target_scene.display().to_string().replace('\\', "\\\\")
1567 ),
1568 );
1569 world.scripts.insert(trigger_entity, ScriptList(vec![ScriptAttachment { path: trigger_script.clone(), enabled: true }]));
1570
1571 let mut runtime = test_runtime();
1572 assert!(runtime.start_script(&mut world, persistent_entity, 0, &persistent_script, None).is_empty(), "persistent script should start");
1576 assert!(world.is_persistent(persistent_entity), "entity.set_persistent(true) from on_start should have applied immediately");
1577 assert!(runtime.start_script(&mut world, trigger_entity, 0, &trigger_script, None).is_empty(), "trigger script should start");
1578 runtime.begin_frame(&world);
1579
1580 let input = no_input();
1581
1582 assert!(runtime.update_entity(&mut world, persistent_entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1584
1585 assert!(runtime.update_entity(&mut world, trigger_entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1588
1589 assert!(!world.is_alive(transient_entity), "non-persistent entities should be despawned by the scene change");
1590 assert!(!world.is_alive(trigger_entity), "the entity that triggered the change is itself non-persistent");
1591 assert!(world.is_alive(persistent_entity), "a persistent entity should survive the scene change");
1592
1593 let new_entity = world
1596 .iter_entities()
1597 .find(|&e| world.names.get(e).is_some_and(|n| n.0 == "Started"))
1598 .expect("the new scene's entity should be spawned and have run its on_start");
1599 assert_ne!(new_entity, persistent_entity);
1600
1601 assert!(runtime.update_entity(&mut world, persistent_entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1604 assert_eq!(
1605 world.transforms.get(persistent_entity).unwrap().position.x,
1606 2.0,
1607 "persistent entity's script state should continue across the scene change, not reset"
1608 );
1609 assert_eq!(
1610 world.names.get(new_entity).unwrap().0,
1611 "Started",
1612 "the new scene's entity should have run its on_start"
1613 );
1614 }
1615}