diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index bf5d04a5..1e0d7a3b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -126,6 +126,8 @@ jobs: language-adapters: name: Language adapter conformance runs-on: ubuntu-latest + env: + RUSTIC_REQUIRE_ALL_API_ADAPTERS: '1' timeout-minutes: 40 defaults: run: @@ -156,6 +158,8 @@ jobs: - run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-scripting external_runtime -- --nocapture - name: Scene environment scripting across all adapters run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play environment -- --nocapture + - name: Audio, capture and live reads across all adapters + run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play audio_capture_and_live_reads_conform -- --nocapture - name: Entity foundations across all adapters env: RUSTIC_REQUIRE_ALL_ENTITY_ADAPTERS: '1' diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 3555c2c6..537e8455 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -962,6 +962,30 @@ impl GameplayHost for RuntimeHost { .get("op") .and_then(serde_json::Value::as_str) .ok_or("query operation is missing")?; + if op == "find_entity" { + let source = query["source"] + .as_str() + .ok_or("entity source must be a string")?; + return serde_json::to_value(self.find_entity(source)?).map_err(|e| e.to_string()); + } + if op == "object_attribute" { + let source = query["source"] + .as_str() + .ok_or("object source must be a string")?; + let name = query["name"] + .as_str() + .ok_or("attribute name must be a string")?; + return Ok(match self.object_attribute(source, name)? { + None | Some(EngineValue::Entity(None)) => serde_json::Value::Null, + Some(EngineValue::Boolean(value)) => serde_json::json!(value), + Some(EngineValue::Integer(value)) => serde_json::json!(value), + Some(EngineValue::Number(value)) => serde_json::json!(value), + Some(EngineValue::String(value)) => serde_json::json!(value), + Some(EngineValue::Vec2(value)) => serde_json::json!(value), + Some(EngineValue::Vec3(value)) => serde_json::json!(value), + Some(EngineValue::Entity(Some(value))) => serde_json::json!(value), + }); + } if crate::entity_services::handles(op) { let mut world = self.world.lock().map_err(|_| "world lock poisoned")?; return crate::entity_services::query(&mut world, query); @@ -2162,6 +2186,138 @@ mod tests { }; use engine_world::{EntitySnapshot, SceneDocument, ScriptComponent}; + #[test] + #[allow( + clippy::too_many_lines, + reason = "one shared fixture harness verifies all ten adapters" + )] + fn audio_capture_and_live_reads_conform_in_every_available_adapter() { + let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ + ( + ScriptLanguage::Lua54, + "parity.lua", + include_bytes!("../tests/fixtures/api-parity/parity.lua").as_slice(), + ), + ( + ScriptLanguage::Luau, + "parity.luau", + include_bytes!("../tests/fixtures/api-parity/parity.luau").as_slice(), + ), + ( + ScriptLanguage::JavaScript, + "parity.js", + include_bytes!("../tests/fixtures/api-parity/parity.js").as_slice(), + ), + ( + ScriptLanguage::Web, + "parity.html", + include_bytes!("../tests/fixtures/api-parity/parity.html").as_slice(), + ), + ( + ScriptLanguage::Python, + "parity.py", + include_bytes!("../tests/fixtures/api-parity/parity.py").as_slice(), + ), + ( + ScriptLanguage::C, + "parity.c", + include_bytes!("../tests/fixtures/api-parity/parity.c").as_slice(), + ), + ( + ScriptLanguage::Cpp, + "parity.cpp", + include_bytes!("../tests/fixtures/api-parity/parity.cpp").as_slice(), + ), + ( + ScriptLanguage::CSharp, + "parity.cs", + include_bytes!("../tests/fixtures/api-parity/parity.cs").as_slice(), + ), + ( + ScriptLanguage::Java, + "parity.java", + include_bytes!("../tests/fixtures/api-parity/parity.java").as_slice(), + ), + ( + ScriptLanguage::Php, + "parity.php", + include_bytes!("../tests/fixtures/api-parity/parity.php").as_slice(), + ), + ]; + for &(language, name, source) in fixtures { + if !matches!( + language, + ScriptLanguage::Lua54 | ScriptLanguage::JavaScript | ScriptLanguage::Web + ) && !engine_scripting::probe_language_toolchain(language).available + { + assert!( + std::env::var_os("RUSTIC_REQUIRE_ALL_API_ADAPTERS").is_none(), + "{} toolchain required for API conformance", + language.display_name() + ); + eprintln!( + "SKIPPED API conformance {}: missing toolchain", + language.display_name() + ); + continue; + } + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("assets")).unwrap(); + let path = temp.path().join("assets/tone.wav"); + let mut wav = hound::WavWriter::create( + &path, + hound::WavSpec { + channels: 1, + sample_rate: 8000, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }, + ) + .unwrap(); + wav.write_sample(1000_i16).unwrap(); + wav.finalize().unwrap(); + let mut world = SceneWorld::new(); + let entity = EntitySnapshot::default(); + let id = entity.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + let world = Arc::new(Mutex::new(world)); + let mut host = environment_host(&world); + host.entity = id; + host.snapshot_root = temp.path().into(); + let context = Arc::clone(&host.gameplay); + let mut behavior = + RuntimeBehavior::load(language, ScriptId::new(), source, name, Box::new(host)) + .unwrap_or_else(|e| panic!("{} load: {e}", language.display_name())); + behavior + .on_start() + .unwrap_or_else(|e| panic!("{} start: {e}", language.display_name())); + assert_eq!( + context.lock().unwrap().audio.voices.len(), + 1, + "{} playback", + language.display_name() + ); + std::fs::remove_file(path).unwrap(); + context + .lock() + .unwrap() + .audio_capture + .submit(&[0.25, -0.25, 0.5, -0.5]); + behavior + .update(0.01) + .unwrap_or_else(|e| panic!("{} update: {e}", language.display_name())); + assert!(context.lock().unwrap().audio.voices.is_empty()); + let mut wav = hound::WavReader::open(temp.path().join("capture.wav")).unwrap(); + assert_eq!( + wav.samples::().collect::, _>>().unwrap(), + vec![0.25, -0.25, 0.5, -0.5] + ); + eprintln!("PASSED API conformance {}", language.display_name()); + } + } + #[test] fn lua_and_luau_recording_handles_retain_samples_and_save_wav() { for language in [ScriptLanguage::Lua54, ScriptLanguage::Luau] { diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.c b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.c new file mode 100644 index 00000000..ebe2c762 --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.c @@ -0,0 +1,29 @@ +#include "rustic.h" +#include +RusticLoadedAudio audio; +RusticCaptureHandle rec; +void start(void){ + rustic.print("info");rustic.warn("warn"); + rustic.set_translation(3,2,1); + const char *id=rustic.entity_id(); + assert(strcmp(rustic.find_entity(id),id)==0); + assert(rustic.get_object_attribute(id,"Position").vector[0]==3); + RusticList ids=Game.scene.List(NULL);assert(ids.count==1 && strcmp(ids.items[0],id)==0); + assert(Clock.state().scale==1); + audio=Audio.load("assets/tone.wav"); + RusticAudioOptions options={.volume=1,.pitch=1,.loop=true}; + LoadedAudio.play(audio,&options);LoadedAudio.pause(audio);LoadedAudio.resume(audio);LoadedAudio.stop(audio);LoadedAudio.play(audio,&options); + rec=Game.startRecording("capture.wav",NULL); +} +void update(double dt){ + (void)dt; + LoadedAudio.play(audio,NULL);LoadedAudio.stop(audio);LoadedAudio.unload(&audio);LoadedAudio.unload(&audio); + Game.stopRecording(&rec); + RusticCaptureData packet=Capture.read(rec); + assert(packet.sampleRate==48000 && packet.channels==2 && !packet.active); + assert(packet.count==4 && packet.samples[0]==0.25 && packet.samples[3]==-0.5); + assert(Capture.read(rec).count==0); + assert(packet.samples[0]==0.25); /* Reads retain earlier packets during this callback. */ + Capture.unload(rec); +} +int main(void){return rustic_run((RusticBehavior){.on_start=start,.update=update});} diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.cpp b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.cpp new file mode 100644 index 00000000..01f403e3 --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.cpp @@ -0,0 +1,27 @@ +#include "rustic.hpp" +#include +std::optional audio; +RusticCaptureHandle rec; +void start(){ + rustic.print("info");rustic.warn("warn"); + rustic.set_translation(3,2,1); + auto id=rustic.entity_id(); + assert(rustic.find_entity(id)==id); + assert(rustic.get_object_attribute(id,"Position").array()[0].number()==3); + assert(Game.scene.List()==std::vector{id}); + assert(Clock::state().at("scale").number()==1); + audio=Audio::load("assets/tone.wav"); + assert(&audio->load()==&*audio); + audio->play(1,1,true);audio->pause();audio->resume();audio->stop();audio->play(1,1,true); + rec=Game.startRecording("capture.wav"); +} +void update(double){ + audio->play();audio->stop();audio->unload();audio->unload(); + Game.stopRecording(&rec); + auto packet=rec.read(); + assert(packet.at("sampleRate").number()==48000 && packet.at("channels").number()==2 && !packet.at("active").boolean()); + assert(packet.at("samples").array().size()==4 && packet.at("samples").array()[0].number()==0.25 && packet.at("samples").array()[3].number()==-0.5); + assert(rec.read().at("samples").array().empty()); + rec.unload(); +} +int main(){return rustic_run(RusticBehavior{.on_start=start,.update=update});} diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.cs b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.cs new file mode 100644 index 00000000..ab09919a --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.cs @@ -0,0 +1,28 @@ +using System; +using static Rustic; +class Program { + static Audio.LoadedAudio audio=null!; + static CaptureHandle rec=null!; + static void Main()=>Rustic.Run((callback,dt)=>{ + if(callback=="on_start"){ + rustic.print("info");rustic.warn("warn"); + rustic.set_translation(3,2,1); + string id=rustic.entity_id(); + if(rustic.find_entity(id)!=id || ((double[])rustic.get_object_attribute(id,"Position")!)[0]!=3)throw new Exception("live object read"); + if(System.Linq.Enumerable.Count(Game.scene.List())!=1)throw new Exception("scene list"); + if(Clock.state().GetProperty("scale").GetDouble()!=1)throw new Exception("clock"); + audio=Audio.load("assets/tone.wav"); + if(audio.load()!=audio)throw new Exception("load identity"); + audio.play(loop:true);audio.pause();audio.resume();audio.stop();audio.play(loop:true); + rec=Game.startRecording("capture.wav"); + }else if(callback=="update"){ + audio.play();audio.stop();audio.unload();audio.unload(); + Game.stopRecording(rec); + var packet=rec.read(); + var samples=packet.GetProperty("samples"); + if(packet.GetProperty("sampleRate").GetInt32()!=48000 || packet.GetProperty("channels").GetInt32()!=2 || packet.GetProperty("active").GetBoolean() || samples.GetArrayLength()!=4 || samples[0].GetDouble()!=0.25 || samples[3].GetDouble()!=-0.5)throw new Exception("PCM"); + if(rec.read().GetProperty("samples").GetArrayLength()!=0)throw new Exception("cursor"); + rec.unload(); + } + }); +} diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.html b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.html new file mode 100644 index 00000000..1ce62fb1 --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.html @@ -0,0 +1,25 @@ + diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.java b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.java new file mode 100644 index 00000000..0cc3aec0 --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.java @@ -0,0 +1,28 @@ +class RusticBehavior extends Rustic { + static Audio.LoadedAudio audio; + static CaptureHandle rec; + public static void main(String[] args) throws Exception { + run((callback,dt)->{ + if(callback.equals("on_start")){ + rustic.print("info");rustic.warn("warn"); + rustic.set_translation(3,2,1); + String id=rustic.entity_id(); + if(!id.equals(rustic.find_entity(id)) || ((double[])rustic.get_object_attribute(id,"Position"))[0]!=3)throw new AssertionError("live object read"); + if(!Game.scene.List().equals(java.util.List.of(id)))throw new AssertionError("scene list"); + if(((Number)((java.util.Map)Clock.state()).get("scale")).doubleValue()!=1)throw new AssertionError("clock"); + audio=Audio.load("assets/tone.wav"); + if(audio.load()!=audio)throw new AssertionError("load identity"); + audio.play(1,1,true);audio.pause();audio.resume();audio.stop();audio.play(1,1,true); + rec=Game.startRecording("capture.wav"); + }else if(callback.equals("update")){ + audio.play();audio.stop();audio.unload();audio.unload(); + Game.stopRecording(rec); + var packet=(java.util.Map)rec.read(); + var samples=(java.util.List)packet.get("samples"); + if(((Number)packet.get("sampleRate")).intValue()!=48000 || ((Number)packet.get("channels")).intValue()!=2 || (Boolean)packet.get("active") || samples.size()!=4 || ((Number)samples.get(0)).doubleValue()!=0.25 || ((Number)samples.get(3)).doubleValue()!=-0.5)throw new AssertionError("PCM"); + if(!((java.util.List)((java.util.Map)rec.read()).get("samples")).isEmpty())throw new AssertionError("cursor"); + rec.unload(); + } + }); + } +} diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.js b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.js new file mode 100644 index 00000000..3df6ed1e --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.js @@ -0,0 +1,24 @@ +let audio,rec; +globalThis.behavior={Start(){ + rustic.print("info");rustic.warn("warn"); + rustic.set_translation(3,2,1); + const id=rustic.entity_id(); + if(rustic.find_entity(id)!==id || rustic.get_object_attribute(id,'Position')[0]!==3)throw Error('live object read'); + if(Game.scene.List().length!==1 || Game.scene.List()[0]!==id)throw Error('scene list IDs'); + if(Clock.state().scale!==1)throw Error('clock'); + const asset=rustic.game.assets["tone.wav"]; + const loaded=asset.load(); + if(loaded.load()!==loaded)throw Error('proxy load identity'); + asset.play();asset.stop();asset.unload(); + audio=Audio.load('assets/tone.wav'); + if(audio.load()!==audio)throw Error('load identity'); + audio.play({loop:true});audio.pause();audio.resume();audio.stop();audio.play({loop:true}); + rec=Game.startRecording('capture.wav'); +},Update(){ + audio.play();audio.stop();audio.unload();audio.unload(); + Game.stopRecording(rec); + const packet=rec.read(); + if(packet.sampleRate!==48000 || packet.channels!==2 || packet.active || packet.samples.length!==4 || packet.samples[0]!==0.25 || packet.samples[3]!==-0.5)throw Error('PCM'); + if(rec.read().samples.length!==0)throw Error('cursor'); + rec.unload(); +}}; diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.lua b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.lua new file mode 100644 index 00000000..2a0f6dfc --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.lua @@ -0,0 +1,22 @@ +local audio,rec +return {Start=function() + rustic.print("info");rustic.warn("warn") + rustic.set_translation(3,2,1) + local id=rustic.entity_id() + assert(rustic.find_entity(id)==id) + assert(rustic.get_object_attribute(id,'Position')[1]==3) + assert(#Game.scene.List()==1) + assert(Clock.state().scale==1) + audio=Audio.load('assets/tone.wav') + assert(audio:load()==audio) + audio:play({loop=true});audio:pause();audio:resume();audio:stop();audio:play({loop=true}) + rec=Game.startRecording('capture.wav') +end,Update=function() + audio:play();audio:stop();audio:unload();audio:unload() + Game.stopRecording(rec) + local packet=rec:read() + assert(packet.sampleRate==48000 and packet.channels==2 and not packet.active) + assert(#packet.samples==4 and packet.samples[1]==0.25 and packet.samples[4]==-0.5) + assert(#rec:read().samples==0) + rec:unload() +end} diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.luau b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.luau new file mode 100644 index 00000000..2a0f6dfc --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.luau @@ -0,0 +1,22 @@ +local audio,rec +return {Start=function() + rustic.print("info");rustic.warn("warn") + rustic.set_translation(3,2,1) + local id=rustic.entity_id() + assert(rustic.find_entity(id)==id) + assert(rustic.get_object_attribute(id,'Position')[1]==3) + assert(#Game.scene.List()==1) + assert(Clock.state().scale==1) + audio=Audio.load('assets/tone.wav') + assert(audio:load()==audio) + audio:play({loop=true});audio:pause();audio:resume();audio:stop();audio:play({loop=true}) + rec=Game.startRecording('capture.wav') +end,Update=function() + audio:play();audio:stop();audio:unload();audio:unload() + Game.stopRecording(rec) + local packet=rec:read() + assert(packet.sampleRate==48000 and packet.channels==2 and not packet.active) + assert(#packet.samples==4 and packet.samples[1]==0.25 and packet.samples[4]==-0.5) + assert(#rec:read().samples==0) + rec:unload() +end} diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.php b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.php new file mode 100644 index 00000000..d16bf761 --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.php @@ -0,0 +1,27 @@ +print("info");$rustic->warn("warn"); + $rustic->set_translation(3,2,1); + $id=$rustic->entity_id(); + assert($rustic->find_entity($id)===$id); + assert($rustic->get_object_attribute($id,'Position')[0]==3); + if($Game->scene->List()!==[$id])throw new Exception('scene list'); + assert(Clock::state()['scale']==1); + $audio=Audio::load('assets/tone.wav'); + assert($audio->load()===$audio); + $audio->play(['loop'=>true]);$audio->pause();$audio->resume();$audio->stop();$audio->play(['loop'=>true]); + $rec=$Game->startRecording('capture.wav'); + }elseif($callback==='update'){ + $audio->play();$audio->stop();$audio->unload();$audio->unload(); + $Game->stopRecording($rec); + $packet=$rec->read(); + if($packet['sampleRate']!==48000 || $packet['channels']!==2 || $packet['active'] || $packet['samples']!==[0.25,-0.25,0.5,-0.5])throw new Exception('PCM'); + if($rec->read()['samples']!==[])throw new Exception('cursor'); + $rec->unload(); + } +} +rustic_run(["on_start"=>fn()=>parity("on_start",0),"update"=>fn($dt)=>parity("update",$dt)]); diff --git a/Engine/crates/engine-play/tests/fixtures/api-parity/parity.py b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.py new file mode 100644 index 00000000..8cf9ca74 --- /dev/null +++ b/Engine/crates/engine-play/tests/fixtures/api-parity/parity.py @@ -0,0 +1,24 @@ +from rustic import rustic, Audio, Game, Clock, run +audio=rec=None +def on_start(): + global audio,rec + rustic.print("info");rustic.warn("warn") + rustic.set_translation(3,2,1) + id=rustic.entity_id() + assert rustic.find_entity(id)==id + assert rustic.get_object_attribute(id,'Position')[0]==3 + assert Game.scene.List()==[id] + assert Clock.state()['scale']==1 + audio=Audio.load('assets/tone.wav') + assert audio.load() is audio + audio.play({'loop':True});audio.pause();audio.resume();audio.stop();audio.play({'loop':True}) + rec=Game.startRecording('capture.wav') +def update(dt): + audio.play();audio.stop();audio.unload();audio.unload() + Game.stopRecording(rec) + packet=rec.read() + assert packet['sampleRate']==48000 and packet['channels']==2 and not packet['active'] + assert packet['samples']==[0.25,-0.25,0.5,-0.5] + assert rec.read()['samples']==[] + rec.unload() +run(globals()) diff --git a/Engine/crates/engine-scripting/src/cpp_sdk.rs b/Engine/crates/engine-scripting/src/cpp_sdk.rs index 23d97112..32161cee 100644 --- a/Engine/crates/engine-scripting/src/cpp_sdk.rs +++ b/Engine/crates/engine-scripting/src/cpp_sdk.rs @@ -78,11 +78,23 @@ struct RusticVector3 { double x=0,y=0,z=0; }; struct RusticActionState { bool pressed=false,released=false,held=false; double axis=0; }; struct RusticKeyEvent { std::string key,state; bool repeat=false; }; +struct RusticCaptureHandle { + std::string id; bool recording; + RusticValue read(); + void unload(); + void stop(); +}; class ObjectPath { std::vector** commands; std::string source; public: ObjectPath(std::vector** commands,std::string source="rustic.game"):commands(commands),source(std::move(source)){} ObjectPath operator[](const std::string& name)const{return ObjectPath(commands,source+"."+name);} + RusticValue GetAttribute(const std::string& name) const; + RusticValue get_attribute(const std::string& name) const{return GetAttribute(name);} + RusticCaptureHandle startRecording(const RusticValue& path=nullptr,const std::string& source="game") const; + void stopRecording(const RusticCaptureHandle* handle=nullptr) const; + RusticCaptureHandle startHotMic() const; + void stopHotMic(const RusticCaptureHandle* handle=nullptr) const; void EditAttribute(const std::string& name,RusticValue value){(*commands)->push_back(rustic_detail::object({{"op","edit_attribute"},{"source",source},{"name",name},{"value",std::move(value)}}));} void edit_attribute(const std::string& name,RusticValue value){EditAttribute(name,std::move(value));} }; @@ -97,6 +109,9 @@ public: RusticValue gameplay_connections()const{return state->at("gameplay_connections");} RusticValue gameplay_states()const{return state->at("gameplay_states");} RusticValue gameplay_callbacks()const{return state->at("gameplay_callbacks");} + std::optional find_entity(const std::string& source){auto v=query(rustic_detail::object({{"op","find_entity"},{"source",source}}));return v.is_null()?std::nullopt:std::optional(v.string());} + void edit_object_attribute(const std::string& source,const std::string& name,RusticValue value){commands->push_back(rustic_detail::object({{"op","edit_attribute"},{"source",source},{"name",name},{"value",std::move(value)}}));} + RusticValue get_object_attribute(const std::string& source,const std::string& name){return query(rustic_detail::object({{"op","object_attribute"},{"source",source},{"name",name}}));} std::string entity_id() const{return state->at("entity_id").string();} double delta_time() const{return state->at("delta_time").number();} double fixed_delta_time() const{return state->at("fixed_delta_time").number();} RusticVector3 get_translation() const{auto&a=state->at("translation").array();return{a[0].number(),a[1].number(),a[2].number()};} void set_translation(double x,double y,double z){commands->push_back(rustic_detail::object({{"op","set_translation"},{"value",RusticValue::Array{x,y,z}}}));} @@ -105,15 +120,34 @@ public: void EditAttribute(const std::string& n,RusticValue v){commands->push_back(rustic_detail::object({{"op","edit_attribute"},{"name",n},{"value",std::move(v)}}));} void edit_attribute(const std::string& n,RusticValue v){EditAttribute(n,std::move(v));} RusticActionState input(const std::string& n)const{return action(lookup("actions",n));} RusticActionState key(const std::string& n)const{return action(lookup("keys",n));} std::vector key_events()const{std::vector out;for(auto&v:state->at("key_events").array()){auto&o=v.object();out.push_back({o.at("key").string(),o.at("state").string(),o.at("repeat").boolean()});}return out;} - bool any_key_pressed()const{return state->at("any_key_pressed").boolean();} void log(const std::string& l,const std::string&m){commands->push_back(rustic_detail::object({{"op","log"},{"level",l},{"message",m}}));} void set_enabled(bool e){commands->push_back(rustic_detail::object({{"op","set_enabled"},{"enabled",e}}));} + bool any_key_pressed()const{return state->at("any_key_pressed").boolean();} void print(const std::string& message){log("info",message);}void warn(const std::string& message){log("warn",message);}void log(const std::string& l,const std::string&m){commands->push_back(rustic_detail::object({{"op","log"},{"level",l},{"message",m}}));} void set_enabled(bool e){commands->push_back(rustic_detail::object({{"op","set_enabled"},{"enabled",e}}));} }; class InstanceApi {friend int rustic_run(const struct RusticBehavior&);std::vector*commands=nullptr;void push(const char*op,const std::string&s,const std::optional&p){commands->push_back(rustic_detail::object({{"op",op},{"source",s},{"parent",p?RusticValue(*p):RusticValue()}}));}public:void add(const std::string&s,std::optionalp={}){push("add_instance",s,p);}void clone(const std::string&s,std::optionalp={}){push("clone_instance",s,p);}}; class SceneApi {public: + RusticValue getEnvironment(const std::string& field) const; RusticValue getEnvironment() const; RusticValue setEnvironment(RusticValue::Object settings) const; RusticValue setSkyTexture(const std::string& path) const; -private:friend int rustic_run(const struct RusticBehavior&);const RusticValue*state=nullptr;public:std::optional Find(const std::string&p)const{auto&o=state->object();auto i=o.find(p);return i==o.end()?std::nullopt:std::optional(i->second.string());}std::vector List(const std::string&p="Game.scene")const{std::vectorr;std::string prefix=p=="Game.scene"?"":p+".";for(auto&[n,v]:state->object())if(prefix.empty()||n.starts_with(prefix))r.push_back(v.string());return r;}}; -class GameApi {friend int rustic_run(const struct RusticBehavior&);std::vector*commands=nullptr;public:SceneApi scene;void setCurrentCamera(const std::string&source){commands->push_back(rustic_detail::object({{"op","set_current_camera"},{"source",source}}));}}; inline RusticApi rustic; inline InstanceApi instance; inline GameApi Game; +private:friend int rustic_run(const struct RusticBehavior&);const RusticValue*state=nullptr;public:std::optional Find(const std::string&p)const{auto&o=state->object();auto i=o.find(p);return i==o.end()?std::nullopt:std::optional(i->second.string());}std::vector List(const std::string&p="Game.scene")const{std::vectorr;std::string prefix=(p.empty()?"Game.scene":p)+".";for(auto&[n,v]:state->object())if(prefix.empty()||n.starts_with(prefix))r.push_back(v.string());return r;}}; +class GameApi {friend int rustic_run(const struct RusticBehavior&);std::vector*commands=nullptr;public:SceneApi scene; + RusticCaptureHandle startRecording(const RusticValue& path=nullptr,const std::string& source="game") const; + void stopRecording(const RusticCaptureHandle* handle=nullptr) const; + RusticCaptureHandle startHotMic() const; + void stopHotMic(const RusticCaptureHandle* handle=nullptr) const; + void setCurrentCamera(const std::string&source){commands->push_back(rustic_detail::object({{"op","set_current_camera"},{"source",source}}));}}; inline RusticApi rustic; inline InstanceApi instance; inline GameApi Game; +inline RusticValue RusticCaptureHandle::read(){return rustic.query(rustic_detail::object({{"op","capture_read"},{"id",id}}));} +inline void RusticCaptureHandle::unload(){rustic.query(rustic_detail::object({{"op","capture_unload"},{"id",id}}));} +inline void RusticCaptureHandle::stop(){rustic.query(rustic_detail::object({{"op",recording?"capture_recording_stop":"capture_mic_stop"},{"id",id}}));} +inline RusticCaptureHandle GameApi::startRecording(const RusticValue& path,const std::string& source) const{return {rustic.query(rustic_detail::object({{"op","capture_recording_start"},{"path",path},{"source",source}})).string(),true};} +inline void GameApi::stopRecording(const RusticCaptureHandle* handle) const{rustic.query(rustic_detail::object({{"op","capture_recording_stop"},{"id",handle?RusticValue(handle->id):RusticValue{}}}));} +inline RusticCaptureHandle GameApi::startHotMic() const{return {rustic.query(rustic_detail::object({{"op","capture_mic_start"}})).string(),false};} +inline void GameApi::stopHotMic(const RusticCaptureHandle* handle) const{rustic.query(rustic_detail::object({{"op","capture_mic_stop"},{"id",handle?RusticValue(handle->id):RusticValue{}}}));} +inline RusticValue ObjectPath::GetAttribute(const std::string& name) const{return rustic.get_object_attribute(source,name);} +inline RusticCaptureHandle ObjectPath::startRecording(const RusticValue& path,const std::string& source) const{return Game.startRecording(path,source);} +inline void ObjectPath::stopRecording(const RusticCaptureHandle* handle) const{Game.stopRecording(handle);} +inline RusticCaptureHandle ObjectPath::startHotMic() const{return Game.startHotMic();} +inline void ObjectPath::stopHotMic(const RusticCaptureHandle* handle) const{Game.stopHotMic(handle);} +inline RusticValue SceneApi::getEnvironment(const std::string& field) const{return rustic.query(rustic_detail::object({{"op","scene_environment_field"},{"field",field}}));} inline RusticValue SceneApi::getEnvironment() const {return rustic.query(rustic_detail::object({{"op","scene_environment"}}));} inline RusticValue SceneApi::setEnvironment(RusticValue::Object settings) const {return rustic.query(rustic_detail::object({{"op","scene_environment"},{"settings",std::move(settings)}}));} inline RusticValue SceneApi::setSkyTexture(const std::string& path) const {return setEnvironment({{"sky_image",path}});} @@ -255,7 +289,7 @@ inline auto flash(const std::string&e,RusticVector3 color,double d,const std::st inline auto pulse(const std::string&e,RusticVector3 scale,double d,const std::string& ease="Linear"){return Gameplay::start(Tween::action(e,"Scale",Physics::vec(scale),d,ease),false,1,true);} inline auto shake(const std::string&e,double strength,double duration,const std::string& easing="Linear"){return Gameplay::start(rustic_detail::object({{"kind","shake"},{"entity",Gameplay::entity(e)},{"strength",strength},{"duration",duration},{"easing",easing}}));} } -namespace Clock{inline void timeScale(double v){rustic.query(rustic_detail::object({{"op","clock"},{"scale",v}}));}inline void pause(){rustic.query(rustic_detail::object({{"op","clock"},{"paused",true}}));}inline void resume(){rustic.query(rustic_detail::object({{"op","clock"},{"paused",false}}));}} +namespace Clock{inline RusticValue state(){return rustic.query(rustic_detail::object({{"op","clock"}}));}inline double timeScale(){return state().at("scale").number();}inline double timeScale(double v){return rustic.query(rustic_detail::object({{"op","clock"},{"scale",v}})).at("scale").number();}inline void pause(){rustic.query(rustic_detail::object({{"op","clock"},{"paused",true}}));}inline void resume(){rustic.query(rustic_detail::object({{"op","clock"},{"paused",false}}));}} namespace Camera{inline std::string current(){return rustic.query(rustic_detail::object({{"op","camera_current"}})).string();} using Movement::moveTo;using Movement::follow;using Movement::lookAt;using Movement::orbit;using Effects::shake; inline auto zoom(const std::string&e,double f,double d,const std::string& ease="Linear"){return Tween::to(e,"Fov",f,d,ease);} @@ -276,6 +310,20 @@ inline RusticValue get_input_devices(){return rustic.query(rustic_detail::object inline void set_output_device(const RusticValue& device=nullptr){rustic.query(rustic_detail::object({{"op","audio_set_output_device"},{"device",device}}));} inline void set_input_device(const RusticValue& device=nullptr){rustic.query(rustic_detail::object({{"op","audio_set_input_device"},{"device",device}}));} struct Voice{std::string id;void stop(){rustic.query(rustic_detail::object({{"op","audio_stop"},{"entity",id}}));}void pause(){rustic.query(rustic_detail::object({{"op","audio_pause"},{"entity",id}}));}void resume(){rustic.query(rustic_detail::object({{"op","audio_resume"},{"entity",id}}));}}; +class LoadedAudio { + std::string source; std::shared_ptr> id=std::make_shared>(); + const std::string& loaded()const{if(!*id)throw std::runtime_error("audio is not loaded; call load() before play()");return **id;} +public: + explicit LoadedAudio(std::string source):source(std::move(source)){} + LoadedAudio& load(){if(!*id)*id=rustic.query(rustic_detail::object({{"op","audio_load"},{"source",source}})).string();return *this;} + Voice play(double volume=1,double pitch=1,bool loop=false,std::optional position={}){return {rustic.query(rustic_detail::object({{"op","audio_loaded_play"},{"entity",loaded()},{"volume",volume},{"pitch",pitch},{"loop",loop},{"position",position?Physics::vec(*position):RusticValue{}}})).string()};} + void stop(){rustic.query(rustic_detail::object({{"op","audio_loaded_stop"},{"entity",loaded()}}));} + void pause(){rustic.query(rustic_detail::object({{"op","audio_loaded_pause"},{"entity",loaded()}}));} + void resume(){rustic.query(rustic_detail::object({{"op","audio_loaded_resume"},{"entity",loaded()}}));} + void unload(){if(*id){rustic.query(rustic_detail::object({{"op","audio_unload"},{"entity",**id}}));id->reset();}} + void Unload(){unload();} +}; +inline LoadedAudio load(const std::string&source){LoadedAudio audio(source);audio.load();return audio;} inline Voice play(const std::string&source,double volume=1,double pitch=1,bool loop=false){return {rustic.query(rustic_detail::object({{"op","audio_play"},{"source",source},{"volume",volume},{"pitch",pitch},{"loop",loop}})).string()};} inline Voice playAt(const std::string&source,RusticVector3 position,double volume=1,double pitch=1,bool loop=false){return {rustic.query(rustic_detail::object({{"op","audio_play"},{"source",source},{"position",Physics::vec(position)},{"volume",volume},{"pitch",pitch},{"loop",loop}})).string()};} inline void volume(const Voice&v,double value){rustic.query(rustic_detail::object({{"op","audio_volume"},{"entity",v.id},{"value",value}}));}inline void pitch(const Voice&v,double value){rustic.query(rustic_detail::object({{"op","audio_pitch"},{"entity",v.id},{"value",value}}));} diff --git a/Engine/crates/engine-scripting/src/external_runtime.rs b/Engine/crates/engine-scripting/src/external_runtime.rs index 8e3ee083..7791f58b 100644 --- a/Engine/crates/engine-scripting/src/external_runtime.rs +++ b/Engine/crates/engine-scripting/src/external_runtime.rs @@ -1210,6 +1210,19 @@ mod tests { fn set_enabled(&mut self, _enabled: bool) {} fn gameplay_query(&mut self, query: Value) -> Result { match query["op"].as_str().unwrap_or("") { + "capture_mic_start" => Ok(json!("mic-1")), + "capture_read" => { + assert_eq!(query["id"], "mic-1"); + Ok( + json!({"samples":[0.25,-0.5],"sampleRate":48000,"channels":1, + "active":true,"droppedSamples":7}), + ) + } + "capture_mic_stop" | "capture_unload" => { + assert_eq!(query["id"], "mic-1"); + self.translation[0] += 1.0; + Ok(Value::Null) + } "audio_get_output_devices" => Ok(json!([ {"id":"output:1","name":"USB \"speaker\" \\ 雪"}, {"id":"output:2","name":"USB \"speaker\" \\ 雪"} @@ -1234,6 +1247,88 @@ mod tests { } } + #[test] + fn microphone_sdks_preserve_pcm_packets_and_route_handle_controls() { + let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ + (ScriptLanguage::Python, "mic.py", br"from rustic import Game,run +def on_start(): + mic=Game.startHotMic() + p=mic.read() + assert p['samples']==[0.25,-0.5] and p['sampleRate']==48000 and p['channels']==1 and p['active'] and p['droppedSamples']==7 + Game.stopHotMic(mic) + mic.unload() +run(globals()) +"), + (ScriptLanguage::Luau, "mic.luau", br"return {Start=function() + local mic=Game.startHotMic() + local p=mic:read() + assert(#p.samples==2 and p.samples[1]==0.25 and p.samples[2]==-0.5 and p.sampleRate==48000 and p.channels==1 and p.active and p.droppedSamples==7) + Game.stopHotMic(mic) + mic:unload() +end} +"), + (ScriptLanguage::JavaScript, "mic.js", br"globalThis.behavior={Start(){ + const mic=rustic.game.startHotMic(); + const p=mic.read(); + if(p.samples.length!==2||p.samples[0]!==0.25||p.samples[1]!==-0.5||p.sampleRate!==48000||p.channels!==1||!p.active||p.droppedSamples!==7)throw Error('PCM'); + Game.stopHotMic(mic);mic.unload(); +}}; +"), + (ScriptLanguage::C, "mic.c", br#"#include "rustic.h" +#include +void start(void){RusticCaptureHandle mic=rustic.game.startHotMic();RusticCaptureData p=Capture.read(mic);assert(p.count==2&&p.samples[0]==0.25&&p.samples[1]==-0.5&&p.sampleRate==48000&&p.channels==1&&p.active&&p.droppedSamples==7);Game.stopHotMic(&mic);Capture.unload(mic);} +int main(void){return rustic_run((RusticBehavior){.on_start=start});} +"#), + (ScriptLanguage::Cpp, "mic.cpp", br#"#include "rustic.hpp" +#include +void start(){auto mic=rustic.game.startHotMic();auto p=mic.read();assert(p.at("samples").array().size()==2&&p.at("samples").array()[0].number()==0.25&&p.at("samples").array()[1].number()==-0.5&&p.at("sampleRate").number()==48000&&p.at("channels").number()==1&&p.at("active").boolean()&&p.at("droppedSamples").number()==7);Game.stopHotMic(&mic);mic.unload();} +int main(){return rustic_run(RusticBehavior{.on_start=start});} +"#), + (ScriptLanguage::CSharp, "mic.cs", br#"using static Rustic; +class Program{static void Main()=>Rustic.Run((c,dt)=>{if(c!="on_start")return;var mic=rustic.game.startHotMic();var p=mic.read();var samples=p.GetProperty("samples");if(samples.GetArrayLength()!=2||samples[0].GetDouble()!=0.25||samples[1].GetDouble()!=-0.5||p.GetProperty("sampleRate").GetInt32()!=48000||p.GetProperty("channels").GetInt32()!=1||!p.GetProperty("active").GetBoolean()||p.GetProperty("droppedSamples").GetInt32()!=7)throw new System.Exception("PCM");Game.stopHotMic(mic);mic.unload();});} +"#), + (ScriptLanguage::Java, "mic.java", br#"class RusticBehavior extends Rustic{public static void main(String[] args)throws Exception{run((c,dt)->{if(!c.equals("on_start"))return;var mic=rustic.game.startHotMic();var p=(java.util.Map)mic.read();var samples=(java.util.List)p.get("samples");if(samples.size()!=2||((Number)samples.get(0)).doubleValue()!=0.25||((Number)samples.get(1)).doubleValue()!=-0.5||((Number)p.get("sampleRate")).intValue()!=48000||((Number)p.get("channels")).intValue()!=1||!(Boolean)p.get("active")||((Number)p.get("droppedSamples")).intValue()!=7)throw new AssertionError("PCM");Game.stopHotMic(mic);mic.unload();});}} +"#), + (ScriptLanguage::Php, "mic.php", br"function(){global $rustic,$Game;$mic=$rustic->game->startHotMic();$p=$mic->read();if($p['samples']!==[0.25,-0.5]||$p['sampleRate']!==48000||$p['channels']!==1||!$p['active']||$p['droppedSamples']!==7)throw new Exception('PCM');$Game->stopHotMic($mic);$mic->unload();}]); +"), + ]; + for &(language, name, source) in fixtures { + if language != ScriptLanguage::JavaScript + && !probe_language_toolchain(language).available + { + assert!( + std::env::var_os("RUSTIC_REQUIRE_ALL_API_ADAPTERS").is_none(), + "{} toolchain required for capture conformance", + language.display_name() + ); + continue; + } + let host: Box = Box::new(Host { + entity: EntityId::new(), + translation: [0.0; 3], + }); + let host = if language == ScriptLanguage::JavaScript { + let mut behavior = + JavaScriptBehavior::load(ScriptId::new(), source, name, host, 100_000).unwrap(); + behavior.on_start().unwrap(); + behavior.host() + } else { + let mut behavior = + ExternalBehavior::load(ScriptId::new(), language, source, name, host).unwrap(); + behavior.on_start().unwrap(); + behavior.host() + }; + assert_eq!( + host.lock().unwrap().translation(), + [2.0, 0.0, 0.0], + "{}", + language.display_name() + ); + } + } + #[test] fn audio_device_sdks_preserve_records_across_queries_and_select_defaults() { let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ diff --git a/Engine/crates/engine-scripting/src/javascript.rs b/Engine/crates/engine-scripting/src/javascript.rs index a90d59bc..990f7e14 100644 --- a/Engine/crates/engine-scripting/src/javascript.rs +++ b/Engine/crates/engine-scripting/src/javascript.rs @@ -9,15 +9,28 @@ const BRIDGE: &str = r#" (() => { let state = Object.create(null); let commands = []; - const objectAt = source => new Proxy(Object.create(null), {get: (_, key) => { + const objectAt = (source,assetPath="") => new Proxy(Object.create(null), {get: (_, key) => { + if (Object.hasOwn(_,key)) return _[key]; if (key === "__source") return source; + if (source === "rustic.game" && ["startRecording","stopRecording","startHotMic","stopHotMic"].includes(key)) return (...args) => RusticCapture[key](...args); + if (["load","play","stop","pause","resume","Unload","unload"].includes(key) && source.startsWith("rustic.game.assets.")) { + const audio=new RusticLoadedAudio(assetPath); + for (const method of ["load","play","stop","pause","resume","Unload","unload"]) _[method]=audio[method].bind(audio); + return _[key]; + } + if (key === "GetAttribute" || key === "get_attribute") return name => rustic.query({op:"object_attribute",source,name}); if (key === "EditAttribute" || key === "edit_attribute") return (name, value) => commands.push({op:"edit_attribute", source, name, value}); if (typeof key !== "string") return undefined; - return objectAt(source + "." + key); + return objectAt(source + "." + key, assetPath ? assetPath + "/" + key : key); }}); const rustic = Object.freeze({ game: objectAt("rustic.game"), - query: request => JSON.parse(__rustic_query(JSON.stringify(request))), + query: request => { + const pending=commands; commands=[]; + return JSON.parse(__rustic_query(JSON.stringify({query:request,commands:pending}))); + }, + find_entity: source => rustic.query({op:"find_entity",source}), + get_object_attribute: (source,name) => rustic.query({op:"object_attribute",source,name}), gameplay: request => commands.push({op:"gameplay",request}), gameplay_connections: () => state.gameplay_connections || [], gameplay_states: () => state.gameplay_states || [], @@ -38,6 +51,8 @@ const BRIDGE: &str = r#" key: key => Object.freeze(state.keys[String(key)] || {pressed:false, released:false, held:false, axis:0}), key_events: () => Object.freeze(state.key_events.map(event => Object.freeze({...event}))), any_key_pressed: () => state.any_key_pressed, + print: (...values) => rustic.log("info",values.map(String).join(" ")), + warn: (...values) => rustic.log("warn",values.map(String).join(" ")), log: (level, message) => commands.push({op:"log", level:String(level), message:String(message)}), set_enabled: enabled => commands.push({op:"set_enabled", enabled:Boolean(enabled)}) }); @@ -55,7 +70,7 @@ const BRIDGE: &str = r#" debug: writeLog("debug") }), writable:false, configurable:false}); const scene = new Proxy(Object.create(null), {get: (_, key) => { - if (key === "getEnvironment") return () => rustic.query({op:"scene_environment"}); + if (key === "getEnvironment") return field => rustic.query(field===undefined?{op:"scene_environment"}:{op:"scene_environment_field",field}); if (key === "setEnvironment") return settings => { if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new TypeError("environment settings must be an object"); return rustic.query({op:"scene_environment", settings}); @@ -65,10 +80,10 @@ const BRIDGE: &str = r#" return rustic.query({op:"scene_environment", settings:{sky_image:path}}); }; if (key === "Find") return path => state.scene_paths[path]; - if (key === "List") return () => Object.keys(state.scene_paths); + if (key === "List") return (path="Game.scene") => Object.entries(state.scene_paths).filter(([name])=>name.startsWith((path||"Game.scene").replace(/[./]+$/,"")+".")).map(([,id])=>id); return state.scene_paths[String(key)]; }}); - Object.defineProperty(globalThis, "Game", {value:Object.freeze({scene, setCurrentCamera: source => { + Object.defineProperty(globalThis, "Game", {value:Object.freeze({scene, startRecording:(...a)=>RusticCapture.startRecording(...a),stopRecording:(...a)=>RusticCapture.stopRecording(...a),startHotMic:()=>RusticCapture.startHotMic(),stopHotMic:(...a)=>RusticCapture.stopHotMic(...a), setCurrentCamera: source => { if (typeof source !== "string") throw new TypeError("expected a camera path or entity ID"); commands.push({op:"set_current_camera", source}); }}), writable:false, configurable:false}); @@ -182,12 +197,18 @@ impl JavaScriptBehavior { ctx.clone(), move |request: String| -> rquickjs::Result { let response = (|| -> Result { - let request = + let envelope: serde_json::Value = serde_json::from_str(&request).map_err(|e| e.to_string())?; - query_host - .lock() - .map_err(|_| "gameplay lock poisoned")? - .gameplay_query(request) + let commands: Vec = + serde_json::from_value(envelope["commands"].clone()) + .map_err(|e| e.to_string())?; + let mut host = + query_host.lock().map_err(|_| "gameplay lock poisoned")?; + for command in commands { + apply_command(host.as_mut(), command) + .map_err(|e| e.to_string())?; + } + host.gameplay_query(envelope["query"].clone()) })(); match response { Ok(value) => serde_json::to_string(&value) diff --git a/Engine/crates/engine-scripting/src/sdk/Rustic.cs b/Engine/crates/engine-scripting/src/sdk/Rustic.cs index 12284421..36cbcb61 100644 --- a/Engine/crates/engine-scripting/src/sdk/Rustic.cs +++ b/Engine/crates/engine-scripting/src/sdk/Rustic.cs @@ -1,3 +1,4 @@ +global using Interpolation = Rustic.Smooth; global using Timer = Rustic.Timer; global using Path = Rustic.Path; using System; @@ -207,6 +208,7 @@ public static class Effects { public static Gameplay.Handle fade(string? entity,double opacity,double duration,string easing="Linear")=>Tween.to(entity,"Opacity",opacity,duration,easing); } public static class Clock { + public static JsonElement state()=>Query(new{op="clock"}); public static double timeScale(double? scale=null)=>Query(scale.HasValue?new {op="clock",scale=scale.Value}:(object)new{op="clock"}).GetProperty("scale").GetDouble(); public static void pause()=>Query(new{op="clock",paused=true});public static void resume()=>Query(new{op="clock",paused=false}); } @@ -243,7 +245,29 @@ public record Options(double speed=1,double blendIn=0,double blendOut=0,string e public static Gameplay.Handle headTracking(string e,double[] target,double duration=0,string easing="Linear")=>Movement.lookAt(e,target,duration,easing); public static Gameplay.Handle recoil(string e,double[] rotation,double duration,string easing="Linear")=>Gameplay.Start(Tween.Action(e,"Rotation",rotation,duration,easing),false,1,true); } + public sealed class CaptureHandle { + public string Id {get;} + private readonly bool recording; + internal CaptureHandle(string id,bool recording){Id=id;this.recording=recording;} + public JsonElement read()=>Query(new{op="capture_read",id=Id}); + public void unload()=>Query(new{op="capture_unload",id=Id}); + public void stop()=>Query(new{op=recording?"capture_recording_stop":"capture_mic_stop",id=Id}); + } public static class Audio{ + public sealed class LoadedAudio { + private readonly string source; + private string? id; + internal LoadedAudio(string source){this.source=source;} + public LoadedAudio load(){if(id is null)id=Query(new{op="audio_load",source}).GetString()!;return this;} + private string Loaded()=>id??throw new InvalidOperationException("audio is not loaded; call load() before play()"); + public Voice play(double volume=1,double pitch=1,bool loop=false,double[]? position=null)=>new(Query(new{op="audio_loaded_play",entity=Loaded(),volume,pitch,loop,position}).GetString()!); + public void stop()=>Query(new{op="audio_loaded_stop",entity=Loaded()}); + public void pause()=>Query(new{op="audio_loaded_pause",entity=Loaded()}); + public void resume()=>Query(new{op="audio_loaded_resume",entity=Loaded()}); + public void unload(){if(id is not null){Query(new{op="audio_unload",entity=id});id=null;}} + public void Unload()=>unload(); + } + public static LoadedAudio load(string source)=>new LoadedAudio(source).load(); public static JsonElement get_output_devices()=>Query(new{op="audio_get_output_devices"}); public static JsonElement get_input_devices()=>Query(new{op="audio_get_input_devices"}); public static void set_output_device(string? device=null)=>Query(new{op="audio_set_output_device",device}); @@ -293,6 +317,12 @@ sealed class ObjectPath : System.Dynamic.DynamicObject { public ObjectPath(List commands, string source="rustic.game") { this.commands=commands; this.source=source; } public override bool TryGetMember(System.Dynamic.GetMemberBinder binder, out object? result) { result=new ObjectPath(commands,source+"."+binder.Name); return true; } public ObjectPath this[string name] => new(commands,source+"."+name); + public object? GetAttribute(string name)=>RusticApi.ConvertValue(Rustic.Query(new{op="object_attribute",source,name})); + public object? get_attribute(string name)=>GetAttribute(name); + public Rustic.CaptureHandle startRecording(string? path=null,string source="game")=>Rustic.Game.startRecording(path,source); + public void stopRecording(Rustic.CaptureHandle? handle=null)=>Rustic.Game.stopRecording(handle); + public Rustic.CaptureHandle startHotMic()=>Rustic.Game.startHotMic(); + public void stopHotMic(Rustic.CaptureHandle? handle=null)=>Rustic.Game.stopHotMic(handle); public void EditAttribute(string name, object? value) => commands.Add(new {op="edit_attribute",source,name,value}); public void edit_attribute(string name, object? value) => EditAttribute(name,value); } @@ -302,11 +332,14 @@ sealed class RusticApi { public dynamic game { get; } public RusticApi(List commands) { Commands = commands; game=new ObjectPath(commands); } private object? Read(string group, string name) => State.GetProperty(group).TryGetProperty(name, out var v) ? ConvertValue(v) : null; - private static object? ConvertValue(JsonElement v) => v.ValueKind switch { + internal static object? ConvertValue(JsonElement v) => v.ValueKind switch { JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.String => v.GetString(), JsonValueKind.Number => v.TryGetInt64(out var n) ? (object)n : v.GetDouble(), JsonValueKind.Array => v.EnumerateArray().Select(x=>x.GetDouble()).ToArray(), _ => null }; private RusticActionState Action(string group,string name) => State.GetProperty(group).TryGetProperty(name,out var v) ? JsonSerializer.Deserialize(v)! : new(); + public string? find_entity(string source)=>Rustic.Query(new{op="find_entity",source}).GetString(); + public void edit_object_attribute(string source,string name,object? value)=>Commands.Add(new{op="edit_attribute",source,name,value}); + public object? get_object_attribute(string source,string name)=>ConvertValue(Rustic.Query(new{op="object_attribute",source,name})); public string entity_id() => State.GetProperty("entity_id").GetString()!; public double delta_time() => State.GetProperty("delta_time").GetDouble(); public double fixed_delta_time() => State.GetProperty("fixed_delta_time").GetDouble(); @@ -322,19 +355,26 @@ sealed class RusticApi { public RusticActionState key(string name) => Action("keys", name); public RusticKeyEvent[] key_events() => JsonSerializer.Deserialize(State.GetProperty("key_events"))!; public bool any_key_pressed() => State.GetProperty("any_key_pressed").GetBoolean(); + public void print(string message)=>log("info",message); + public void warn(string message)=>log("warn",message); public void log(string level,string message) => Commands.Add(new { op="log", level, message }); public void set_enabled(bool enabled) => Commands.Add(new { op="set_enabled", enabled }); } sealed class SceneApi { + public JsonElement getEnvironment(string field)=>Rustic.Query(new{op="scene_environment_field",field}); public JsonElement getEnvironment() => Rustic.Query(new {op="scene_environment"}); public JsonElement setEnvironment(object settings) => Rustic.Query(new {op="scene_environment", settings}); public JsonElement setSkyTexture(string path) => setEnvironment(new {sky_image=path}); public JsonElement State { get; set; } public string? Find(string path) => State.TryGetProperty(path, out var value) ? value.GetString() : null; - public IEnumerable List(string path="Game.scene") => State.EnumerateObject().Where(x => path=="Game.scene" || x.Name.StartsWith(path+".")).Select(x => x.Value.GetString()!); + public IEnumerable List(string path="Game.scene") => State.EnumerateObject().Where(x => x.Name.StartsWith((string.IsNullOrEmpty(path)?"Game.scene":path)+".")).Select(x => x.Value.GetString()!); } sealed class GameApi { + public Rustic.CaptureHandle startRecording(string? path=null,string source="game")=>new(Rustic.Query(new{op="capture_recording_start",path,source}).GetString()!,true); + public void stopRecording(Rustic.CaptureHandle? handle=null)=>Rustic.Query(new{op="capture_recording_stop",id=handle?.Id}); + public Rustic.CaptureHandle startHotMic()=>new(Rustic.Query(new{op="capture_mic_start"}).GetString()!,false); + public void stopHotMic(Rustic.CaptureHandle? handle=null)=>Rustic.Query(new{op="capture_mic_stop",id=handle?.Id}); public SceneApi scene { get; } = new(); private readonly List commands; public GameApi(List commands) => this.commands = commands; diff --git a/Engine/crates/engine-scripting/src/sdk/Rustic.java b/Engine/crates/engine-scripting/src/sdk/Rustic.java index febbe0b8..221b329e 100644 --- a/Engine/crates/engine-scripting/src/sdk/Rustic.java +++ b/Engine/crates/engine-scripting/src/sdk/Rustic.java @@ -72,6 +72,7 @@ static class Smooth{ static double remap(double v,double a,double b,double c,double d){return ((Number)query(args("remap","value",v,"in_min",a,"in_max",b,"out_min",c,"out_max",d))).doubleValue();} static double[] smoothDamp(double c,double t,double v,double s,double dt){var r=(Map)query(args("smooth_damp","current",c,"target",t,"velocity",v,"smooth_time",s,"delta",dt));return new double[]{((Number)r.get("value")).doubleValue(),((Number)r.get("velocity")).doubleValue()};} } + static class Interpolation extends Smooth {} static class Physics{ static Object raycast(double[] origin,double[] direction,double distance){return query(args("physics_raycast","origin",origin,"direction",direction,"distance",distance));} static Object sphereCast(double[] origin,double[] direction,double distance,double radius){return query(args("physics_sphere_cast","origin",origin,"direction",direction,"distance",distance,"radius",radius));} @@ -95,6 +96,14 @@ static class ObjectPath { ObjectPath(String source){this.source=source;} ObjectPath scene(String name){return new ObjectPath(source+"."+name);} ObjectPath object(String path){return new ObjectPath(source+"."+path);} + Object GetAttribute(String name){return value(query(args("object_attribute","source",source,"name",name)));} + Object get_attribute(String name){return GetAttribute(name);} + CaptureHandle startRecording(){return Game.startRecording();} + CaptureHandle startRecording(String path){return Game.startRecording(path);} + CaptureHandle startRecording(String path,String source){return Game.startRecording(path,source);} + void stopRecording(CaptureHandle handle){Game.stopRecording(handle);} + CaptureHandle startHotMic(){return Game.startHotMic();} + void stopHotMic(CaptureHandle handle){Game.stopHotMic(handle);} void EditAttribute(String name,Object value){command("edit_attribute","source",source,"name",name,"value",value);} void edit_attribute(String name,Object value){EditAttribute(name,value);} } @@ -199,7 +208,7 @@ static class Effects{ static Gameplay.Handle shake(String e,double strength,double duration){return shake(e,strength,duration,"Linear");} static Gameplay.Handle shake(String e,double strength,double duration,String easing){return Gameplay.start(Map.of("kind","shake","entity",Gameplay.entity(e),"strength",strength,"duration",duration,"easing",easing));} } - static class Clock { static double timeScale(double scale){return ((Number)((Map)query(args("clock","scale",scale))).get("scale")).doubleValue();} static void pause(){query(args("clock","paused",true));} static void resume(){query(args("clock","paused",false));} } + static class Clock { static Object state(){return query(args("clock"));} static double timeScale(){return ((Number)((Map)state()).get("scale")).doubleValue();} static double timeScale(double scale){return ((Number)((Map)query(args("clock","scale",scale))).get("scale")).doubleValue();} static void pause(){query(args("clock","paused",true));} static void resume(){query(args("clock","paused",false));} } static class Camera{ static String current(){return (String)query(args("camera_current"));} static Gameplay.Handle moveTo(String e,double[] p,double d){return moveTo(e,p,d,"Linear");} @@ -251,7 +260,29 @@ record Options(double speed,double blendIn,double blendOut,String easing,String static Gameplay.Handle recoil(String e,double[] rotation,double duration){return recoil(e,rotation,duration,"Linear");} static Gameplay.Handle recoil(String e,double[] rotation,double duration,String ease){return Gameplay.start(Tween.action(e,"Rotation",rotation,duration,ease),false,1,true);} } + static class CaptureHandle { + final String id; final boolean recording; + CaptureHandle(String id,boolean recording){this.id=id;this.recording=recording;} + Object read(){return query(args("capture_read","id",id));} + void unload(){query(args("capture_unload","id",id));} + void stop(){query(args(recording?"capture_recording_stop":"capture_mic_stop","id",id));} + } static class Audio{ + static class LoadedAudio { + final String source; String id; + LoadedAudio(String source){this.source=source;} + LoadedAudio load(){if(id==null)id=(String)query(args("audio_load","source",source));return this;} + String loaded(){if(id==null)throw new IllegalStateException("audio is not loaded; call load() before play()");return id;} + Voice play(){return play(1,1,false,null);} + Voice play(double volume,double pitch,boolean loop){return play(volume,pitch,loop,null);} + Voice play(double volume,double pitch,boolean loop,double[] position){return new Voice((String)query(args("audio_loaded_play","entity",loaded(),"volume",volume,"pitch",pitch,"loop",loop,"position",position)));} + void stop(){query(args("audio_loaded_stop","entity",loaded()));} + void pause(){query(args("audio_loaded_pause","entity",loaded()));} + void resume(){query(args("audio_loaded_resume","entity",loaded()));} + void unload(){if(id!=null){query(args("audio_unload","entity",id));id=null;}} + void Unload(){unload();} + } + static LoadedAudio load(String source){return new LoadedAudio(source).load();} static Object get_output_devices(){return query(args("audio_get_output_devices"));} static Object get_input_devices(){return query(args("audio_get_input_devices"));} static void set_output_device(String device){query(args("audio_set_output_device","device",device));} @@ -311,6 +342,9 @@ static class Ease { } static class RusticApi { final ObjectPath game=new ObjectPath("rustic.game"); + String find_entity(String source){return (String)query(args("find_entity","source",source));} + void edit_object_attribute(String source,String name,Object value){command("edit_attribute","source",source,"name",name,"value",value);} + Object get_object_attribute(String source,String name){return value(query(args("object_attribute","source",source,"name",name)));} String entity_id(){return (String)state.get("entity_id");} double delta_time(){return ((Number)state.get("delta_time")).doubleValue();} double fixed_delta_time(){return ((Number)state.get("fixed_delta_time")).doubleValue();} @@ -326,6 +360,8 @@ static class RusticApi { ActionState key(String n){return new ActionState(field("keys",n));} List key_events(){var out=new ArrayList();for(Object v:(List)state.get("key_events"))out.add(new KeyEvent((Map)v));return out;} boolean any_key_pressed(){return Boolean.TRUE.equals(state.get("any_key_pressed"));} + void print(String message){log("info",message);} + void warn(String message){log("warn",message);} void log(String l,String m){command("log","level",l,"message",m);} void set_enabled(boolean e){command("set_enabled","enabled",e);} } @@ -334,14 +370,24 @@ static class InstanceApi { void clone(String s){clone(s,null);}void clone(String s,String p){command("clone_instance","source",s,"parent",p);} } static class SceneApi { + Object getEnvironment(String field){return query(args("scene_environment_field","field",field));} Object getEnvironment(){return query(args("scene_environment"));} Object setEnvironment(Map settings){return query(args("scene_environment","settings",settings));} Object setSkyTexture(String path){return setEnvironment(Map.of("sky_image",path));} String Find(String p){return (String)field("scene_paths",p);} List List(){return List("Game.scene");} - List List(String p){var out=new ArrayList();for(var e:((Map)state.get("scene_paths")).entrySet())if(p.isEmpty()||p.equals("Game.scene")||((String)e.getKey()).startsWith(p+"."))out.add((String)e.getValue());return out;} + List List(String p){var out=new ArrayList();for(var e:((Map)state.get("scene_paths")).entrySet())if(((String)e.getKey()).startsWith((p.isEmpty()?"Game.scene":p)+"."))out.add((String)e.getValue());return out;} } - static class GameApi {final SceneApi scene=new SceneApi();void setCurrentCamera(String s){command("set_current_camera","source",s);} } + static class GameApi { + CaptureHandle startRecording(){return startRecording(null,"game");} + CaptureHandle startRecording(String path){return startRecording(path,"game");} + CaptureHandle startRecording(String path,String source){return new CaptureHandle((String)query(args("capture_recording_start","path",path,"source",source)),true);} + void stopRecording(){stopRecording(null);} + void stopRecording(CaptureHandle handle){query(args("capture_recording_stop","id",handle==null?null:handle.id));} + CaptureHandle startHotMic(){return new CaptureHandle((String)query(args("capture_mic_start")),false);} + void stopHotMic(){stopHotMic(null);} + void stopHotMic(CaptureHandle handle){query(args("capture_mic_stop","id",handle==null?null:handle.id));} + final SceneApi scene=new SceneApi();void setCurrentCamera(String s){command("set_current_camera","source",s);} } private static String quote(String s){StringBuilder b=new StringBuilder("\"");for(char c:s.toCharArray()){if(c<32||c=='"'||c=='\\')b.append(String.format("\\u%04x",(int)c));else b.append(c);}return b.append('"').toString();} private static String encode(Object v){ if(v instanceof Record){var fields=new LinkedHashMap();try{for(var c:v.getClass().getRecordComponents())fields.put(c.getName(),c.getAccessor().invoke(v));}catch(ReflectiveOperationException e){throw new IllegalArgumentException(e);}return encode(fields);} diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.js b/Engine/crates/engine-scripting/src/sdk/gameplay.js index 2397c6f2..ab655819 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.js +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.js @@ -172,3 +172,30 @@ globalThis.World={ find_by_tag(name){return rustic.query({op:"world_find_by_tag",name:name});}, find_in_radius(center,radius){return rustic.query({op:"world_find_in_radius",center:center,radius:radius});}, }; + +class RusticLoadedAudio { + constructor(source) { this.source=source; this.id=null; } + load() { if(this.id===null)this.id=rustic.query({op:'audio_load',source:this.source}); return this; } + requireLoaded() { if(this.id===null)throw new Error('audio is not loaded; call load() before play()'); return this.id; } + play(options={}) { + const id=rustic.query({...options,op:'audio_loaded_play',entity:this.requireLoaded()}); + return {id,stop:()=>rustic.query({op:'audio_stop',entity:id}),pause:()=>rustic.query({op:'audio_pause',entity:id}),resume:()=>rustic.query({op:'audio_resume',entity:id})}; + } + stop() { rustic.query({op:'audio_loaded_stop',entity:this.requireLoaded()}); } + pause() { rustic.query({op:'audio_loaded_pause',entity:this.requireLoaded()}); } + resume() { rustic.query({op:'audio_loaded_resume',entity:this.requireLoaded()}); } + unload() { if(this.id!==null){rustic.query({op:'audio_unload',entity:this.id});this.id=null;} } + Unload() { this.unload(); } +} +Audio.load=source=>new RusticLoadedAudio(source).load(); +const _rusticCapture=(id,recording)=>({id, + read:()=>rustic.query({op:'capture_read',id}), + unload:()=>rustic.query({op:'capture_unload',id}), + stop:()=>rustic.query({op:recording?'capture_recording_stop':'capture_mic_stop',id}) +}); +globalThis.RusticCapture={ + startRecording:(path=null,options={})=>_rusticCapture(rustic.query({op:'capture_recording_start',path,source:options.source??'game'}),true), + stopRecording:(handle=null)=>rustic.query({op:'capture_recording_stop',id:handle?.id}), + startHotMic:()=>_rusticCapture(rustic.query({op:'capture_mic_start'}),false), + stopHotMic:(handle=null)=>rustic.query({op:'capture_mic_stop',id:handle?.id}) +}; diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.lua b/Engine/crates/engine-scripting/src/sdk/gameplay.lua index e001fcba..c8644160 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.lua +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.lua @@ -1,5 +1,5 @@ -- Thin Lua binding: Rust owns all progress, curves, paths, and composition. -function Game.scene.getEnvironment() return rustic.query({op='scene_environment'}) end +function Game.scene.getEnvironment(field) return rustic.query(field and {op='scene_environment_field',field=field} or {op='scene_environment'}) end function Game.scene.setEnvironment(settings) assert(type(settings)=='table','environment settings must be a table') if next(settings)==nil then return Game.scene.getEnvironment() end @@ -369,3 +369,14 @@ function Components.find_in_children(entity,name) return rustic.query({op="compo World={} function World.find_by_tag(name) return rustic.query({op="world_find_by_tag",name=name}) end function World.find_in_radius(center,radius) return rustic.query({op="world_find_in_radius",center=center,radius=radius}) end + +-- Game mirrors the existing rustic.game capture methods. +function Game.startRecording(path,options) return rustic.game:startRecording(path,options) end +function Game.stopRecording(handle) return rustic.game:stopRecording(handle) end +function Game.startHotMic() return rustic.game:startHotMic() end +function Game.stopHotMic(handle) return rustic.game:stopHotMic(handle) end + +function rustic.get_object_attribute(source,name) return rustic.query({op='object_attribute',source=source,name=name}) end + +function rustic.print(...) print(...) end +function rustic.warn(...) warn(...) end diff --git a/Engine/crates/engine-scripting/src/sdk/media-api.d.ts b/Engine/crates/engine-scripting/src/sdk/media-api.d.ts new file mode 100644 index 00000000..1475cd9b --- /dev/null +++ b/Engine/crates/engine-scripting/src/sdk/media-api.d.ts @@ -0,0 +1,80 @@ +interface RusticCapturePacket { + samples: number[]; + sampleRate: number; + channels: number; + active: boolean; + droppedSamples: number; +} +interface RusticCaptureHandle { + id: string; + read(): RusticCapturePacket; + stop(): void; + unload(): void; +} +interface RusticCaptureApi { + startRecording(path?: string | null, options?: { source?: "game" | "microphone" }): RusticCaptureHandle; + stopRecording(handle?: RusticCaptureHandle | null): void; + startHotMic(): RusticCaptureHandle; + stopHotMic(handle?: RusticCaptureHandle | null): void; +} +interface RusticAudioOptions { + volume?: number; + pitch?: number; + loop?: boolean; + position?: [number, number, number]; +} +interface RusticAudioVoice { + id: string; + stop(): void; + pause(): void; + resume(): void; +} +interface RusticLoadedAudio { + load(): RusticLoadedAudio; + play(options?: RusticAudioOptions): RusticAudioVoice; + stop(): void; + pause(): void; + resume(): void; + unload(): void; + Unload(): void; +} +interface RusticObjectPath extends RusticCaptureApi { + GetAttribute(name: string): unknown; + get_attribute(name: string): unknown; +} +interface RusticApi { + print(...values: unknown[]): void; + warn(...values: unknown[]): void; + get_attribute(name: string): unknown; + GetAttribute(name: string): unknown; + edit_attribute(name: string, value: unknown): void; + EditAttribute(name: string, value: unknown): void; + key(name: string): RusticActionState; + key_events(): ReadonlyArray<{ key: string; state: string; repeat: boolean }>; + any_key_pressed(): boolean; + find_entity(source: string): string | null; + get_object_attribute(source: string, name: string): unknown; +} +interface RusticOperationHandle { + id: string; + state(): { status: string; error?: string | null } | undefined; + onFinished(callback: () => void): RusticOperationHandle; + pause(): RusticOperationHandle; + resume(): RusticOperationHandle; + cancel(): RusticOperationHandle; + reverse(): RusticOperationHandle; +} +declare const Audio: { + load(source: string): RusticLoadedAudio; + play(source: string, options?: RusticAudioOptions): RusticAudioVoice; + playAt(source: string, position: [number, number, number], options?: RusticAudioOptions): RusticAudioVoice; + get_output_devices(): { id: string; name: string }[]; + get_input_devices(): { id: string; name: string }[]; + set_output_device(id?: string | null): void; + set_input_device(id?: string | null): void; + volume(voice: RusticAudioVoice, value: number): void; + pitch(voice: RusticAudioVoice, value: number): void; + fadeIn(voice: RusticAudioVoice, duration: number, easing?: string, volume?: number): RusticOperationHandle; + fadeOut(voice: RusticAudioVoice, duration: number, easing?: string): RusticOperationHandle; + crossfade(oldVoice: RusticAudioVoice, newVoice: RusticAudioVoice, duration: number, easing?: string): RusticOperationHandle; +}; diff --git a/Engine/crates/engine-scripting/src/sdk/media-api.lua b/Engine/crates/engine-scripting/src/sdk/media-api.lua new file mode 100644 index 00000000..58c11613 --- /dev/null +++ b/Engine/crates/engine-scripting/src/sdk/media-api.lua @@ -0,0 +1,31 @@ +---@class RusticCapturePacket +---@field samples number[] +---@field sampleRate integer +---@field channels integer +---@field active boolean +---@field droppedSamples integer +---@class RusticCaptureHandle +---@field id string +---@field read fun(self:RusticCaptureHandle): RusticCapturePacket +---@field stop fun(self:RusticCaptureHandle) +---@field unload fun(self:RusticCaptureHandle) +---@class RusticLoadedAudio +---@field load fun(self:RusticLoadedAudio): RusticLoadedAudio +---@field play fun(self:RusticLoadedAudio,options:table|nil): table +---@field stop fun(self:RusticLoadedAudio) +---@field pause fun(self:RusticLoadedAudio) +---@field resume fun(self:RusticLoadedAudio) +---@field unload fun(self:RusticLoadedAudio) +---@field Unload fun(self:RusticLoadedAudio) +---@class RusticApi +---@field get_object_attribute fun(source:string,name:string): any +---@class RusticGameApi +---@field startRecording fun(path:string|nil,options:table|nil): RusticCaptureHandle +---@field stopRecording fun(handle:RusticCaptureHandle|nil) +---@field startHotMic fun(): RusticCaptureHandle +---@field stopHotMic fun(handle:RusticCaptureHandle|nil) +---@class RusticAudioApi +---@field load fun(source:string): RusticLoadedAudio +---@field play fun(source:string,options:table|nil): table +---@type RusticAudioApi +Audio = Audio diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.h b/Engine/crates/engine-scripting/src/sdk/rustic.h index 4f16302b..31b4b528 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.h +++ b/Engine/crates/engine-scripting/src/sdk/rustic.h @@ -18,6 +18,15 @@ typedef struct { size_t count; const char **items; } RusticList; typedef struct { const char *id,*name; } RusticAudioDevice; typedef struct { size_t count; RusticAudioDevice *items; } RusticAudioDevices; typedef struct { size_t count; RusticKeyEvent *items; } RusticKeyEvents; +typedef struct {char id[64];bool recording;} RusticCaptureHandle; +typedef struct {size_t count;double *samples;unsigned sampleRate,channels;bool active;size_t droppedSamples;} RusticCaptureData; +static RusticCaptureHandle r_recording_start(const char*,const char*); +static void r_recording_stop(const RusticCaptureHandle*); +static RusticCaptureHandle r_mic_start(void); +static void r_mic_stop(const RusticCaptureHandle*); +static RusticValue r_object_attribute(const char*,const char*); +static const char *r_find_live(const char*); +static void r_edit_object_path(const char*,const char*,RusticValue); typedef struct { void (*on_create)(void), (*on_start)(void), (*on_enable)(void), (*on_disable)(void), (*on_destroy)(void), (*on_stop)(void); void (*fixed_update)(double), (*update)(double); @@ -126,6 +135,8 @@ static RusticActionState r_input(const char *n) {return r_action("actions",n);} static RusticActionState r_key(const char *n) {return r_action("keys",n);} static bool r_any(void) {return r_bool(r_field(0,"any_key_pressed"));} static void r_log(const char *l,const char *m) {r_command("log");r_append(",\"level\":");r_quote(l);r_append(",\"message\":");r_quote(m);r_append("}");} +static void r_print(const char *message){r_log("info",message);} +static void r_warn(const char *message){r_log("warn",message);} static void r_enabled(bool e) {r_command("set_enabled");r_append(e?",\"enabled\":true}":",\"enabled\":false}");} static void r_instance(const char *op,const char *s,const char *p) {r_command(op);r_append(",\"source\":");r_quote(s);r_append(",\"parent\":");r_quote(p);r_append("}");} static void r_add(const char *s,const char *p) {r_instance("add_instance",s,p);} @@ -134,8 +145,9 @@ static void r_camera(const char *s) {r_command("set_current_camera");r_append(", static const char *r_find(const char *s) {return r_string(r_field(r_field(0,"scene_paths"),s));} static RusticList r_list(const char *path) { static const char *items[65536]; RusticList out={0,items}; size_t o=r_field(0,"scene_paths"); if(o>=r_count)return out; - size_t n=path?strlen(path):0;bool all=!path||!*path||strcmp(path,"Game.scene")==0; - for(size_t i=o+1;i=8192)r_fail("Rustic C: query allocation budget");char *copy=(char*)malloc(length+1);if(!copy)r_fail("Rustic C: allocation failed");if(s)memcpy(copy,s,length);else memset(copy,0,length);copy[length]=0;r_query_owned[r_query_owned_count++]=copy;return copy;} static RQuerySave r_query_begin(const char *op){ @@ -187,6 +207,15 @@ static RQueryResult r_query_end(RQuerySave saved){ size_t result=r_field(0,"result");RQueryResult out={0}; if(resultid:NULL);(void)r_query_end(q);} +static void r_recording_stop(const RusticCaptureHandle *h){r_capture_stop_kind(h,true);} +static void r_mic_stop(const RusticCaptureHandle *h){r_capture_stop_kind(h,false);} +static void r_capture_stop(RusticCaptureHandle h){r_capture_stop_kind(&h,h.recording);} +static void r_capture_unload(RusticCaptureHandle h){RQuerySave q=r_query_begin("capture_unload");r_q_string("id",h.id);(void)r_query_end(q);} +static RusticCaptureData r_capture_read(RusticCaptureHandle h){RQuerySave q=r_query_begin("capture_read");r_q_string("id",h.id);return r_query_end(q).capture;} +static const struct {RusticCaptureData(*read)(RusticCaptureHandle);void(*stop)(RusticCaptureHandle),(*unload)(RusticCaptureHandle);} Capture={r_capture_read,r_capture_stop,r_capture_unload}; /* Entity services: returned lists/strings live until the next callback. */ static bool r_tags_add(const char* entity,const char* name){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("tags_add");r_q_string("entity",resolved_entity);r_q_string("name",name);return r_query_end(s).value.boolean;} static bool r_tags_remove(const char* entity,const char* name){const char *resolved_entity=r_resolve(entity);RQuerySave s=r_query_begin("tags_remove");r_q_string("entity",resolved_entity);r_q_string("name",name);return r_query_end(s).value.boolean;} @@ -368,6 +416,15 @@ static RusticGameplayHandle r_camera_zoom(const char *e,double fov,double durati static RusticGameplayHandle r_camera_transition(const char *e,RusticVector3 p,RusticValue rotation,double fov,double duration,const char *ease,void(*fn)(void)){RusticSequence b=r_sequence_new();r_sequence_to(&b,e,"Position",r_vec(p),duration,ease);r_sequence_to(&b,e,"Rotation",rotation,duration,ease);r_sequence_to(&b,e,"Fov",(RusticValue){.type=RUSTIC_NUMBER,.number=fov},duration,ease);RusticGameplayHandle h=r_run_action(r_sequence_action(&b,"parallel"),fn);r_sequence_destroy(&b);return h;} typedef struct {char id[64];} RusticAudioVoice; typedef struct {double volume,pitch;bool loop;} RusticAudioOptions; +typedef struct {char id[64];} RusticLoadedAudio; +static RusticLoadedAudio r_audio_load(const char *source){RQuerySave q=r_query_begin("audio_load");r_q_string("source",source);RQueryResult result=r_query_end(q);RusticLoadedAudio h={{0}};if(result.value.type!=RUSTIC_STRING)r_fail("Rustic C: invalid loaded audio handle");snprintf(h.id,sizeof h.id,"%s",result.value.string);return h;} +static RusticAudioVoice r_audio_loaded_play(RusticLoadedAudio h,const RusticAudioOptions *opts){RQuerySave q=r_query_begin("audio_loaded_play");r_q_string("entity",h.id);r_q_number("volume",opts?opts->volume:1);r_q_number("pitch",opts?opts->pitch:1);r_append(",\"loop\":");r_append(opts&&opts->loop?"true":"false");RQueryResult result=r_query_end(q);RusticAudioVoice v={{0}};if(result.value.type!=RUSTIC_STRING)r_fail("Rustic C: invalid voice");snprintf(v.id,sizeof v.id,"%s",result.value.string);return v;} +static void r_audio_loaded_control(RusticLoadedAudio h,const char *op){RQuerySave q=r_query_begin(op);r_q_string("entity",h.id);(void)r_query_end(q);} +static void r_audio_loaded_stop(RusticLoadedAudio h){r_audio_loaded_control(h,"audio_loaded_stop");} +static void r_audio_loaded_pause(RusticLoadedAudio h){r_audio_loaded_control(h,"audio_loaded_pause");} +static void r_audio_loaded_resume(RusticLoadedAudio h){r_audio_loaded_control(h,"audio_loaded_resume");} +static void r_audio_unload(RusticLoadedAudio *h){if(h->id[0]){r_audio_loaded_control(*h,"audio_unload");h->id[0]=0;}} +static const struct {RusticAudioVoice(*play)(RusticLoadedAudio,const RusticAudioOptions*);void(*stop)(RusticLoadedAudio),(*pause)(RusticLoadedAudio),(*resume)(RusticLoadedAudio);void(*unload)(RusticLoadedAudio*);} LoadedAudio={r_audio_loaded_play,r_audio_loaded_stop,r_audio_loaded_pause,r_audio_loaded_resume,r_audio_unload}; static RusticAudioDevices r_audio_get_output_devices(void){RQuerySave s=r_query_begin("audio_get_output_devices");return r_query_end(s).devices;} static RusticAudioDevices r_audio_get_input_devices(void){RQuerySave s=r_query_begin("audio_get_input_devices");return r_query_end(s).devices;} static void r_audio_set_output_device(const char *device){RQuerySave s=r_query_begin("audio_set_output_device");r_q_string("device",device);(void)r_query_end(s);} @@ -422,26 +479,28 @@ static RusticGameplayHandle r_tween_value(RusticValue from,RusticValue to,double static void r_clock_scale(double value){RQuerySave s=r_query_begin("clock");r_q_number("scale",value);(void)r_query_end(s);} static void r_clock_pause(void){RQuerySave s=r_query_begin("clock");r_append(",\"paused\":true");(void)r_query_end(s);} static void r_clock_resume(void){RQuerySave s=r_query_begin("clock");r_append(",\"paused\":false");(void)r_query_end(s);} -static const struct{void(*timeScale)(double),(*pause)(void),(*resume)(void);} Clock={r_clock_scale,r_clock_pause,r_clock_resume}; -static const struct {RusticAudioVoice(*play)(const char*,const RusticAudioOptions*),(*playAt)(const char*,RusticVector3,const RusticAudioOptions*);void(*stop)(RusticAudioVoice),(*pause)(RusticAudioVoice),(*resume)(RusticAudioVoice),(*volume)(RusticAudioVoice,double),(*pitch)(RusticAudioVoice,double);RusticGameplayHandle(*fadeIn)(RusticAudioVoice,double,const char*,void(*)(void)),(*fadeOut)(RusticAudioVoice,double,const char*,void(*)(void)),(*crossfade)(RusticAudioVoice,RusticAudioVoice,double,const char*,void(*)(void));RusticAudioDevices(*get_output_devices)(void),(*get_input_devices)(void);void(*set_output_device)(const char*),(*set_input_device)(const char*);} Audio={r_audio_play,r_audio_at,r_audio_stop,r_audio_pause,r_audio_resume,r_audio_volume,r_audio_pitch,r_audio_fade_in,r_audio_fade_out,r_audio_crossfade,r_audio_get_output_devices,r_audio_get_input_devices,r_audio_set_output_device,r_audio_set_input_device}; +static RusticClockState r_clock_state(void){RQuerySave q=r_query_begin("clock");return r_query_end(q).clock;} +static const struct{void(*timeScale)(double),(*pause)(void),(*resume)(void);RusticClockState(*state)(void);} Clock={r_clock_scale,r_clock_pause,r_clock_resume,r_clock_state}; +static const struct {RusticAudioVoice(*play)(const char*,const RusticAudioOptions*),(*playAt)(const char*,RusticVector3,const RusticAudioOptions*);void(*stop)(RusticAudioVoice),(*pause)(RusticAudioVoice),(*resume)(RusticAudioVoice),(*volume)(RusticAudioVoice,double),(*pitch)(RusticAudioVoice,double);RusticGameplayHandle(*fadeIn)(RusticAudioVoice,double,const char*,void(*)(void)),(*fadeOut)(RusticAudioVoice,double,const char*,void(*)(void)),(*crossfade)(RusticAudioVoice,RusticAudioVoice,double,const char*,void(*)(void));RusticAudioDevices(*get_output_devices)(void),(*get_input_devices)(void);void(*set_output_device)(const char*),(*set_input_device)(const char*);RusticLoadedAudio(*load)(const char*);} Audio={r_audio_play,r_audio_at,r_audio_stop,r_audio_pause,r_audio_resume,r_audio_volume,r_audio_pitch,r_audio_fade_in,r_audio_fade_out,r_audio_crossfade,r_audio_get_output_devices,r_audio_get_input_devices,r_audio_set_output_device,r_audio_set_input_device,r_audio_load}; static const struct {void(*addMarker)(const char*,const char*,double,const char*);RusticGameplayHandle(*value)(const RusticKeyframe*,size_t,void(*)(RusticValue),void(*)(void));RusticAnimationOptions(*defaults)(void);RusticList(*load)(const char*,const char*),(*clips)(const char*);void(*registerClip)(const char*,RusticAnimationClip);RusticGameplayHandle(*play)(const char*,const char*,const RusticAnimationOptions*,void(*)(void));void(*onMarker)(RusticGameplayHandle,const char*,void(*)(void)),(*stop)(RusticGameplayHandle),(*pause)(RusticGameplayHandle),(*speed)(RusticGameplayHandle,double),(*loop)(RusticGameplayHandle,bool);RusticGameplayHandle(*blend)(const char*,const char*,const char*,double,const char*,void(*)(void)),(*transition)(const char*,const char*,const char*,double,const char*,void(*)(void)),(*ik)(const char*,const char*,const char*,RusticVector3,RusticVector3,double,double,const char*,void(*)(void)),(*footPlacement)(const char*,const char*,const char*,RusticVector3,RusticVector3,double,double,const char*,void(*)(void)),(*lookAt)(const char*,RusticVector3,double,const char*,void(*)(void)),(*headTracking)(const char*,RusticVector3,double,const char*,void(*)(void)),(*recoil)(const char*,RusticValue,double,const char*,void(*)(void));} Animation={r_animation_add_marker,r_animation_value,r_animation_defaults,r_animation_load,r_animation_refs,r_animation_register,r_animation_play,r_animation_marker,r_gameplay_cancel,r_gameplay_pause,r_gameplay_speed,r_gameplay_loop,r_animation_blend,r_animation_blend,r_animation_ik,r_animation_ik,r_look_at,r_look_at,r_recoil}; static const struct {RusticConnection(*on)(const char*,void(*)(const RusticValue*,size_t)),(*once)(const char*,void(*)(const RusticValue*,size_t)),(*connect)(const char*,const char*,void(*)(const RusticValue*,size_t),bool);void(*emit)(const char*,const RusticValue*,size_t,const char*),(*disconnect)(RusticConnection);} Events={r_events_on,r_events_once,r_events_connect,r_events_emit,r_events_disconnect}; typedef struct {double duration,speed;bool useSpeed;const char *easing;} RusticMotionOptions; static RusticGameplayHandle r_tween_options(const char *e,const char *p,RusticValue to,RusticMotionOptions opts,void(*fn)(void)){const char *id=r_resolve(e);RActionSave s=r_action_begin();r_append("{\"kind\":\"tween\",\"target\":{\"entity\":");r_quote(id);r_q_string("property",p);r_append("}");r_q_value("to",to);r_q_number(opts.useSpeed?"speed":"duration",opts.useSpeed?opts.speed:opts.duration);r_q_string("easing",opts.easing?opts.easing:"Linear");r_append("}");return r_run_action(r_action_end(s),fn);} static RusticGameplayHandle r_move_options(const char *e,RusticVector3 p,RusticMotionOptions opts,void(*fn)(void)){return r_tween_options(e,"Position",r_vec(p),opts,fn);} static const struct {RusticGameplayHandle (*to)(const char*,const char*,RusticValue,double,const char*,void(*)(void));RusticGameplayHandle (*move)(const char*,RusticVector3,double,const char*,void(*)(void));RusticGameplayHandle(*rotate)(const char*,RusticValue,double,const char*,void(*)(void)),(*scale)(const char*,RusticVector3,double,const char*,void(*)(void));RusticAction(*action)(const char*,const char*,RusticValue,double,const char*);RusticGameplayHandle(*value)(RusticValue,RusticValue,double,const char*,void(*)(RusticValue),void(*)(void));RusticGameplayHandle(*toOptions)(const char*,const char*,RusticValue,RusticMotionOptions,void(*)(void));} Tween={r_tween_to,r_tween_move,r_rotate_to,r_scale_to,r_action_tween,r_tween_value,r_tween_options}; +static const char *r_camera_current(void){RQuerySave q=r_query_begin("camera_current");return r_query_end(q).value.string;} static const struct {RusticGameplayHandle(*moveTo)(const char*,RusticVector3,double,const char*,void(*)(void)),(*move)(const char*,RusticVector3,double,const char*,void(*)(void)),(*rotateTo)(const char*,RusticValue,double,const char*,void(*)(void)),(*lookAt)(const char*,RusticVector3,double,const char*,void(*)(void)),(*follow)(const char*,const char*,RusticVector3,double,const char*,void(*)(void)),(*orbit)(const char*,RusticVector3,double,double,double,const char*,void(*)(void));RusticGameplayHandle(*moveToOptions)(const char*,RusticVector3,RusticMotionOptions,void(*)(void));} Movement={r_tween_move,r_move_relative,r_rotate_to,r_look_at,r_follow,r_orbit,r_move_options}; static const struct {RusticGameplayHandle (*after)(double,void(*)(void)),(*every)(double,void(*)(void),unsigned);} Timer={r_timer_after,r_timer_every}; #define R_SEQUENCE_API {r_sequence_new,r_sequence_to,r_sequence_move,r_sequence_wait,r_sequence_call,r_sequence_parallel,r_sequence_play,r_sequence_destroy,r_sequence_animation} static const struct {RusticSequence(*create)(void);RusticSequence*(*to)(RusticSequence*,const char*,const char*,RusticValue,double,const char*),*(*move)(RusticSequence*,const char*,RusticVector3,double,const char*),*(*wait)(RusticSequence*,double),*(*call)(RusticSequence*,void(*)(void)),*(*parallel)(RusticSequence*,RusticSequence*,size_t);RusticGameplayHandle(*play)(RusticSequence*,void(*)(void));void(*destroy)(RusticSequence*);RusticSequence*(*animation)(RusticSequence*,const char*,const char*,const RusticAnimationOptions*);} Sequence=R_SEQUENCE_API,Timeline=R_SEQUENCE_API; static const struct {RusticPath(*create)(const RusticPathPoint*,size_t,const char*);RusticGameplayHandle(*follow)(const char*,RusticPath,double,double,const char*,bool,bool,bool,void(*)(void));void(*destroy)(RusticPath*);} Path={r_path_create,r_path_follow,r_path_destroy}; static const struct {RusticGameplayHandle(*fade)(const char*,double,double,const char*,void(*)(void)),(*flash)(const char*,RusticVector3,double,const char*,void(*)(void)),(*pulse)(const char*,RusticVector3,double,const char*,void(*)(void)),(*shake)(const char*,double,double,const char*,void(*)(void));} Effects={r_fade,r_flash,r_pulse,r_shake}; -static const struct {RusticGameplayHandle(*moveTo)(const char*,RusticVector3,double,const char*,void(*)(void)),(*zoom)(const char*,double,double,const char*,void(*)(void)),(*fov)(const char*,double,double,const char*,void(*)(void)),(*follow)(const char*,const char*,RusticVector3,double,const char*,void(*)(void)),(*lookAt)(const char*,RusticVector3,double,const char*,void(*)(void)),(*orbit)(const char*,RusticVector3,double,double,double,const char*,void(*)(void)),(*shake)(const char*,double,double,const char*,void(*)(void)),(*transition)(const char*,RusticVector3,RusticValue,double,double,const char*,void(*)(void));} Camera={r_tween_move,r_camera_zoom,r_camera_zoom,r_follow,r_look_at,r_orbit,r_shake,r_camera_transition}; +static const struct {RusticGameplayHandle(*moveTo)(const char*,RusticVector3,double,const char*,void(*)(void)),(*zoom)(const char*,double,double,const char*,void(*)(void)),(*fov)(const char*,double,double,const char*,void(*)(void)),(*follow)(const char*,const char*,RusticVector3,double,const char*,void(*)(void)),(*lookAt)(const char*,RusticVector3,double,const char*,void(*)(void)),(*orbit)(const char*,RusticVector3,double,double,double,const char*,void(*)(void)),(*shake)(const char*,double,double,const char*,void(*)(void)),(*transition)(const char*,RusticVector3,RusticValue,double,double,const char*,void(*)(void));const char *(*current)(void);} Camera={r_tween_move,r_camera_zoom,r_camera_zoom,r_follow,r_look_at,r_orbit,r_shake,r_camera_transition,r_camera_current}; static const struct {RusticOperationState(*state)(RusticGameplayHandle);void (*pause)(RusticGameplayHandle),(*resume)(RusticGameplayHandle),(*cancel)(RusticGameplayHandle),(*reverse)(RusticGameplayHandle),(*onFinished)(RusticGameplayHandle,void(*)(void)),(*speed)(RusticGameplayHandle,double),(*loop)(RusticGameplayHandle,bool);} Gameplay={r_gameplay_state,r_gameplay_pause,r_gameplay_resume,r_gameplay_cancel,r_gameplay_reverse,r_gameplay_finished,r_gameplay_speed,r_gameplay_loop}; static const struct {const char *Linear,*InSine,*OutSine,*InOutSine,*InQuad,*OutQuad,*InOutQuad,*InCubic,*OutCubic,*InOutCubic,*InQuart,*OutQuart,*InOutQuart,*InQuint,*OutQuint,*InOutQuint,*InExpo,*OutExpo,*InOutExpo,*InCirc,*OutCirc,*InOutCirc,*InBack,*OutBack,*InOutBack,*InElastic,*OutElastic,*InOutElastic,*InBounce,*OutBounce,*InOutBounce;} Ease={"Linear","InSine","OutSine","InOutSine","InQuad","OutQuad","InOutQuad","InCubic","OutCubic","InOutCubic","InQuart","OutQuart","InOutQuart","InQuint","OutQuint","InOutQuint","InExpo","OutExpo","InOutExpo","InCirc","OutCirc","InOutCirc","InBack","OutBack","InOutBack","InElastic","OutElastic","InOutElastic","InBounce","OutBounce","InOutBounce"}; static int rustic_run(RusticBehavior b) { /* Reference exported objects even in an empty behavior to support -Werror. */ - (void)Animation;(void)Audio;(void)Events;(void)Sequence;(void)Timeline;(void)Path;(void)Effects;(void)Clock;(void)Camera;(void)Smooth;(void)Interpolation;(void)Physics;(void)rustic;(void)instance;(void)Game;(void)Tween;(void)Movement;(void)Timer;(void)Gameplay;(void)Ease; + (void)Capture;(void)LoadedAudio;(void)Animation;(void)Audio;(void)Events;(void)Sequence;(void)Timeline;(void)Path;(void)Effects;(void)Clock;(void)Camera;(void)Smooth;(void)Interpolation;(void)Physics;(void)rustic;(void)instance;(void)Game;(void)Tween;(void)Movement;(void)Timer;(void)Gameplay;(void)Ease; while(fgets(r_source,sizeof r_source,stdin)) { for(size_t i=0;iinstance, $this->source.".".$name); } + public function GetAttribute(string $name):mixed{global $rustic;return $rustic->query(["op"=>"object_attribute","source"=>$this->source,"name"=>$name]);} + public function get_attribute(string $name):mixed{return $this->GetAttribute($name);} + public function __call(string $name,array $args):mixed{global $Game;if($this->source!=="rustic.game")throw new BadMethodCallException($name);return $Game->$name(...$args);} public function EditAttribute(string $name, mixed $value): void { $this->instance->commands[]=["op"=>"edit_attribute","source"=>$this->source,"name"=>$name,"value"=>$value]; } public function edit_attribute(string $name, mixed $value): void { $this->EditAttribute($name,$value); } } @@ -22,6 +25,9 @@ public function query(array $query): mixed { echo json_encode(["query"=>$query,"commands"=>$this->instance->commands],JSON_THROW_ON_ERROR),PHP_EOL;flush();$this->instance->commands=[]; $r=json_decode(fgets(STDIN),true,flags:JSON_THROW_ON_ERROR);if(isset($r["error"]))throw new RuntimeException($r["error"]);return $r["result"]; } + public function find_entity(string $source):?string{return $this->query(["op"=>"find_entity","source"=>$source]);} + public function edit_object_attribute(string $source,string $name,mixed $value):void{$this->instance->commands[]=["op"=>"edit_attribute","source"=>$source,"name"=>$name,"value"=>$value];} + public function get_object_attribute(string $source,string $name):mixed{return $this->query(["op"=>"object_attribute","source"=>$source,"name"=>$name]);} public function entity_id(): string { return $this->state["entity_id"]; } public function delta_time(): float { return $this->state["delta_time"]; } public function fixed_delta_time(): float { return $this->state["fixed_delta_time"]; } @@ -37,18 +43,25 @@ public function input(string $name): array { return $this->state["actions"][$nam public function key(string $name): array { return $this->state["keys"][$name] ?? ["pressed"=>false,"released"=>false,"held"=>false,"axis"=>0]; } public function key_events(): array { return $this->state["key_events"]; } public function any_key_pressed(): bool { return $this->state["any_key_pressed"]; } + public function print(mixed ...$values):void{$this->log("info",implode(" ",array_map("strval",$values)));} + public function warn(mixed ...$values):void{$this->log("warn",implode(" ",array_map("strval",$values)));} public function log(string $level,string $message): void { $this->instance->commands[]=["op"=>"log","level"=>$level,"message"=>$message]; } public function set_enabled(bool $enabled): void { $this->instance->commands[]=["op"=>"set_enabled","enabled"=>$enabled]; } } final class SceneApi { + public function getEnvironmentField(string $field):mixed{global $rustic;return $rustic->query(["op"=>"scene_environment_field","field"=>$field]);} public function getEnvironment(): array { global $rustic; return $rustic->query(["op"=>"scene_environment"]); } public function setEnvironment(array $settings): array { global $rustic; return $rustic->query(["op"=>"scene_environment","settings"=>(object)$settings]); } public function setSkyTexture(string $path): array { return $this->setEnvironment(["sky_image"=>$path]); } public array $state=[]; public function Find(string $path): ?string { return $this->state[$path] ?? null; } - public function List(string $path="Game.scene"): array { return array_values(array_filter($this->state, fn($id,$name)=>$path==="Game.scene" || str_starts_with($name,$path."."), ARRAY_FILTER_USE_BOTH)); } + public function List(string $path="Game.scene"): array { return array_values(array_filter($this->state, fn($id,$name)=>str_starts_with($name,($path===""?"Game.scene":$path)."."), ARRAY_FILTER_USE_BOTH)); } } final class GameApi { + function startRecording(?string $path=null,array $options=[]):CaptureHandle{global $rustic;return new CaptureHandle($rustic->query(["op"=>"capture_recording_start","path"=>$path,"source"=>$options["source"]??"game"]),true);} + function stopRecording(?CaptureHandle $handle=null):void{global $rustic;$rustic->query(["op"=>"capture_recording_stop","id"=>$handle?->id]);} + function startHotMic():CaptureHandle{global $rustic;return new CaptureHandle($rustic->query(["op"=>"capture_mic_start"]),false);} + function stopHotMic(?CaptureHandle $handle=null):void{global $rustic;$rustic->query(["op"=>"capture_mic_stop","id"=>$handle?->id]);} public SceneApi $scene; public function __construct(private InstanceApi $instance){ $this->scene=new SceneApi(); } public function setCurrentCamera(string $source): void { $this->instance->commands[]=["op"=>"set_current_camera","source"=>$source]; } @@ -212,7 +225,7 @@ static function flash(?string $e,array $color,float $d,string $ease="Linear"):Ga static function pulse(?string $e,array $scale,float $d,string $ease="Linear"):GameplayHandle{return Gameplay::start(Tween::action($e,"Scale",$scale,$d,$ease),false,1,true);} static function shake(?string $e,float $strength,float $duration,string $easing="Linear"):GameplayHandle{return Gameplay::start(["kind"=>"shake","entity"=>Gameplay::entity($e),"strength"=>$strength,"duration"=>$duration,"easing"=>$easing]);} } -final class Clock{static function timeScale(?float $scale=null):float{global $rustic;return $rustic->query($scale===null?["op"=>"clock"]:["op"=>"clock","scale"=>$scale])["scale"];}static function pause():void{global $rustic;$rustic->query(["op"=>"clock","paused"=>true]);}static function resume():void{global $rustic;$rustic->query(["op"=>"clock","paused"=>false]);}} +final class Clock{static function state():array{global $rustic;return $rustic->query(["op"=>"clock"]);}static function timeScale(?float $scale=null):float{global $rustic;return $rustic->query($scale===null?["op"=>"clock"]:["op"=>"clock","scale"=>$scale])["scale"];}static function pause():void{global $rustic;$rustic->query(["op"=>"clock","paused"=>true]);}static function resume():void{global $rustic;$rustic->query(["op"=>"clock","paused"=>false]);}} final class Camera{ static function current():string{global $rustic;return $rustic->query(["op"=>"camera_current"]);} static function moveTo(?string $e,array $p,float $d,string $ease="Linear"):GameplayHandle{return Tween::move($e,$p,$d,$ease);} @@ -244,7 +257,25 @@ static function recoil(string $e,array $rotation,float|array $duration,string $e final class AudioVoice{ function __construct(public string $id){}function stop():void{global $rustic;$rustic->query(["op"=>"audio_stop","entity"=>$this->id]);}function pause():void{global $rustic;$rustic->query(["op"=>"audio_pause","entity"=>$this->id]);}function resume():void{global $rustic;$rustic->query(["op"=>"audio_resume","entity"=>$this->id]);} } +final class LoadedAudio { + private ?string $id=null; + function __construct(private string $source){} + function load():self{global $rustic;if($this->id===null)$this->id=$rustic->query(["op"=>"audio_load","source"=>$this->source]);return $this;} + private function loaded():string{if($this->id===null)throw new RuntimeException("audio is not loaded; call load() before play()");return $this->id;} + function play(array $options=[]):AudioVoice{global $rustic;return new AudioVoice($rustic->query(["op"=>"audio_loaded_play","entity"=>$this->loaded()]+$options));} + function stop():void{global $rustic;$rustic->query(["op"=>"audio_loaded_stop","entity"=>$this->loaded()]);} + function pause():void{global $rustic;$rustic->query(["op"=>"audio_loaded_pause","entity"=>$this->loaded()]);} + function resume():void{global $rustic;$rustic->query(["op"=>"audio_loaded_resume","entity"=>$this->loaded()]);} + function unload():void{global $rustic;if($this->id!==null){$rustic->query(["op"=>"audio_unload","entity"=>$this->id]);$this->id=null;}} +} +final class CaptureHandle { + function __construct(public string $id,private bool $recording){} + function read():array{global $rustic;return $rustic->query(["op"=>"capture_read","id"=>$this->id]);} + function unload():void{global $rustic;$rustic->query(["op"=>"capture_unload","id"=>$this->id]);} + function stop():void{global $rustic;$rustic->query(["op"=>$this->recording?"capture_recording_stop":"capture_mic_stop","id"=>$this->id]);} +} final class Audio{ + static function load(string $source):LoadedAudio{return (new LoadedAudio($source))->load();} static function get_output_devices():array{global $rustic;return $rustic->query(["op"=>"audio_get_output_devices"]);} static function get_input_devices():array{global $rustic;return $rustic->query(["op"=>"audio_get_input_devices"]);} static function set_output_device(?string $device=null):void{global $rustic;$rustic->query(["op"=>"audio_set_output_device","device"=>$device]);} diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.py b/Engine/crates/engine-scripting/src/sdk/rustic.py index a0d042be..6b758978 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.py +++ b/Engine/crates/engine-scripting/src/sdk/rustic.py @@ -11,8 +11,12 @@ def clone(self, source, parent=None): class ObjectPath: def __init__(self, source="rustic.game"): self.source = source - def __getattr__(self, name): return ObjectPath(self.source + "." + name) + def __getattr__(self, name): + if self.source=="rustic.game" and name in ("startRecording","stopRecording","startHotMic","stopHotMic"): return getattr(Game,name) + return ObjectPath(self.source + "." + name) def __getitem__(self, name): return ObjectPath(self.source + "." + str(name)) + def GetAttribute(self,name): return rustic.query(dict(op="object_attribute",source=self.source,name=name)) + get_attribute=GetAttribute def EditAttribute(self, name, value): instance.commands.append({"op":"edit_attribute", "source":self.source, "name":name, "value":value}) edit_attribute = EditAttribute @@ -30,6 +34,9 @@ def gameplay(self, request): self.commands.append({"op":"gameplay", "request":re def gameplay_connections(self): return self.state.get("gameplay_connections",[]) def gameplay_states(self): return self.state.get("gameplay_states", []) def gameplay_callbacks(self): return self.state.get("gameplay_callbacks", []) + def find_entity(self,source): return self.query(dict(op="find_entity",source=source)) + def edit_object_attribute(self,source,name,value): self.commands.append(dict(op="edit_attribute",source=source,name=name,value=value)) + def get_object_attribute(self,source,name): return self.query(dict(op="object_attribute",source=source,name=name)) def entity_id(self): return self.state["entity_id"] def delta_time(self): return self.state["delta_time"] def fixed_delta_time(self): return self.state["fixed_delta_time"] @@ -45,18 +52,20 @@ def input(self, name): return self.state.get("actions", {}).get(name, {"pressed" def key(self, name): return self.state["keys"].get(name, {"pressed":False,"released":False,"held":False,"axis":0}) def key_events(self): return self.state["key_events"][:] def any_key_pressed(self): return self.state["any_key_pressed"] + def print(self,*values): self.log("info","\t".join(map(str,values))) + def warn(self,*values): self.log("warn","\t".join(map(str,values))) def log(self, level, message): self.commands.append({"op":"log","level":str(level),"message":str(message)}) def set_enabled(self, enabled): self.commands.append({"op":"set_enabled","enabled":bool(enabled)}) class SceneApi: - def getEnvironment(self): return rustic.query({"op":"scene_environment"}) + def getEnvironment(self,field=None): return rustic.query({"op":"scene_environment"} if field is None else dict(op="scene_environment_field",field=field)) def setEnvironment(self, settings): return rustic.query({"op":"scene_environment", "settings":settings}) def setSkyTexture(self, path): return self.setEnvironment({"sky_image":path}) def __init__(self): self.state = {} def Find(self, path): return self.state.get(path) def List(self, path="Game.scene"): - prefix = "" if path in ("", "Game.scene") else path.rstrip("./") + "." - return [entity for name, entity in self.state.items() if not prefix or name.startswith(prefix)] + prefix = (path or "Game.scene").rstrip("./") + "." + return [entity for name, entity in self.state.items() if name.startswith(prefix)] class GameApi: def setCurrentCamera(self, source): @@ -397,3 +406,30 @@ class World: def find_by_tag(name): return rustic.query({"op":"world_find_by_tag",'name':name}) @staticmethod def find_in_radius(center,radius): return rustic.query({"op":"world_find_in_radius",'center':center,'radius':radius}) + +class LoadedAudio: + def __init__(self, source): self.source=source; self.id=None + def load(self): + if self.id is None: self.id=rustic.query(dict(op="audio_load",source=self.source)) + return self + def _loaded(self): + if self.id is None: raise RuntimeError("audio is not loaded; call load() before play()") + return self.id + def play(self, options=None): return AudioVoice(rustic.query(dict(options or {},op="audio_loaded_play",entity=self._loaded()))) + def stop(self): rustic.query(dict(op="audio_loaded_stop",entity=self._loaded())) + def pause(self): rustic.query(dict(op="audio_loaded_pause",entity=self._loaded())) + def resume(self): rustic.query(dict(op="audio_loaded_resume",entity=self._loaded())) + def unload(self): + if self.id is not None: rustic.query(dict(op="audio_unload",entity=self.id)); self.id=None + Unload=unload +Audio.load=staticmethod(lambda source:LoadedAudio(source).load()) +class CaptureHandle: + def __init__(self,id,recording): self.id=id; self.recording=recording + def read(self): return rustic.query(dict(op="capture_read",id=self.id)) + def unload(self): rustic.query(dict(op="capture_unload",id=self.id)) + def stop(self): rustic.query(dict(op="capture_recording_stop" if self.recording else "capture_mic_stop",id=self.id)) +def _start_recording(path=None,options=None): return CaptureHandle(rustic.query(dict(op="capture_recording_start",path=path,source=(options or {}).get("source","game"))),True) +Game.startRecording=_start_recording +Game.stopRecording=lambda handle=None:rustic.query(dict(op="capture_recording_stop",id=None if handle is None else handle.id)) +Game.startHotMic=lambda:CaptureHandle(rustic.query(dict(op="capture_mic_start")),False) +Game.stopHotMic=lambda handle=None:rustic.query(dict(op="capture_mic_stop",id=None if handle is None else handle.id)) diff --git a/Engine/crates/engine-scripting/src/workspace.rs b/Engine/crates/engine-scripting/src/workspace.rs index 49423d18..d6f6e6e6 100644 --- a/Engine/crates/engine-scripting/src/workspace.rs +++ b/Engine/crates/engine-scripting/src/workspace.rs @@ -120,7 +120,11 @@ pub fn generate_programming_workspace( let api_text = format!( "---@meta\n-- {GENERATED_MARKER}\n---@class RusticTransform\n---@field x number\n---@field y number\n---@field z number\n---@class RusticObjectPath\n---@field EditAttribute fun(self:RusticObjectPath,name:string,value:any)\n---@field GetAttribute fun(self:RusticObjectPath,name:string): any\n---@class RusticApi\n---@field game table\n---@field edit_object_attribute fun(source:string,name:string,value:any)\n---@field entity_id fun(): string\n---@field delta_time fun(): number\n---@field fixed_delta_time fun(): number\n---@field get_translation fun(): number, number, number\n---@field set_translation fun(x:number,y:number,z:number)\n---@field find_entity fun(name_or_id:string): string|nil, string|nil\n---@field input fun(action:string): table\n---@field log fun(level:string,message:string,fields:table|nil)\n---@field get_property fun(name:string): any\n---@field set_property fun(name:string,value:any): boolean, string|nil\n---@field set_enabled fun(enabled:boolean)\n---@type RusticApi\nrustic = rustic\n---@class RusticInstanceApi\n---@field add fun(source:string,parent:string|nil): string\n---@field clone fun(source:string,parent:string|nil): string\n---@type RusticInstanceApi\ninstance = instance\n---@class RusticSceneEnvironment\n---@field enabled boolean\n---@field sky_image string\n---@field sky_color number[]\n---@field rotation_degrees number\n---@field exposure number\n---@field ambient_color number[]\n---@field ambient_intensity number\n---@field sun_color number[]\n---@field sun_intensity number\n---@field sun_direction number[]\n---@field haze_color number[]\n---@field haze_density number\n---@field haze_start number\n---@class RusticSceneApi\n---@field Find fun(path:string): string|nil\n---@field List fun(path:string|nil): string[]\n---@field getEnvironment fun(): RusticSceneEnvironment\n---@field setEnvironment fun(settings:table): RusticSceneEnvironment\n---@field setSkyTexture fun(path:string): RusticSceneEnvironment\n---@class RusticGameApi\n---@field scene RusticSceneApi\n---@field setCurrentCamera fun(source:string|table)\n---@type RusticGameApi\nGame = Game\n" ); - let api_text = format!("{api_text}{}", include_str!("sdk/entity-api.lua")); + let api_text = format!( + "{api_text}{}{}", + include_str!("sdk/entity-api.lua"), + include_str!("sdk/media-api.lua") + ); files.write_with_options( &api, api_text.as_bytes(), @@ -128,11 +132,12 @@ pub fn generate_programming_workspace( )?; let javascript_api = generated.join("rustic_api.d.ts"); let javascript_api_text = format!( - "// {GENERATED_MARKER}\ntype RusticActionState = Readonly<{{ pressed: boolean; released: boolean; held: boolean; axis: number }}>;\ninterface RusticSceneEnvironment {{\n enabled: boolean;\n sky_image: string;\n sky_color: [number, number, number];\n rotation_degrees: number;\n exposure: number;\n ambient_color: [number, number, number];\n ambient_intensity: number;\n sun_color: [number, number, number];\n sun_intensity: number;\n sun_direction: [number, number, number];\n haze_color: [number, number, number];\n haze_density: number;\n haze_start: number;\n}}\ninterface RusticObjectPath {{ [name: string]: any; EditAttribute(name: string, value: unknown): void; edit_attribute(name: string, value: unknown): void; }}\ninterface RusticApi {{\n game: RusticObjectPath;\n edit_object_attribute(source: string, name: string, value: unknown): void;\n entity_id(): string;\n delta_time(): number;\n fixed_delta_time(): number;\n get_translation(): [number, number, number];\n set_translation(x: number, y: number, z: number): void;\n input(action: string): RusticActionState;\n log(level: string, message: string): void;\n get_property(name: string): unknown;\n set_property(name: string, value: unknown): void;\n set_enabled(enabled: boolean): void;\n}}\ndeclare const Game: Readonly<{{ setCurrentCamera(source: string): void; scene: {{ Find(path: string): string | undefined; List(): string[]; getEnvironment(): RusticSceneEnvironment; setEnvironment(settings: Partial): RusticSceneEnvironment; setSkyTexture(path: string): RusticSceneEnvironment }} }}>;\ndeclare const rustic: Readonly;\ndeclare const instance: Readonly<{{ add(source: string, parent?: string | null): void; clone(source: string, parent?: string | null): void }}>;\ndeclare interface RusticBehavior {{\n on_create?(): void;\n on_start?(): void;\n fixed_update?(delta: number): void;\n update?(delta: number): void;\n on_destroy?(): void;\n on_stop?(): void;\n}}\ndeclare var behavior: RusticBehavior;\n" + "// {GENERATED_MARKER}\n/// \n/// \ntype RusticActionState = Readonly<{{ pressed: boolean; released: boolean; held: boolean; axis: number }}>;\ninterface RusticSceneEnvironment {{\n enabled: boolean;\n sky_image: string;\n sky_color: [number, number, number];\n rotation_degrees: number;\n exposure: number;\n ambient_color: [number, number, number];\n ambient_intensity: number;\n sun_color: [number, number, number];\n sun_intensity: number;\n sun_direction: [number, number, number];\n haze_color: [number, number, number];\n haze_density: number;\n haze_start: number;\n}}\ninterface RusticObjectPath {{ [name: string]: any; EditAttribute(name: string, value: unknown): void; edit_attribute(name: string, value: unknown): void; }}\ninterface RusticApi {{\n game: RusticObjectPath;\n edit_object_attribute(source: string, name: string, value: unknown): void;\n entity_id(): string;\n delta_time(): number;\n fixed_delta_time(): number;\n get_translation(): [number, number, number];\n set_translation(x: number, y: number, z: number): void;\n input(action: string): RusticActionState;\n log(level: string, message: string): void;\n get_property(name: string): unknown;\n set_property(name: string, value: unknown): void;\n set_enabled(enabled: boolean): void;\n}}\ndeclare const Game: Readonly): RusticSceneEnvironment; setSkyTexture(path: string): RusticSceneEnvironment }} }}>;\ndeclare const rustic: Readonly;\ndeclare const instance: Readonly<{{ add(source: string, parent?: string | null): void; clone(source: string, parent?: string | null): void }}>;\ndeclare interface RusticBehavior {{\n on_create?(): void;\n on_start?(): void;\n fixed_update?(delta: number): void;\n update?(delta: number): void;\n on_destroy?(): void;\n on_stop?(): void;\n}}\ndeclare var behavior: RusticBehavior;\n" ); let javascript_api_text = format!( - "{javascript_api_text}{}", - include_str!("sdk/entity-api.d.ts") + "{javascript_api_text}{}{}", + include_str!("sdk/entity-api.d.ts"), + include_str!("sdk/media-api.d.ts") ); files.write_with_options( &javascript_api, diff --git a/Engine/docs/GAMEPLAY_PROGRAMMING.md b/Engine/docs/GAMEPLAY_PROGRAMMING.md index 0091577f..66c66bf7 100644 --- a/Engine/docs/GAMEPLAY_PROGRAMMING.md +++ b/Engine/docs/GAMEPLAY_PROGRAMMING.md @@ -302,3 +302,5 @@ use native member calls, subscripts, or path-selection methods. See [Edit scene objects](Scripting/sceneObjects.md) for exact attachment steps, all language examples, supported values, and current limitations. This does not invoke another script's custom functions or load another scene. + +See [APIs in every language](Scripting/apiLanguages.md) for API families shared by every gameplay binding, reusable audio, capture handles, and live object queries. diff --git a/Engine/docs/Scripting/API/audio.md b/Engine/docs/Scripting/API/audio.md index 0532d009..e12df247 100644 --- a/Engine/docs/Scripting/API/audio.md +++ b/Engine/docs/Scripting/API/audio.md @@ -20,7 +20,7 @@ The signatures and complete example below use JavaScript. Attach a JavaScript Ob | OGG Opus | `.ogg`, `.oga`, `.opus` | Mono or stereo Opus (mapping family 0) | Put the file inside your project's `assets/` folder. Extensions are case -insensitive. The same formats work with loaded Lua/Luau audio handles and +insensitive. The same formats work with loaded audio handles in every gameplay language and `Audio.play` / `Audio.playAt` in every supported script language. For example, `rustic.game.assets.music["theme.mp3"]:load()` or `Audio.play("assets/music/theme.flac")` uses the usual audio controls. @@ -38,7 +38,7 @@ bytes and 200 million decoded samples per clip. Opus decodes at 48 kHz and respects header gain, encoder delay, and end padding; other formats preserve the decoded sample rate. Lossy decoder peaks are clamped to the mixer's [-1, 1] range. -## Load and control audio objects (Lua and Luau) +## Load and control audio objects Create `assets/sounds/` in your project and place `crash.wav` there (WAV, MP3, AAC, FLAC, and OGG are supported). Create a Lua or Luau Object Component script, attach it to an enabled scene object, and press **Play**. Use `Start` to load audio before playing: @@ -87,7 +87,10 @@ with no active playback are harmless. error. Missing, unsupported, or invalid files fail during `load()`. Check the Console, attachment, file name and extension. Paths must stay inside `assets/`. -The object methods and `Audio.load` are available in Lua 5.4 and Luau. Existing +Audio.load and loaded playback/control/unload are available in every gameplay language. +Asset path proxies retain their Lua/Luau syntax and are also available in JavaScript; +other languages use Audio.load(source). See [APIs in every language](/docs/guides/api-languages) +for native signatures and examples. Existing `Audio.play(source, options?)` and `Audio.playAt` in all languages still decode and play immediately; their voice controls continue to work. @@ -104,8 +107,9 @@ list and report an unavailable-backend error when selecting a device. duplicated; use the ID to select a device. - `Audio.set_output_device(id)` switches this scene's mixed audio to that output. - `Audio.set_input_device(id)` validates and retains the scene's selected input - device. **Microphone recording/capture is not implemented**, so this call does - not produce microphone samples or route microphone sound to playback. + device. This call does not start capture or route microphone sound to playback. + Recording and hot mic capture currently use the system default input + independently of this selection; see the recording section below. - Pass `null` (Lua/Luau `nil`, Python `None`, C `NULL`) to either setter to select the current system default. Java requires an explicit `null` argument; other bindings except C also allow omitting the argument. Setters return no value. @@ -155,7 +159,7 @@ plugging/unplugging hardware. Empty lists are valid on hosts without devices. - `Audio.get_output_devices() / get_input_devices()` - `Audio.set_output_device(id = null) / set_input_device(id = null)` -- `Audio.load(source)` (Lua/Luau; returns a reusable audio handle) +- `Audio.load(source)` (every gameplay language; returns a reusable audio handle) - `Audio.play(source, options?)` - `Audio.playAt(source, position, options?)` - `voice.pause() / resume() / stop()` @@ -199,11 +203,14 @@ globalThis.behavior = { - Check script attachment, enabled state and the Console when an operation fails. Asynchronous errors also appear in operation state. Callback exceptions disable the owner and clean up its actions. - Owner destruction, disable, successful reload or Stop cleans up owned actions and subscriptions. Failed reload retains the previous behavior's operations. See [Operation handles](/docs/api/operation-handles) for controls and lifetime details. -## Record audio and read a hot microphone (Lua and Luau) +## Record audio and read a hot microphone Create a Lua or Luau Object Component script in `scripts/`, attach it to an **enabled** scene object, and press **Play**. Start capture inside `Start` or -another callback. Capture methods use camel case and colon syntax: +another callback. The example below uses Lua/Luau colon syntax. All gameplay languages expose +Game.startRecording, stopRecording, startHotMic, stopHotMic and capture +handle read/stop/unload; see [APIs in every language](/docs/guides/api-languages) +for copyable native bindings: ```lua local rec @@ -211,7 +218,7 @@ return { Start = function() rec = rustic.game:startRecording() end, - Stop = function() + OnStop = function() rec:stopRecording() local packet = rec:read() -- packet.samples contains interleaved floating-point PCM. @@ -268,7 +275,7 @@ return { -- Feed packet.samples, packet.sampleRate and packet.channels to your -- audio processing or relay code. peak can drive a microphone meter. end, - Stop = function() + OnStop = function() mic:stopHotMic() mic:unload() end @@ -298,5 +305,5 @@ return { and OS microphone access. Default F32, I16 and U16 device formats are supported. Missing devices, permission failures, unsupported formats, startup timeouts and device disconnection raise errors. Game mixer recording works headlessly too. -- These convenience bindings are available in Lua 5.4 and Luau. Hot mic supplies +- These services are available in every gameplay language. Hot mic supplies local PCM; automatic network transport and speaker monitoring are not provided. diff --git a/Engine/docs/Scripting/API/camera-actions.md b/Engine/docs/Scripting/API/camera-actions.md index 84221639..bfbc0444 100644 --- a/Engine/docs/Scripting/API/camera-actions.md +++ b/Engine/docs/Scripting/API/camera-actions.md @@ -44,7 +44,7 @@ globalThis.behavior = { - Create/select an active perspective camera before Play. It reaches the given position, identity rotation and 45-degree FOV in two seconds. - Lua/Luau/JavaScript additionally allow moveTo(position, duration, easing) and zoom(fov, duration, easing) for the current camera. Explicit targets work across all bindings. -- C has no Camera.current helper: resolve an explicit camera ID. Other native bindings expose current. +- Camera.current is available in every gameplay language, including C; it returns the active camera ID. - LookAt samples when starting; follow re-reads its target only during the finite duration. Orbit uses local XZ. Shake restores its initial position. - Check script attachment, enabled state and the Console when an operation fails. Asynchronous errors also appear in operation state. Callback exceptions disable the owner and clean up its actions. - Owner destruction, disable, successful reload or Stop cleans up owned actions and subscriptions. Failed reload retains the previous behavior's operations. See [Operation handles](/docs/api/operation-handles) for controls and lifetime details. diff --git a/Engine/docs/Scripting/API/clock.md b/Engine/docs/Scripting/API/clock.md index ed96e9a7..df577c5e 100644 --- a/Engine/docs/Scripting/API/clock.md +++ b/Engine/docs/Scripting/API/clock.md @@ -17,7 +17,7 @@ The signatures and complete example below use JavaScript. Attach a JavaScript Ob ## Parameters -Scale is finite non-negative; 1 is normal rate and 0.5 is half speed. JavaScript state returns the current scale and paused flag. Native getter/state availability varies; C/C++/Java timeScale requires a value. +Scale is finite non-negative; 1 is normal rate and 0.5 is half speed. Clock.state() returns the current scale and paused flag in every gameplay language. C returns RusticClockState; C++ returns RusticValue; C# returns JsonElement; Java returns a map. C sets time scale with Clock.timeScale(value) and reads it with Clock.state().scale. ## Return and timing diff --git a/Engine/docs/Scripting/README.md b/Engine/docs/Scripting/README.md index 21ab2b1c..9e066fe8 100644 --- a/Engine/docs/Scripting/README.md +++ b/Engine/docs/Scripting/README.md @@ -69,3 +69,5 @@ object using `rustic.game..` in native language syntax. See [Entity foundations](API/entity-foundations.md) for live tags, groups, hierarchy, lifetime, component and world queries in the shared scripting runtime. + +See [APIs in every language](/docs/guides/api-languages) for the shared API families, reusable audio, capture, and live object reads. diff --git a/Engine/docs/Scripting/apiLanguages.md b/Engine/docs/Scripting/apiLanguages.md new file mode 100644 index 00000000..64eb9244 --- /dev/null +++ b/Engine/docs/Scripting/apiLanguages.md @@ -0,0 +1,225 @@ +# APIs in every supported language + +Lua 5.4, Luau, JavaScript, Python, C, C++, C#, Java, PHP, and HTML inline JavaScript +use the same engine services. CSS supplies styling and executes no API calls. + +## Create and attach a script + +1. Open a project and create an **Object Component Script** from + **Programming > New Script**, choosing your language. +2. Select an enabled scene object and use **+ Add Component** to attach the script. +3. External languages need their toolchain on PATH: Python 3, a C17 or C++20 + compiler, .NET SDK 10+, Java with javac, or PHP CLI. Lua, JavaScript and the + isolated Luau host ship with Rustic. +4. Generate the programming workspace from the editor before using an external + editor. Include/import the engine-owned SDK shown in your + [language guide](/docs/guides/scripting). PHP and HTML assets belong under ui/. +5. Put tone.wav in assets/, then run the examples below from **Start**. + Press **Play**. Playback is audible on Windows; other platforms currently mix + without a native output device. + +## Shared API families + +| Service | Available operations | +| --- | --- | +| Lifecycle and owner state | Construction, startup, enable/disable, fixed/frame updates, destroy/stop, translation, properties, built-in attributes, input, logging | +| Scene and object services | Find/list, live entity lookup, read/edit other objects, instance add/clone, current camera selection, environment/sky settings | +| Entity foundations | Tags, groups, hierarchy, destruction/lifetime, components, radius/tag queries | +| Gameplay actions | Tween/value, easing, interpolation, movement, paths, timers, sequence/timeline, effects, camera actions | +| Animation | Load/register clips, play/blend, speed/loop/pause/stop, markers, keyframes, IK and procedural actions | +| Events and time | Signals, connections, operation handles, scene clock read/pause/resume/time scale | +| Audio | Play/spatial play, reusable loaded clips, voice controls, volume/pitch/fades, device selection | +| Capture | Record game mix or microphone, hot mic, stop/read/unload script-owned PCM handles | + +All these families are available in every gameplay language. Names and signatures +follow each language's SDK; native bindings use typed options and handles. +HTML shares the JavaScript sandbox. This does not add browser APIs or DOM access. + +## Load audio and record the mix + +These are callback-body examples, not standalone programs. Use the callback +registration from your language guide. They play the same clip, pause/resume it, +stop and replay it, then release it. Recording starts before playback and stops +afterward; a read returns the PCM mixed between callbacks, so stopping immediately +within Start can produce an empty packet. Retain the handles across callbacks and +stop/read in Update or a Timer callback when recording a longer session. +The immediate stop/unload calls below run before a mixed frame, so these short +snippets produce no audible output. Omit those controls until a later callback to +hear playback and collect a nonempty recording. + +### Lua 5.4 / Luau + +```lua +local rec = Game.startRecording() +local clip = Audio.load('assets/tone.wav') +clip:play({loop=true}) +clip:pause(); clip:resume(); clip:stop(); clip:play() +clip:unload() +Game.stopRecording(rec) +local packet = rec:read() +rec:unload() +``` + +### JavaScript / HTML inline JavaScript + +```javascript +const rec = Game.startRecording(); +const clip = Audio.load('assets/tone.wav'); +clip.play({loop:true}); +clip.pause(); clip.resume(); clip.stop(); clip.play(); +clip.unload(); +Game.stopRecording(rec); +const packet = rec.read(); +rec.unload(); +``` + +### Python + +```python +# from rustic import Audio, Game +rec = Game.startRecording() +clip = Audio.load('assets/tone.wav') +clip.play({'loop':True}) +clip.pause(); clip.resume(); clip.stop(); clip.play() +clip.unload() +Game.stopRecording(rec) +packet = rec.read() +rec.unload() +``` + +### C + +```c +/* #include "rustic.h" */ +RusticCaptureHandle rec = Game.startRecording(NULL, NULL); +RusticLoadedAudio clip = Audio.load("assets/tone.wav"); +RusticAudioOptions options = {.volume=1, .pitch=1, .loop=true}; +LoadedAudio.play(clip, &options); +LoadedAudio.pause(clip); LoadedAudio.resume(clip); LoadedAudio.stop(clip); +LoadedAudio.play(clip, NULL); LoadedAudio.unload(&clip); +Game.stopRecording(&rec); +RusticCaptureData packet = Capture.read(rec); +Capture.unload(rec); +``` + +### C++ + +```cpp +// #include "rustic.hpp" +auto rec = Game.startRecording(); +auto clip = Audio::load("assets/tone.wav"); +clip.play(1,1,true); +clip.pause(); clip.resume(); clip.stop(); clip.play(); +clip.unload(); +Game.stopRecording(&rec); +auto packet = rec.read(); +rec.unload(); +``` + +### C# + +```csharp +// using static Rustic; +var rec = Game.startRecording(); +var clip = Audio.load("assets/tone.wav"); +clip.play(loop:true); +clip.pause(); clip.resume(); clip.stop(); clip.play(); +clip.unload(); +Game.stopRecording(rec); +var packet = rec.read(); +rec.unload(); +``` + +### Java + +```java +// Inside a behavior extending Rustic. +var rec = Game.startRecording(); +var clip = Audio.load("assets/tone.wav"); +clip.play(1,1,true); +clip.pause(); clip.resume(); clip.stop(); clip.play(); +clip.unload(); +Game.stopRecording(rec); +var packet = (java.util.Map)rec.read(); +rec.unload(); +``` + +### PHP + +```php +// Inside a callback after require_once 'rustic.php'. +global $Game; +$rec = $Game->startRecording(); +$clip = Audio::load('assets/tone.wav'); +$clip->play(['loop'=>true]); +$clip->pause(); $clip->resume(); $clip->stop(); $clip->play(); +$clip->unload(); +$Game->stopRecording($rec); +$packet = $rec->read(); +$rec->unload(); +``` + +Audio.load decodes once. play reuses retained PCM even if the source file is +removed afterward. Stop retains PCM; unload frees it and stops its playback. +Unload twice is harmless. Lua/Luau, JavaScript, Python, C++, C#, Java and PHP can +call clip.load() (Lua uses a colon) to load the same object again after unload. +C calls Audio.load(source) to obtain a new handle. C++ copies share loaded state. +A voice returned by loaded playback supports the same fades and controls as an +Audio.play voice. + +## Microphone capture + +Use Game.startHotMic() instead of startRecording() to obtain a microphone +handle, then use its read, stop, and unload methods. C uses +Game.startHotMic(), Capture.read(mic), Capture.stop(mic) and +Capture.unload(mic). Stop all owned hot mics with Game.stopHotMic(); C passes +NULL, C++ omits the pointer, and Java passes null or omits the argument. + +To record microphone PCM, use Game.startRecording(null, {source:'microphone'}) +in JavaScript, Game.startRecording(None, {'source':'microphone'}) in Python, +Game.startRecording(nil, {source='microphone'}) in Lua/Luau, or +$Game->startRecording(null, ['source'=>'microphone']) in PHP. C, C++, C# and Java +pass "microphone" as the second argument. The existing rustic.game entry point +also exposes the capture methods in each SDK. + +Capture packets contain samples, sampleRate, channels, active, and +droppedSamples. Samples are interleaved floating-point PCM. Reads advance the +cursor; repeated reads do not repeat samples. C uses packet.count for the number +of samples; its sample storage remains valid until the next script callback. +Recording and microphone capture retain the existing platform limits: +Windows supplies microphone input; other platforms report an unavailable-backend +error. Device selection does not currently change capture's system-default input. +See the [Audio API](/docs/api/audio) for file paths, limits and cleanup. + +## Read another object's current attributes + +Use rustic.get_object_attribute(source, name) in every language, with native +member syntax ($rustic-> in PHP). source accepts a stable entity ID or +rustic.game.Scene.Root.Child. C returns RusticValue; C++ returns RusticValue; +C#/Java return ordinary scalar or vector values. JavaScript/Python/Lua return +their ordinary values. Path proxies also expose GetAttribute and get_attribute; +C uses rustic.game.GetAttribute(source, name). + +Edits queue in JavaScript and external adapters. A live query flushes earlier +commands before reading, so an edit followed by get_object_attribute in the same +callback observes the new value. Owner snapshot getters such as get_translation +and get_attribute retain their callback snapshot in those adapters. Lua applies +writes immediately. rustic.find_entity(source) performs live lookup; scene +Find/List retain their existing snapshot semantics in external adapters. List returns\none entity ID per matched object, supports a subtree path and defaults to Game.scene. + +## Diagnose failures + +- Missing external SDK/toolchain: regenerate the workspace and check the Console's + toolchain diagnostic. Do not write your own protocol loop. +- Missing clip: check the exact assets/ path and supported encoding. Playing after + unload requires load first. A C handle must be replaced by Audio.load. +- Silent output or microphone unavailable: check the platform, Windows device + permissions and the Console. A stopped recording still permits reading. +- Missing/stale object: use a stable entity ID or verify the scene/hierarchy path; + a destroyed object cannot supply attributes. +- Capture/action lifetime: handles belong to the script and clean up on disable, + destruction, successful reload or Stop. + +Input remains limited to held WASD, arrows and Shift in embedded Play. Collision +and touch callbacks are not dispatched. These are shared runtime limits across +languages. diff --git a/Engine/docs/Scripting/sceneObjects.md b/Engine/docs/Scripting/sceneObjects.md index 035ec38a..df689277 100644 --- a/Engine/docs/Scripting/sceneObjects.md +++ b/Engine/docs/Scripting/sceneObjects.md @@ -95,11 +95,12 @@ All gameplay languages use engine-owned APIs. The engine handles command transpo use the native calls above without serializing or printing requests/responses. CSS alone cannot call functions; use inline JavaScript in an HTML UI asset. -Embedded Lua applies edits immediately and supports -`:GetAttribute("Position")` on the same path. JavaScript and external languages -queue edits in issue order and apply them after the callback; they do not expose -path-based getters. Do not use a queued edit followed by an owner getter to read -another object's new value. +Embedded Lua applies edits immediately. Every gameplay language can read another +object using rustic.get_object_attribute(source, name) or its path proxy's +GetAttribute method (C uses rustic.game.GetAttribute(source, name)). +JavaScript and external languages queue edits in issue order. A live query flushes +those edits before reading; owner getters still read the callback snapshot. +See [APIs in every language](/docs/guides/api-languages) for native signatures. ## Supported attributes @@ -114,7 +115,7 @@ another object's new value. | `Anchored` | boolean | Prevents physics movement; unchecked primitives fall under gravity | | `Parent` | stable entity ID or null | Reparents the target; changes future path | -`EditAttribute` and `edit_attribute` are equivalent. The direct Lua and JavaScript +`EditAttribute` and `edit_attribute` are equivalent. The shared helper `rustic.edit_object_attribute(source, name, value)` also accepts the full path string. Material and Enabled are not supported attributes; `set_enabled` still affects the script owner.