Skip to main content

kvas_engine/
script.rs

1//! Rust scripting. Scripts are ordinary Rust types compiled into a dynamic library next to the
2//! project and hot-reloaded by the host. Host and library must be built by the same toolchain
3//! from the same engine sources; `AbiInfo` is checked on load to catch a mismatch early.
4//!
5//! Note: statics are duplicated across the library boundary, so scripts must talk to the engine
6//! only through `Ctx` (that is why logging goes through `Ctx::log`).
7
8use crate::components::*;
9use crate::hierarchy;
10use crate::input::{Input, Key, MouseButton};
11use crate::physics::{Physics, QueryOptions, RayHit};
12use crate::reflect::{apply_props, apply_props_checked, read_props, Field};
13use crate::scene::Scene;
14use glam::Vec3;
15use kvas_ecs::{Component as EcsComponent, Entity, World};
16use std::collections::HashSet;
17use std::panic::{catch_unwind, AssertUnwindSafe};
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicUsize, Ordering};
20use std::sync::Arc;
21
22/// Simulation time supplied to one script callback.
23#[derive(Clone, Copy, Default)]
24pub struct Time {
25    /// Callback interval in seconds (fixed interval during `fixed_update`).
26    pub dt: f32,
27    /// Accumulated scaled gameplay time in seconds.
28    pub elapsed: f32,
29    /// Render/update tick counter, shared by fixed steps of the same frame.
30    pub frame: u64,
31}
32
33/// Implement this (plus `Default`) and list the type in `register_scripts!`.
34/// Public fields are exposed in the editor by returning them from `fields()`.
35pub trait Script {
36    /// Called once before this instance's first gameplay callback.
37    /// A hot reload creates a new instance and calls `start` again.
38    fn start(&mut self, _ctx: &mut Ctx) {}
39    /// Called once per accepted simulation frame before fixed physics steps.
40    fn update(&mut self, _ctx: &mut Ctx) {}
41    /// Called before every fixed physics step (60 Hz by default). Apply forces here.
42    fn fixed_update(&mut self, _ctx: &mut Ctx) {}
43    /// Editable/persistent properties. Missing saved properties retain defaults.
44    fn fields(&mut self) -> Vec<Field<'_>> {
45        Vec::new()
46    }
47}
48
49/// Exported script factory used by [`crate::register_scripts!`].
50pub struct ScriptDef {
51    /// Type name exposed in the editor and serialized in [`ScriptSlot`].
52    pub name: &'static str,
53    /// Factory valid only while the owning native library remains loaded.
54    pub create: fn() -> Box<dyn Script>,
55    /// The script overrides `update`; when false it is never called per frame.
56    pub update: bool,
57    /// The script overrides `fixed_update`; when false it is never called per physics step.
58    pub fixed: bool,
59}
60
61impl ScriptDef {
62    /// Defines a host-linked script factory without a dynamic library.
63    /// The factory and script type must live in permanent host code. Export
64    /// reloadable script types with [`crate::register_scripts!`] instead.
65    pub fn of<T: Script + Default + 'static>(name: &'static str) -> Self {
66        Self { name, create: || Box::new(T::default()), update: true, fixed: true }
67    }
68}
69
70/// Library registration payload; construct through [`crate::register_scripts!`].
71pub struct ScriptLib {
72    /// Host/library compatibility fingerprint.
73    pub abi: AbiInfo,
74    /// Ordered exported factories.
75    pub scripts: Vec<ScriptDef>,
76}
77
78const ABI_VERSION: u32 = 3;
79
80fn fnv(text: &str) -> u64 {
81    text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3))
82}
83
84/// Everything that must be identical in the host and in a scripts library for sharing engine
85/// memory to be sound: compiler, engine sources, and the layout of the types scripts touch.
86#[derive(Clone, Copy, PartialEq, Eq, Debug)]
87#[repr(C)]
88pub struct AbiInfo {
89    version: u32,
90    rustc: u64,
91    sources: u64,
92    layouts: [usize; 8],
93}
94
95impl AbiInfo {
96    /// Fingerprint of this compiler, engine sources and script-visible layouts.
97    /// The C-compatible probe is checked before exchanging Rust-owned containers.
98    pub fn current() -> Self {
99        use std::mem::{align_of, size_of};
100        Self {
101            version: ABI_VERSION,
102            rustc: fnv(env!("KVAS_RUSTC_VERSION")),
103            sources: fnv(&format!("{}|{}", env!("KVAS_SRC_HASH"), env!("KVAS_BUILD_CONFIG"))),
104            layouts: [
105                size_of::<World>(),
106                size_of::<Scene>(),
107                size_of::<Ctx>(),
108                size_of::<Transform>(),
109                size_of::<glam::Mat4>() * 16 + align_of::<glam::Mat4>(),
110                size_of::<glam::Quat>() * 16 + align_of::<glam::Quat>(),
111                size_of::<RigidBody>(),
112                // Keep the C probe's fixed layout unchanged across engine versions.
113                // Extra layout checks are folded into the final scalar slot.
114                [
115                    size_of::<Scripts>(),
116                    align_of::<Scripts>(),
117                    size_of::<Input>(),
118                    align_of::<Input>(),
119                    size_of::<serde_json::Value>(),
120                    align_of::<serde_json::Value>(),
121                    size_of::<crate::reflect::FieldRef<'static>>(),
122                    size_of::<crate::mesh::MeshData>(),
123                    align_of::<crate::mesh::MeshData>(),
124                    size_of::<QueryOptions>(),
125                ]
126                .into_iter()
127                .fold(0xcbf2_9ce4_8422_2325u64, |hash, value| (hash ^ value as u64).wrapping_mul(0x0100_0000_01b3))
128                    as usize,
129            ],
130        }
131    }
132}
133
134/// Last segment of a type path (`a::b::Spin` becomes `Spin`); used by [`register_scripts!`].
135pub fn short_name(path: &'static str) -> &'static str {
136    path.rsplit("::").next().unwrap_or(path).trim()
137}
138
139/// Exports the scripts of a library crate: `register_scripts!(Spinner, Player);`
140#[macro_export]
141macro_rules! register_scripts {
142    // Generated by the project's build.rs: `Type => has_update, has_fixed_update; ...`.
143    (@flags $($t:ty => $u:expr, $f:expr);* $(;)?) => {
144        /// C-compatible compatibility probe checked before loading Rust registrations.
145        #[no_mangle]
146        pub extern "C" fn kvas_script_abi() -> $crate::script::AbiInfo {
147            $crate::script::AbiInfo::current()
148        }
149        /// Native script registration entry point. Loaded by `ScriptHost` after ABI validation.
150        #[no_mangle]
151        pub fn kvas_script_lib() -> $crate::script::ScriptLib {
152            $crate::script::ScriptLib {
153                abi: $crate::script::AbiInfo::current(),
154                scripts: vec![$($crate::script::ScriptDef {
155                    name: $crate::script::short_name(stringify!($t)),
156                    create: || Box::new(<$t as Default>::default()),
157                    update: $u,
158                    fixed: $f,
159                }),*],
160            }
161        }
162    };
163    ($($t:ty),* $(,)?) => {
164        /// C-compatible compatibility probe checked before loading Rust registrations.
165        #[no_mangle]
166        pub extern "C" fn kvas_script_abi() -> $crate::script::AbiInfo {
167            $crate::script::AbiInfo::current()
168        }
169        /// Native script registration entry point. Loaded by `ScriptHost` after ABI validation.
170        #[no_mangle]
171        pub fn kvas_script_lib() -> $crate::script::ScriptLib {
172            $crate::script::ScriptLib {
173                abi: $crate::script::AbiInfo::current(),
174                scripts: vec![$($crate::script::ScriptDef {
175                    name: $crate::script::short_name(stringify!($t)),
176                    create: || Box::new(<$t as Default>::default()),
177                    update: true,
178                    fixed: true,
179                }),*],
180            }
181        }
182    };
183}
184
185/// What a script sees each call.
186pub struct Ctx<'a> {
187    /// Entity owning this script slot; it may become stale if a script despawns it.
188    pub entity: Entity,
189    /// Mutable host scene. Register custom storages only in permanent host code.
190    pub scene: &'a mut Scene,
191    /// Frame snapshot and shared shell requests.
192    pub input: &'a Input,
193    /// Scaled callback time.
194    pub time: Time,
195    physics: &'a Physics,
196    // Host function: rapier code must run in the host, not in a script library.
197    raycast_fn: fn(&Physics, Vec3, Vec3, f32, QueryOptions) -> Option<RayHit>,
198    overlap_fn: fn(&Physics, Vec3, f32) -> Vec<Entity>,
199    touching_fn: fn(&Physics, Entity) -> Vec<Entity>,
200    overlap_filtered_fn: fn(&Physics, Vec3, f32, QueryOptions) -> Vec<Entity>,
201    log: &'a mut Vec<String>,
202}
203
204impl Ctx<'_> {
205    /// Callback delta in seconds; fixed callbacks receive the configured fixed interval.
206    pub fn dt(&self) -> f32 {
207        self.time.dt
208    }
209    /// Scaled gameplay seconds since runtime creation.
210    pub fn elapsed(&self) -> f32 {
211        self.time.elapsed
212    }
213    /// Frames simulated so far (a counter, handy for "every N frames").
214    pub fn frame(&self) -> u64 {
215        self.time.frame
216    }
217    /// Frames per second implied by this callback's `dt` (0 when `dt` is 0).
218    pub fn fps(&self) -> f32 {
219        if self.time.dt > 0.0 { 1.0 / self.time.dt } else { 0.0 }
220    }
221    /// True once every `seconds` of game time (use inside `update`).
222    pub fn every(&self, seconds: f32) -> bool {
223        seconds > 0.0 && (self.time.elapsed % seconds) < self.time.dt
224    }
225
226    // ---- sky and time of day
227
228    /// Current in-game hour, 0..24.
229    pub fn time_of_day(&self) -> f32 {
230        self.scene.settings.atmosphere.time
231    }
232    /// Jumps to an hour of the day (wraps into 0..24) and switches the procedural sky on.
233    pub fn set_time_of_day(&mut self, hour: f32) {
234        let sky = &mut self.scene.settings.atmosphere;
235        sky.procedural = true;
236        sky.time = hour.rem_euclid(24.0);
237    }
238    /// Game hours that pass per real second (0 freezes the sun).
239    pub fn set_day_speed(&mut self, hours_per_second: f32) {
240        self.scene.settings.atmosphere.speed = hours_per_second.max(0.0);
241    }
242    /// The whole sky configuration for fine control: clouds, stars, haze, aurora, moon.
243    pub fn sky(&mut self) -> &mut crate::sky::SkySettings {
244        &mut self.scene.settings.atmosphere
245    }
246    /// 0 = clear sky, 1 = overcast.
247    pub fn set_clouds(&mut self, amount: f32) {
248        self.scene.settings.atmosphere.clouds = amount.clamp(0.0, 1.0);
249    }
250    /// Whether it is night (sun below the horizon, roughly 20:00-05:00).
251    pub fn is_night(&self) -> bool {
252        let h = self.scene.settings.atmosphere.time;
253        !(5.0..20.0).contains(&h)
254    }
255
256    // ---- look: post effects and shadows
257
258    /// Screen effects for fine control: `ctx.post().bloom = 0.8; ctx.post().motion_blur = 0.5;`.
259    pub fn post(&mut self) -> &mut crate::fx::PostSettings {
260        &mut self.scene.settings.post
261    }
262    /// Sun shadow settings: `ctx.shadows().softness = 2.0;`.
263    pub fn shadows(&mut self) -> &mut crate::fx::ShadowSettings {
264        &mut self.scene.settings.shadows
265    }
266    /// Applies a ready-made look: "cinematic", "dreamy", "crisp", "retro" or anything else for none.
267    pub fn post_preset(&mut self, name: &str) {
268        self.scene.settings.post = crate::fx::PostSettings::preset(name);
269    }
270    /// Exposure multiplier (brightness) of the picture.
271    pub fn set_exposure(&mut self, exposure: f32) {
272        self.scene.settings.exposure = exposure.clamp(0.05, 8.0);
273    }
274
275    /// Adds a custom full-screen shader to the camera (a WGSL file with `fn effect(..)`) and returns its index.
276    pub fn add_post_effect(&mut self, shader: &str) -> usize {
277        let list = &mut self.scene.settings.post.effects;
278        list.push(crate::fx::PostEffect { shader: shader.to_string(), ..Default::default() });
279        list.len() - 1
280    }
281    /// Sets the four numbers a custom effect receives as `params`.
282    pub fn set_post_effect_params(&mut self, index: usize, params: [f32; 4]) {
283        if let Some(effect) = self.scene.settings.post.effects.get_mut(index) {
284            effect.params = params;
285        }
286    }
287    /// Switches a custom effect on or off.
288    pub fn enable_post_effect(&mut self, index: usize, on: bool) {
289        if let Some(effect) = self.scene.settings.post.effects.get_mut(index) {
290            effect.enabled = on;
291        }
292    }
293    /// Removes every custom camera effect.
294    pub fn clear_post_effects(&mut self) {
295        self.scene.settings.post.effects.clear();
296    }
297
298    // ---- audio
299
300    /// Plays a clip (path inside `assets/`) in 2D, e.g. `ctx.play_sound("sounds/click.wav", 1.0)`.
301    pub fn play_sound(&mut self, clip: &str, volume: f32) -> crate::audio::SoundId {
302        self.scene.audio.play(clip, volume)
303    }
304    /// Plays a looping 2D clip (music). Keep the id to stop it with [`Self::stop_sound`].
305    pub fn play_music(&mut self, clip: &str, volume: f32) -> crate::audio::SoundId {
306        self.scene.audio.play_loop(clip, volume)
307    }
308    /// Plays a clip at a world position (distance falloff and left/right stereo).
309    pub fn play_sound_at(&mut self, clip: &str, volume: f32, position: Vec3) -> crate::audio::SoundId {
310        self.scene.audio.play_at(clip, volume, position)
311    }
312    /// Plays a clip with stereo position `pan` (-1 left .. 1 right).
313    pub fn play_sound_panned(&mut self, clip: &str, volume: f32, pan: f32) -> crate::audio::SoundId {
314        self.scene.audio.play_panned(clip, volume, pan)
315    }
316    /// Plays a clip with a pitch / speed multiplier.
317    pub fn play_sound_pitched(&mut self, clip: &str, volume: f32, pitch: f32) -> crate::audio::SoundId {
318        self.scene.audio.play_pitched(clip, volume, pitch)
319    }
320    /// Stops a sound started earlier.
321    pub fn stop_sound(&mut self, id: crate::audio::SoundId) {
322        self.scene.audio.stop(id);
323    }
324    /// Changes the volume of a running sound.
325    pub fn set_sound_volume(&mut self, id: crate::audio::SoundId, volume: f32) {
326        self.scene.audio.set_volume(id, volume);
327    }
328    /// Stops every sound.
329    pub fn stop_all_sounds(&mut self) {
330        self.scene.audio.stop_all();
331    }
332    /// Master volume for the whole game, 0..1.
333    pub fn set_master_volume(&mut self, volume: f32) {
334        self.scene.audio.master_volume = volume.clamp(0.0, 1.0);
335    }
336
337    /// Mutable host world; its built-in component storages already exist.
338    pub fn world(&mut self) -> &mut World {
339        &mut self.scene.world
340    }
341    /// Appends a message to the host log shown in the editor/player.
342    pub fn log(&mut self, msg: impl Into<String>) {
343        self.log.push(msg.into());
344    }
345
346    /// Borrows this entity's component; returns `None` when missing or despawned.
347    pub fn get<T: EcsComponent>(&self) -> Option<&T> {
348        self.scene.world.get::<T>(self.entity)
349    }
350    /// Mutably borrows this entity's component, if present.
351    pub fn get_mut<T: EcsComponent>(&mut self) -> Option<&mut T> {
352        self.scene.world.get_mut::<T>(self.entity)
353    }
354    /// Borrows another living entity's component, if present.
355    pub fn get_of<T: EcsComponent>(&self, e: Entity) -> Option<&T> {
356        self.scene.world.get::<T>(e)
357    }
358    /// Mutably borrows another living entity's component, if present.
359    pub fn get_mut_of<T: EcsComponent>(&mut self, e: Entity) -> Option<&mut T> {
360        self.scene.world.get_mut::<T>(e)
361    }
362
363    /// This entity's transform (panics only if a script removed it; the editor does not allow that).
364    pub fn transform(&self) -> &Transform {
365        self.get::<Transform>().expect("entity has no Transform")
366    }
367    /// Mutable local pose.
368    /// # Panics
369    /// Panics when this entity has no [`Transform`], including after self-despawn.
370    pub fn transform_mut(&mut self) -> &mut Transform {
371        self.get_mut::<Transform>().expect("entity has no Transform")
372    }
373    /// Current world position computed from the hierarchy, including pending local edits.
374    pub fn world_position(&self) -> Vec3 {
375        hierarchy::global_matrix(&self.scene.world, self.entity).w_axis.truncate()
376    }
377
378    /// Sets the material colour of an entity (linear-ish sRGB, 0..1).
379    pub fn set_color(&mut self, e: Entity, rgba: [f32; 4]) {
380        if let Some(m) = self.scene.world.get_mut::<MeshRenderer>(e) {
381            m.color = rgba;
382        }
383    }
384    /// Sets a texture by asset path or by a procedural key (`NoiseTexture::key()`).
385    pub fn set_texture(&mut self, e: Entity, texture: &str) {
386        if let Some(m) = self.scene.world.get_mut::<MeshRenderer>(e) {
387            m.texture = texture.to_string();
388        }
389    }
390    /// Replaces the mesh with a parametric shape (the mesh is rebuilt when parameters change).
391    pub fn set_shape(&mut self, e: Entity, shape: Shape) {
392        self.scene.world.insert(e, shape);
393        if !self.scene.world.has::<MeshRenderer>(e) {
394            self.scene.world.insert(e, MeshRenderer::default());
395        }
396    }
397    /// Direct non-transient children in entity index order.
398    pub fn children(&self, e: Entity) -> Vec<Entity> {
399        hierarchy::children(&self.scene.world, e)
400    }
401    /// First exact entity-name match; names are not unique identifiers.
402    pub fn find(&self, name: &str) -> Option<Entity> {
403        hierarchy::find_by_name(&self.scene.world, name)
404    }
405    /// Every entity with this exact name.
406    pub fn find_all(&self, name: &str) -> Vec<Entity> {
407        self.scene.world.entities().into_iter().filter(|&e| hierarchy::name_of(&self.scene.world, e) == name).collect()
408    }
409    /// Entities that have component `T`.
410    pub fn find_with<T: EcsComponent>(&self) -> Vec<Entity> {
411        self.scene.world.iter::<T>().map(|(e, _)| e).collect()
412    }
413    /// Reads a public variable (a field listed in the script's `fields()`) of a script on any entity,
414    /// e.g. `ctx.var(door, "DoorLogic", "locked")`. Returns JSON: numbers, bools, strings, arrays.
415    pub fn var(&self, entity: Entity, script: &str, name: &str) -> Option<serde_json::Value> {
416        self.scene.world.get::<Scripts>(entity)?.0.iter().find(|s| s.name == script)?.props.get(name).cloned()
417    }
418    /// Changes a public variable of a script on any entity; the script sees it before its next callback.
419    /// Returns false when the entity has no such script.
420    pub fn set_var(&mut self, entity: Entity, script: &str, name: &str, value: serde_json::Value) -> bool {
421        match self.scene.world.get_mut::<Scripts>(entity).and_then(|s| s.0.iter_mut().find(|s| s.name == script)) {
422            Some(slot) => {
423                slot.props.insert(name.to_string(), value);
424                true
425            }
426            None => false,
427        }
428    }
429    /// Number variable of another script, see [`Ctx::var`].
430    pub fn var_f32(&self, entity: Entity, script: &str, name: &str) -> Option<f32> {
431        self.var(entity, script, name)?.as_f64().map(|v| v as f32)
432    }
433    /// Boolean variable of another script, see [`Ctx::var`].
434    pub fn var_bool(&self, entity: Entity, script: &str, name: &str) -> Option<bool> {
435        self.var(entity, script, name)?.as_bool()
436    }
437
438    /// Creates a named entity with an identity local transform.
439    pub fn spawn(&mut self, name: &str) -> Entity {
440        hierarchy::spawn_named(&mut self.scene.world, name)
441    }
442    /// Creates a visible primitive at a world-space position with no collider/body.
443    /// Supported primitive names are listed in [`crate::mesh::PRIMITIVES`].
444    pub fn spawn_primitive(&mut self, name: &str, primitive: &str, position: Vec3) -> Entity {
445        let e = self.spawn(name);
446        self.scene.world.insert(e, Transform::at(position));
447        self.scene.world.insert(e, MeshRenderer { mesh: format!("primitive:{primitive}"), ..Default::default() });
448        e
449    }
450    /// Destroys an entity and its subtree. Physics objects are removed on the next fixed step.
451    pub fn despawn(&mut self, e: Entity) {
452        hierarchy::despawn_recursive(&mut self.scene.world, e);
453    }
454
455    /// Accumulates a world-space impulse for this body to consume on the next physics step.
456    /// Does nothing when no [`RigidBody`] is attached.
457    pub fn add_impulse(&mut self, impulse: Vec3) {
458        if let Some(rb) = self.get_mut::<RigidBody>() {
459            rb.impulse += impulse;
460        }
461    }
462    /// Requests a world-space linear velocity in metres/second for the next fixed step.
463    pub fn set_velocity(&mut self, v: Vec3) {
464        if let Some(rb) = self.get_mut::<RigidBody>() {
465            rb.set_velocity = Some(v);
466        }
467    }
468    /// Sets the spin of a rigid body (any entity); `Vec3::ZERO` stops it rotating.
469    pub fn set_angular_velocity_of(&mut self, e: Entity, w: Vec3) {
470        if let Some(rb) = self.get_mut_of::<RigidBody>(e) {
471            rb.set_angular_velocity = Some(w);
472        }
473    }
474    /// Sets the linear velocity of any rigid body.
475    pub fn set_velocity_of(&mut self, e: Entity, v: Vec3) {
476        if let Some(rb) = self.get_mut_of::<RigidBody>(e) {
477            rb.set_velocity = Some(v);
478        }
479    }
480    /// Last simulated linear velocity, or zero when no body is attached.
481    pub fn velocity(&self) -> Vec3 {
482        self.get::<RigidBody>().map_or(Vec3::ZERO, |rb| rb.velocity)
483    }
484    /// First solid collider hit by the ray; this entity's own collider and triggers are ignored.
485    pub fn raycast(&self, origin: Vec3, dir: Vec3, max_distance: f32) -> Option<RayHit> {
486        (self.raycast_fn)(self.physics, origin, dir, max_distance, QueryOptions { exclude: Some(self.entity), ..Default::default() })
487    }
488
489    /// Ray query with layer/sensor filters; defaults to excluding this script's body.
490    /// An explicit `options.exclude` overrides that default owner exclusion.
491    pub fn raycast_filtered(&self, origin: Vec3, direction: Vec3, max_distance: f32, mut options: QueryOptions) -> Option<RayHit> {
492        options.exclude.get_or_insert(self.entity);
493        (self.raycast_fn)(self.physics, origin, direction, max_distance, options)
494    }
495
496    /// Sphere query under explicit layer/sensor/exclusion filters. No implicit owner exclusion.
497    pub fn overlap_sphere_filtered(&self, center: Vec3, radius: f32, options: QueryOptions) -> Vec<Entity> {
498        (self.overlap_filtered_fn)(self.physics, center, radius, options)
499    }
500
501    /// Collider entities overlapping a world-space sphere in the last physics state.
502    /// Includes triggers and this entity; results are sorted and deduplicated.
503    pub fn overlap_sphere(&self, center: Vec3, radius: f32) -> Vec<Entity> {
504        (self.overlap_fn)(self.physics, center, radius)
505    }
506
507    /// Active solid contacts and sensor overlaps for a collider entity.
508    pub fn touching(&self, entity: Entity) -> Vec<Entity> {
509        (self.touching_fn)(self.physics, entity)
510    }
511
512    /// Whether an entity handle still refers to a living object in this scene.
513    pub fn is_alive(&self, entity: Entity) -> bool {
514        self.scene.world.is_alive(entity)
515    }
516
517    /// Current world matrix, evaluated directly from local transforms.
518    pub fn world_matrix_of(&self, entity: Entity) -> glam::Mat4 {
519        hierarchy::global_matrix(&self.scene.world, entity)
520    }
521
522    /// Reparents while preserving world pose when representable as TRS.
523    pub fn set_parent(&mut self, entity: Entity, parent: Option<Entity>) -> bool {
524        hierarchy::set_parent(&mut self.scene.world, entity, parent)
525    }
526
527    /// Reparents while preserving local pose; rejects stale handles and cycles.
528    pub fn set_parent_local(&mut self, entity: Entity, parent: Option<Entity>) -> bool {
529        hierarchy::set_parent_local(&mut self.scene.world, entity, parent)
530    }
531
532    /// Adds a component of a host-registered type to a living entity.
533    /// # Panics
534    /// Panics for unregistered or conflicting component types; scripts use built-ins only.
535    pub fn insert<T: EcsComponent>(&mut self, entity: Entity, component: T) {
536        self.scene.world.insert(entity, component);
537    }
538
539    /// Removes a component, returning its former value if present.
540    pub fn remove<T: EcsComponent>(&mut self, entity: Entity) -> Option<T> {
541        self.scene.world.remove::<T>(entity)
542    }
543
544    /// Whether this entity currently has a component.
545    pub fn has<T: EcsComponent>(&self) -> bool {
546        self.scene.world.has::<T>(self.entity)
547    }
548
549    /// Adds a named script slot. The runtime creates its instance at the next sync.
550    /// Returns false for a stale entity; duplicate names in different slots are allowed.
551    pub fn attach_script(&mut self, entity: Entity, name: &str) -> bool {
552        let Some(scripts) = self.scene.world.get_or_insert_with::<Scripts>(entity, Scripts::default) else {
553            return false;
554        };
555        scripts.0.push(ScriptSlot { name: name.to_owned(), ..Default::default() });
556        true
557    }
558
559    /// Removes every slot with this script name. Returns the number removed.
560    pub fn detach_script(&mut self, entity: Entity, name: &str) -> usize {
561        let Some(scripts) = self.scene.world.get_mut::<Scripts>(entity) else {
562            return 0;
563        };
564        let before = scripts.0.len();
565        scripts.0.retain(|s| s.name != name);
566        before - scripts.0.len()
567    }
568
569    /// Snapshot of all living entities with an exact tag, in index order.
570    pub fn tagged(&self, tag: &str) -> Vec<Entity> {
571        crate::gameplay::tagged(&self.scene.world, tag)
572    }
573
574    /// Whether an entity contains this exact group label.
575    pub fn has_tag(&self, entity: Entity, tag: &str) -> bool {
576        self.get_of::<Tags>(entity).is_some_and(|tags| tags.contains(tag))
577    }
578
579    /// Adds a group label, creating Tags if needed. Invalid/duplicate labels or stale entities return false.
580    pub fn add_tag(&mut self, entity: Entity, tag: &str) -> bool {
581        if tag.is_empty() || tag.chars().any(char::is_whitespace) {
582            return false;
583        }
584        self.scene.world.get_or_insert_with::<Tags>(entity, Tags::default).is_some_and(|tags| tags.insert(tag))
585    }
586
587    /// Removes a label without removing other tags; returns whether it existed.
588    pub fn remove_tag(&mut self, entity: Entity, tag: &str) -> bool {
589        self.get_mut_of::<Tags>(entity).is_some_and(|tags| tags.remove(tag))
590    }
591
592    /// Schedules/replaces subtree expiration after non-negative simulation seconds.
593    /// Zero expires after update callbacks in the current/next accepted frame.
594    /// Invalid durations or stale entities return false without changing components.
595    pub fn despawn_after(&mut self, entity: Entity, seconds: f32) -> bool {
596        let Some(lifetime) = Lifetime::new(seconds) else {
597            return false;
598        };
599        if !self.is_alive(entity) {
600            return false;
601        }
602        self.scene.world.insert(entity, lifetime);
603        true
604    }
605
606    /// Sets inherited mesh visibility without changing scripts, lights or physics.
607    /// Returns false for a stale entity.
608    pub fn set_visible(&mut self, entity: Entity, visible: bool) -> bool {
609        if !self.is_alive(entity) {
610            return false;
611        }
612        self.scene.world.insert(entity, Visibility { visible });
613        true
614    }
615
616    /// Effective mesh visibility, including hidden ancestors.
617    pub fn is_visible(&self, entity: Entity) -> bool {
618        hierarchy::is_visible(&self.scene.world, entity)
619    }
620
621    /// Starts/replaces a parent-local position tween from the current pose.
622    /// Returns false for invalid targets/durations, stale entities or missing transforms.
623    pub fn move_to(&mut self, entity: Entity, target: Vec3, seconds: f32, ease: crate::tween::Ease) -> bool {
624        let Some(mut tween) = crate::tween::MoveTween::to(target, seconds) else {
625            return false;
626        };
627        if !self.scene.world.has::<Transform>(entity) {
628            return false;
629        }
630        tween.ease = ease;
631        self.scene.world.insert(entity, tween);
632        true
633    }
634
635    /// Moves an entity to a world-space point and queues physical teleportation.
636    /// With `reset_velocity`, pending impulses and both velocities are cleared.
637    /// For a dynamic body the final pose is integrated on the next fixed step;
638    /// non-physical entities move immediately. Invalid/singular requests do nothing.
639    pub fn teleport_of(&mut self, entity: Entity, position: Vec3, reset_velocity: bool) -> bool {
640        if !hierarchy::set_world_position(&mut self.scene.world, entity, position) {
641            return false;
642        }
643        if let Some(body) = self.get_mut_of::<RigidBody>(entity) {
644            body.teleport = true;
645            if reset_velocity {
646                body.set_velocity = Some(Vec3::ZERO);
647                body.set_angular_velocity = Some(Vec3::ZERO);
648                body.impulse = Vec3::ZERO;
649            }
650        }
651        true
652    }
653
654    /// Teleports this script's entity, optionally resetting its motion.
655    pub fn teleport(&mut self, position: Vec3, reset_velocity: bool) -> bool {
656        self.teleport_of(self.entity, position, reset_velocity)
657    }
658
659    /// Broadcasts JSON data on a channel for delivery before the next frame's callbacks.
660    /// Sending is bounded and returns an explicit error rather than silently dropping data.
661    pub fn emit(&mut self, channel: &str, payload: serde_json::Value) -> Result<u64, crate::signals::SignalError> {
662        self.scene.signals.send(channel, payload, Some(self.entity), None)
663    }
664
665    /// Sends a directed message to a living entity for the next simulation frame.
666    pub fn send_to(&mut self, target: Entity, channel: &str, payload: serde_json::Value) -> Result<u64, crate::signals::SignalError> {
667        if !self.is_alive(target) {
668            return Err(crate::signals::SignalError::TargetNotAlive);
669        }
670        self.scene.signals.send(channel, payload, Some(self.entity), Some(target))
671    }
672
673    /// Clones the current channel messages addressed to this entity or broadcast.
674    /// Owned snapshots let callbacks mutate the scene while handling messages.
675    /// Reading does not consume signals; use update for one-shot processing.
676    pub fn signals(&self, channel: &str) -> Vec<crate::signals::Signal> {
677        self.scene.signals.for_entity(self.entity).filter(|signal| signal.channel == channel).cloned().collect()
678    }
679
680    /// Whether the physical key is held this frame.
681    pub fn key_down(&self, k: Key) -> bool {
682        self.input.key_down(k)
683    }
684    /// Whether the physical key went down in this frame snapshot.
685    pub fn key_pressed(&self, k: Key) -> bool {
686        self.input.key_pressed(k)
687    }
688    /// Whether the physical key went up in this frame snapshot.
689    pub fn key_released(&self, key: Key) -> bool {
690        self.input.key_released(key)
691    }
692
693    /// Whether a physical pointer button is held.
694    pub fn mouse_down(&self, b: MouseButton) -> bool {
695        self.input.mouse_down(b)
696    }
697
698    /// Whether a physical pointer button went down this frame.
699    pub fn mouse_pressed(&self, button: MouseButton) -> bool {
700        self.input.mouse_pressed(button)
701    }
702    /// Whether a physical pointer button went up this frame.
703    pub fn mouse_released(&self, button: MouseButton) -> bool {
704        self.input.mouse_released(button)
705    }
706
707    /// Hides the cursor and keeps it centred while true; `input.mouse_delta` then is the look movement.
708    pub fn lock_cursor(&self, lock: bool) {
709        self.input.lock_cursor(lock);
710    }
711    /// Switches to another scene once this frame ends, e.g. `ctx.load_scene("scenes/level2.scene.json")`.
712    /// Objects and scripts of the current scene go away; the new scene starts with fresh scripts.
713    pub fn load_scene(&self, path: &str) {
714        self.input.request_scene(crate::input::SceneRequest::Load(path.to_string()));
715    }
716    /// Adds every object of another scene to this one once this frame ends (a level piece, a shared
717    /// menu...). Its scripts start on the next frame; the scene settings stay as they are.
718    pub fn add_scene(&self, path: &str) {
719        self.input.request_scene(crate::input::SceneRequest::Add(path.to_string()));
720    }
721    /// Starts the running scene again from its file once this frame ends (restart a level).
722    pub fn reload_scene(&self) {
723        self.input.request_scene(crate::input::SceneRequest::Reload);
724    }
725    /// Asset path of the running scene, e.g. `scenes/main.scene.json` (empty if unknown).
726    pub fn scene_path(&self) -> String {
727        self.input.scene_path()
728    }
729    /// Shows the interaction hint on screen for this frame ("Press E to open").
730    pub fn hud_prompt(&self, text: &str) {
731        self.input.hud().prompt = text.to_string();
732    }
733    /// Displays a transient message for finite non-negative simulation seconds.
734    pub fn hud_message(&self, text: &str, seconds: f32) {
735        let mut h = self.input.hud();
736        h.message = text.to_string();
737        h.message_time = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 };
738    }
739    /// Replaces persistent HUD objective text; empty clears it.
740    pub fn hud_objective(&self, text: &str) {
741        self.input.hud().objective = text.to_string();
742    }
743    /// Requests that the hosting shell show/hide the crosshair.
744    pub fn hud_crosshair(&self, on: bool) {
745        self.input.hud().crosshair = on;
746    }
747
748    /// Named input actions from Project Settings > Input.
749    pub fn action_down(&self, name: &str) -> bool {
750        self.input.action_down(name)
751    }
752    /// Whether any bound input went down this frame; missing actions return false.
753    pub fn action_pressed(&self, name: &str) -> bool {
754        self.input.action_pressed(name)
755    }
756    /// Whether the last held input bound to this action was released.
757    pub fn action_released(&self, name: &str) -> bool {
758        self.input.action_released(name)
759    }
760    /// Unit-length two-dimensional movement from four named actions.
761    pub fn action_vector(&self, left: &str, right: &str, down: &str, up: &str) -> glam::Vec2 {
762        self.input.action_vector(left, right, down, up)
763    }
764    /// Digital axis: positive minus negative action, in `[-1, 1]`.
765    pub fn action_axis(&self, negative: &str, positive: &str) -> f32 {
766        self.input.action_axis(negative, positive)
767    }
768    /// Changes an action's bindings while the game runs, e.g. `ctx.rebind("Jump", &["Space", "W"])`.
769    pub fn rebind(&self, name: &str, inputs: &[&str]) {
770        self.input.rebind(name, inputs);
771    }
772}
773
774/// Loaded native script factories. Instances created by this host keep the library
775/// loaded even if the host is dropped, so their callback vtables remain valid.
776pub struct ScriptHost {
777    defs: Vec<ScriptDef>,
778    library: Option<Arc<LoadedLibrary>>,
779}
780
781struct LoadedLibrary {
782    library: Option<libloading::Library>,
783    copy: PathBuf,
784}
785
786impl Drop for LoadedLibrary {
787    fn drop(&mut self) {
788        // Windows releases the file lock only after unloading the module.
789        self.library.take();
790        let _ = std::fs::remove_file(&self.copy);
791    }
792}
793
794struct RetainedScript {
795    // Field order matters: script destructor runs while library code is resident.
796    script: Box<dyn Script>,
797    _library: Option<Arc<LoadedLibrary>>,
798}
799
800impl Script for RetainedScript {
801    fn start(&mut self, ctx: &mut Ctx) {
802        self.script.start(ctx);
803    }
804    fn update(&mut self, ctx: &mut Ctx) {
805        self.script.update(ctx);
806    }
807    fn fixed_update(&mut self, ctx: &mut Ctx) {
808        self.script.fixed_update(ctx);
809    }
810    fn fields(&mut self) -> Vec<Field<'_>> {
811        self.script.fields()
812    }
813}
814
815static LOAD_COUNTER: AtomicUsize = AtomicUsize::new(0);
816
817impl ScriptHost {
818    /// Creates a host for permanently linked Rust scripts, useful for embedding
819    /// and headless tests. Rejects empty or duplicate script names.
820    /// Factories must belong to permanent host code, not an unloadable library.
821    ///
822    /// ```
823    /// use kvas_engine::script::{Script, ScriptDef, ScriptHost};
824    /// #[derive(Default)] struct Behaviour;
825    /// impl Script for Behaviour {}
826    /// let host = ScriptHost::from_defs(vec![ScriptDef::of::<Behaviour>("Behaviour")]).unwrap();
827    /// assert!(host.create("Behaviour").is_some());
828    /// ```
829    pub fn from_defs(defs: Vec<ScriptDef>) -> Result<Self, String> {
830        validate_defs(&defs)?;
831        Ok(Self { defs, library: None })
832    }
833
834    /// Loads a library. It is copied first so the original stays writable for the next build.
835    pub fn load(path: &Path) -> Result<ScriptHost, String> {
836        let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
837        let copy = std::env::temp_dir().join(format!(
838            "kvas_scripts_{}_{}.{ext}",
839            std::process::id(),
840            LOAD_COUNTER.fetch_add(1, Ordering::Relaxed)
841        ));
842        std::fs::copy(path, &copy).map_err(|e| format!("{}: {e}", path.display()))?;
843        // SAFETY: loading arbitrary native code is inherently trusted; the project's own scripts are.
844        let lib = match unsafe { libloading::Library::new(&copy) } {
845            Ok(lib) => lib,
846            Err(e) => {
847                let _ = std::fs::remove_file(&copy);
848                return Err(format!("cannot load scripts: {e}"));
849            }
850        };
851        let library = Arc::new(LoadedLibrary { library: Some(lib), copy });
852        let lib = library.library.as_ref().expect("loaded library");
853        let probe: libloading::Symbol<extern "C" fn() -> AbiInfo> = unsafe { lib.get(b"kvas_script_abi") }
854            .map_err(|_| "scripts library has no ABI probe; rebuild it with the current engine".to_string())?;
855        if probe() != AbiInfo::current() {
856            return Err("scripts were built against a different engine/compiler build; rebuild them".into());
857        }
858        let entry: libloading::Symbol<fn() -> ScriptLib> =
859            unsafe { lib.get(b"kvas_script_lib") }.map_err(|_| "scripts library does not call register_scripts!".to_string())?;
860        // Check the ABI before running any code that touches engine types.
861        let lib_fn: fn() -> ScriptLib = *entry;
862        let script_lib = lib_fn();
863        if script_lib.abi != AbiInfo::current() {
864            return Err("scripts were built against a different engine/compiler build; rebuild them".into());
865        }
866        validate_defs(&script_lib.scripts)?;
867        Ok(ScriptHost { defs: script_lib.scripts, library: Some(library) })
868    }
869
870    /// Exported type names borrowed for the host lifetime (not falsely `'static`).
871    /// Use [`Self::names_owned`] to retain names across hot reload or host drop.
872    pub fn names(&self) -> Vec<&str> {
873        self.defs.iter().map(|d| d.name).collect()
874    }
875
876    /// Copies exported names so they can be retained after the library is unloaded.
877    pub fn names_owned(&self) -> Vec<String> {
878        self.names().into_iter().map(str::to_owned).collect()
879    }
880
881    /// Whether a script has `update` / `fixed_update` (unknown names: both true).
882    pub fn callbacks(&self, name: &str) -> (bool, bool) {
883        self.defs.iter().find(|d| d.name == name).map_or((true, true), |d| (d.update, d.fixed))
884    }
885
886    /// Constructs a script by exact type name and retains the library for its lifetime.
887    /// Returns `None` for unknown names. User factory panics propagate to the caller;
888    /// [`ScriptRuntime::sync`] catches them when constructing gameplay instances.
889    pub fn create(&self, name: &str) -> Option<Box<dyn Script>> {
890        self.defs
891            .iter()
892            .find(|d| d.name == name)
893            .map(|d| Box::new(RetainedScript { script: (d.create)(), _library: self.library.clone() }) as Box<dyn Script>)
894    }
895}
896
897/// Gameplay callback phase dispatched by [`ScriptRuntime`].
898#[derive(Clone, Copy, PartialEq, Eq)]
899pub enum Phase {
900    /// Per-frame callback.
901    Update,
902    /// Before a fixed physics step.
903    Fixed,
904}
905
906struct Instance {
907    entity: Entity,
908    slot: usize,
909    name: String,
910    script: Box<dyn Script>,
911    started: bool,
912    failed: bool,
913    /// Which per-frame callbacks the script has; the others are skipped entirely.
914    update: bool,
915    fixed: bool,
916    /// Property values last exchanged with the `Scripts` component (so outside edits are noticed).
917    synced: serde_json::Map<String, serde_json::Value>,
918}
919
920/// Native script instance lifecycle and per-instance panic isolation.
921/// A panic disables only the affected instance until it is recreated or reloaded.
922#[derive(Default)]
923pub struct ScriptRuntime {
924    // Instances must be dropped before the host (their vtables live in the library).
925    instances: Vec<Instance>,
926    host: Option<ScriptHost>,
927    failed_creations: HashSet<(Entity, usize, String)>,
928}
929
930fn panic_text(p: Box<dyn std::any::Any + Send>) -> String {
931    p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
932}
933
934impl ScriptRuntime {
935    /// Current native host, if a scripts library is loaded.
936    pub fn host(&self) -> Option<&ScriptHost> {
937        self.host.as_ref()
938    }
939
940    /// Number of instantiated attached scripts, including disabled instances.
941    pub fn instance_count(&self) -> usize {
942        self.instances.len()
943    }
944
945    /// Disabled callback instances plus slots whose initialization failed.
946    pub fn failed_count(&self) -> usize {
947        self.instances.iter().filter(|i| i.failed).count() + self.failed_creations.len()
948    }
949
950    /// Drops all instances and failure records while retaining the host.
951    /// The next sync reconstructs scripts from serialized slot properties.
952    pub fn restart_instances(&mut self) {
953        self.instances.clear();
954        self.failed_creations.clear();
955    }
956
957    /// Replaces the library (hot reload). All instances are recreated by the next `sync`.
958    pub fn set_host(&mut self, host: Option<ScriptHost>) {
959        self.instances.clear();
960        self.failed_creations.clear();
961        self.host = host;
962    }
963
964    /// Creates instances for newly attached scripts and drops those of removed entities.
965    pub fn sync(&mut self, scene: &Scene, log: &mut Vec<String>) {
966        let attached = |entity: Entity, slot: usize, name: &str| {
967            scene.world.get::<Scripts>(entity).and_then(|s| s.0.get(slot)).is_some_and(|s| s.name == name)
968        };
969        self.instances.retain(|i| attached(i.entity, i.slot, &i.name));
970        self.failed_creations.retain(|(entity, slot, name)| attached(*entity, *slot, name));
971        let have: HashSet<(Entity, usize)> = self.instances.iter().map(|i| (i.entity, i.slot)).collect();
972        for (entity, scripts) in scene.world.iter::<Scripts>() {
973            for (slot, s) in scripts.0.iter().enumerate() {
974                if have.contains(&(entity, slot)) {
975                    continue;
976                }
977                let Some(host) = &self.host else { continue };
978                let key = (entity, slot, s.name.clone());
979                if self.failed_creations.contains(&key) {
980                    continue;
981                }
982                let created = catch_unwind(AssertUnwindSafe(|| {
983                    let mut script = host.create(&s.name)?;
984                    let mut fields = script.fields();
985                    log.extend(
986                        apply_props_checked(&mut fields, &s.props).into_iter().map(|warning| format!("script '{}': {warning}", s.name)),
987                    );
988                    drop(fields);
989                    Some(script)
990                }));
991                match created {
992                    Ok(Some(script)) => {
993                        let (update, fixed) = host.callbacks(&s.name);
994                        self.instances.push(Instance {
995                            entity,
996                            slot,
997                            name: s.name.clone(),
998                            script,
999                            started: false,
1000                            failed: false,
1001                            update,
1002                            fixed,
1003                            synced: s.props.clone(),
1004                        });
1005                    }
1006                    Ok(None) => {
1007                        self.failed_creations.insert(key);
1008                        log.push(format!("script '{}' not found (is the scripts library built?)", s.name));
1009                    }
1010                    Err(p) => {
1011                        self.failed_creations.insert(key);
1012                        log.push(format!("script '{}' initialization panicked and was disabled: {}", s.name, panic_text(p)));
1013                    }
1014                }
1015            }
1016        }
1017    }
1018
1019    /// Runs attached instances in creation order, catching callback panics.
1020    /// Slots removed earlier in this frame do not receive further callbacks.
1021    pub fn run(&mut self, phase: Phase, scene: &mut Scene, physics: &Physics, input: &Input, time: Time, log: &mut Vec<String>) {
1022        for inst in &mut self.instances {
1023            let wanted = match phase {
1024                Phase::Update => inst.update,
1025                Phase::Fixed => inst.fixed,
1026            };
1027            // Nothing to call: no per-frame work at all for this instance.
1028            if inst.failed || (inst.started && !wanted) {
1029                continue;
1030            }
1031            let Some(slot) = scene.world.get::<Scripts>(inst.entity).and_then(|s| s.0.get(inst.slot)).filter(|s| s.name == inst.name) else {
1032                continue;
1033            };
1034            // Another script (or the Inspector) may have changed this slot's variables.
1035            if slot.props != inst.synced {
1036                let props = slot.props.clone();
1037                let mut fields = inst.script.fields();
1038                apply_props(&mut fields, &props);
1039                drop(fields);
1040                inst.synced = props;
1041            }
1042            let mut ctx = Ctx {
1043                entity: inst.entity,
1044                scene,
1045                input,
1046                time,
1047                physics,
1048                raycast_fn: Physics::raycast_with_options,
1049                overlap_fn: Physics::overlap_sphere,
1050                touching_fn: Physics::touching,
1051                overlap_filtered_fn: Physics::overlap_sphere_with_options,
1052                log,
1053            };
1054            let result = catch_unwind(AssertUnwindSafe(|| {
1055                if !inst.started {
1056                    inst.started = true;
1057                    inst.script.start(&mut ctx);
1058                }
1059                if !ctx.scene.world.get::<Scripts>(inst.entity).and_then(|s| s.0.get(inst.slot)).is_some_and(|s| s.name == inst.name) {
1060                    return;
1061                }
1062                match phase {
1063                    Phase::Update if wanted => inst.script.update(&mut ctx),
1064                    Phase::Fixed if wanted => inst.script.fixed_update(&mut ctx),
1065                    _ => {}
1066                }
1067            }));
1068            if let Err(p) = result {
1069                inst.failed = true;
1070                log.push(format!("script panicked and was disabled: {}", panic_text(p)));
1071                continue;
1072            }
1073            // Publish the script's variables so other scripts and the Inspector can read them.
1074            let current = read_props(&inst.script.fields());
1075            if current != inst.synced {
1076                if let Some(slot) = scene.world.get_mut::<Scripts>(inst.entity).and_then(|s| s.0.get_mut(inst.slot)) {
1077                    if slot.name == inst.name {
1078                        slot.props = current.clone();
1079                    }
1080                }
1081                inst.synced = current;
1082            }
1083        }
1084    }
1085}
1086
1087fn validate_defs(defs: &[ScriptDef]) -> Result<(), String> {
1088    let mut names = HashSet::new();
1089    for def in defs {
1090        if def.name.trim().is_empty() {
1091            return Err("script name cannot be empty".into());
1092        }
1093        if !names.insert(def.name) {
1094            return Err(format!("duplicate script name '{}'", def.name));
1095        }
1096    }
1097    Ok(())
1098}