tirbofish/dropbear · diff
plugin loading system and the very basics of an SDK
i need to create a console and an error dock in the editor, and work on some more scripting API's.
also improved gradle build times on windows by expanding the memory from 512MB to 2GB so i suppose thats also nice :)
Signature present but could not be verified.
Unverified
@@ -6,7 +6,7 @@ package.repository = "https://github.com/4tkbytes/dropbear" package.readme = "README.md" resolver = "3" -members = [ "dropbear-engine", "dropbear_future-queue", "eucalyptus-core", "eucalyptus-editor", "magna-carta"] +members = [ "dropbear-engine", "dropbear_future-queue", "eucalyptus-core", "eucalyptus-editor", "eucalyptus-sdk", "magna-carta"] # members = ["dropbear-engine", "eucalyptus-core", "eucalyptus-editor", "redback-runtime"] @@ -52,10 +52,7 @@ winit = { version = "0.30", features = [] } zip = "5.1" walkdir = "2.3" rayon = "1.11" -flate2 = "1.1" reqwest = { version = "0.11", features = ["stream"] } -tar = "0.4" -futures-util = "0.3" backtrace = "0.3" gltf = "1" os_info = "3.12" @@ -63,6 +60,9 @@ rustc_version_runtime = "0.3" jni = { version = "0.21", features = ["invocation"] } tree-sitter = "0.22" tree-sitter-kotlin = "0.3.8" +libloading = "0.8" +indexmap = "2.11" +rand = "0.9" [workspace.dependencies.image] version = "0.25" @@ -12,6 +12,7 @@ If you might have not realised, all the crates/projects names are after Australi - [eucalyptus-editor](https://github.com/4tkbytes/dropbear/tree/main/eucalyptus-editor) is the visual editor used to create games visually, taking inspiration from Unity, Unreal, Roblox Studio and other engines. - [eucalyptus-core](https://github.com/4tkbytes/dropbear/tree/main/eucalyptus-core) is the library used by both `redback-runtime` and `eucalyptus-editor` to share configs and metadata between each other. - [redback-runtime](https://github.com/4tkbytes/redback-runtime) is the runtime used to load .eupak files and run the game loaded on them. +- [eucalyptus-sdk](https://github.com/4tkbytes/dropbear/tree/main/eucalyptus-sdk) is used to develop plugins to be used with the `eucalyptus-editor` ### Related Projects @@ -8,6 +8,8 @@ pub mod states; pub mod utils; pub mod ptr; +pub use egui; + pub const APP_INFO: app_dirs2::AppInfo = app_dirs2::AppInfo { name: "Eucalyptus", author: "4tkbytes", @@ -1355,7 +1355,6 @@ impl SceneConfig { ); #[cfg(feature = "editor")] { - // Editor mode - look for debug camera, create one if none exists let debug_camera = { world .query::<(&Camera, &CameraComponent)>() @@ -1392,7 +1391,6 @@ impl SceneConfig { #[cfg(not(feature = "editor"))] { - // Runtime mode - look for player camera, panic if none exists let player_camera = world .query::<(&Camera, &CameraComponent)>() .iter() @@ -1460,7 +1458,12 @@ pub enum EditorTab { ResourceInspector, // left side, ModelEntityList, // right side, Viewport, // middle, - KotlinREPL, // bottom side - new REPL tab + Plugin(usize), +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct PluginInfo { + pub display_name: String, } /// An enum that describes the status of loading the world. @@ -26,7 +26,6 @@ hecs.workspace = true log.workspace = true log-once.workspace = true model_to_image.workspace = true -open.workspace = true parking_lot.workspace = true transform-gizmo-egui.workspace = true wgpu.workspace = true @@ -35,11 +34,9 @@ clap.workspace = true walkdir.workspace = true zip.workspace = true tokio.workspace = true -reqwest.workspace = true -tar.workspace = true -flate2.workspace = true crossbeam-channel.workspace = true -pollster = "0.4.0" +libloading.workspace = true +indexmap.workspace = true [target.'cfg(not(target_os = "android"))'.dependencies] rfd.workspace = true @@ -51,5 +48,4 @@ editor = ["eucalyptus-core/editor"] [build-dependencies] anyhow = "1.0" app_dirs2 = "2.5" -reqwest = { version = "0.12", features = ["blocking"] } zip = "4.3" @@ -2,7 +2,7 @@ use super::*; use crate::editor::ViewportMode; use std::{collections::HashSet, sync::LazyLock}; -use crate::APP_INFO; +use eucalyptus_core::APP_INFO; use crate::editor::component::InspectableComponent; use dropbear_engine::utils::{ResourceReference, ResourceReferenceType}; use dropbear_engine::{ @@ -10,14 +10,15 @@ use dropbear_engine::{ lighting::{Light, LightComponent}, }; use egui; -use egui::CollapsingHeader; -use egui_dock_fork::TabViewer; +use egui::{CollapsingHeader}; +use egui_dock_fork::{TabViewer}; use egui_extras; use eucalyptus_core::spawn::{PendingSpawn, push_pending_spawn}; use eucalyptus_core::states::{File, Node, RESOURCES, ResourceType}; use log; use parking_lot::Mutex; use transform_gizmo_egui::{EnumSet, Gizmo, GizmoConfig, GizmoExt, GizmoMode, math::DVec3}; +use crate::plugin::PluginRegistry; pub struct EditorTabViewer<'a> { pub view: egui::TextureId, @@ -32,6 +33,9 @@ pub struct EditorTabViewer<'a> { pub gizmo_mode: &'a mut EnumSet<GizmoMode>, pub editor_mode: &'a mut EditorState, pub active_camera: &'a mut Arc<Mutex<Option<hecs::Entity>>>, + pub plugin_registry: &'a mut PluginRegistry, + // "wah wah its unsafe, its using raw pointers" shut the fuck up if it breaks i will know + pub editor: *mut Editor, } impl<'a> EditorTabViewer<'a> { @@ -77,10 +81,6 @@ pub struct DraggedAsset { pub static TABS_GLOBAL: LazyLock<Mutex<StaticallyKept>> = LazyLock::new(|| Mutex::new(StaticallyKept::default())); -// Separate static for REPL to avoid deadlocks -pub static KOTLIN_REPL: LazyLock<Mutex<Option<crate::editor::repl::KotlinREPL>>> = - LazyLock::new(|| Mutex::new(None)); - /// Variables kept statically. /// /// The entire module (including the tab viewer) due to it @@ -114,7 +114,13 @@ impl<'a> TabViewer for EditorTabViewer<'a> { EditorTab::ModelEntityList => "Model/Entity List".into(), EditorTab::AssetViewer => "Asset Viewer".into(), EditorTab::ResourceInspector => "Resource Inspector".into(), - EditorTab::KotlinREPL => "Kotlin REPL".into(), + EditorTab::Plugin(dock_index) => { + if let Some((_, plugin)) = self.plugin_registry.plugins.get_index_mut(*dock_index) { + plugin.display_name().into() + } else { + "Unknown Plugin Name".into() + } + } } } @@ -202,7 +208,6 @@ impl<'a> TabViewer for EditorTabViewer<'a> { self.world.query_one::<( &Camera, &CameraComponent, - // Option<&CameraFollowTarget>, )>(active_camera) { if let Some((camera, _)) = q.get() { @@ -934,17 +939,6 @@ impl<'a> TabViewer for EditorTabViewer<'a> { log_once::debug_once!("Unable to query entity inside resource inspector"); } - // if let Some(e) = local_insert_follow_target { - // match self.world.insert_one(e, CameraFollowTarget::default()) { - // Ok(_) => { - // success!("Added CameraFollowTarget"); - // } - // Err(e) => { - // warn!("Unable to add CameraFollowTarget: {}", e); - // } - // } - // } - // lighting system if let Ok(mut q) = self .world @@ -1022,16 +1016,6 @@ impl<'a> TabViewer for EditorTabViewer<'a> { self.signal, &mut camera.label.clone(), ); - // if let Some(target) = follow_target { - // target.inspect( - // entity, - // &mut cfg, - // ui, - // self.undo_stack, - // self.signal, - // &mut camera.label.clone(), - // ); - // } ui.separator(); @@ -1075,15 +1059,18 @@ impl<'a> TabViewer for EditorTabViewer<'a> { ui.label("No entity selected, therefore no info to provide. Go on, what are you waiting for? Click an entity!"); } } - EditorTab::KotlinREPL => { - // Use separate static to avoid deadlock with TABS_GLOBAL - let mut repl_lock = KOTLIN_REPL.lock(); - if repl_lock.is_none() { - *repl_lock = Some(crate::editor::repl::KotlinREPL::new()); + EditorTab::Plugin(dock_info) => { + if self.editor.is_null() { + panic!("Editor pointer is null, unexpected behaviour"); } - - if let Some(repl) = repl_lock.as_mut() { - repl.ui(ui); + let editor = unsafe { &mut *self.editor }; + if let Some((_, plugin)) = self.plugin_registry.plugins.get_index_mut(*dock_info) { + plugin.ui(ui, editor); + } else { + ui.colored_label( + egui::Color32::RED, + format!("Plugin at index '{}' not found", *dock_info), + ); } } } @@ -1136,8 +1123,20 @@ impl<'a> TabViewer for EditorTabViewer<'a> { menu_action = Some(EditorTabMenuAction::ViewportOption); } } - EditorTab::KotlinREPL => { - // No context menu actions for REPL tab yet + EditorTab::Plugin(dock_info) => { + if self.editor.is_null() { + panic!("Editor pointer is null, unexpected behaviour"); + } + + let editor = unsafe { &mut *self.editor }; + if let Some((_, plugin)) = self.plugin_registry.plugins.get_index_mut(dock_info) { + plugin.context_menu(ui, editor); + } else { + ui.colored_label( + egui::Color32::RED, + format!("Plugin at index '{}' not found", dock_info), + ); + } } } }) @@ -208,6 +208,8 @@ impl Keyboard for Editor { } else { self.switch_to_debug_camera(); } + } else { + self.input_state.pressed_keys.insert(key); } } KeyCode::KeyX => { @@ -221,12 +223,15 @@ impl Keyboard for Editor { KeyCode::KeyP => { if !is_playing && ctrl_pressed { self.signal = Signal::Play + } else { + self.input_state.pressed_keys.insert(key); } } _ => { self.input_state.pressed_keys.insert(key); } } + self.input_state.pressed_keys.insert(key); } fn key_up(&mut self, key: KeyCode, _event_loop: &ActiveEventLoop) { @@ -1,7 +1,6 @@ pub mod component; pub mod dock; pub mod input; -pub mod repl; pub mod scene; pub(crate) use crate::editor::dock::*; @@ -47,58 +46,59 @@ use tokio::sync::oneshot; use transform_gizmo_egui::{EnumSet, Gizmo, GizmoMode}; use wgpu::{Color, Extent3d, RenderPipeline}; use winit::{keyboard::KeyCode, window::Window}; +use crate::plugin::PluginRegistry; pub struct Editor { - scene_command: SceneCommand, + pub scene_command: SceneCommand, pub world: Box<World>, - dock_state: DockState<EditorTab>, - texture_id: Option<egui::TextureId>, - size: Extent3d, - render_pipeline: Option<RenderPipeline>, - light_manager: LightManager, - color: Color, + pub dock_state: DockState<EditorTab>, + pub texture_id: Option<egui::TextureId>, + pub size: Extent3d, + pub render_pipeline: Option<RenderPipeline>, + pub light_manager: LightManager, + pub color: Color, - active_camera: Arc<Mutex<Option<hecs::Entity>>>, + pub active_camera: Arc<Mutex<Option<hecs::Entity>>>, - is_viewport_focused: bool, + pub is_viewport_focused: bool, // is_cursor_locked: bool, - window: Option<Arc<Window>>, + pub window: Option<Arc<Window>>, - show_new_project: bool, - project_name: String, + pub show_new_project: bool, + pub project_name: String, pub(crate) project_path: Arc<Mutex<Option<PathBuf>>>, - pending_scene_switch: bool, + pub pending_scene_switch: bool, - gizmo: Gizmo, + pub gizmo: Gizmo, pub(crate) selected_entity: Option<hecs::Entity>, - viewport_mode: ViewportMode, + pub viewport_mode: ViewportMode, pub(crate) signal: Signal, pub(crate) undo_stack: Vec<UndoableAction>, // todo: add redo (later) // redo_stack: Vec<UndoableAction>, pub(crate) editor_state: EditorState, - gizmo_mode: EnumSet<GizmoMode>, + pub gizmo_mode: EnumSet<GizmoMode>, pub(crate) script_manager: ScriptManager, - play_mode_backup: Option<PlayModeBackup>, + pub play_mode_backup: Option<PlayModeBackup>, /// State of the input pub(crate) input_state: InputState, // channels /// A threadsafe Unbounded Receiver, typically used for checking the status of the world loading - progress_tx: Option<UnboundedReceiver<WorldLoadingStatus>>, + pub progress_tx: Option<UnboundedReceiver<WorldLoadingStatus>>, /// Used to check if the world has been loaded in is_world_loaded: IsWorldLoadedYet, /// Used to fetch the current status of the loading, so it can be used for different /// egui loading windows or splash screens and such. - current_state: WorldLoadingStatus, + pub current_state: WorldLoadingStatus, // handles for futures - world_load_handle: Option<FutureHandle>, + pub world_load_handle: Option<FutureHandle>, pub(crate) alt_pending_spawn_queue: Vec<FutureHandle>, - world_receiver: Option<oneshot::Receiver<hecs::World>>, + pub world_receiver: Option<oneshot::Receiver<hecs::World>>, // building pub progress_rx: Option<Receiver<BuildStatus>>, @@ -109,7 +109,10 @@ pub struct Editor { pub last_build_error: Option<String>, pub show_build_error_window: bool, - dock_state_shared: Option<Arc<Mutex<DockState<EditorTab>>>>, + // plugins + pub plugin_registry: PluginRegistry, + + pub dock_state_shared: Option<Arc<Mutex<DockState<EditorTab>>>>, } impl Editor { @@ -152,6 +155,8 @@ impl Editor { } }); + let plugin_registry = PluginRegistry::new(); + Ok(Self { scene_command: SceneCommand::None, dock_state, @@ -192,6 +197,7 @@ impl Editor { show_build_window: false, last_build_error: None, show_build_error_window: false, + plugin_registry, dock_state_shared: None, }) } @@ -612,8 +618,10 @@ impl Editor { if ui_window.button("Open Viewport").clicked() { self.dock_state.push_to_focused_leaf(EditorTab::Viewport); } - if ui_window.button("Open Kotlin REPL").clicked() { - self.dock_state.push_to_focused_leaf(EditorTab::KotlinREPL); + for (i, (_, plugin)) in self.plugin_registry.plugins.iter().enumerate() { + if ui_window.button(format!("Open {}", plugin.display_name())).clicked() { + self.dock_state.push_to_focused_leaf(EditorTab::Plugin(i)); + } } }); { @@ -625,6 +633,8 @@ impl Editor { }); }); + let editor_ptr = self as *mut Editor; + egui::CentralPanel::default().show(ctx, |ui| { DockArea::new(&mut self.dock_state) .style(Style::from_egui(ui.style().as_ref())) @@ -643,6 +653,8 @@ impl Editor { active_camera: &mut self.active_camera, gizmo_mode: &mut self.gizmo_mode, editor_mode: &mut self.editor_state, + plugin_registry: &mut self.plugin_registry, + editor: editor_ptr, }, ); }); @@ -1,225 +0,0 @@ -use egui::{Color32, FontId, RichText, ScrollArea, TextEdit}; - -/// A simple Kotlin REPL interface for testing scripts -pub struct KotlinREPL { - input: String, - output: Vec<ReplOutputLine>, - history: Vec<String>, - history_index: Option<usize>, -} - -#[derive(Clone)] -pub struct ReplOutputLine { - pub text: String, - pub is_error: bool, - pub is_input: bool, -} - -impl Default for KotlinREPL { - fn default() -> Self { - Self::new() - } -} - -impl KotlinREPL { - pub fn new() -> Self { - let mut repl = Self { - input: String::new(), - output: Vec::new(), - history: Vec::new(), - history_index: None, - }; - - repl.add_output("Kotlin REPL - Ready", false, false); - repl.add_output( - "Type Kotlin expressions to test script functionality", - false, - false, - ); - repl.add_output("Example: Input.isKeyPressed(KeyCode.W)", false, false); - repl.add_output("", false, false); - - repl - } - - fn add_output(&mut self, text: &str, is_error: bool, is_input: bool) { - self.output.push(ReplOutputLine { - text: text.to_string(), - is_error, - is_input, - }); - } - - fn execute(&mut self, code: &str) { - // Add input to output - self.add_output(&format!("> {}", code), false, true); - - // Add to history - if !code.trim().is_empty() { - self.history.push(code.to_string()); - self.history_index = None; - } - - // Execute the code - match self.execute_kotlin_code(code) { - Ok(result) => { - if !result.is_empty() { - self.add_output(&result, false, false); - } - } - Err(e) => { - self.add_output(&format!("Error: {}", e), true, false); - } - } - - self.add_output("", false, false); // blank line - } - - fn execute_kotlin_code(&self, code: &str) -> anyhow::Result<String> { - // For now, we'll provide a simplified evaluation - // In the future, this could compile and run actual Kotlin code via JNI - - // Check for common test commands - if code.trim().starts_with("Input.isKeyPressed") { - Ok("boolean (check console for actual value)".to_string()) - } else if code.trim().starts_with("Input.getMouseX") - || code.trim().starts_with("Input.getMouseY") - { - Ok("double (check console for actual value)".to_string()) - } else if code.contains("Transform") { - Ok("Transform manipulation (check entity in viewport)".to_string()) - } else if code.trim() == "help" { - Ok(r#"Available APIs: -- Input.isKeyPressed(KeyCode.W) - Check if key is pressed -- Input.getMouseX() - Get mouse X position -- Input.getMouseY() - Get mouse Y position -- engine.getTransform() - Get current entity transform -- transform.position - Get/set position (Vector3D) -- transform.rotation - Get/set rotation (Quaternion) -- transform.scale - Get/set scale (Vector3D) - -Note: This is a simplified REPL. Full script execution requires -attaching a script to an entity and running the game."# - .to_string()) - } else if code.trim() == "clear" { - Err(anyhow::anyhow!("CLEAR_SCREEN")) - } else { - Ok(format!( - "Command received: {}\n(Full Kotlin evaluation not yet implemented)", - code - )) - } - } - - pub fn ui(&mut self, ui: &mut egui::Ui) { - ui.vertical(|ui| { - // Title bar - ui.horizontal(|ui| { - ui.heading("Kotlin REPL"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Clear").clicked() { - self.output.clear(); - self.add_output("Kotlin REPL - Ready", false, false); - } - if ui.button("Help").clicked() { - self.execute("help"); - } - }); - }); - - ui.separator(); - - // Output area - ScrollArea::vertical() - .auto_shrink([false, false]) - .stick_to_bottom(true) - .show(ui, |ui| { - ui.set_min_height(ui.available_height() - 80.0); - - for line in &self.output { - let color = if line.is_error { - Color32::from_rgb(255, 100, 100) - } else if line.is_input { - Color32::from_rgb(100, 200, 255) - } else { - Color32::from_rgb(200, 200, 200) - }; - - ui.label( - RichText::new(&line.text) - .color(color) - .font(FontId::monospace(14.0)) - ); - } - }); - - ui.separator(); - - // Input area - ui.horizontal(|ui| { - ui.label(">>>"); - - let response = ui.add( - TextEdit::singleline(&mut self.input) - .desired_width(f32::INFINITY) - .font(FontId::monospace(14.0)) - ); - - // Handle keyboard input - if response.has_focus() { - if ui.input(|i| i.key_pressed(egui::Key::Enter)) { - let code = self.input.clone(); - self.input.clear(); - - if code.trim() == "clear" { - self.output.clear(); - self.add_output("Kotlin REPL - Ready", false, false); - } else { - self.execute(&code); - } - } - - // History navigation - if ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) - && !self.history.is_empty() { - if let Some(idx) = self.history_index { - if idx > 0 { - self.history_index = Some(idx - 1); - self.input = self.history[idx - 1].clone(); - } - } else { - self.history_index = Some(self.history.len() - 1); - self.input = self.history[self.history.len() - 1].clone(); - } - } - - if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) - && let Some(idx) = self.history_index { - if idx < self.history.len() - 1 { - self.history_index = Some(idx + 1); - self.input = self.history[idx + 1].clone(); - } else { - self.history_index = None; - self.input.clear(); - } - } - } - - if ui.button("Execute").clicked() && !self.input.is_empty() { - let code = self.input.clone(); - self.input.clear(); - self.execute(&code); - } - }); - - // Tips - ui.horizontal(|ui| { - ui.label( - RichText::new("Tip: Type 'help' for available commands, 'clear' to clear output. Use ↑↓ for history.") - .size(10.0) - .color(Color32::from_rgb(150, 150, 150)) - ); - }); - }); - } -} @@ -100,6 +100,14 @@ impl Scene for Editor { fatal!("{}", e); } } + + if !self.plugin_registry.plugins_loaded { + if let Err(e) = self.plugin_registry.load_plugins() { + fatal!("Failed to load plugins: {}", e); + } else { + log::info!("Plugins loaded"); + } + } if let Some((_, tab)) = self.dock_state.find_active_focused() { self.is_viewport_focused = matches!(tab, EditorTab::Viewport); @@ -0,0 +1,9 @@ +pub mod build; +pub mod camera; +pub mod debug; +pub mod editor; +pub mod menu; +pub mod signal; +pub mod spawn; +pub mod utils; +pub mod plugin; @@ -1,24 +1,13 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod build; -mod camera; -mod debug; -mod editor; -mod menu; -mod signal; -mod spawn; -mod utils; use clap::{Arg, Command}; use dropbear_engine::future::FutureQueue; -use dropbear_engine::{WindowConfiguration, scene}; +use dropbear_engine::{scene, WindowConfiguration}; use parking_lot::RwLock; use std::sync::Arc; use std::{fs, path::PathBuf, rc::Rc}; - -pub const APP_INFO: app_dirs2::AppInfo = app_dirs2::AppInfo { - name: "Eucalyptus", - author: "4tkbytes", -}; +use eucalyptus_editor::{build, editor, menu}; +use eucalyptus_core::APP_INFO; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -89,7 +78,7 @@ async fn main() -> anyhow::Result<()> { }, }; - crate::build::build(project_path)?; + build::build(project_path)?; } Some(("package", sub_matches)) => { let project_path = match sub_matches.get_one::<String>("project") { @@ -103,7 +92,7 @@ async fn main() -> anyhow::Result<()> { }, }; - crate::build::package(project_path, sub_matches)?; + build::package(project_path, sub_matches)?; } Some(("health", _)) => { build::health()?; @@ -0,0 +1,141 @@ +use std::fs::ReadDir; +use std::path::PathBuf; +use std::time::Instant; +use app_dirs2::AppDataType; +use egui::Ui; +use indexmap::IndexMap; +use eucalyptus_core::{APP_INFO}; +use libloading as lib; +use eucalyptus_core::states::PluginInfo; +use crate::editor::Editor; + +pub trait EditorPlugin: Send + Sync { + fn id(&self) -> &str; + fn display_name(&self) -> &str; + fn ui(&mut self, ui: &mut Ui, editor: &mut Editor); + fn tab_title(&self) -> &str; + fn context_menu(&mut self, _ui: &mut Ui, _editor: &mut Editor) { /* No context */ } +} + +pub type PluginConstructor = fn() -> Box<dyn EditorPlugin>; + +pub struct PluginRegistry { + pub plugins: IndexMap<String, Box<dyn EditorPlugin>>, + loaded_libraries: Vec<lib::Library>, + pub(crate) plugins_loaded: bool, +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +impl PluginRegistry { + pub fn new() -> Self { + Self { + plugins: IndexMap::new(), + loaded_libraries: Vec::new(), + plugins_loaded: false, + } + } + + pub fn register(&mut self, plugin: Box<dyn EditorPlugin>) { + let id = plugin.id().to_string(); + self.plugins.insert(id, plugin); + } + + #[allow(clippy::borrowed_box)] + pub fn get(&self, id: &str) -> Option<&Box<dyn EditorPlugin>> { + self.plugins.get(id) + } + + pub fn get_mut(&mut self, id: &str) -> Option<&mut Box<dyn EditorPlugin>> { + self.plugins.get_mut(id) + } + + pub fn list_plugins(&self) -> Vec<PluginInfo> { + self.plugins + .values() + .map(|p| PluginInfo { + display_name: p.display_name().to_string() + }) + .collect() + } + + pub fn load_plugins(&mut self) -> anyhow::Result<()> { + let appdir: PathBuf = app_dirs2::app_root(AppDataType::UserData, &APP_INFO)?; + let plugins_folder = appdir.join("plugins"); + + std::fs::create_dir_all(&plugins_folder)?; + + let contents: ReadDir = std::fs::read_dir(&plugins_folder)?; + + log::info!("Loading plugins from {}", plugins_folder.display()); + let mut index: i32 = -1; + for (i, entry) in contents.enumerate() { + match entry { + Ok(e) => { + index = i as i32; + let now = Instant::now(); + let path = e.path(); + if let Some(ext) = path.extension() { + let ext_str = ext.to_str().unwrap_or(""); + + if !self.is_valid_extension_for_platform(ext_str) { + log::warn!("Skipping plugin {} - incompatible extension for this platform", + path.display()); + continue; + } + + match self.load_plugin_from_file(&path) { + Ok(plugin) => { + log::info!("Successfully loaded plugin: {}", path.display()); + log::debug!("Plugin {} loaded in {:?}", path.display(), now.elapsed()); + self.register(plugin); + } + Err(e) => { + log::error!("Failed to load plugin {}: {}", path.display(), e); + continue; + } + } + } + } + Err(err) => { + log::warn!("Failed to read directory entry: {}", err); + continue; + } + } + } + + if index == -1 { + log::info!("No plugins found"); + } + + self.plugins_loaded = true; + Ok(()) + } + + fn is_valid_extension_for_platform(&self, ext: &str) -> bool { + match ext { + "dll" => cfg!(windows), + "so" => cfg!(unix) && !cfg!(target_os = "macos"), + "dylib" => cfg!(target_os = "macos"), + _ => false, + } + } + + fn load_plugin_from_file(&mut self, path: &PathBuf) -> anyhow::Result<Box<dyn EditorPlugin>> { + let library = unsafe { lib::Library::new(path)? }; + + let constructor: lib::Symbol<PluginConstructor> = unsafe { + library.get(b"create_plugin")? + }; + + let plugin = constructor(); + + self.loaded_libraries.push(library); + + Ok(plugin) + } +} @@ -16,6 +16,7 @@ use hecs::{Entity, World}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use winit::keyboard::KeyCode; pub trait SignalController { fn run_signal(&mut self, graphics: Arc<SharedGraphicsContext>) -> anyhow::Result<()>; @@ -136,6 +137,152 @@ impl SignalController for Editor { } if matches!(self.editor_state, EditorState::Building) { + #[cfg(not(target_os = "macos"))] + let ctrl_pressed = self + .input_state + .pressed_keys + .contains(&KeyCode::ControlLeft) + || self + .input_state + .pressed_keys + .contains(&KeyCode::ControlRight); + #[cfg(target_os = "macos")] + let ctrl_pressed = self.input_state.pressed_keys.contains(&KeyCode::SuperLeft) + || self.input_state.pressed_keys.contains(&KeyCode::SuperRight); + + let alt_pressed = self.input_state.pressed_keys.contains(&KeyCode::AltLeft) + || self.input_state.pressed_keys.contains(&KeyCode::AltRight); + + // Ctrl+Alt+P skips build process and starts running, such as if using cached jar + if ctrl_pressed && alt_pressed && self.input_state.pressed_keys.contains(&KeyCode::KeyP) { + + if let Some(handle) = self.handle_created { + log::debug!("Cancelling build task due to manual intervention"); + graphics.future_queue.cancel(&handle); + } else { + log::warn!("No handle was created during this time. Weird...") + } + + let project_root = { + let cfg = PROJECT.read(); + cfg.project_path.clone() + }; + let libs_dir = project_root.join("build").join("libs"); + if !libs_dir.exists() { + let err = "Build succeeded but 'build/libs' directory is missing".to_string(); + return Err(anyhow::anyhow!(err)); + } + + let jar_files: Vec<PathBuf> = std::fs::read_dir(&libs_dir)? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|path| { + path.extension().map_or(false, |ext| ext.eq_ignore_ascii_case("jar")) + && !path.file_name().unwrap_or_default().to_string_lossy().contains("-sources") + && !path.file_name().unwrap_or_default().to_string_lossy().contains("-javadoc") + }) + .collect(); + + if jar_files.is_empty() { + let err = "No JAR artifact found in 'build/libs'".to_string(); + return Err(anyhow::anyhow!(err)); + } + + let fat_jar = jar_files + .iter() + .find(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .map_or(false, |name| name.contains("-all")) + }); + + let jar_path = if let Some(fat) = fat_jar { + fat.clone() + } else { + jar_files + .into_iter() + .max_by_key(|path| { + std::fs::metadata(path).map(|m| m.len()) + .unwrap_or(0) + }) + .unwrap() + }; + + info!("Using cached JAR: {}", jar_path.display()); + + self.show_build_window = false; + + let has_player_camera_target = self + .world + .query::<(&Camera, &CameraComponent)>() + .iter() + .any(|(_, (_, comp))| comp.starting_camera); + + if has_player_camera_target { + if let Err(e) = self.create_backup() { + self.signal = Signal::None; + fatal!("Failed to create play mode backup: {}", e); + } + + self.editor_state = EditorState::Playing; + + self.switch_to_player_camera(); + + let mut script_entities = Vec::new(); + { + for (entity_id, script) in self.world.query::<&ScriptComponent>().iter() { + script_entities.push((entity_id, script.clone())); + } + } + + let mut etag: HashMap<String, Vec<Entity>> = HashMap::new(); + for (entity_id, script) in script_entities { + for tag in script.tags { + if etag.contains_key(&tag) { + etag.get_mut(&tag).unwrap().push(entity_id); + } else { + etag.insert(tag.clone(), vec![entity_id]); + } + } + } + + let etag_clone = etag.clone(); + + if let Err(e) = self + .script_manager + .init_script(etag_clone, ScriptTarget::JVM { library_path: jar_path }) + { + fatal!("Failed to ready script manager interface because {}", e); + self.signal = Signal::StopPlaying; + return Err(anyhow::anyhow!(e)); + } + + let world_ptr = self.world.as_mut() as *mut World; + + if let Err(e) = self.script_manager + .load_script( + world_ptr, + &self.input_state, + ) { + fatal!( + "Failed to initialise script because {}", + e + ); + self.signal = Signal::StopPlaying; + return Err(anyhow::anyhow!(e)); + } else { + success_without_console!( + "You are in play mode now! Press Escape to exit" + ); + log::info!("You are in play mode now! Press Escape to exit"); + } + + self.signal = Signal::None; + } else { + self.signal = Signal::None; + fatal!("Unable to build: No initial camera set"); + } + } + let mut local_handle_exchanged: Option<anyhow::Result<PathBuf>> = None; if let Some(rx) = &self.progress_rx { while let Ok(status) = rx.try_recv() { @@ -227,6 +374,7 @@ impl SignalController for Editor { .italics() .color(egui::Color32::GRAY) ); + ui.label("Tip: Press Ctrl+Alt+P to skip build and start running"); } }); @@ -0,0 +1,12 @@ +[package] +name = "eucalyptus-sdk" +version.workspace = true +edition.workspace = true +license-file.workspace = true +repository.workspace = true +readme = "README.md" + +[dependencies] +eucalyptus-core = { path = "../eucalyptus-core" } +eucalyptus-editor = { path = "../eucalyptus-editor" } +dropbear-engine = { path = "../dropbear-engine" } @@ -0,0 +1,10 @@ +# eucalyptus-sdk + +This is the SDK that deals with plugins and its management, as well as user defined plugins. + +To use this crate, check out the related docs.rs (TODO ADD WEBSITE) to define your own plugin. + +Note: This SDK is only used in the eucalyptus-editor, and not in any games exported by it. If you wish to +add your own dependencies to help other developers with scripting, you should create your own Kotlin Multiplatform +library, or fork the dropbear-engine and add your own FFI interop functions. (Then recontribute it back to the +repository.) @@ -0,0 +1,3 @@ +pub use dropbear_engine; +pub use eucalyptus_core; +pub use eucalyptus_editor; @@ -1,3 +1,8 @@ kotlin.code.style=official + org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled -org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true +org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true + +org.gradle.jvmargs=-Xmx2g -Xms512m -XX:MaxMetaspaceSize=512m +org.gradle.daemon=true +org.gradle.caching=true Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ @@ -1,6 +1,7 @@ -#Tue Sep 30 12:55:55 AEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +133,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +200,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +216,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +78,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal @@ -9,4 +9,10 @@ pluginManagement { plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } -include("magna-carta-plugin") +include("magna-carta-plugin") + +buildCache { + local { + isEnabled = true + } +}