├── .gitignore ├── assets ├── scripts │ ├── utils │ │ ├── math.koto │ │ ├── main.koto │ │ └── palette.koto │ ├── text.koto │ ├── image.koto │ ├── whitney.koto │ └── scrolling_squares.koto └── images │ └── sunset.png ├── src ├── random.rs ├── koto_plugins.rs ├── lib.rs ├── prelude.rs ├── window.rs ├── geometry.rs ├── camera.rs ├── color.rs ├── entity.rs ├── text.rs ├── shape.rs └── runtime.rs ├── LICENSE ├── README.md ├── Cargo.toml └── examples └── demo.rs /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | 3 | /.cargo 4 | /target 5 | -------------------------------------------------------------------------------- /assets/scripts/utils/math.koto: -------------------------------------------------------------------------------- 1 | export lerp = |min, max, x| min + (max - min) * x 2 | -------------------------------------------------------------------------------- /assets/scripts/utils/main.koto: -------------------------------------------------------------------------------- 1 | export 2 | math: import math 3 | palette: import palette 4 | -------------------------------------------------------------------------------- /assets/images/sunset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koto-lang/bevy_koto/HEAD/assets/images/sunset.png -------------------------------------------------------------------------------- /assets/scripts/text.koto: -------------------------------------------------------------------------------- 1 | # A 'hello world' example for the text plugin 2 | 3 | export 4 | on_load: |state| 5 | state.text = 6 | make_text("Hello, World!") 7 | # TODO - How to scale text in a more useful way? 8 | .set_size 0.001 9 | -------------------------------------------------------------------------------- /src/random.rs: -------------------------------------------------------------------------------- 1 | //! Random number utilities for Koto scripts 2 | 3 | use crate::runtime::{KotoRuntime, KotoRuntimePlugin}; 4 | use bevy::prelude::*; 5 | 6 | /// Random number utilities for Koto 7 | /// 8 | /// The plugin adds the `random` module from `koto_random` to Koto's prelude. 9 | #[derive(Default)] 10 | pub struct KotoRandomPlugin; 11 | 12 | impl Plugin for KotoRandomPlugin { 13 | fn build(&self, app: &mut App) { 14 | assert!(app.is_plugin_added::()); 15 | 16 | app.add_systems(Startup, on_startup); 17 | } 18 | } 19 | 20 | fn on_startup(koto: Res) { 21 | koto.prelude().insert("random", koto_random::make_module()); 22 | } 23 | -------------------------------------------------------------------------------- /src/koto_plugins.rs: -------------------------------------------------------------------------------- 1 | //! See [`KotoPlugins`] 2 | 3 | use crate::prelude::*; 4 | use bevy::app::plugin_group; 5 | 6 | plugin_group! { 7 | /// A group containing all available `bevy_koto` plugins 8 | pub struct KotoPlugins { 9 | :KotoRuntimePlugin, 10 | :KotoEntityPlugin, 11 | 12 | #[cfg(feature = "camera")] 13 | :KotoCameraPlugin, 14 | #[cfg(feature = "color")] 15 | :KotoColorPlugin, 16 | #[cfg(feature = "geometry")] 17 | :KotoGeometryPlugin, 18 | #[cfg(feature = "random")] 19 | :KotoRandomPlugin, 20 | #[cfg(feature = "shape")] 21 | :KotoShapePlugin, 22 | #[cfg(feature = "text")] 23 | :KotoTextPlugin, 24 | #[cfg(feature = "window")] 25 | :KotoWindowPlugin, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # bevy_koto 2 | //! 3 | //! Plugins for Bevy that add support for scripting with Koto. 4 | 5 | #![warn(missing_docs)] 6 | 7 | pub mod entity; 8 | pub mod koto_plugins; 9 | pub mod prelude; 10 | pub mod runtime; 11 | 12 | #[cfg(feature = "camera")] 13 | pub mod camera; 14 | #[cfg(feature = "color")] 15 | pub mod color; 16 | #[cfg(feature = "geometry")] 17 | pub mod geometry; 18 | #[cfg(feature = "random")] 19 | pub mod random; 20 | #[cfg(feature = "shape")] 21 | pub mod shape; 22 | #[cfg(feature = "text")] 23 | pub mod text; 24 | #[cfg(feature = "window")] 25 | pub mod window; 26 | 27 | pub use koto; 28 | 29 | #[cfg(feature = "color")] 30 | pub use koto_color; 31 | 32 | #[cfg(feature = "geometry")] 33 | pub use koto_geometry; 34 | 35 | #[cfg(feature = "random")] 36 | pub use koto_random; 37 | -------------------------------------------------------------------------------- /assets/scripts/image.koto: -------------------------------------------------------------------------------- 1 | # A simple test of window alignment and image loading 2 | 3 | from number import pi_4 4 | 5 | export 6 | on_load: |data| 7 | top_left = shape.square() 8 | .set_position 0.5, 0.5 9 | .set_color 1.0, 0.0, 0.0 10 | .set_image 'images/sunset.png' 11 | top_right = shape.square() 12 | .set_position 0.5, -0.5 13 | .set_color 0.0, 1.0, 0.0 14 | .set_image 'images/sunset.png' 15 | bottom_left = shape.square() 16 | .set_position -0.5, -0.5 17 | .set_color 0.0, 0.0, 1.0 18 | .set_image 'images/sunset.png' 19 | bottom_right = shape.square() 20 | .set_position -0.5, 0.5 21 | .set_color 1.0, 1.0, 0.0 22 | .set_image 'images/sunset.png' 23 | diamond = shape.square() 24 | .set_position 0, 0, 1 25 | .set_size 2.sqrt() 26 | .set_rotation pi_4 27 | .set_color 1.0, 1.0, 1.0 28 | .set_alpha 0.3 29 | 30 | data.shapes = top_left, top_right, bottom_left, bottom_right, diamond 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ian Hobson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! A collection of useful items to import when using `bevy_koto` 2 | 3 | pub use crate::entity::{ 4 | koto_entity_channel, KotoEntity, KotoEntityEvent, KotoEntityMapping, KotoEntityPlugin, 5 | KotoEntityReceiver, KotoEntitySender, UpdateKotoEntity, 6 | }; 7 | pub use crate::koto_plugins::KotoPlugins; 8 | pub use crate::runtime::{ 9 | koto_channel, KotoReceiver, KotoRuntime, KotoRuntimePlugin, KotoSchedule, KotoScript, 10 | KotoSender, KotoTime, KotoUpdate, LoadScript, ScriptLoaded, 11 | }; 12 | 13 | #[cfg(feature = "camera")] 14 | pub use crate::camera::{KotoCamera, KotoCameraPlugin, UpdateOrthographicProjection}; 15 | 16 | #[cfg(feature = "color")] 17 | pub use crate::color::{ 18 | koto_to_bevy_color, KotoColor, KotoColorPlugin, SetClearColor, UpdateColorMaterial, 19 | }; 20 | 21 | #[cfg(feature = "geometry")] 22 | pub use crate::geometry::{KotoGeometryPlugin, KotoVec2, UpdateTransform}; 23 | 24 | #[cfg(feature = "random")] 25 | pub use crate::random::KotoRandomPlugin; 26 | 27 | #[cfg(feature = "shape")] 28 | pub use crate::shape::KotoShapePlugin; 29 | 30 | #[cfg(feature = "text")] 31 | pub use crate::text::KotoTextPlugin; 32 | 33 | #[cfg(feature = "window")] 34 | pub use crate::window::KotoWindowPlugin; 35 | -------------------------------------------------------------------------------- /assets/scripts/utils/palette.koto: -------------------------------------------------------------------------------- 1 | import color 2 | from random import pick 3 | 4 | palette_meta = 5 | @type: 'Palette' 6 | 7 | # The number of colors in the palette 8 | @size: || size self.colors 9 | 10 | @meta last_index: || (size self.colors) - 1 11 | 12 | # Picks a color from the palette at random 13 | @meta pick: || pick self.colors 14 | 15 | # Gets a color from the palette, non-integer values produce a faded result 16 | @meta get: |x| 17 | colors = self.colors 18 | match (colors.get x.floor()), (colors.get x.ceil()) 19 | first, null then copy first 20 | null, last then copy last 21 | a, b then a.mix b, x % 1 22 | 23 | # Maps 0-1 to an interpolated color from the list of colors in the palette 24 | @meta fade: |x| 25 | self.get x * self.last_index() 26 | 27 | export make_palette = |colors...| 28 | result = {colors: colors.to_tuple()} 29 | result.with_meta palette_meta 30 | 31 | @test palette_fade = || 32 | red = color 1, 0, 0 33 | green = color 0, 1, 0 34 | blue = color 0, 0, 1 35 | p = make_palette red, green, blue 36 | assert_eq p.fade(0), red 37 | assert_eq p.fade(0.5), green 38 | assert_eq p.fade(1), blue 39 | assert_eq p.fade(0.25), color(0.5, 0.5, 0) 40 | assert_eq p.fade(0.75), color(0.0, 0.5, 0.5) 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bevy_koto 2 | 3 | --- 4 | 5 | [Koto][koto] scripting support for the [Bevy][bevy] game engine. 6 | 7 | ## Current State 8 | 9 | This crate is serves as a proof of concept of integrating Koto with Bevy. 10 | 11 | You can see it in action by running the demo application: 12 | 13 | `cargo run --example demo` 14 | 15 | Video of the demo in action: 16 | 17 | [![Playing around with the bevy_koto demo](https://img.youtube.com/vi/EqgAEOucBP8/0.jpg)](https://www.youtube.com/watch?v=EqgAEOucBP8) 18 | 19 | It's still early in development and hasn't been used outside of toy projects, 20 | use at your own risk! 21 | 22 | Your feedback is welcomed, please feel free to reach out via issues, 23 | discussions, or the [Koto discord][discord]. 24 | 25 | ## Features 26 | 27 | - Modular plugins exposing various levels of integration. 28 | - Hot-reloading of Koto scripts using Bevy's asset system 29 | - Mapping between Koto and Bevy entities 30 | - Plugins for some useful Koto libraries like [`color`][koto_color], 31 | [`geometry`][koto_geometry], and [`random`][koto_random]. 32 | - Proof of concept plugins for scripted animation of 2d shapes. 33 | 34 | ## Supported Versions 35 | 36 | | `bevy_koto` | `bevy` | `koto` | 37 | | ----------- | ------- | ------- | 38 | | `v0.3` | `v0.16` | `v0.16` | 39 | | `v0.2` | `v0.14` | `v0.14` | 40 | | `v0.1` | `v0.13` | `v0.14` | 41 | 42 | [bevy]: https://bevyengine.org 43 | [discord]: https://discord.gg/JeV8RuK4CT 44 | [koto]: https://koto.dev 45 | [koto_color]: https://koto.dev/docs/next/libs/color 46 | [koto_geometry]: https://koto.dev/docs/next/libs/geometry 47 | [koto_random]: https://koto.dev/docs/next/libs/random 48 | -------------------------------------------------------------------------------- /assets/scripts/whitney.koto: -------------------------------------------------------------------------------- 1 | # Rotating circles, inspired by the work of John Whitney 2 | # 3 | # https://en.wikipedia.org/wiki/John_Whitney_(animator) 4 | 5 | from color import hsv 6 | from geometry import vec2 7 | from number import pi_4 8 | from utils.math import lerp 9 | from utils.palette import make_palette 10 | 11 | mod_sin = |x, frequency, min, max| 12 | min + (max - min) * (x * frequency).sin() 13 | 14 | background_color = hsv 184, 0.5, 0.8 15 | shape_count = 32 16 | 17 | palette = make_palette 18 | hsv(30, 0.72, 0.99), 19 | hsv(116, 0.21, 0.90), 20 | hsv(58, 0.39, 0.99), 21 | hsv(0, 0.45, 0.94) 22 | 23 | shape_color = |x, time| 24 | t = time * 0.6 25 | palette 26 | .fade ((x * t).sin() * 0.5 + 0.5) 27 | .set_alpha 0.8 28 | 29 | shape_size = |x| 30 | lerp 0.3, 0.1, x 31 | 32 | shape_position = |x, time| 33 | t = time * x * 4 34 | r = 0.85 - 0.8 * x 35 | vec2 r * t.sin(), r * t.cos() 36 | 37 | make_shape = |state, i| 38 | x = i / shape_count 39 | initial_time = state.time 40 | shape.circle() 41 | .set_size shape_size x 42 | .set_position shape_position(x, initial_time), i 43 | .set_color shape_color x, initial_time 44 | .set_rotation pi_4 45 | .on_update |time_delta| 46 | time = state.time 47 | self 48 | .set_position shape_position(x, time), i 49 | .set_color shape_color x, time 50 | 51 | export 52 | setup: || 53 | camera_offset: vec2 0 54 | time: 0 55 | 56 | on_load: |state| 57 | # state.time = 0 58 | set_clear_color background_color 59 | state.shapes = 1..=shape_count 60 | .each |i| make_shape state, i 61 | .to_tuple() 62 | 63 | update: |state, time| 64 | state.time = time.elapsed() 65 | set_zoom mod_sin state.time, 0.1, 0.9, 1.1 66 | -------------------------------------------------------------------------------- /src/window.rs: -------------------------------------------------------------------------------- 1 | //! Window events for bevy_koto 2 | 3 | use crate::prelude::*; 4 | use bevy::{ 5 | prelude::*, 6 | window::{PrimaryWindow, WindowResized}, 7 | }; 8 | 9 | /// Window events for bevy_koto 10 | /// 11 | /// The plugin currently only detects window resize events, and then calls the script's 12 | /// exported `on_window_size` function (if it exists). 13 | #[derive(Default)] 14 | pub struct KotoWindowPlugin; 15 | 16 | impl Plugin for KotoWindowPlugin { 17 | fn build(&self, app: &mut App) { 18 | debug_assert!(app.is_plugin_added::()); 19 | 20 | app.add_systems( 21 | KotoSchedule, 22 | (on_script_compiled, on_window_resized).in_set(KotoUpdate::PreUpdate), 23 | ); 24 | } 25 | } 26 | 27 | fn on_script_compiled( 28 | mut koto: ResMut, 29 | mut script_loaded_events: EventReader, 30 | primary_window: Query<&Window, With>, 31 | ) { 32 | for _ in script_loaded_events.read() { 33 | if let Ok(window) = primary_window.single() { 34 | run_on_window_size(&mut koto, window.width(), window.height()); 35 | } else { 36 | error!("Missing primary window"); 37 | } 38 | } 39 | } 40 | 41 | fn on_window_resized( 42 | mut koto: ResMut, 43 | mut window_resized_events: EventReader, 44 | ) { 45 | for event in window_resized_events.read() { 46 | run_on_window_size(&mut koto, event.width, event.height); 47 | } 48 | } 49 | 50 | fn run_on_window_size(koto: &mut KotoRuntime, width: f32, height: f32) { 51 | if koto.is_ready() { 52 | if let Err(error) = koto.run_exported_function( 53 | "on_window_size", 54 | &[koto.user_data().clone(), width.into(), height.into()], 55 | ) { 56 | error!("Error in 'on_window_size':\n{error}"); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/geometry.rs: -------------------------------------------------------------------------------- 1 | //! 2D geometry utilities for Koto 2 | 3 | use crate::prelude::*; 4 | use bevy::prelude::*; 5 | pub use koto_geometry::Vec2 as KotoVec2; 6 | 7 | /// 2D geometry utilities for Koto 8 | /// 9 | /// The plugin adds the `geometry` module from `koto_geometry` to Koto's prelude. 10 | #[derive(Default)] 11 | pub struct KotoGeometryPlugin; 12 | 13 | impl Plugin for KotoGeometryPlugin { 14 | fn build(&self, app: &mut App) { 15 | debug_assert!(app.is_plugin_added::()); 16 | debug_assert!(app.is_plugin_added::()); 17 | 18 | let (update_transform_sender, update_transform_receiver) = 19 | koto_entity_channel::(); 20 | 21 | app.insert_resource(update_transform_sender) 22 | .insert_resource(update_transform_receiver) 23 | .add_systems(Startup, on_startup) 24 | .add_systems(Update, update_transform); 25 | } 26 | } 27 | 28 | fn on_startup(koto: Res) { 29 | koto.prelude() 30 | .insert("geometry", koto_geometry::make_module()); 31 | } 32 | 33 | fn update_transform( 34 | channel: Res>, 35 | mut q: Query<&mut Transform>, 36 | ) { 37 | while let Some(event) = channel.receive() { 38 | let mut transform = q.get_mut(event.entity.get()).unwrap(); 39 | match event.event { 40 | UpdateTransform::Position(position) => transform.translation = position, 41 | UpdateTransform::Rotation(rotation) => { 42 | transform.rotation = Quat::from_rotation_z(rotation) 43 | } 44 | UpdateTransform::Scale(scale) => transform.scale = scale, 45 | } 46 | } 47 | } 48 | 49 | /// Event for updating the properties of an entity's transform 50 | #[derive(Clone, Event)] 51 | pub enum UpdateTransform { 52 | /// Sets the transform's position 53 | Position(Vec3), 54 | /// Sets the transform's rotation 55 | Rotation(f32), 56 | /// Sets the transform's scale 57 | Scale(Vec3), 58 | } 59 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bevy_koto" 3 | version = "0.3.0" 4 | edition = "2021" 5 | authors = ["irh "] 6 | license = "MIT" 7 | description = "Koto support for Bevy" 8 | homepage = "https://koto.dev" 9 | repository = "https://github.com/koto-lang/bevy_koto" 10 | keywords = ["scripting", "language", "koto", "bevy"] 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [features] 15 | default = ["camera", "color", "geometry", "random", "shape", "text", "window"] 16 | 17 | camera = [] 18 | color = ["koto_color", "bevy/bevy_sprite"] 19 | geometry = ["koto_geometry"] 20 | random = ["koto_random"] 21 | shape = ["bevy/bevy_sprite"] 22 | text = ["bevy/bevy_text"] 23 | window = [] 24 | 25 | [dependencies] 26 | # Multi-producer multi-consumer channels for message passing 27 | crossbeam-channel = "0.5" 28 | # Provides a clone macro 29 | fb_cloned = "0.1" 30 | # More compact and efficient implementations of the standard synchronization primitives. 31 | parking_lot = "0.12" 32 | # derive(Error) 33 | thiserror = "1" 34 | 35 | koto = { version = "0.16", default-features = false, features = [ 36 | "arc", 37 | ] } 38 | koto_color = { version = "0.16", default-features = false, optional = true } 39 | koto_geometry = { version = "0.16", default-features = false, optional = true } 40 | koto_random = { version = "0.16", default-features = false, optional = true } 41 | 42 | [dependencies.bevy] 43 | version = "0.16" 44 | default-features = false 45 | features = ["bevy_asset", "bevy_core_pipeline", "bevy_log", "bevy_window"] 46 | 47 | [dev-dependencies] 48 | # Flexible concrete Error type built on std::error::Error 49 | anyhow = "1.0.82" 50 | # A simple to use, efficient, and full-featured Command Line Argument Parser 51 | clap = { version = "4.5.4", features = ["derive"] } 52 | 53 | [dev-dependencies.bevy] 54 | version = "0.16" 55 | default-features = false 56 | features = [ 57 | "bevy_asset", 58 | "bevy_core_pipeline", 59 | "bevy_dev_tools", 60 | "bevy_gilrs", 61 | "bevy_render", 62 | "bevy_sprite", 63 | "bevy_state", 64 | "bevy_text", 65 | "bevy_ui", 66 | "bevy_window", 67 | "bevy_winit", 68 | "default_font", 69 | "png", 70 | "x11", 71 | "file_watcher", 72 | "multi_threaded", 73 | "tonemapping_luts", 74 | ] 75 | -------------------------------------------------------------------------------- /assets/scripts/scrolling_squares.koto: -------------------------------------------------------------------------------- 1 | # Lots of scrolling squares 2 | # This serves as a test of dynamically spawing and despawning entities. 3 | 4 | from color import rgb, hsv 5 | from geometry import vec2 6 | from number import pi_4 7 | from random import bool as random_bool, number as random_number 8 | 9 | from utils import palette 10 | from utils.math import lerp 11 | 12 | palette = palette.make_palette 13 | rgb(0.99, 0.95, 0.31), 14 | rgb(0.59, 0.05, 0.91), 15 | rgb(0.39, 0.95, 0.31), 16 | rgb(0.19, 0.29, 0.99) 17 | 18 | background_color = hsv 100, 0.1, 0.8 19 | 20 | screen_bound = 1.2 21 | max_shape_count = 50 22 | speed_max = 0.3 23 | fade_duration = 2 24 | scene_rate = 0.125 25 | scene_count = size palette 26 | color_variation = 0.8 27 | alpha_min = 0.2 28 | alpha_max = 0.9 29 | 30 | random_lerp = |min, max| lerp min, max, random_number() 31 | 32 | random_color = |time| 33 | x = random_lerp 0.24, 0.99 34 | scene = (time * scene_rate % scene_count).floor() 35 | offset = (random_number() - 0.5) * color_variation 36 | palette.get(scene + offset).set_alpha 0.9 37 | 38 | spawn_rect = |time, id| 39 | position = 40 | vec2 random_lerp(-screen_bound, screen_bound), random_lerp(-screen_bound, screen_bound) 41 | 42 | velocity = 43 | vec2 random_lerp(-speed_max, speed_max), random_lerp(-speed_max, speed_max) 44 | shape_size = random_lerp 0.2, 0.6 45 | color = (random_color time).set_alpha 0 46 | target_alpha = random_lerp alpha_min, alpha_max 47 | rotation = if random_bool() then pi_4 else 0 48 | 49 | shape.square() 50 | .set_color color 51 | .set_size shape_size 52 | .set_position position, id 53 | .set_rotation rotation 54 | .set_state {position, velocity, color, target_alpha, rotation, id, is_active: true} 55 | .on_update |time_delta| 56 | this = self.state() 57 | this.position += this.velocity * time_delta 58 | self.set_position this.position, this.id 59 | x, y = this.position 60 | if x.abs() > screen_bound or y.abs() > screen_bound 61 | this.target_alpha = 0.0 # Fade the shape out before it gets removed 62 | a = this.color.alpha() 63 | if a < this.target_alpha 64 | this.color.set_alpha (a + fade_duration * time_delta).min this.target_alpha 65 | else if a > this.target_alpha 66 | this.color.set_alpha (a - fade_duration * time_delta).max this.target_alpha 67 | else if this.target_alpha == 0 68 | this.is_active = false 69 | self.set_color this.color 70 | 71 | export 72 | setup: || 73 | rects: [] 74 | time: 0 75 | id: 0 76 | 77 | on_load: |state| 78 | # Uncomment the following line to regenerate the rects when the script reloads 79 | # state.rects = [] 80 | set_clear_color background_color 81 | 82 | update: |state, time| 83 | state.time = time.elapsed() 84 | # Remove rects that have gone out of bounds 85 | state.rects.retain |rect| rect.state().is_active 86 | # Make new rects to replace ones that have been removed 87 | state.rects.resize_with max_shape_count, || 88 | # The ID is used for Z-order, and has a max of 999 89 | state.id = if state.id < 999 then state.id + 1 else 0 90 | spawn_rect state.time, state.id 91 | -------------------------------------------------------------------------------- /src/camera.rs: -------------------------------------------------------------------------------- 1 | //! Support for modifying properties of a Bevy camera 2 | 3 | use crate::prelude::*; 4 | use bevy::{prelude::*, render::camera::ScalingMode, window::WindowResized}; 5 | use cloned::cloned; 6 | use koto::prelude::*; 7 | 8 | /// Exposes a `set_zoom` function to Koto that modifies the zoom of a 2D camera 9 | /// 10 | /// The camera needs to have the [KotoCamera] component attached to it for the 11 | #[derive(Default)] 12 | pub struct KotoCameraPlugin; 13 | 14 | impl Plugin for KotoCameraPlugin { 15 | fn build(&self, app: &mut App) { 16 | debug_assert!(app.is_plugin_added::()); 17 | 18 | let (update_ortho_projection_sender, update_ortho_projection_receiver) = 19 | koto_channel::(); 20 | 21 | app.insert_resource(update_ortho_projection_sender) 22 | .insert_resource(update_ortho_projection_receiver) 23 | .add_systems(Startup, on_startup) 24 | .add_systems(KotoSchedule, on_script_loaded.in_set(KotoUpdate::PreUpdate)) 25 | .add_systems(Update, (on_window_resized, update_projection)); 26 | } 27 | } 28 | 29 | /// Event for updating the camera's orthographic projection 30 | #[derive(Clone, Event)] 31 | pub enum UpdateOrthographicProjection { 32 | /// Sets the projection's scale 33 | Scale(f32), 34 | } 35 | 36 | /// Used to help identify our main camera 37 | #[derive(Component)] 38 | pub struct KotoCamera; 39 | 40 | fn on_startup( 41 | koto: Res, 42 | update_projection: Res>, 43 | ) { 44 | koto.prelude().add_fn("set_zoom", { 45 | cloned!(update_projection); 46 | move |ctx| match ctx.args() { 47 | [KValue::Number(zoom)] => { 48 | update_projection.send(UpdateOrthographicProjection::Scale(zoom.into())); 49 | Ok(KValue::Null) 50 | } 51 | unexpected => unexpected_args("a Number", unexpected), 52 | } 53 | }); 54 | } 55 | 56 | // Reset the camera's projection when a script is loaded 57 | fn on_script_loaded( 58 | mut script_loaded_events: EventReader, 59 | mut camera_query: Query<&mut Projection, With>, 60 | ) -> Result { 61 | for _ in script_loaded_events.read() { 62 | match camera_query.single_mut()?.as_mut() { 63 | Projection::Orthographic(projection) => projection.scale = 1.0, 64 | _ => return Err("Expected an orthographic projection".into()), 65 | } 66 | } 67 | 68 | Ok(()) 69 | } 70 | 71 | fn update_projection( 72 | channel: Res>, 73 | mut camera_query: Query<&mut Projection, With>, 74 | ) -> Result { 75 | match camera_query.single_mut()?.as_mut() { 76 | Projection::Orthographic(projection) => { 77 | while let Some(event) = channel.receive() { 78 | match event { 79 | UpdateOrthographicProjection::Scale(scale) => projection.scale = scale, 80 | } 81 | } 82 | } 83 | _ => return Err("Expected an orthographic projection".into()), 84 | } 85 | 86 | Ok(()) 87 | } 88 | 89 | fn on_window_resized( 90 | mut window_resized_events: EventReader, 91 | mut camera_query: Query<&mut Projection, With>, 92 | ) -> Result { 93 | match camera_query.single_mut()?.as_mut() { 94 | Projection::Orthographic(projection) => { 95 | for event in window_resized_events.read() { 96 | projection.scaling_mode = get_scaling_mode(event.width, event.height); 97 | } 98 | } 99 | _ => return Err("Expected an orthographic projection".into()), 100 | } 101 | 102 | Ok(()) 103 | } 104 | 105 | fn get_scaling_mode(width: f32, height: f32) -> ScalingMode { 106 | if width > height { 107 | ScalingMode::FixedVertical { 108 | viewport_height: 2.0, 109 | } 110 | } else { 111 | ScalingMode::FixedHorizontal { 112 | viewport_width: 2.0, 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/color.rs: -------------------------------------------------------------------------------- 1 | //! Support for working with Bevy colors in Koto scripts 2 | 3 | use crate::prelude::*; 4 | use bevy::prelude::*; 5 | use cloned::cloned; 6 | use koto::prelude::*; 7 | pub use koto_color::Color as KotoColor; 8 | 9 | /// Color support for bevy_koto 10 | /// 11 | /// The plugin adds the `color` module from `koto_color` to Koto's prelude, 12 | /// along with a `set_clear_color` function. 13 | #[derive(Default)] 14 | pub struct KotoColorPlugin; 15 | 16 | impl Plugin for KotoColorPlugin { 17 | fn build(&self, app: &mut App) { 18 | assert!(app.is_plugin_added::()); 19 | assert!(app.is_plugin_added::()); 20 | 21 | let (set_clear_color_sender, set_clear_color_receiver) = koto_channel::(); 22 | let (update_color_sender, update_color_receiver) = 23 | koto_entity_channel::(); 24 | 25 | app.insert_resource(set_clear_color_sender) 26 | .insert_resource(set_clear_color_receiver) 27 | .insert_resource(update_color_sender) 28 | .insert_resource(update_color_receiver) 29 | .add_event::() 30 | .add_systems(Startup, on_startup) 31 | .add_systems(KotoSchedule, on_script_loaded.in_set(KotoUpdate::PreUpdate)) 32 | .add_systems( 33 | Update, 34 | (set_clear_color, koto_to_bevy_color_material_events), 35 | ); 36 | } 37 | } 38 | 39 | fn on_startup(koto: Res, set_clear_color: Res>) { 40 | let prelude = koto.prelude(); 41 | 42 | prelude.insert("color", koto_color::make_module()); 43 | 44 | prelude.add_fn("set_clear_color", { 45 | cloned!(set_clear_color); 46 | move |ctx| { 47 | use KValue::*; 48 | 49 | let color = match ctx.args() { 50 | [Number(n1), Number(n2), Number(n3)] => { 51 | Color::srgba(f32::from(n1), f32::from(n2), f32::from(n3), 1.0) 52 | } 53 | [Number(n1), Number(n2), Number(n3), Number(n4)] => { 54 | Color::srgba(f32::from(n1), f32::from(n2), f32::from(n3), f32::from(n4)) 55 | } 56 | [Object(o)] if o.is_a::() => { 57 | koto_to_bevy_color(&*o.cast::()?) 58 | } 59 | unexpected => return unexpected_args("three or four Numbers", unexpected), 60 | }; 61 | 62 | set_clear_color.send(SetClearColor(color)); 63 | 64 | Ok(Null) 65 | } 66 | }); 67 | } 68 | 69 | // Reset the clear color when a script is loaded 70 | fn on_script_loaded( 71 | mut script_loaded_events: EventReader, 72 | mut clear_color: ResMut, 73 | ) { 74 | for _ in script_loaded_events.read() { 75 | clear_color.0 = Color::BLACK; 76 | } 77 | } 78 | 79 | fn set_clear_color(channel: Res>, mut clear_color: ResMut) { 80 | while let Some(event) = channel.receive() { 81 | clear_color.0 = event.0; 82 | } 83 | } 84 | 85 | /// Event sent to set the value of the ClearColor resource 86 | #[derive(Clone, Event)] 87 | pub struct SetClearColor(Color); 88 | 89 | /// A function that converts a Koto color into a Bevy color 90 | pub fn koto_to_bevy_color(koto_color: &KotoColor) -> Color { 91 | match koto_color.color { 92 | koto_color::Encoding::Srgb(c) => Color::srgba(c.red, c.green, c.blue, koto_color.alpha), 93 | koto_color::Encoding::Hsl(c) => { 94 | Color::hsla(c.hue.into(), c.saturation, c.lightness, koto_color.alpha) 95 | } 96 | koto_color::Encoding::Hsv(c) => { 97 | Color::hsva(c.hue.into(), c.saturation, c.value, koto_color.alpha) 98 | } 99 | koto_color::Encoding::Oklab(c) => Color::oklaba(c.l, c.a, c.b, koto_color.alpha), 100 | koto_color::Encoding::Oklch(c) => { 101 | Color::oklcha(c.l, c.chroma, c.hue.into(), koto_color.alpha) 102 | } 103 | } 104 | } 105 | 106 | fn koto_to_bevy_color_material_events( 107 | channel: Res>, 108 | query: Query<&MeshMaterial2d>, 109 | asset_server: Res, 110 | mut materials: ResMut>, 111 | ) { 112 | while let Some(event) = channel.receive() { 113 | let handle = query.get(event.entity.get()).unwrap(); 114 | let material = materials.get_mut(handle.id()).unwrap(); 115 | match event.event { 116 | UpdateColorMaterial::Color(color) => material.color = color, 117 | UpdateColorMaterial::Alpha(alpha) => { 118 | material.color.set_alpha(alpha); 119 | } 120 | UpdateColorMaterial::SetImagePath(image_path) => { 121 | material.texture = image_path.map(|path| asset_server.load(path)); 122 | } 123 | } 124 | } 125 | } 126 | 127 | /// Event for updating properties of a `ColorMaterial` 128 | #[derive(Clone, Event)] 129 | pub enum UpdateColorMaterial { 130 | /// Sets the material's color 131 | Color(Color), 132 | /// Sets the material's alpha value 133 | Alpha(f32), 134 | /// Sets the material's image path 135 | SetImagePath(Option), 136 | } 137 | -------------------------------------------------------------------------------- /examples/demo.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use anyhow::Result; 4 | use bevy::{asset::LoadedFolder, diagnostic::FrameTimeDiagnosticsPlugin, prelude::*}; 5 | use bevy_koto::prelude::*; 6 | use clap::Parser; 7 | 8 | #[derive(Parser)] 9 | #[command(version)] 10 | struct Args { 11 | /// The width of the window 12 | #[arg(short = 'W', long, default_value_t = 800)] 13 | width: u32, 14 | 15 | /// The height of the window 16 | #[arg(short = 'H', long, default_value_t = 600)] 17 | height: u32, 18 | 19 | /// The name of the script to run on launch 20 | #[arg(value_name = "SCRIPT_NAME", default_value = "scrolling_squares")] 21 | script: String, 22 | } 23 | 24 | fn main() -> Result<()> { 25 | let args = Args::parse(); 26 | 27 | println!( 28 | " 29 | >> Welcome to the bevy_koto demo << 30 | 31 | Press tab to load the next script. 32 | Press R to reload the current script. 33 | " 34 | ); 35 | 36 | App::new() 37 | .add_plugins(( 38 | DefaultPlugins 39 | .set(WindowPlugin { 40 | primary_window: Some(Window { 41 | title: "bevy_koto".into(), 42 | resolution: (args.width as f32, args.height as f32).into(), 43 | ..Default::default() 44 | }), 45 | ..Default::default() 46 | }) 47 | .set(AssetPlugin { 48 | watch_for_changes_override: Some(true), 49 | ..Default::default() 50 | }), 51 | FrameTimeDiagnosticsPlugin::default(), 52 | KotoPlugins, 53 | )) 54 | .init_state::() 55 | .add_systems(OnEnter(AppState::Setup), setup) 56 | .add_systems( 57 | Update, 58 | check_script_events.run_if(in_state(AppState::Setup)), 59 | ) 60 | .add_systems(OnEnter(AppState::Ready), ready) 61 | .add_systems(Update, process_keypresses.run_if(in_state(AppState::Ready))) 62 | .run(); 63 | 64 | println!("Exiting"); 65 | 66 | Ok(()) 67 | } 68 | 69 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, States)] 70 | enum AppState { 71 | #[default] 72 | Setup, 73 | Ready, 74 | } 75 | 76 | fn setup(asset_server: Res, mut commands: Commands) { 77 | commands.spawn(Camera2d::default()).insert(KotoCamera); 78 | 79 | commands.insert_resource(ScriptLoader { 80 | script_folder: asset_server.load_folder("scripts"), 81 | ..default() 82 | }); 83 | } 84 | 85 | fn check_script_events( 86 | mut next_state: ResMut>, 87 | script_loader: Res, 88 | mut events: EventReader>, 89 | ) { 90 | for event in events.read() { 91 | if event.is_loaded_with_dependencies(&script_loader.script_folder) { 92 | next_state.set(AppState::Ready); 93 | } 94 | } 95 | } 96 | 97 | fn ready( 98 | loaded_folders: Res>, 99 | mut script_loader: ResMut, 100 | mut scripts: ResMut>, 101 | mut load_script: EventWriter, 102 | ) { 103 | let script_folder = loaded_folders 104 | .get(&script_loader.script_folder) 105 | .expect("Missing script folder"); 106 | 107 | for handle in script_folder.handles.iter() { 108 | if let Ok(script_id) = handle.id().try_typed::() { 109 | let Some(script) = scripts.get(script_id) else { 110 | error!("Script missing (id: {script_id})"); 111 | continue; 112 | }; 113 | 114 | // We only want to make top-level scripts available for loading 115 | let mut ancestors = script.path.ancestors(); 116 | ancestors.next(); 117 | if ancestors.next() == Some(Path::new("scripts")) 118 | && ancestors.next() == Some(Path::new("")) 119 | { 120 | info!("Loaded script: {}", script.path.to_string_lossy()); 121 | 122 | let Some(script_handle) = scripts.get_strong_handle(script_id) else { 123 | error!("Failed to get strong handle (id: {script_id})"); 124 | continue; 125 | }; 126 | 127 | script_loader.scripts.push(script_handle); 128 | } 129 | } 130 | } 131 | 132 | script_loader 133 | .scripts 134 | .sort_by_key(|id| &scripts.get(id).unwrap().path); 135 | 136 | script_loader.next_script(&mut load_script); 137 | } 138 | 139 | fn process_keypresses( 140 | input: Res>, 141 | mut load_script_events: EventWriter, 142 | mut script_loader: ResMut, 143 | mut time: ResMut>, 144 | ) { 145 | if input.just_pressed(KeyCode::Tab) { 146 | if input.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) { 147 | script_loader.previous_script(&mut load_script_events); 148 | } else { 149 | script_loader.next_script(&mut load_script_events); 150 | } 151 | } else if input.just_pressed(KeyCode::KeyR) { 152 | script_loader.reload_script(&mut load_script_events); 153 | } else if input.just_pressed(KeyCode::Space) { 154 | if time.is_paused() { 155 | time.unpause(); 156 | } else { 157 | time.pause(); 158 | } 159 | } 160 | } 161 | 162 | #[derive(Resource, Default)] 163 | struct ScriptLoader { 164 | script_folder: Handle, 165 | scripts: Vec>, 166 | current_script: Option, 167 | } 168 | 169 | impl ScriptLoader { 170 | fn next_script(&mut self, load_script_events: &mut EventWriter) { 171 | let next_index = self 172 | .current_script 173 | .map_or(0, |index| (index + 1) % self.scripts.len()); 174 | self.load_script(next_index, load_script_events); 175 | } 176 | 177 | fn previous_script(&mut self, load_script_events: &mut EventWriter) { 178 | let previous_index = self.current_script.map_or(0, |index| { 179 | if index > 0 { 180 | index - 1 181 | } else { 182 | self.scripts.len().saturating_sub(1) 183 | } 184 | }); 185 | self.load_script(previous_index, load_script_events); 186 | } 187 | 188 | fn reload_script(&mut self, load_script_events: &mut EventWriter) { 189 | if let Some(index) = self.current_script { 190 | self.load_script(index, load_script_events); 191 | } 192 | } 193 | 194 | fn load_script(&mut self, index: usize, load_script_events: &mut EventWriter) { 195 | if let Some(script) = self.scripts.get(index) { 196 | load_script_events.write(LoadScript::load(script.clone())); 197 | self.current_script = Some(index); 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/entity.rs: -------------------------------------------------------------------------------- 1 | //! Support for mapping Koto objects to Bevy entities 2 | 3 | use crate::prelude::*; 4 | use bevy::prelude::*; 5 | use koto::prelude::*; 6 | use parking_lot::RwLock; 7 | use std::sync::Arc; 8 | 9 | /// Support for mapping Koto objects to Bevy entities 10 | /// 11 | /// Entities with the [KotoEntity] component will be automatically despawned when the script no 12 | /// longer refers to them. 13 | #[derive(Default)] 14 | pub struct KotoEntityPlugin; 15 | 16 | impl Plugin for KotoEntityPlugin { 17 | fn build(&self, app: &mut App) { 18 | assert!(app.is_plugin_added::()); 19 | 20 | let (update_entity_sender, update_entity_receiver) = 21 | koto_entity_channel::(); 22 | 23 | app.insert_resource(update_entity_sender) 24 | .insert_resource(update_entity_receiver) 25 | .add_systems( 26 | KotoSchedule, 27 | ( 28 | on_script_loaded.in_set(KotoUpdate::PreUpdate), 29 | update_koto_entities.in_set(KotoUpdate::PostUpdate), 30 | ), 31 | ) 32 | .add_systems(Update, koto_to_bevy_entity_events); 33 | } 34 | } 35 | 36 | fn on_script_loaded( 37 | mut entities: Query<&mut KotoEntity>, 38 | mut script_loaded_events: EventReader, 39 | ) { 40 | let mut clear_entities = false; 41 | for _ in script_loaded_events.read() { 42 | clear_entities = true; 43 | } 44 | if clear_entities { 45 | debug!("Marking entities as inactive"); 46 | for mut koto_entity in entities.iter_mut() { 47 | koto_entity.is_active = false; 48 | } 49 | } 50 | } 51 | 52 | fn update_koto_entities( 53 | time: Res