1use std::collections::{HashMap, HashSet};
18
19use rhai::{AST, Array, Dynamic, Engine, Map, Scope};
20
21use nightshade::ecs::event::{Event, events};
22use nightshade::ecs::script::components::ScriptSource;
23use nightshade::ecs::world::SCRIPT;
24use nightshade::prelude::{Entity, KeyCode, MouseState, Vec3, World, tracing};
25
26use crate::command::{Command, CommandReply, submit_commands};
27
28const TAG_ID_KEY: &str = "$id";
32const TAG_COMMAND_KEY: &str = "$cmd";
33
34#[derive(Clone, Default)]
39pub enum CommandPolicy {
40 #[default]
41 AllowAll,
42 Allow(HashSet<String>),
43}
44
45impl CommandPolicy {
46 fn allows(&self, name: &str) -> bool {
47 match self {
48 CommandPolicy::AllowAll => true,
49 CommandPolicy::Allow(allowed) => allowed.contains(name),
50 }
51 }
52}
53
54pub struct ScriptRuntime {
58 pub enabled: bool,
59 pub policy: CommandPolicy,
60 engine: Engine,
61 compiled: HashMap<u64, AST>,
62 scopes: HashMap<Entity, Scope<'static>>,
63 global_scopes: HashMap<String, Scope<'static>>,
64 started_globals: HashSet<String>,
65 started_entities: HashSet<Entity>,
66 next_replies: Map,
67 assets: HashMap<String, Vec<u8>>,
68 random_state: u64,
69}
70
71const RANDOM_SEED: u64 = 0x853c_49e6_748f_ea9b;
74
75fn new_engine() -> Engine {
79 let mut engine = Engine::new();
80 engine.set_max_operations(200_000);
81 engine.set_max_call_levels(64);
82 engine.set_max_string_size(8 * 1024);
83 engine.set_max_array_size(4096);
84 engine.set_max_expr_depths(0, 0);
85 register_api(&mut engine);
86 engine
87}
88
89impl Default for ScriptRuntime {
90 fn default() -> Self {
91 let engine = new_engine();
92 Self {
93 enabled: false,
94 policy: CommandPolicy::AllowAll,
95 engine,
96 compiled: HashMap::new(),
97 scopes: HashMap::new(),
98 global_scopes: HashMap::new(),
99 started_globals: HashSet::new(),
100 started_entities: HashSet::new(),
101 next_replies: Map::new(),
102 assets: HashMap::new(),
103 random_state: RANDOM_SEED,
104 }
105 }
106}
107
108#[derive(Default)]
113pub struct ScriptReport {
114 pub commands: Vec<Command>,
115 pub errors: Vec<String>,
116 pub logs: Vec<String>,
117}
118
119thread_local! {
120 static SCRIPT_RNG: std::cell::Cell<u64> = const { std::cell::Cell::new(RANDOM_SEED) };
126
127 static SCRIPT_LOGS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
132}
133
134fn next_unit_random() -> f64 {
137 SCRIPT_RNG.with(|cell| {
138 let mut state = cell.get();
139 state ^= state << 13;
140 state ^= state >> 7;
141 state ^= state << 17;
142 cell.set(state);
143 ((state >> 11) as f64) / ((1u64 << 53) as f64)
144 })
145}
146
147fn register_api(engine: &mut Engine) {
154 engine.register_fn("log", |message: &str| {
155 tracing::info!("[script] {message}");
156 SCRIPT_LOGS.with(|logs| logs.borrow_mut().push(message.to_string()));
157 });
158 engine.register_fn("random", next_unit_random);
161 engine.register_fn("random_range", |low: f64, high: f64| {
162 low + next_unit_random() * (high - low)
163 });
164 engine.register_fn("random_int", |low: i64, high: i64| {
165 if high <= low {
166 low
167 } else {
168 low + (next_unit_random() * ((high - low + 1) as f64)) as i64
169 }
170 });
171 register_mixed_number_ops(engine);
172 register_color_helpers(engine);
173 engine.register_fn("entity_ref", |packed: i64| {
174 let entity = script_to_entity(packed);
175 let mut map = Map::new();
176 map.insert("Existing".into(), Dynamic::from(entity.id as i64));
177 Dynamic::from_map(map)
178 });
179 engine.register_fn("result", |index: i64| {
183 let mut map = Map::new();
184 map.insert("Result".into(), Dynamic::from(index));
185 Dynamic::from_map(map)
186 });
187 engine.register_fn("last", |commands: &mut Array| {
191 let index = commands.len().saturating_sub(1) as i64;
192 let mut map = Map::new();
193 map.insert("Result".into(), Dynamic::from(index));
194 Dynamic::from_map(map)
195 });
196 engine.register_fn("tag", |commands: &mut Array, id: rhai::ImmutableString| {
201 if let Some(last) = commands.last_mut() {
202 let command = std::mem::take(last);
203 let mut request = Map::new();
204 request.insert(TAG_ID_KEY.into(), Dynamic::from(id));
205 request.insert(TAG_COMMAND_KEY.into(), command);
206 *last = Dynamic::from_map(request);
207 }
208 });
209 crate::command::register_command_methods(engine);
210}
211
212fn register_mixed_number_ops(engine: &mut Engine) {
218 engine.register_fn("+", |a: i64, b: f64| a as f64 + b);
219 engine.register_fn("+", |a: f64, b: i64| a + b as f64);
220 engine.register_fn("-", |a: i64, b: f64| a as f64 - b);
221 engine.register_fn("-", |a: f64, b: i64| a - b as f64);
222 engine.register_fn("*", |a: i64, b: f64| a as f64 * b);
223 engine.register_fn("*", |a: f64, b: i64| a * b as f64);
224 engine.register_fn("/", |a: i64, b: f64| a as f64 / b);
225 engine.register_fn("/", |a: f64, b: i64| a / b as f64);
226 engine.register_fn("%", |a: i64, b: f64| a as f64 % b);
227 engine.register_fn("%", |a: f64, b: i64| a % b as f64);
228 engine.register_fn("<", |a: i64, b: f64| (a as f64) < b);
229 engine.register_fn("<", |a: f64, b: i64| a < b as f64);
230 engine.register_fn(">", |a: i64, b: f64| a as f64 > b);
231 engine.register_fn(">", |a: f64, b: i64| a > b as f64);
232 engine.register_fn("<=", |a: i64, b: f64| a as f64 <= b);
233 engine.register_fn("<=", |a: f64, b: i64| a <= b as f64);
234 engine.register_fn(">=", |a: i64, b: f64| a as f64 >= b);
235 engine.register_fn(">=", |a: f64, b: i64| a >= b as f64);
236}
237
238fn register_color_helpers(engine: &mut Engine) {
242 engine.register_fn("rgb", |r: f64, g: f64, b: f64| -> Array {
243 vec![
244 Dynamic::from(r),
245 Dynamic::from(g),
246 Dynamic::from(b),
247 Dynamic::from(1.0_f64),
248 ]
249 });
250 engine.register_fn("rgba", |r: f64, g: f64, b: f64, a: f64| -> Array {
251 vec![
252 Dynamic::from(r),
253 Dynamic::from(g),
254 Dynamic::from(b),
255 Dynamic::from(a),
256 ]
257 });
258 engine.register_fn("hsv", |h: f64, s: f64, v: f64| -> Array {
259 let (r, g, b) = hsv_to_rgb(h, s, v);
260 vec![
261 Dynamic::from(r),
262 Dynamic::from(g),
263 Dynamic::from(b),
264 Dynamic::from(1.0_f64),
265 ]
266 });
267}
268
269fn hsv_to_rgb(hue: f64, saturation: f64, value: f64) -> (f64, f64, f64) {
272 let sector = hue.rem_euclid(1.0) * 6.0;
273 let chroma = value * saturation;
274 let secondary = chroma * (1.0 - (sector % 2.0 - 1.0).abs());
275 let base = value - chroma;
276 let (red, green, blue) = match sector as i64 {
277 0 => (chroma, secondary, 0.0),
278 1 => (secondary, chroma, 0.0),
279 2 => (0.0, chroma, secondary),
280 3 => (0.0, secondary, chroma),
281 4 => (secondary, 0.0, chroma),
282 _ => (chroma, 0.0, secondary),
283 };
284 (red + base, green + base, blue + base)
285}
286
287fn ast_defines_hook(ast: &AST, name: &str) -> bool {
290 ast.iter_functions()
291 .any(|function| function.name == name && function.params.is_empty())
292}
293
294fn reply_to_dynamic(reply: &CommandReply) -> Dynamic {
299 match reply {
300 CommandReply::None => Dynamic::UNIT,
301 CommandReply::Entity(entity) => Dynamic::from(entity_to_script(*entity)),
302 CommandReply::Bool(value) => Dynamic::from(*value),
303 CommandReply::Float(value) => Dynamic::from(*value as f64),
304 CommandReply::Int(value) => Dynamic::from(*value),
305 CommandReply::Text(value) => Dynamic::from(value.clone()),
306 CommandReply::Vector(value) => {
307 Dynamic::from_array(array3(Vec3::new(value[0], value[1], value[2])))
308 }
309 CommandReply::Entities(entities) => Dynamic::from_array(
310 entities
311 .iter()
312 .map(|entity| Dynamic::from(entity_to_script(*entity)))
313 .collect(),
314 ),
315 CommandReply::Strings(strings) => Dynamic::from_array(
316 strings
317 .iter()
318 .map(|value| Dynamic::from(value.clone()))
319 .collect(),
320 ),
321 CommandReply::Bytes(bytes) => Dynamic::from_blob(bytes.clone()),
322 CommandReply::Json(value) => rhai::serde::to_dynamic(value).unwrap_or(Dynamic::UNIT),
323 CommandReply::Error(message) => {
324 let mut map = Map::new();
325 map.insert("error".into(), Dynamic::from(message.clone()));
326 Dynamic::from_map(map)
327 }
328 }
329}
330
331fn entity_to_script(entity: Entity) -> i64 {
334 (((entity.id as u64) << 32) | entity.generation as u64) as i64
335}
336
337fn script_to_entity(value: i64) -> Entity {
339 let bits = value as u64;
340 Entity {
341 id: (bits >> 32) as u32,
342 generation: bits as u32,
343 }
344}
345
346fn hash_source(source: &str) -> u64 {
347 use std::hash::{Hash, Hasher};
348 let mut hasher = std::collections::hash_map::DefaultHasher::new();
349 source.hash(&mut hasher);
350 hasher.finish()
351}
352
353fn compiled_ast<'a>(
354 runtime: &'a mut ScriptRuntime,
355 source: &str,
356 errors: &mut Vec<String>,
357) -> Option<&'a AST> {
358 let key = hash_source(source);
359 if !runtime.compiled.contains_key(&key) {
360 match runtime.engine.compile(source) {
361 Ok(ast) => {
362 runtime.compiled.insert(key, ast);
363 }
364 Err(error) => {
365 tracing::error!("script compile error: {error}");
366 errors.push(format!("compile error: {error}"));
367 return None;
368 }
369 }
370 }
371 runtime.compiled.get(&key)
372}
373
374pub fn script_runtime_forget_entity(runtime: &mut ScriptRuntime, entity: Entity) {
376 runtime.scopes.remove(&entity);
377 runtime.started_entities.remove(&entity);
378}
379
380pub fn script_check(source: &str) -> Result<(), String> {
384 new_engine()
385 .compile(source)
386 .map(|_| ())
387 .map_err(|error| error.to_string())
388}
389
390pub fn script_runtime_set_max_operations(runtime: &mut ScriptRuntime, max: u64) {
394 runtime.engine.set_max_operations(max);
395}
396
397pub fn script_runtime_set_max_array_size(runtime: &mut ScriptRuntime, max: usize) {
400 runtime.engine.set_max_array_size(max);
401}
402
403pub fn script_runtime_set_asset(runtime: &mut ScriptRuntime, name: &str, bytes: Vec<u8>) {
407 runtime.assets.insert(name.to_string(), bytes);
408}
409
410pub fn script_runtime_reset(runtime: &mut ScriptRuntime) {
413 runtime.compiled.clear();
414 runtime.scopes.clear();
415 runtime.global_scopes.clear();
416 runtime.started_globals.clear();
417 runtime.started_entities.clear();
418 runtime.next_replies.clear();
419 runtime.random_state = RANDOM_SEED;
420}
421
422pub fn run_scripts(world: &mut World, runtime: &mut ScriptRuntime) -> ScriptReport {
427 SCRIPT_RNG.with(|cell| cell.set(runtime.random_state));
428 SCRIPT_LOGS.with(|logs| logs.borrow_mut().clear());
429
430 let frame_events: Vec<Event> = events(world).to_vec();
431
432 for event in &frame_events {
433 if let Event::Despawned { entity } = event {
434 script_runtime_forget_entity(runtime, *entity);
435 }
436 }
437
438 let policy = runtime.policy.clone();
439 let dt = world.resources.window.timing.delta_time as f64;
440 let time = world.resources.window.timing.uptime_milliseconds as f64 / 1000.0;
441 let keyboard = &world.resources.input.keyboard;
442 let move_left =
443 keyboard.is_key_pressed(KeyCode::ArrowLeft) || keyboard.is_key_pressed(KeyCode::KeyA);
444 let move_right =
445 keyboard.is_key_pressed(KeyCode::ArrowRight) || keyboard.is_key_pressed(KeyCode::KeyD);
446 let launch = keyboard.is_key_pressed(KeyCode::Space);
447
448 let mut keys = Map::new();
453 for keycode in keyboard.keystates.keys() {
454 if keyboard.is_key_pressed(*keycode) {
455 keys.insert(format!("{keycode:?}").into(), Dynamic::from(true));
456 }
457 }
458 let mut pressed = Map::new();
459 for keycode in &keyboard.just_pressed_keys {
460 pressed.insert(format!("{keycode:?}").into(), Dynamic::from(true));
461 }
462 let mouse = &world.resources.input.mouse;
463 let mut mouse_map = Map::new();
464 mouse_map.insert("x".into(), Dynamic::from(mouse.position.x as f64));
465 mouse_map.insert("y".into(), Dynamic::from(mouse.position.y as f64));
466 mouse_map.insert(
467 "left".into(),
468 Dynamic::from(mouse.state.contains(MouseState::LEFT_CLICKED)),
469 );
470 mouse_map.insert(
471 "right".into(),
472 Dynamic::from(mouse.state.contains(MouseState::RIGHT_CLICKED)),
473 );
474 mouse_map.insert(
475 "middle".into(),
476 Dynamic::from(mouse.state.contains(MouseState::MIDDLE_CLICKED)),
477 );
478 mouse_map.insert(
479 "left_pressed".into(),
480 Dynamic::from(mouse.state.contains(MouseState::LEFT_JUST_PRESSED)),
481 );
482 mouse_map.insert(
483 "right_pressed".into(),
484 Dynamic::from(mouse.state.contains(MouseState::RIGHT_JUST_PRESSED)),
485 );
486 mouse_map.insert("scroll".into(), Dynamic::from(mouse.wheel_delta.y as f64));
487
488 #[cfg(feature = "picking")]
491 let ground_hit = nightshade::prelude::get_ground_position_from_screen(
492 world,
493 world.resources.input.mouse.position,
494 0.0,
495 );
496 #[cfg(not(feature = "picking"))]
497 let ground_hit: Option<Vec3> = None;
498 let ground = match ground_hit {
499 Some(point) => array3(point),
500 None => Array::new(),
501 };
502 let pointer_over_ui = world
503 .resources
504 .retained_ui
505 .interaction
506 .hovered_entity
507 .is_some();
508
509 let mut named = Map::new();
510 let mut positions = Map::new();
511 let mut velocities = Map::new();
512 for (name, entity) in &world.resources.entities.names {
513 named.insert(
514 name.as_str().into(),
515 Dynamic::from(entity_to_script(*entity)),
516 );
517 let (position, velocity) = entity_pos_vel(world, *entity);
518 positions.insert(name.as_str().into(), Dynamic::from(position));
519 velocities.insert(name.as_str().into(), Dynamic::from(velocity));
520 }
521
522 let mut tag_lists: HashMap<String, Array> = HashMap::new();
525 for (entity, tags) in &world.resources.entities.tags {
526 let (position, velocity) = entity_pos_vel(world, *entity);
527 let mut record = Map::new();
528 record.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
529 record.insert("pos".into(), Dynamic::from(position));
530 record.insert("vel".into(), Dynamic::from(velocity));
531 let record = Dynamic::from_map(record);
532 for tag in tags {
533 tag_lists
534 .entry(tag.clone())
535 .or_default()
536 .push(record.clone());
537 }
538 }
539 let mut tagged = Map::new();
540 for (tag, list) in tag_lists {
541 tagged.insert(tag.as_str().into(), Dynamic::from(list));
542 }
543
544 let mut widgets = Map::new();
548 for (entity, tags) in &world.resources.entities.tags {
549 if let Some(value) = widget_value(world, *entity) {
550 for tag in tags {
551 widgets.insert(tag.as_str().into(), value.clone());
552 }
553 }
554 }
555 #[cfg(feature = "picking")]
556 let picked = match crate::picking::clicked_entity(world) {
557 Some(entity) => Dynamic::from(entity_to_script(entity)),
558 None => Dynamic::UNIT,
559 };
560 #[cfg(not(feature = "picking"))]
561 let picked = Dynamic::UNIT;
562 let mut camera = Map::new();
563 camera.insert(
564 "pos".into(),
565 Dynamic::from_array(array3(crate::camera::camera_position(world))),
566 );
567 camera.insert(
568 "forward".into(),
569 Dynamic::from_array(array3(crate::camera::camera_forward(world))),
570 );
571
572 let mut produced: Vec<Command> = Vec::new();
573 let mut result_tags: Vec<(usize, String)> = Vec::new();
574 let mut errors: Vec<String> = Vec::new();
575 let replies = std::mem::take(&mut runtime.next_replies);
576
577 let global_scripts: Vec<(String, String)> = world
578 .resources
579 .global_scripts
580 .entries
581 .iter()
582 .filter(|script| script.enabled && !script.source.trim().is_empty())
583 .map(|script| (script.name.clone(), script.source.clone()))
584 .collect();
585
586 for (name, source) in &global_scripts {
587 let Some(ast) = compiled_ast(runtime, source, &mut errors).cloned() else {
588 continue;
589 };
590 let run_start =
591 !runtime.started_globals.contains(name) && ast_defines_hook(&ast, "on_start");
592 runtime.started_globals.insert(name.clone());
593 let scope = runtime.global_scopes.entry(name.clone()).or_default();
594 if !scope.contains("state") {
595 scope.set_value("state", Map::new());
596 }
597 if !scope.contains("assets") {
598 let mut assets = Map::new();
599 for (name, bytes) in &runtime.assets {
600 assets.insert(name.as_str().into(), Dynamic::from_blob(bytes.clone()));
601 }
602 scope.set_value("assets", assets);
603 }
604 scope.set_value("dt", dt);
605 scope.set_value("time", time);
606 scope.set_value("tau", std::f64::consts::TAU);
607 scope.set_value("pi", std::f64::consts::PI);
608 scope.set_value("replies", replies.clone());
609 scope.set_value("move_left", move_left);
610 scope.set_value("move_right", move_right);
611 scope.set_value("launch", launch);
612 scope.set_value("keys", keys.clone());
613 scope.set_value("pressed", pressed.clone());
614 scope.set_value("mouse", mouse_map.clone());
615 scope.set_value("named", named.clone());
616 scope.set_value("positions", positions.clone());
617 scope.set_value("velocities", velocities.clone());
618 scope.set_value("tagged", tagged.clone());
619 scope.set_value("widgets", widgets.clone());
620 scope.set_value("picked", picked.clone());
621 scope.set_value("camera", camera.clone());
622 scope.set_value("ground", ground.clone());
623 scope.set_value("pointer_over_ui", pointer_over_ui);
624 scope.set_value("events", events_array(&frame_events));
625 scope.set_value("commands", Array::new());
626
627 if run_start {
628 if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_start", ()) {
629 tracing::warn!("global script '{name}' on_start error: {error}");
630 errors.push(format!("on_start error: {error}"));
631 } else {
632 collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
633 }
634 scope.set_value("commands", Array::new());
635 }
636
637 if ast_defines_hook(&ast, "on_tick") {
638 if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_tick", ()) {
639 tracing::warn!("global script '{name}' on_tick error: {error}");
640 errors.push(format!("on_tick error: {error}"));
641 continue;
642 }
643 collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
644 }
645 }
646
647 let mut scripted: Vec<(Entity, String)> = Vec::new();
648 for entity in world.core.query_entities(SCRIPT).collect::<Vec<_>>() {
649 if let Some(script) = world.core.get_script(entity)
650 && script.enabled
651 && let ScriptSource::Embedded { source } = &script.source
652 {
653 scripted.push((entity, source.clone()));
654 }
655 }
656
657 for (entity, source) in &scripted {
658 let position = world
659 .core
660 .get_local_transform(*entity)
661 .map(|transform| transform.translation)
662 .unwrap_or_else(Vec3::zeros);
663 let velocity = nightshade::ecs::physics::resources::physics_world_linear_velocity(
664 &world.resources.physics,
665 *entity,
666 )
667 .unwrap_or_else(Vec3::zeros);
668 let entity_events = relevant_events(&frame_events, *entity);
669
670 let Some(ast) = compiled_ast(runtime, source, &mut errors).cloned() else {
671 continue;
672 };
673 let run_start =
674 !runtime.started_entities.contains(entity) && ast_defines_hook(&ast, "on_start");
675 runtime.started_entities.insert(*entity);
676 let scope = runtime.scopes.entry(*entity).or_default();
677 if !scope.contains("state") {
678 scope.set_value("state", Map::new());
679 }
680 if !scope.contains("assets") {
681 let mut assets = Map::new();
682 for (name, bytes) in &runtime.assets {
683 assets.insert(name.as_str().into(), Dynamic::from_blob(bytes.clone()));
684 }
685 scope.set_value("assets", assets);
686 }
687 scope.set_value("self", entity_to_script(*entity));
688 scope.set_value("dt", dt);
689 scope.set_value("time", time);
690 scope.set_value("tau", std::f64::consts::TAU);
691 scope.set_value("pi", std::f64::consts::PI);
692 scope.set_value("replies", replies.clone());
693 scope.set_value("move_left", move_left);
694 scope.set_value("move_right", move_right);
695 scope.set_value("launch", launch);
696 scope.set_value("keys", keys.clone());
697 scope.set_value("pressed", pressed.clone());
698 scope.set_value("mouse", mouse_map.clone());
699 scope.set_value("pos", array3(position));
700 scope.set_value("vel", array3(velocity));
701 scope.set_value("named", named.clone());
702 scope.set_value("positions", positions.clone());
703 scope.set_value("velocities", velocities.clone());
704 scope.set_value("tagged", tagged.clone());
705 scope.set_value("widgets", widgets.clone());
706 scope.set_value("picked", picked.clone());
707 scope.set_value("camera", camera.clone());
708 scope.set_value("ground", ground.clone());
709 scope.set_value("pointer_over_ui", pointer_over_ui);
710 scope.set_value("events", events_array(&entity_events));
711 scope.set_value("commands", Array::new());
712
713 if run_start {
714 if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_start", ()) {
715 tracing::warn!("script on_start error: {error}");
716 errors.push(format!("on_start error: {error}"));
717 } else {
718 collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
719 }
720 scope.set_value("commands", Array::new());
721 }
722
723 if ast_defines_hook(&ast, "on_tick") {
724 if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_tick", ()) {
725 tracing::warn!("script on_tick error: {error}");
726 errors.push(format!("on_tick error: {error}"));
727 continue;
728 }
729 collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
730 }
731 }
732
733 let replies_out = submit_commands(world, &produced);
734 let mut next_replies = Map::new();
735 for (index, id) in &result_tags {
736 if let Some(reply) = replies_out.get(*index) {
737 next_replies.insert(id.as_str().into(), reply_to_dynamic(reply));
738 }
739 }
740 runtime.next_replies = next_replies;
741 runtime.random_state = SCRIPT_RNG.with(|cell| cell.get());
742 let logs = SCRIPT_LOGS.with(|logs| std::mem::take(&mut *logs.borrow_mut()));
743 ScriptReport {
744 commands: produced,
745 errors,
746 logs,
747 }
748}
749
750fn collect_commands(
755 scope: &Scope<'static>,
756 produced: &mut Vec<Command>,
757 tags: &mut Vec<(usize, String)>,
758 errors: &mut Vec<String>,
759 policy: &CommandPolicy,
760) {
761 let Some(commands) = scope
762 .get("commands")
763 .and_then(|value| value.read_lock::<Array>())
764 else {
765 return;
766 };
767 for entry in commands.iter() {
768 let is_tagged = entry
769 .read_lock::<Map>()
770 .is_some_and(|map| map.contains_key(TAG_COMMAND_KEY));
771 let parsed = if is_tagged {
772 let request = entry.read_lock::<Map>().unwrap();
773 let id = request
774 .get(TAG_ID_KEY)
775 .and_then(|value| value.clone().into_string().ok());
776 match request.get(TAG_COMMAND_KEY) {
777 Some(command) => {
778 crate::dynamic_de::from_dynamic::<Command>(command).map(|c| (c, id))
779 }
780 None => continue,
781 }
782 } else {
783 crate::dynamic_de::from_dynamic::<Command>(entry).map(|c| (c, None))
784 };
785 match parsed {
786 Ok((command, id)) if policy.allows(command.name()) => {
787 if let Some(id) = id {
788 tags.push((produced.len(), id));
789 }
790 produced.push(command);
791 }
792 Ok((command, _)) => {
793 tracing::warn!(
794 "script command '{}' blocked by write policy",
795 command.name()
796 );
797 errors.push(format!(
798 "command '{}' blocked by write policy",
799 command.name()
800 ));
801 }
802 Err(error) => {
803 tracing::warn!("dropped malformed script command: {error}");
804 errors.push(format!("dropped command: {error}"));
805 }
806 }
807 }
808}
809
810fn entity_pos_vel(world: &World, entity: Entity) -> (Array, Array) {
813 let position = world
814 .core
815 .get_local_transform(entity)
816 .map(|transform| transform.translation)
817 .unwrap_or_else(Vec3::zeros);
818 let velocity = nightshade::ecs::physics::resources::physics_world_linear_velocity(
819 &world.resources.physics,
820 entity,
821 )
822 .unwrap_or_else(Vec3::zeros);
823 (array3(position), array3(velocity))
824}
825
826fn array3(value: Vec3) -> Array {
827 vec![
828 Dynamic::from(value.x as f64),
829 Dynamic::from(value.y as f64),
830 Dynamic::from(value.z as f64),
831 ]
832}
833
834fn array4(value: nightshade::prelude::Vec4) -> Array {
835 vec![
836 Dynamic::from(value.x as f64),
837 Dynamic::from(value.y as f64),
838 Dynamic::from(value.z as f64),
839 Dynamic::from(value.w as f64),
840 ]
841}
842
843fn widget_value(world: &World, entity: Entity) -> Option<Dynamic> {
849 use nightshade::prelude::{
850 ui_checkbox_value, ui_collapsing_header_open, ui_color_picker_color, ui_drag_value,
851 ui_dropdown_selected, ui_slider_value, ui_tab_bar_selected, ui_text_input_text,
852 ui_toggle_value,
853 };
854 if let Some(value) = ui_slider_value(world, entity) {
855 return Some(Dynamic::from(value as f64));
856 }
857 if let Some(value) = ui_drag_value(world, entity) {
858 return Some(Dynamic::from(value as f64));
859 }
860 if let Some(value) = ui_toggle_value(world, entity) {
861 return Some(Dynamic::from(value));
862 }
863 if let Some(value) = ui_checkbox_value(world, entity) {
864 return Some(Dynamic::from(value));
865 }
866 if let Some(value) = ui_dropdown_selected(world, entity) {
867 return Some(Dynamic::from(value as i64));
868 }
869 if let Some(value) = ui_tab_bar_selected(world, entity) {
870 return Some(Dynamic::from(value as i64));
871 }
872 if let Some(value) = ui_collapsing_header_open(world, entity) {
873 return Some(Dynamic::from(value));
874 }
875 if let Some(value) = ui_color_picker_color(world, entity) {
876 return Some(Dynamic::from_array(array4(value)));
877 }
878 if let Some(value) = ui_text_input_text(world, entity) {
879 return Some(Dynamic::from(value.to_string()));
880 }
881 None
882}
883
884fn relevant_events(frame_events: &[Event], entity: Entity) -> Vec<Event> {
887 frame_events
888 .iter()
889 .filter(|event| match event {
890 Event::Collision { a, b, .. } => *a == entity || *b == entity,
891 Event::Despawned { entity: target }
892 | Event::AnimationFinished { entity: target }
893 | Event::AnimationEvent { entity: target, .. }
894 | Event::NavigationArrived { entity: target } => *target == entity,
895 })
896 .cloned()
897 .collect()
898}
899
900fn events_array(frame_events: &[Event]) -> Array {
901 frame_events.iter().map(event_to_map).collect()
902}
903
904fn event_to_map(event: &Event) -> Dynamic {
905 let mut map = Map::new();
906 match event {
907 Event::Collision {
908 a,
909 b,
910 sensor,
911 started,
912 } => {
913 map.insert("kind".into(), Dynamic::from("collision".to_string()));
914 map.insert("a".into(), Dynamic::from(entity_to_script(*a)));
915 map.insert("b".into(), Dynamic::from(entity_to_script(*b)));
916 map.insert("sensor".into(), Dynamic::from(*sensor));
917 map.insert("started".into(), Dynamic::from(*started));
918 }
919 Event::Despawned { entity } => {
920 map.insert("kind".into(), Dynamic::from("despawned".to_string()));
921 map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
922 }
923 Event::AnimationFinished { entity } => {
924 map.insert(
925 "kind".into(),
926 Dynamic::from("animation_finished".to_string()),
927 );
928 map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
929 }
930 Event::AnimationEvent { entity, name } => {
931 map.insert("kind".into(), Dynamic::from("animation_event".to_string()));
932 map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
933 map.insert("name".into(), Dynamic::from(name.clone()));
934 }
935 Event::NavigationArrived { entity } => {
936 map.insert(
937 "kind".into(),
938 Dynamic::from("navigation_arrived".to_string()),
939 );
940 map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
941 }
942 }
943 Dynamic::from_map(map)
944}
945
946#[cfg(test)]
947mod tests {
948 use super::new_engine;
949
950 #[test]
951 fn mixed_integer_and_float_arithmetic_works() {
952 let engine = new_engine();
953 assert_eq!(engine.eval::<f64>("3 * 1.5").unwrap(), 4.5);
954 assert_eq!(engine.eval::<f64>("2.0 + 3").unwrap(), 5.0);
955 assert_eq!(engine.eval::<f64>("7 / 2.0").unwrap(), 3.5);
956 assert!(engine.eval::<bool>("2 < 2.5").unwrap());
957 assert!(engine.eval::<bool>("3.0 >= 3").unwrap());
958 }
959
960 #[test]
961 fn same_type_integer_division_stays_integer() {
962 let engine = new_engine();
963 assert_eq!(engine.eval::<i64>("7 / 2").unwrap(), 3);
964 }
965
966 #[test]
967 fn closures_capture_and_mutate_outer_state() {
968 let engine = new_engine();
969 let result = engine
970 .eval::<i64>(
971 r#"
972 let state = #{ n: 0 };
973 let bump = || { state.n += 1; };
974 bump.call();
975 bump.call();
976 state.n
977 "#,
978 )
979 .unwrap();
980 assert_eq!(result, 2);
981 }
982
983 #[test]
984 fn color_helpers_build_four_channel_arrays() {
985 let engine = new_engine();
986 assert_eq!(
987 engine
988 .eval::<rhai::Array>("rgb(0.5, 0.25, 0.1)")
989 .unwrap()
990 .len(),
991 4
992 );
993 assert_eq!(
994 engine
995 .eval::<rhai::Array>("rgba(0.1, 0.2, 0.3, 0.4)")
996 .unwrap()
997 .len(),
998 4
999 );
1000 assert_eq!(
1001 engine
1002 .eval::<rhai::Array>("hsv(0.0, 1.0, 1.0)")
1003 .unwrap()
1004 .len(),
1005 4
1006 );
1007 }
1008}