use std::collections::{HashMap, HashSet};
use rhai::{AST, Array, Dynamic, Engine, Map, Scope};
use nightshade::ecs::event::{Event, events};
use nightshade::ecs::script::components::ScriptSource;
use nightshade::ecs::world::SCRIPT;
use nightshade::prelude::{Entity, KeyCode, MouseState, Vec3, World, tracing};
use crate::command::{Command, submit_commands};
#[derive(Clone, Default)]
pub enum CommandPolicy {
#[default]
AllowAll,
Allow(HashSet<String>),
}
impl CommandPolicy {
fn allows(&self, name: &str) -> bool {
match self {
CommandPolicy::AllowAll => true,
CommandPolicy::Allow(allowed) => allowed.contains(name),
}
}
}
pub struct ScriptRuntime {
pub enabled: bool,
pub policy: CommandPolicy,
engine: Engine,
compiled: HashMap<u64, AST>,
scopes: HashMap<Entity, Scope<'static>>,
global_scopes: HashMap<String, Scope<'static>>,
}
impl Default for ScriptRuntime {
fn default() -> Self {
let mut engine = Engine::new();
engine.set_max_operations(200_000);
engine.set_max_call_levels(32);
engine.set_max_string_size(8 * 1024);
engine.set_max_array_size(4096);
register_api(&mut engine);
Self {
enabled: false,
policy: CommandPolicy::AllowAll,
engine,
compiled: HashMap::new(),
scopes: HashMap::new(),
global_scopes: HashMap::new(),
}
}
}
fn register_api(engine: &mut Engine) {
engine.register_fn("log", |message: &str| tracing::info!("[script] {message}"));
engine.register_fn("entity_ref", |packed: i64| {
let entity = script_to_entity(packed);
let mut map = Map::new();
map.insert("Existing".into(), Dynamic::from(entity.id as i64));
Dynamic::from_map(map)
});
}
fn entity_to_script(entity: Entity) -> i64 {
(((entity.id as u64) << 32) | entity.generation as u64) as i64
}
fn script_to_entity(value: i64) -> Entity {
let bits = value as u64;
Entity {
id: (bits >> 32) as u32,
generation: bits as u32,
}
}
fn hash_source(source: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
source.hash(&mut hasher);
hasher.finish()
}
fn compiled_ast<'a>(runtime: &'a mut ScriptRuntime, source: &str) -> Option<&'a AST> {
let key = hash_source(source);
if !runtime.compiled.contains_key(&key) {
match runtime.engine.compile(source) {
Ok(ast) => {
runtime.compiled.insert(key, ast);
}
Err(error) => {
tracing::error!("script compile error: {error}");
return None;
}
}
}
runtime.compiled.get(&key)
}
pub fn script_runtime_forget_entity(runtime: &mut ScriptRuntime, entity: Entity) {
runtime.scopes.remove(&entity);
}
pub fn script_runtime_reset(runtime: &mut ScriptRuntime) {
runtime.compiled.clear();
runtime.scopes.clear();
runtime.global_scopes.clear();
}
pub fn run_scripts(world: &mut World, runtime: &mut ScriptRuntime) -> Vec<Command> {
let frame_events: Vec<Event> = events(world).to_vec();
for event in &frame_events {
if let Event::Despawned { entity } = event {
script_runtime_forget_entity(runtime, *entity);
}
}
let policy = runtime.policy.clone();
let dt = world.resources.window.timing.delta_time as f64;
let keyboard = &world.resources.input.keyboard;
let move_left =
keyboard.is_key_pressed(KeyCode::ArrowLeft) || keyboard.is_key_pressed(KeyCode::KeyA);
let move_right =
keyboard.is_key_pressed(KeyCode::ArrowRight) || keyboard.is_key_pressed(KeyCode::KeyD);
let launch = keyboard.is_key_pressed(KeyCode::Space);
let mut keys = Map::new();
for keycode in keyboard.keystates.keys() {
if keyboard.is_key_pressed(*keycode) {
keys.insert(format!("{keycode:?}").into(), Dynamic::from(true));
}
}
let mut pressed = Map::new();
for keycode in &keyboard.just_pressed_keys {
pressed.insert(format!("{keycode:?}").into(), Dynamic::from(true));
}
let mouse = &world.resources.input.mouse;
let mut mouse_map = Map::new();
mouse_map.insert("x".into(), Dynamic::from(mouse.position.x as f64));
mouse_map.insert("y".into(), Dynamic::from(mouse.position.y as f64));
mouse_map.insert(
"left".into(),
Dynamic::from(mouse.state.contains(MouseState::LEFT_CLICKED)),
);
mouse_map.insert(
"right".into(),
Dynamic::from(mouse.state.contains(MouseState::RIGHT_CLICKED)),
);
mouse_map.insert(
"middle".into(),
Dynamic::from(mouse.state.contains(MouseState::MIDDLE_CLICKED)),
);
mouse_map.insert(
"left_pressed".into(),
Dynamic::from(mouse.state.contains(MouseState::LEFT_JUST_PRESSED)),
);
mouse_map.insert(
"right_pressed".into(),
Dynamic::from(mouse.state.contains(MouseState::RIGHT_JUST_PRESSED)),
);
mouse_map.insert("scroll".into(), Dynamic::from(mouse.wheel_delta.y as f64));
#[cfg(feature = "picking")]
let ground_hit = nightshade::prelude::get_ground_position_from_screen(
world,
world.resources.input.mouse.position,
0.0,
);
#[cfg(not(feature = "picking"))]
let ground_hit: Option<Vec3> = None;
let ground = match ground_hit {
Some(point) => array3(point),
None => Array::new(),
};
let pointer_over_ui = world
.resources
.retained_ui
.interaction
.hovered_entity
.is_some();
let mut named = Map::new();
let mut positions = Map::new();
let mut velocities = Map::new();
for (name, entity) in &world.resources.entities.names {
named.insert(
name.as_str().into(),
Dynamic::from(entity_to_script(*entity)),
);
let (position, velocity) = entity_pos_vel(world, *entity);
positions.insert(name.as_str().into(), Dynamic::from(position));
velocities.insert(name.as_str().into(), Dynamic::from(velocity));
}
let mut tag_lists: HashMap<String, Array> = HashMap::new();
for (entity, tags) in &world.resources.entities.tags {
let (position, velocity) = entity_pos_vel(world, *entity);
let mut record = Map::new();
record.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
record.insert("pos".into(), Dynamic::from(position));
record.insert("vel".into(), Dynamic::from(velocity));
let record = Dynamic::from_map(record);
for tag in tags {
tag_lists
.entry(tag.clone())
.or_default()
.push(record.clone());
}
}
let mut tagged = Map::new();
for (tag, list) in tag_lists {
tagged.insert(tag.as_str().into(), Dynamic::from(list));
}
let mut produced: Vec<Command> = Vec::new();
let global_scripts: Vec<(String, String)> = world
.resources
.global_scripts
.entries
.iter()
.filter(|script| script.enabled && !script.source.trim().is_empty())
.map(|script| (script.name.clone(), script.source.clone()))
.collect();
for (name, source) in &global_scripts {
let Some(ast) = compiled_ast(runtime, source).cloned() else {
continue;
};
let scope = runtime.global_scopes.entry(name.clone()).or_default();
if !scope.contains("state") {
scope.set_value("state", Map::new());
}
scope.set_value("dt", dt);
scope.set_value("move_left", move_left);
scope.set_value("move_right", move_right);
scope.set_value("launch", launch);
scope.set_value("keys", keys.clone());
scope.set_value("pressed", pressed.clone());
scope.set_value("mouse", mouse_map.clone());
scope.set_value("named", named.clone());
scope.set_value("positions", positions.clone());
scope.set_value("velocities", velocities.clone());
scope.set_value("tagged", tagged.clone());
scope.set_value("ground", ground.clone());
scope.set_value("pointer_over_ui", pointer_over_ui);
scope.set_value("events", events_array(&frame_events));
scope.set_value("commands", Array::new());
if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_tick", ()) {
tracing::warn!("global script '{name}' on_tick error: {error}");
continue;
}
collect_commands(scope, &mut produced, &policy);
}
let mut scripted: Vec<(Entity, String)> = Vec::new();
for entity in world.core.query_entities(SCRIPT).collect::<Vec<_>>() {
if let Some(script) = world.core.get_script(entity)
&& script.enabled
&& let ScriptSource::Embedded { source } = &script.source
{
scripted.push((entity, source.clone()));
}
}
for (entity, source) in &scripted {
let position = world
.core
.get_local_transform(*entity)
.map(|transform| transform.translation)
.unwrap_or_else(Vec3::zeros);
let velocity = nightshade::ecs::physics::resources::physics_world_linear_velocity(
&world.resources.physics,
*entity,
)
.unwrap_or_else(Vec3::zeros);
let entity_events = relevant_events(&frame_events, *entity);
let Some(ast) = compiled_ast(runtime, source).cloned() else {
continue;
};
let scope = runtime.scopes.entry(*entity).or_default();
if !scope.contains("state") {
scope.set_value("state", Map::new());
}
scope.set_value("self", entity_to_script(*entity));
scope.set_value("dt", dt);
scope.set_value("move_left", move_left);
scope.set_value("move_right", move_right);
scope.set_value("launch", launch);
scope.set_value("keys", keys.clone());
scope.set_value("pressed", pressed.clone());
scope.set_value("mouse", mouse_map.clone());
scope.set_value("pos", array3(position));
scope.set_value("vel", array3(velocity));
scope.set_value("named", named.clone());
scope.set_value("positions", positions.clone());
scope.set_value("velocities", velocities.clone());
scope.set_value("tagged", tagged.clone());
scope.set_value("ground", ground.clone());
scope.set_value("pointer_over_ui", pointer_over_ui);
scope.set_value("events", events_array(&entity_events));
scope.set_value("commands", Array::new());
if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_tick", ()) {
tracing::warn!("script on_tick error: {error}");
continue;
}
collect_commands(scope, &mut produced, &policy);
}
submit_commands(world, &produced);
produced
}
fn collect_commands(scope: &Scope<'static>, produced: &mut Vec<Command>, policy: &CommandPolicy) {
if let Some(commands) = scope.get_value::<Array>("commands") {
for command in &commands {
let value = match serde_json::to_value(command) {
Ok(value) => value,
Err(error) => {
tracing::warn!("dropped unserializable script command: {error}");
continue;
}
};
match serde_json::from_value::<Command>(value) {
Ok(parsed) if policy.allows(parsed.name()) => produced.push(parsed),
Ok(parsed) => {
tracing::warn!("script command '{}' blocked by write policy", parsed.name())
}
Err(error) => tracing::warn!("dropped malformed script command: {error}"),
}
}
}
}
fn entity_pos_vel(world: &World, entity: Entity) -> (Array, Array) {
let position = world
.core
.get_local_transform(entity)
.map(|transform| transform.translation)
.unwrap_or_else(Vec3::zeros);
let velocity = nightshade::ecs::physics::resources::physics_world_linear_velocity(
&world.resources.physics,
entity,
)
.unwrap_or_else(Vec3::zeros);
(array3(position), array3(velocity))
}
fn array3(value: Vec3) -> Array {
vec![
Dynamic::from(value.x as f64),
Dynamic::from(value.y as f64),
Dynamic::from(value.z as f64),
]
}
fn relevant_events(frame_events: &[Event], entity: Entity) -> Vec<Event> {
frame_events
.iter()
.filter(|event| match event {
Event::Collision { a, b, .. } => *a == entity || *b == entity,
Event::Despawned { entity: target }
| Event::AnimationFinished { entity: target }
| Event::AnimationEvent { entity: target, .. }
| Event::NavigationArrived { entity: target } => *target == entity,
})
.cloned()
.collect()
}
fn events_array(frame_events: &[Event]) -> Array {
frame_events.iter().map(event_to_map).collect()
}
fn event_to_map(event: &Event) -> Dynamic {
let mut map = Map::new();
match event {
Event::Collision {
a,
b,
sensor,
started,
} => {
map.insert("kind".into(), Dynamic::from("collision".to_string()));
map.insert("a".into(), Dynamic::from(entity_to_script(*a)));
map.insert("b".into(), Dynamic::from(entity_to_script(*b)));
map.insert("sensor".into(), Dynamic::from(*sensor));
map.insert("started".into(), Dynamic::from(*started));
}
Event::Despawned { entity } => {
map.insert("kind".into(), Dynamic::from("despawned".to_string()));
map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
}
Event::AnimationFinished { entity } => {
map.insert(
"kind".into(),
Dynamic::from("animation_finished".to_string()),
);
map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
}
Event::AnimationEvent { entity, name } => {
map.insert("kind".into(), Dynamic::from("animation_event".to_string()));
map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
map.insert("name".into(), Dynamic::from(name.clone()));
}
Event::NavigationArrived { entity } => {
map.insert(
"kind".into(),
Dynamic::from("navigation_arrived".to_string()),
);
map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
}
}
Dynamic::from_map(map)
}