diff --git a/CHANGELOG.md b/CHANGELOG.md index b377ad7..7d1f108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Python API redesign with direct handles and vector math: + - `Vec2` class: native 2D math type supporting vector operations (`+`, `-`, `*`, `/`), in-place mutation (`+=`, `-=`, `*=`, `/=`), indexing (`[0]`, `[1]`), tuple sequence unpacking (`x, y = self.position`), and `.length()` / `.normalized()`. + - Direct engine transform mutations: mutating `self.position.x` or assigning `self.position = (x, y)` directly updates `ScriptHost`, deleting the redundant end-of-frame Python `getattr` and string-allocation sync pass. + - Attribute-based input API: `pite.held.`, `pite.pressed.`, and `pite.released.` (e.g. `pite.held.right`, `pite.pressed.space`), with `TypeError` raised on string calls like `pite.held("Space")`. + - Declarative node descriptors: `pite.child("Name")` and `pite.node("../Path")` descriptors with automatic first-access proxy caching. + - Node proxy caching: repeated `get_node()` lookups are cached in `ScriptHost` to eliminate tree walks. + - Hybrid signal callbacks: `signal.connect()` accepts arbitrary Python callables (lambdas, standalone functions, bound methods) alongside engine-dispatched signals. + - Editor pause-on-error: script exceptions during gameplay automatically pause the editor, log formatted Python tracebacks, and open the failing script. + - Download fallback for export: `pite export` automatically downloads the target platform's `pite-player` release archive when absent beside the engine binary, caching it under `~/.cache/pite/toolchains/players/` (or `PITE_PLAYER_DIR`). - Standalone and all-platform player release assets: release workflow publishes standalone player bundles for each platform (`pite-player-vx.y.z-linux.tar.gz`, `windows.zip`, `macos.tar.gz`) alongside all-platforms player archives (`pite-player-vx.y.z-allplatforms.tar.gz` and `.zip`). - Expanded audio formats and containers: pure-Rust decoding via `symphonia` brings support for Ogg Vorbis (`.ogg`), MP3 (`.mp3`), FLAC (`.flac`), and extended WAV (8/16/24/32-bit int and 32-bit float) to `pite.play` and `pite-audio`. `pite-assets` adds `OggImporter`, `Mp3Importer`, and `FlacImporter` for project scanning and manifest tracking, and `pite check` validates decodability across all supported audio formats. diff --git a/docs/scripting-api.md b/docs/scripting-api.md index 6532189..fabce0a 100644 --- a/docs/scripting-api.md +++ b/docs/scripting-api.md @@ -12,16 +12,22 @@ class Player(pite.Node2D): self.position = (100.0, 200.0) def _process(self, delta: float): - x, y = self.position - self.position = (x + self.speed * delta, y) + if pite.held.right: + self.position.x += self.speed * delta ``` ## Rules - One file exports one class; attach with `path` + `class` in the scene file. `snake_case` everywhere. - `_ready()` runs once when the node attaches; `_process(delta)` runs every frame (`delta` in seconds, capped at 0.1). **Missing methods are no-ops, never errors.** -- `position` is a plain `(x, y)` float tuple, synced both ways with the scene tree each frame. `Sprite2D` adds `texture: str`, `Timer` adds `wait_time: float` and an engine-owned `timeout` signal: every elapsed `wait_time` seconds the engine fires `timeout` and restarts the clock (looping). Non-positive `wait_time` never fires. Connect during `_ready` like `Button.pressed` — no declaration needed, `Label` adds `text: str` (same two-way sync — set it and the screen updates). `Button` extends `Node2D` with `text` plus a `size` prop (`[w, h]`, default `[120, 40]`). -- The `get_node` proxy also exposes `text`, so any node can drive a label: `self.get_node("../HpLabel").text = f"HP: {self.hp}"`. +- `position` is a mutable `Vec2` proxy backed directly by the engine's node transform. You can mutate `.x` and `.y` in-place (`self.position.x += 10`), reassign with a tuple or vector (`self.position = (100, 200)`), or unpack as a tuple (`x, y = self.position`). `Sprite2D` adds `texture: str`, `Timer` adds `wait_time: float` and an engine-owned `timeout` signal: every elapsed `wait_time` seconds the engine fires `timeout` and restarts the clock (looping). Non-positive `wait_time` never fires. Connect during `_ready` like `Button.pressed` — no declaration needed, `Label` adds `text: str` (same direct update — set it and the screen updates). `Button` extends `Node2D` with `text` plus a `size` prop (`[w, h]`, default `[120, 40]`). +- Declarative node descriptors: declare child or node references as class attributes: + ```python + sprite = pite.child("Sprite2D") + target = pite.node("../Target") + ``` + Descriptors resolve once on first access and cache the proxy. A missing node raises `KeyError`. +- The `get_node` proxy also exposes `text`, so any node can drive a label: `self.get_node("../HpLabel").text = f"HP: {self.hp}"`. Repeated `get_node` calls are automatically cached. - Talk across nodes with direct calls through `get_node`, which returns a proxy to the live node: ```python @@ -41,14 +47,13 @@ class Player(pite.Node2D): hit = pite.signal(int) def _process(self, delta): - if pite.pressed("Space"): + if pite.pressed.space: self.hit.emit(1) - ``` - `pite.signal(...)` takes payload types, bare or as strings (`pite.signal(int, "str")`). Both `hit = pite.signal(int)` and the annotated `hit: pite.signal(int)` form declare the same signal. - Emitting the wrong arity or type fails loudly; emitting an undeclared signal fails loudly. There are no untyped string signals. -- `connect(handler)` takes a bound method of a live node; when that node drops, its connections go with it — emitting afterwards simply skips it, no crash. +- `connect(handler)` connects any Python callable (bound methods, lambdas, free functions). - `Button` clicks arrive as a zero-payload `pressed` signal fired by the engine (press-inside plus release-inside). `pressed` belongs to the `Button` type itself — just connect it, no declaration needed (re-declaring it is harmless): ```python @@ -63,14 +68,15 @@ class HitButton(pite.Button): ## Input ```python -if pite.held("ArrowRight"): - x += self.speed * delta -if pite.pressed("Space"): - x += 10.0 +if pite.held.right: + self.position.x += self.speed * delta +if pite.pressed.space: + self.position.x += 10.0 ``` -- `held(key)` is true while down (continuous movement); `pressed(key)` fires on the single frame it goes down (single steps); `released(key)` on the frame it comes up. `mouse()` returns the cursor `(x, y)` in pixels. -- Key names: `A`–`Z`, `0`–`9`, `ArrowLeft/Right/Up/Down`, `Space`, `Enter`, `Escape`, `Tab`, `Backspace`, `Shift`, `Control`, `Alt`, plus `MouseLeft/MouseRight/MouseMiddle`. Input is pumped on the main thread and visible inside `_process`. +- `pite.held.` is true while down (continuous movement); `pite.pressed.` fires on the single frame it goes down (single steps); `pite.released.` on the frame it comes up. `mouse()` returns a `Vec2` with `.x` and `.y` (and tuple unpacking `x, y = pite.mouse()`). +- Key attributes: `space`, `enter`, `escape`, `tab`, `backspace`, `shift`, `ctrl`, `alt`, `left`, `right`, `up`, `down`, `mouse_left`, `mouse_right`, `mouse_middle`, `a`–`z`, `num0`–`num9`. +- String-based input calls (e.g., `pite.held("Space")`) are deprecated and raise `TypeError`. Input is pumped on the main thread and visible inside `_process` and `_physics_process`. ## Audio @@ -104,21 +110,19 @@ class Player(pite.CharacterBody2D): speed: float = 200.0 def _physics_process(self, delta: float): - vx, vy = self.velocity - if pite.held("ArrowRight"): - vx = self.speed - elif pite.held("ArrowLeft"): - vx = -self.speed + if pite.held.right: + self.velocity.x = self.speed + elif pite.held.left: + self.velocity.x = -self.speed else: - vx = 0.0 + self.velocity.x = 0.0 # Gravity - vy += 980.0 * delta + self.velocity.y += 980.0 * delta - if pite.pressed("Space") and self.is_on_floor(): - vy = -400.0 + if pite.pressed.space and self.is_on_floor(): + self.velocity.y = -400.0 - self.velocity = (vx, vy) self.move_and_slide() ``` diff --git a/examples/minimal-2d/scripts/player.py b/examples/minimal-2d/scripts/player.py index d924938..98c2374 100644 --- a/examples/minimal-2d/scripts/player.py +++ b/examples/minimal-2d/scripts/player.py @@ -9,16 +9,14 @@ def _ready(self): self.position = (160.0, 240.0) def _process(self, delta: float): - x, y = self.position - if pite.held("ArrowRight"): - x += self.speed * delta - if pite.held("ArrowLeft"): - x -= self.speed * delta - if pite.held("ArrowDown"): - y += self.speed * delta - if pite.held("ArrowUp"): - y -= self.speed * delta - if pite.pressed("Space"): - x += 10.0 + if pite.held.right: + self.position.x += self.speed * delta + if pite.held.left: + self.position.x -= self.speed * delta + if pite.held.down: + self.position.y += self.speed * delta + if pite.held.up: + self.position.y -= self.speed * delta + if pite.pressed.space: + self.position.x += 10.0 self.hit.emit(1) - self.position = (x, y) diff --git a/pite-editor/src/app/mod.rs b/pite-editor/src/app/mod.rs index 6f6926e..c78a063 100644 --- a/pite-editor/src/app/mod.rs +++ b/pite-editor/src/app/mod.rs @@ -108,7 +108,8 @@ pub struct EditorApp { impl EditorApp { pub fn new(scene: &Path) -> Result { - let session = GameSession::open(scene, false)?; + let mut session = GameSession::open(scene, false)?; + session.set_pause_on_error(true); let project_dir = pite_project::find_project_root(scene); let view_size = project_dir .as_deref() @@ -170,6 +171,21 @@ impl EditorApp { } pub(super) fn drain_script_errors(&mut self) { + if let Some(err) = self.session.take_last_error() { + self.playing = false; + let log_msg = if !err.traceback.is_empty() { + format!("{}\n{}", err.message, err.traceback) + } else { + err.message.clone() + }; + self.log(log_msg); + if let Some(file) = &err.file { + let p = Path::new(file); + if p.exists() { + self.open_asset(p); + } + } + } for err in self.session.errors() { if self.seen_errors.insert(err.clone()) { self.log(err); diff --git a/pite-editor/src/app/scene.rs b/pite-editor/src/app/scene.rs index a5d82ca..9f2cbfe 100644 --- a/pite-editor/src/app/scene.rs +++ b/pite-editor/src/app/scene.rs @@ -116,7 +116,8 @@ impl EditorApp { pub(super) fn open_scene_file(&mut self, file: &Path) { match GameSession::open(file, false) { - Ok(session) => { + Ok(mut session) => { + session.set_pause_on_error(true); self.session = session; self.scene_path = file.to_path_buf(); self.project_dir = pite_project::find_project_root(file); diff --git a/pite-editor/src/app/viewport.rs b/pite-editor/src/app/viewport.rs index d986da9..e033a6f 100644 --- a/pite-editor/src/app/viewport.rs +++ b/pite-editor/src/app/viewport.rs @@ -134,7 +134,8 @@ impl EditorApp { pub(super) fn stop(&mut self) { pite_script::input_clear(); match GameSession::open(&self.scene_path, false) { - Ok(session) => { + Ok(mut session) => { + session.set_pause_on_error(true); self.session = session; self.playing = false; self.viewport_dirty = true; diff --git a/pite-editor/src/lsp.rs b/pite-editor/src/lsp.rs index 9abca19..58e77e4 100644 --- a/pite-editor/src/lsp.rs +++ b/pite-editor/src/lsp.rs @@ -1505,8 +1505,15 @@ mod tests { } // Completion inside `self.` — proves the open + completion round-trip. - // `x, y = self.position` is line 11 (0-based); the dot sits at column 20. - let found = smoke_await_completion(&mut client, &uri, 11, 20); + let (dot_line, dot_col) = text + .lines() + .enumerate() + .find_map(|(l, line)| { + let col = line.find("self.")? + 5; + Some((l as u32, col as u32)) + }) + .expect("smoke script contains `self.`"); + let found = smoke_await_completion(&mut client, &uri, dot_line, dot_col); assert!(!found.is_empty(), "smoke: completion came back empty"); assert!( found.iter().any(|i| i.label == "position"), @@ -1518,7 +1525,7 @@ mod tests { // server buffer only; the dogfood file on disk is never touched. let broken = format!("{text}\nundefined_smoke_name_xyz\n"); client.did_change(&uri, &broken).expect("didChange sends"); - let after = smoke_await_completion(&mut client, &uri, 11, 20); + let after = smoke_await_completion(&mut client, &uri, dot_line, dot_col); assert!( after.iter().any(|i| i.label == "position"), "smoke: completion broke after didChange, got {:?}", diff --git a/pite-project/src/lib.rs b/pite-project/src/lib.rs index 28de753..a5a1b8a 100644 --- a/pite-project/src/lib.rs +++ b/pite-project/src/lib.rs @@ -278,18 +278,16 @@ class Player(pite.Node2D): self.position = (100.0, 200.0) def _process(self, delta: float): - x, y = self.position - if pite.held("ArrowRight"): - x += self.speed * delta - if pite.held("ArrowLeft"): - x -= self.speed * delta - if pite.held("ArrowDown"): - y += self.speed * delta - if pite.held("ArrowUp"): - y -= self.speed * delta - if pite.pressed("Space"): - x += 10.0 - self.position = (x, y) + if pite.held.right: + self.position.x += self.speed * delta + if pite.held.left: + self.position.x -= self.speed * delta + if pite.held.down: + self.position.y += self.speed * delta + if pite.held.up: + self.position.y -= self.speed * delta + if pite.pressed.space: + self.position.x += 10.0 "#; /// Valid 1x1 RGBA PNG backing the `res://assets/player.png` reference in diff --git a/pite-runtime/src/lib.rs b/pite-runtime/src/lib.rs index b088763..3ed4caa 100644 --- a/pite-runtime/src/lib.rs +++ b/pite-runtime/src/lib.rs @@ -103,6 +103,8 @@ pub struct GameSession { timers: std::collections::HashMap, physics: Arc>, physics_accumulator: f64, + pub pause_on_error: bool, + pub last_error: Option, } impl GameSession { @@ -127,6 +129,8 @@ impl GameSession { timers: std::collections::HashMap::new(), physics, physics_accumulator: 0.0, + pause_on_error: false, + last_error: None, }; pite_script::set_current_host(session.host.clone()); pite_script::set_current_physics(Some(session.physics.clone())); @@ -143,6 +147,14 @@ impl GameSession { &self.host } + pub fn set_pause_on_error(&mut self, pause: bool) { + self.pause_on_error = pause; + } + + pub fn take_last_error(&mut self) -> Option { + self.last_error.take() + } + pub fn resolve_path(&self, res_path: &str) -> PathBuf { resolve_script(res_path, &self.scene_dir, self.project_dir.as_deref()) .unwrap_or_else(|| PathBuf::from(res_path)) @@ -439,20 +451,26 @@ impl GameSession { slot.errored = true; } } - self.host.with_tree(|tree| { - if let Some((x, y)) = seed_position(tree, &id) { - slot.backend.set_position(x, y); - } - if let Some(text) = seed_text(tree, &id) { - slot.backend.set_text(&text); - } - if let Some((w, h)) = seed_size(tree, &id) { - slot.backend.set_size(w, h); - } - if let Some(wait) = seed_wait(tree, &id) { - slot.backend.set_wait_time(wait); - } + let seeds = self.host.with_tree(|tree| { + ( + seed_position(tree, &id), + seed_text(tree, &id), + seed_size(tree, &id), + seed_wait(tree, &id), + ) }); + if let Some((x, y)) = seeds.0 { + slot.backend.set_position(x, y); + } + if let Some(text) = seeds.1 { + slot.backend.set_text(&text); + } + if let Some((w, h)) = seeds.2 { + slot.backend.set_size(w, h); + } + if let Some(wait) = seeds.3 { + slot.backend.set_wait_time(wait); + } self.slots.push(slot); continue; } @@ -464,20 +482,26 @@ impl GameSession { backend.set_host(self.host.clone()); match backend.load(&path.to_string_lossy(), &class) { Ok(()) => { - self.host.with_tree(|tree| { - if let Some((x, y)) = seed_position(tree, &id) { - backend.set_position(x, y); - } - if let Some(text) = seed_text(tree, &id) { - backend.set_text(&text); - } - if let Some((w, h)) = seed_size(tree, &id) { - backend.set_size(w, h); - } - if let Some(wait) = seed_wait(tree, &id) { - backend.set_wait_time(wait); - } + let seeds = self.host.with_tree(|tree| { + ( + seed_position(tree, &id), + seed_text(tree, &id), + seed_size(tree, &id), + seed_wait(tree, &id), + ) }); + if let Some((x, y)) = seeds.0 { + backend.set_position(x, y); + } + if let Some(text) = seeds.1 { + backend.set_text(&text); + } + if let Some((w, h)) = seeds.2 { + backend.set_size(w, h); + } + if let Some(wait) = seeds.3 { + backend.set_wait_time(wait); + } let mut errored = false; if let Err(e) = backend.call_ready() { tracing::error!(node = %id, "{e:#}"); @@ -747,6 +771,9 @@ impl GameSession { if let Err(e) = slot.backend.call_physics_process(FIXED_STEP) { tracing::error!(node = %slot.id, "physics process error: {e:#}"); slot.errored = true; + if self.pause_on_error && self.last_error.is_none() { + self.last_error = slot.backend.last_script_error(); + } } if let Some((x, y)) = slot.backend.position() { let mut lock = self.physics.lock().unwrap(); @@ -830,28 +857,9 @@ impl GameSession { if let Err(e) = slot.backend.call_process(delta) { tracing::error!(node = %slot.id, "{e:#}"); slot.errored = true; - } - if let Some((x, y)) = slot.backend.position() { - self.host.with_tree_mut(|tree| { - if let Some(node) = tree.get_mut(&slot.id) { - node.props - .insert("position".to_string(), PropValue::Vec2(x, y)); - } - }); - } - if let Some(text) = slot.backend.text() { - self.host.with_tree_mut(|tree| { - if let Some(node) = tree.get_mut(&slot.id) { - node.props.insert("text".to_string(), PropValue::Str(text)); - } - }); - } - if let Some((w, h)) = slot.backend.size() { - self.host.with_tree_mut(|tree| { - if let Some(node) = tree.get_mut(&slot.id) { - node.props.insert("size".to_string(), PropValue::Vec2(w, h)); - } - }); + if self.pause_on_error && self.last_error.is_none() { + self.last_error = slot.backend.last_script_error(); + } } if let Some(wait) = slot.backend.wait_time() { self.host.with_tree_mut(|tree| { @@ -1503,8 +1511,6 @@ mod tests { fn click(session: &mut GameSession, down: (f64, f64), up: (f64, f64)) { use pite_script::{input_begin_frame, input_set_key, input_set_mouse}; - // Mirror the real loop, which syncs the viewport from the renderer - // every frame: clicks map 1:1 only at the design size. let (w, h) = session.design_size().unwrap_or((800, 600)); session.set_viewport(w, h); input_set_mouse(down.0, down.1); @@ -1584,7 +1590,7 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - const WALKER: &str = "import pite\n\nclass Walker(pite.Node2D):\n def _process(self, delta):\n x, y = self.position\n if pite.held(\"T-WalkRight\"):\n x += 200.0 * delta\n if pite.pressed(\"T-Step\"):\n x += 10.0\n self.position = (x, y)\n"; + const WALKER: &str = "import pite\n\nclass Walker(pite.Node2D):\n def _process(self, delta):\n if pite.held.right:\n self.position.x += 200.0 * delta\n if pite.pressed.space:\n self.position.x += 10.0\n"; fn walker_x(session: &GameSession) -> f64 { session @@ -1608,12 +1614,12 @@ mod tests { let mut session = GameSession::open(&scene, true).unwrap(); input_begin_frame(); - input_set_key("T-WalkRight", true); + input_set_key("ArrowRight", true); session.update(0.5); assert!((walker_x(&session) - 100.0).abs() < 1e-6); input_begin_frame(); - input_set_key("T-Step", true); + input_set_key("Space", true); session.update(0.5); let after_step = walker_x(&session); assert!((after_step - 210.0).abs() < 1e-6); @@ -1621,12 +1627,12 @@ mod tests { input_begin_frame(); session.update(0.5); assert!((walker_x(&session) - 310.0).abs() < 1e-6); - input_set_key("T-WalkRight", false); - input_set_key("T-Step", false); + input_set_key("ArrowRight", false); + input_set_key("Space", false); std::fs::remove_dir_all(&dir).ok(); } - const STEPPER: &str = "import pite\n\nclass Walker(pite.Node2D):\n def _process(self, delta):\n x, y = self.position\n if pite.pressed(\"T-EdgeStep\"):\n x += 10.0\n self.position = (x, y)\n"; + const STEPPER: &str = "import pite\n\nclass Walker(pite.Node2D):\n def _process(self, delta):\n if pite.pressed.space:\n self.position.x += 10.0\n"; #[test] fn frame_delivers_production_order_edges_exactly_once() { @@ -1640,7 +1646,7 @@ mod tests { // Production order: the OS event lands between frames, so the // edge is set BEFORE frame() with no begin_frame in between. // (frame() itself retires edges at its end.) - input_set_key("T-EdgeStep", true); + input_set_key("Space", true); session.frame(0.5); assert!((walker_x(&session) - 10.0).abs() < 1e-6); session.frame(0.5); @@ -1648,7 +1654,7 @@ mod tests { (walker_x(&session) - 10.0).abs() < 1e-6, "edge must be consumed exactly once" ); - input_set_key("T-EdgeStep", false); + input_set_key("Space", false); pite_script::input_begin_frame(); std::fs::remove_dir_all(&dir).ok(); } diff --git a/pite-script/src/descriptor.rs b/pite-script/src/descriptor.rs new file mode 100644 index 0000000..ed09ebf --- /dev/null +++ b/pite-script/src/descriptor.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use pyo3::prelude::*; + +#[pyclass(name = "ChildDescriptor")] +pub struct PyChildDescriptor { + name: String, + attr: Option, +} + +#[pymethods] +impl PyChildDescriptor { + #[new] + fn new(name: String) -> Self { + Self { name, attr: None } + } + + fn __set_name__(&mut self, _owner: &Bound<'_, PyAny>, name: String) { + self.attr = Some(name); + } + + fn __get__( + &self, + py: Python<'_>, + obj: Option<&Bound<'_, PyAny>>, + _owner: Option<&Bound<'_, PyAny>>, + ) -> PyResult> { + let Some(obj) = obj else { + return Ok(Bound::new( + py, + PyChildDescriptor { + name: self.name.clone(), + attr: self.attr.clone(), + }, + )? + .into_any() + .unbind()); + }; + + let cache = match obj.getattr("_descriptor_cache") { + Ok(c) => c, + Err(_) => { + let dict = pyo3::types::PyDict::new(py); + obj.setattr("_descriptor_cache", &dict)?; + dict.into_any() + } + }; + + let attr_key = self.attr.as_deref().unwrap_or(self.name.as_str()); + if let Ok(cached) = cache.get_item(attr_key) { + return Ok(cached.into_any().unbind()); + } + + let resolved = obj.call_method1("get_node", (&self.name,))?; + cache.set_item(attr_key, &resolved)?; + Ok(resolved.into()) + } +} + +#[pyclass(name = "NodeDescriptor")] +pub struct PyNodeDescriptor { + path: String, + attr: Option, +} + +#[pymethods] +impl PyNodeDescriptor { + #[new] + fn new(path: String) -> Self { + Self { path, attr: None } + } + + fn __set_name__(&mut self, _owner: &Bound<'_, PyAny>, name: String) { + self.attr = Some(name); + } + + fn __get__( + &self, + py: Python<'_>, + obj: Option<&Bound<'_, PyAny>>, + _owner: Option<&Bound<'_, PyAny>>, + ) -> PyResult> { + let Some(obj) = obj else { + return Ok(Bound::new( + py, + PyNodeDescriptor { + path: self.path.clone(), + attr: self.attr.clone(), + }, + )? + .into_any() + .unbind()); + }; + + let cache = match obj.getattr("_descriptor_cache") { + Ok(c) => c, + Err(_) => { + let dict = pyo3::types::PyDict::new(py); + obj.setattr("_descriptor_cache", &dict)?; + dict.into_any() + } + }; + + let attr_key = self.attr.as_deref().unwrap_or(self.path.as_str()); + if let Ok(cached) = cache.get_item(attr_key) { + return Ok(cached.into_any().unbind()); + } + + let resolved = obj.call_method1("get_node", (&self.path,))?; + cache.set_item(attr_key, &resolved)?; + Ok(resolved.into()) + } +} + +#[pyfunction] +pub fn child(name: String) -> PyChildDescriptor { + PyChildDescriptor::new(name) +} + +#[pyfunction] +pub fn node(path: String) -> PyNodeDescriptor { + PyNodeDescriptor::new(path) +} diff --git a/pite-script/src/host.rs b/pite-script/src/host.rs index 64d2f66..016e603 100644 --- a/pite-script/src/host.rs +++ b/pite-script/src/host.rs @@ -40,6 +40,8 @@ pub struct ScriptHost { instances: Mutex>>, signals: Mutex>, connections: Mutex>>, + callable_connections: Mutex>>>, + proxy_cache: Mutex>>, } pub const SIGNAL_TYPES: &[&str] = &["int", "float", "str", "bool"]; @@ -59,9 +61,71 @@ impl ScriptHost { instances: Mutex::new(HashMap::new()), signals: Mutex::new(HashMap::new()), connections: Mutex::new(HashMap::new()), + callable_connections: Mutex::new(HashMap::new()), + proxy_cache: Mutex::new(HashMap::new()), } } + pub fn get_node_position(&self, id: &str) -> Option<(f64, f64)> { + use pite_core::PropValue; + let tree = self.tree.lock().unwrap(); + let node = tree.get(&NodeId::from(id.to_string()))?; + match node.props.get("position") { + Some(PropValue::Vec2(x, y)) => Some((*x, *y)), + _ => Some((0.0, 0.0)), + } + } + + pub fn set_node_position(&self, id: &str, x: f64, y: f64) { + use pite_core::PropValue; + let mut tree = self.tree.lock().unwrap(); + if let Some(node) = tree.get_mut(&NodeId::from(id.to_string())) { + node.props + .insert("position".to_string(), PropValue::Vec2(x, y)); + } + } + + pub fn set_node_text(&self, id: &str, text: &str) { + use pite_core::PropValue; + let mut tree = self.tree.lock().unwrap(); + if let Some(node) = tree.get_mut(&NodeId::from(id.to_string())) { + node.props + .insert("text".to_string(), PropValue::Str(text.to_string())); + } + } + + pub fn set_node_size(&self, id: &str, w: f64, h: f64) { + use pite_core::PropValue; + let mut tree = self.tree.lock().unwrap(); + if let Some(node) = tree.get_mut(&NodeId::from(id.to_string())) { + node.props.insert("size".to_string(), PropValue::Vec2(w, h)); + } + } + + pub fn resolve_proxy( + self: &std::sync::Arc, + py: Python<'_>, + caller: &str, + path: &str, + ) -> anyhow::Result> { + let key = (caller.to_string(), path.to_string()); + { + let cache = self.proxy_cache.lock().unwrap(); + if let Some(proxy) = cache.get(&key) { + return Ok(proxy.clone_ref(py)); + } + } + let target = self + .resolve(caller, path) + .ok_or_else(|| anyhow::anyhow!("{caller}: get_node({path:?}) found nothing"))?; + let proxy = Py::new(py, proxy_for(self.clone(), target))?; + self.proxy_cache + .lock() + .unwrap() + .insert(key, proxy.clone_ref(py)); + Ok(proxy) + } + pub fn register(&self, node: impl Into, instance: Py) { self.instances.lock().unwrap().insert(node.into(), instance); } @@ -167,6 +231,31 @@ impl ScriptHost { Ok(()) } + pub fn connect_callable( + &self, + _py: Python<'_>, + source: &str, + signal: &str, + callable: Py, + ) -> anyhow::Result<()> { + let signals = self.signals.lock().unwrap(); + if signals + .get(source) + .and_then(|reg| reg.get(signal)) + .is_none() + { + anyhow::bail!("{source}: connect to unknown signal {signal:?}"); + } + drop(signals); + self.callable_connections + .lock() + .unwrap() + .entry((source.to_string(), signal.to_string())) + .or_default() + .push(callable); + Ok(()) + } + pub fn emit( &self, py: Python<'_>, @@ -194,6 +283,22 @@ impl ScriptHost { .with_context(|| format!("{source}: signal {signal:?} arg {i} wants {want}"))?; values.push(value); } + let callables: Vec> = self + .callable_connections + .lock() + .unwrap() + .get(&(source.to_string(), signal.to_string())) + .map(|list| list.iter().map(|c| c.clone_ref(py)).collect()) + .unwrap_or_default(); + if !callables.is_empty() { + let py_args: Vec> = values.iter().map(|v| v.to_object(py)).collect(); + let tuple = PyTuple::new(py, py_args).map_err(|e| anyhow::anyhow!("{e}"))?; + for c in callables { + c.bind(py) + .call(tuple.clone(), None) + .map_err(|e| anyhow::anyhow!("callable raised: {e}"))?; + } + } let targets: Vec<(String, String)> = self .connections .lock() @@ -240,6 +345,14 @@ impl ScriptHost { targets.retain(|(t, _)| t != node); !targets.is_empty() }); + self.callable_connections + .lock() + .unwrap() + .retain(|(src, _), _| src != node); + self.proxy_cache + .lock() + .unwrap() + .retain(|(src, _), _| src != node); } } @@ -303,7 +416,7 @@ pub struct NodeProxy { #[pymethods] impl NodeProxy { #[getter] - fn position(&self) -> PyResult<(f64, f64)> { + fn position(&self) -> PyResult { use pite_core::PropValue; self.host.with_tree(|tree| { let node = tree @@ -315,21 +428,31 @@ impl NodeProxy { )) })?; match node.props.get("position") { - Some(PropValue::Vec2(x, y)) => Ok((*x, *y)), - _ => Ok((0.0, 0.0)), + Some(PropValue::Vec2(x, y)) => Ok(crate::vec2::Vec2::new(*x, *y)), + _ => Ok(crate::vec2::Vec2::new(0.0, 0.0)), } }) } #[setter] - fn set_position(&self, pos: (f64, f64)) { + fn set_position(&self, val: &Bound<'_, PyAny>) -> PyResult<()> { + let (x, y) = if let Ok(v) = val.extract::() { + (v.x, v.y) + } else if let Ok(t) = val.extract::<(f64, f64)>() { + t + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "position must be Vec2 or (x, y) tuple", + )); + }; use pite_core::PropValue; self.host.with_tree_mut(|tree| { if let Some(node) = tree.get_mut(&NodeId::from(self.target.clone())) { node.props - .insert("position".to_string(), PropValue::Vec2(pos.0, pos.1)); + .insert("position".to_string(), PropValue::Vec2(x, y)); } }); + Ok(()) } #[getter] @@ -362,7 +485,7 @@ impl NodeProxy { } #[getter] - fn size(&self) -> PyResult<(f64, f64)> { + fn size(&self) -> PyResult { use pite_core::PropValue; self.host.with_tree(|tree| { let node = tree @@ -374,21 +497,30 @@ impl NodeProxy { )) })?; match node.props.get("size") { - Some(PropValue::Vec2(w, h)) => Ok((*w, *h)), - _ => Ok((0.0, 0.0)), + Some(PropValue::Vec2(w, h)) => Ok(crate::vec2::Vec2::new(*w, *h)), + _ => Ok(crate::vec2::Vec2::new(0.0, 0.0)), } }) } #[setter] - fn set_size(&self, size: (f64, f64)) { + fn set_size(&self, val: &Bound<'_, PyAny>) -> PyResult<()> { + let (w, h) = if let Ok(v) = val.extract::() { + (v.x, v.y) + } else if let Ok(t) = val.extract::<(f64, f64)>() { + t + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "size must be Vec2 or (w, h) tuple", + )); + }; use pite_core::PropValue; self.host.with_tree_mut(|tree| { if let Some(node) = tree.get_mut(&NodeId::from(self.target.clone())) { - node.props - .insert("size".to_string(), PropValue::Vec2(size.0, size.1)); + node.props.insert("size".to_string(), PropValue::Vec2(w, h)); } }); + Ok(()) } fn __getattr__(&self, py: Python<'_>, name: String) -> PyResult> { @@ -429,12 +561,15 @@ impl BoundSignal { #[pymethods] impl BoundSignal { fn connect(&self, py: Python<'_>, callable: Bound<'_, PyAny>) -> PyResult<()> { - let (target, handler) = bound_target(&callable) - .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string()))?; - let _ = py; - self.host - .connect(&self.node, &self.signal, &target, &handler) - .map_err(|e| pyo3::exceptions::PyKeyError::new_err(e.to_string())) + if let Ok((target, handler)) = bound_target(&callable) { + self.host + .connect(&self.node, &self.signal, &target, &handler) + .map_err(|e| pyo3::exceptions::PyKeyError::new_err(e.to_string())) + } else { + self.host + .connect_callable(py, &self.node, &self.signal, callable.into_any().unbind()) + .map_err(|e| pyo3::exceptions::PyKeyError::new_err(e.to_string())) + } } #[pyo3(signature = (*args))] @@ -457,6 +592,15 @@ pub fn resolve_caller(caller: &str, path: &str) -> anyhow::Result { Ok(proxy_for(host, target)) } +pub fn resolve_caller_py( + py: Python<'_>, + caller: &str, + path: &str, +) -> anyhow::Result> { + let host = current_host().ok_or_else(|| anyhow::anyhow!("get_node has no script host"))?; + host.resolve_proxy(py, caller, path) +} + pub fn fire_pressed(host: &ScriptHost, id: &str) -> anyhow::Result<()> { Python::attach(|py| host.emit(py, id, "pressed", &PyTuple::empty(py))) } diff --git a/pite-script/src/lib.rs b/pite-script/src/lib.rs index 1a7af93..92c6f71 100644 --- a/pite-script/src/lib.rs +++ b/pite-script/src/lib.rs @@ -3,24 +3,28 @@ //! future backends (or a mock for tests) plug in without touching runtime. mod audio; +pub mod descriptor; mod host; mod input; pub mod physics_bridge; mod python; +pub mod vec2; use anyhow::Result; +pub use descriptor::{child, node, PyChildDescriptor, PyNodeDescriptor}; pub use host::{ fire_area_entered, fire_area_exited, fire_body_entered, fire_body_exited, fire_pressed, - fire_timeout, proxy_for, resolve_caller, set_current_host, NodeProxy, ScriptHost, + fire_timeout, proxy_for, resolve_caller, resolve_caller_py, set_current_host, NodeProxy, + ScriptHost, }; pub use input::{ input_begin_frame, input_clear, input_held, input_mouse, input_pressed, input_released, input_set_key, input_set_mouse, }; pub use physics_bridge::{current_physics, set_current_physics, with_physics}; -pub use python::is_stdlib_module; -pub use python::Pyo3Backend; +pub use python::{is_stdlib_module, InputQueryKind, PyInputQuery, Pyo3Backend, ScriptError}; +pub use vec2::Vec2; pub const RESERVED_METHODS: &[&str] = &["emit", "connect"]; @@ -85,6 +89,10 @@ pub trait ScriptBackend { fn last_error(&self) -> Option { None } + + fn last_script_error(&self) -> Option { + None + } } #[derive(Debug, Default)] diff --git a/pite-script/src/python.rs b/pite-script/src/python.rs index 82c6217..1a3a18b 100644 --- a/pite-script/src/python.rs +++ b/pite-script/src/python.rs @@ -9,7 +9,7 @@ use pyo3::PyClassInitializer; use std::collections::HashMap; use crate::host::NodeProxy; -use crate::{resolve_caller, ScriptBackend}; +use crate::ScriptBackend; #[pyclass(subclass, name = "Node")] struct PyNode { @@ -26,26 +26,47 @@ impl PyNode { } } - fn get_node(&self, py: Python<'_>, path: &str) -> PyResult> { - let proxy = resolve_caller(&self.node_path, path) - .map_err(|e| pyo3::exceptions::PyKeyError::new_err(e.to_string()))?; - Py::new(py, proxy) + fn get_node(slf: &Bound<'_, Self>, path: &str) -> PyResult> { + let py = slf.py(); + let node_path = slf.borrow().node_path.clone(); + crate::resolve_caller_py(py, &node_path, path) + .map_err(|e| pyo3::exceptions::PyKeyError::new_err(e.to_string())) } } #[pyclass(extends = PyNode, subclass, name = "Node2D")] struct PyNode2D { - #[pyo3(get, set)] - position: (f64, f64), + position: Py, } #[pymethods] impl PyNode2D { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()).add_subclass(PyNode2D { - position: (0.0, 0.0), - }) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + Ok(PyClassInitializer::from(PyNode::new()).add_subclass(PyNode2D { position: pos })) + } + + #[getter] + fn position(&self, py: Python<'_>) -> Py { + self.position.clone_ref(py) + } + + #[setter] + fn set_position(&self, py: Python<'_>, val: &Bound<'_, PyAny>) -> PyResult<()> { + let mut b = self.position.borrow_mut(py); + if let Ok(v) = val.extract::() { + b.set_x(v.x()); + b.set_y(v.y()); + } else if let Ok((x, y)) = val.extract::<(f64, f64)>() { + b.set_x(x); + b.set_y(y); + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "position must be Vec2 or (x, y) tuple", + )); + } + Ok(()) } } @@ -62,16 +83,15 @@ struct PySprite2D { #[pymethods] impl PySprite2D { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()) - .add_subclass(PyNode2D { - position: (0.0, 0.0), - }) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + Ok(PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { position: pos }) .add_subclass(PySprite2D { texture: String::new(), atlas: String::new(), frame: String::new(), - }) + })) } } @@ -81,12 +101,11 @@ struct PyCamera2D; #[pymethods] impl PyCamera2D { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()) - .add_subclass(PyNode2D { - position: (0.0, 0.0), - }) - .add_subclass(PyCamera2D) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + Ok(PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { position: pos }) + .add_subclass(PyCamera2D)) } } @@ -106,7 +125,7 @@ impl PyTimer { #[pyclass(extends = PyNode, subclass, name = "Label")] struct PyLabel { - #[pyo3(get, set)] + #[pyo3(get)] text: String, } @@ -118,28 +137,77 @@ impl PyLabel { text: String::new(), }) } + + #[setter] + fn set_text(slf: &Bound<'_, Self>, text: String) -> PyResult<()> { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + host.set_node_text(&node_path, &text); + } + let mut b = slf.borrow_mut(); + b.text = text; + Ok(()) + } } #[pyclass(extends = PyNode2D, subclass, name = "Button")] struct PyButton { - #[pyo3(get, set)] + #[pyo3(get)] text: String, - #[pyo3(get, set)] - size: (f64, f64), + size: Py, } #[pymethods] impl PyButton { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()) - .add_subclass(PyNode2D { - position: (0.0, 0.0), - }) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + let size = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + Ok(PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { position: pos }) .add_subclass(PyButton { text: String::new(), - size: (0.0, 0.0), - }) + size, + })) + } + + #[setter] + fn set_text(slf: &Bound<'_, Self>, text: String) -> PyResult<()> { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + host.set_node_text(&node_path, &text); + } + let mut b = slf.borrow_mut(); + b.text = text; + Ok(()) + } + + #[getter] + fn size(&self, py: Python<'_>) -> Py { + self.size.clone_ref(py) + } + + #[setter] + fn set_size(slf: &Bound<'_, Self>, val: &Bound<'_, PyAny>) -> PyResult<()> { + let (w, h) = if let Ok(v) = val.extract::() { + (v.x(), v.y()) + } else if let Ok(t) = val.extract::<(f64, f64)>() { + t + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "size must be Vec2 or (w, h) tuple", + )); + }; + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + host.set_node_size(&node_path, w, h); + } + let py = slf.py(); + let b = slf.borrow(); + let mut s = b.size.borrow_mut(py); + s.set_x(w); + s.set_y(h); + Ok(()) } } @@ -154,24 +222,21 @@ struct PyStaticBody2D { #[pymethods] impl PyStaticBody2D { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()) - .add_subclass(PyNode2D { - position: (0.0, 0.0), - }) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + Ok(PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { position: pos }) .add_subclass(PyStaticBody2D { collision_layer: 1, collision_mask: 1, - }) + })) } } #[pyclass(extends = PyNode2D, subclass, name = "CharacterBody2D")] struct PyCharacterBody2D { - #[pyo3(get, set)] - velocity: (f64, f64), - #[pyo3(get, set)] - up_direction: (f64, f64), + velocity: Py, + up_direction: Py, #[pyo3(get, set)] collision_layer: u32, #[pyo3(get, set)] @@ -187,20 +252,65 @@ struct PyCharacterBody2D { #[pymethods] impl PyCharacterBody2D { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()) - .add_subclass(PyNode2D { - position: (0.0, 0.0), - }) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + let vel = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + let up = Py::new(py, crate::vec2::Vec2::new(0.0, -1.0))?; + Ok(PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { position: pos }) .add_subclass(PyCharacterBody2D { - velocity: (0.0, 0.0), - up_direction: (0.0, -1.0), + velocity: vel, + up_direction: up, collision_layer: 1, collision_mask: 1, _is_on_floor: false, _is_on_wall: false, _is_on_ceiling: false, - }) + })) + } + + #[getter] + fn velocity(&self, py: Python<'_>) -> Py { + self.velocity.clone_ref(py) + } + + #[setter] + fn set_velocity(&self, py: Python<'_>, val: &Bound<'_, PyAny>) -> PyResult<()> { + let mut b = self.velocity.borrow_mut(py); + if let Ok(v) = val.extract::() { + b.set_x(v.x()); + b.set_y(v.y()); + } else if let Ok((vx, vy)) = val.extract::<(f64, f64)>() { + b.set_x(vx); + b.set_y(vy); + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "velocity must be Vec2 or (vx, vy) tuple", + )); + } + Ok(()) + } + + #[getter] + fn up_direction(&self, py: Python<'_>) -> Py { + self.up_direction.clone_ref(py) + } + + #[setter] + fn set_up_direction(&self, py: Python<'_>, val: &Bound<'_, PyAny>) -> PyResult<()> { + let mut b = self.up_direction.borrow_mut(py); + if let Ok(v) = val.extract::() { + b.set_x(v.x()); + b.set_y(v.y()); + } else if let Ok((ux, uy)) = val.extract::<(f64, f64)>() { + b.set_x(ux); + b.set_y(uy); + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "up_direction must be Vec2 or (ux, uy) tuple", + )); + } + Ok(()) } fn is_on_floor(&self) -> bool { @@ -216,11 +326,14 @@ impl PyCharacterBody2D { } fn move_and_slide(slf: &Bound<'_, Self>) -> PyResult { + let py = slf.py(); let node_path: String = slf.getattr("node_path")?.extract()?; let node_id = pite_core::NodeId::from(node_path); let (vel, up) = { let b = slf.borrow(); - (b.velocity, b.up_direction) + let v = b.velocity.borrow(py); + let u = b.up_direction.borrow(py); + ((v.x(), v.y()), (u.x(), u.y())) }; let dt = 1.0 / 60.0; @@ -234,9 +347,14 @@ impl PyCharacterBody2D { }); if let Some(res) = res { + { + let b = slf.borrow(); + let mut v = b.velocity.borrow_mut(py); + v.set_x(res.velocity.0 as f64); + v.set_y(res.velocity.1 as f64); + } { let mut b = slf.borrow_mut(); - b.velocity = (res.velocity.0 as f64, res.velocity.1 as f64); b._is_on_floor = res.is_on_floor; b._is_on_wall = res.is_on_wall; b._is_on_ceiling = res.is_on_ceiling; @@ -272,11 +390,10 @@ struct PyRigidBody2D { #[pymethods] impl PyRigidBody2D { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()) - .add_subclass(PyNode2D { - position: (0.0, 0.0), - }) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + Ok(PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { position: pos }) .add_subclass(PyRigidBody2D { mass: 1.0, gravity_scale: 1.0, @@ -285,7 +402,7 @@ impl PyRigidBody2D { lock_rotation: false, collision_layer: 1, collision_mask: 1, - }) + })) } fn apply_impulse(slf: &Bound<'_, Self>, impulse: (f64, f64)) -> PyResult<()> { @@ -314,15 +431,14 @@ struct PyArea2D { #[pymethods] impl PyArea2D { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()) - .add_subclass(PyNode2D { - position: (0.0, 0.0), - }) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + Ok(PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { position: pos }) .add_subclass(PyArea2D { collision_layer: 1, collision_mask: 1, - }) + })) } #[getter] @@ -395,18 +511,17 @@ struct PyCollisionShape2D { #[pymethods] impl PyCollisionShape2D { #[new] - fn new() -> PyClassInitializer { - PyClassInitializer::from(PyNode::new()) - .add_subclass(PyNode2D { - position: (0.0, 0.0), - }) + fn new(py: Python<'_>) -> PyResult> { + let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?; + Ok(PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { position: pos }) .add_subclass(PyCollisionShape2D { shape: "box".to_string(), size: (32.0, 32.0), radius: 16.0, height: 32.0, disabled: false, - }) + })) } } @@ -470,14 +585,35 @@ fn pite(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add( + "held", + PyInputQuery { + kind: InputQueryKind::Held, + }, + )?; + m.add( + "pressed", + PyInputQuery { + kind: InputQueryKind::Pressed, + }, + )?; + m.add( + "released", + PyInputQuery { + kind: InputQueryKind::Released, + }, + )?; m.add_function(wrap_pyfunction!(raycast, m)?)?; - m.add_function(wrap_pyfunction!(held, m)?)?; - m.add_function(wrap_pyfunction!(pressed, m)?)?; - m.add_function(wrap_pyfunction!(released, m)?)?; m.add_function(wrap_pyfunction!(mouse, m)?)?; m.add_function(wrap_pyfunction!(signal, m)?)?; m.add_class::()?; m.add_function(wrap_pyfunction!(frames, m)?)?; + m.add_function(wrap_pyfunction!(crate::descriptor::child, m)?)?; + m.add_function(wrap_pyfunction!(crate::descriptor::node, m)?)?; m.add_function(wrap_pyfunction!(crate::audio::play, m)?)?; m.add_function(wrap_pyfunction!(crate::audio::stop, m)?)?; m.add_function(wrap_pyfunction!(crate::audio::set_volume, m)?)?; @@ -485,6 +621,116 @@ fn pite(m: &Bound<'_, PyModule>) -> PyResult<()> { Ok(()) } +fn map_key_attr(attr: &str) -> &'static str { + match attr { + "space" => "Space", + "enter" | "return" => "Enter", + "escape" | "esc" => "Escape", + "tab" => "Tab", + "backspace" => "Backspace", + "shift" => "Shift", + "ctrl" | "control" => "Control", + "alt" => "Alt", + "left" | "arrow_left" => "ArrowLeft", + "right" | "arrow_right" => "ArrowRight", + "up" | "arrow_up" => "ArrowUp", + "down" | "arrow_down" => "ArrowDown", + "mouse_left" | "mouse1" => "MouseLeft", + "mouse_right" | "mouse2" => "MouseRight", + "mouse_middle" | "mouse3" => "MouseMiddle", + "a" => "A", + "b" => "B", + "c" => "C", + "d" => "D", + "e" => "E", + "f" => "F", + "g" => "G", + "h" => "H", + "i" => "I", + "j" => "J", + "k" => "K", + "l" => "L", + "m" => "M", + "n" => "N", + "o" => "O", + "p" => "P", + "q" => "Q", + "r" => "R", + "s" => "S", + "t" => "T", + "u" => "U", + "v" => "V", + "w" => "W", + "x" => "X", + "y" => "Y", + "z" => "Z", + "num0" | "zero" => "0", + "num1" | "one" => "1", + "num2" | "two" => "2", + "num3" | "three" => "3", + "num4" | "four" => "4", + "num5" | "five" => "5", + "num6" | "six" => "6", + "num7" | "seven" => "7", + "num8" | "eight" => "8", + "num9" | "nine" => "9", + _ => "", + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputQueryKind { + Held, + Pressed, + Released, +} + +impl InputQueryKind { + fn name(&self) -> &'static str { + match self { + Self::Held => "held", + Self::Pressed => "pressed", + Self::Released => "released", + } + } +} + +#[pyclass(name = "InputQuery")] +pub struct PyInputQuery { + pub(crate) kind: InputQueryKind, +} + +#[pymethods] +impl PyInputQuery { + fn __getattr__(&self, name: &str) -> bool { + let key = map_key_attr(name); + let key_str = if key.is_empty() { name } else { key }; + match self.kind { + InputQueryKind::Held => crate::input_held(key_str), + InputQueryKind::Pressed => crate::input_pressed(key_str), + InputQueryKind::Released => crate::input_released(key_str), + } + } + + #[pyo3(signature = (*_args, **_kwargs))] + fn __call__( + &self, + _args: &Bound<'_, pyo3::types::PyTuple>, + _kwargs: Option<&Bound<'_, pyo3::types::PyDict>>, + ) -> PyResult<()> { + let n = self.kind.name(); + Err(pyo3::exceptions::PyTypeError::new_err(format!( + "pite.{n} is an attribute-based accessor. Use 'pite.{n}.' instead (e.g. pite.{n}.space, pite.{n}.right). Passing strings is no longer supported." + ))) + } +} + +#[pyfunction] +fn mouse() -> crate::vec2::Vec2 { + let (x, y) = crate::input_mouse(); + crate::vec2::Vec2::new(x, y) +} + #[pyfunction] #[pyo3(signature = (*types))] fn signal(types: Vec>) -> PyResult { @@ -557,26 +803,6 @@ impl PySignalDecl { } } -#[pyfunction] -fn held(key: &str) -> bool { - crate::input_held(key) -} - -#[pyfunction] -fn pressed(key: &str) -> bool { - crate::input_pressed(key) -} - -#[pyfunction] -fn released(key: &str) -> bool { - crate::input_released(key) -} - -#[pyfunction] -fn mouse() -> (f64, f64) { - crate::input_mouse() -} - static INIT: Once = Once::new(); fn ensure_init() { @@ -616,15 +842,49 @@ pub fn is_stdlib_module(name: &str) -> bool { }) } -fn format_error(py: Python<'_>, err: PyErr, ctx: &str) -> String { - let tb = err +#[derive(Debug, Clone)] +pub struct ScriptError { + pub message: String, + pub file: Option, + pub line: Option, + pub traceback: String, +} + +pub fn extract_script_error(py: Python<'_>, err: &PyErr, ctx: &str) -> ScriptError { + let mut file = None; + let mut line = None; + if let Some(tb) = err.traceback(py) { + let mut curr = Some(tb); + while let Some(t) = curr { + let frame = t.getattr("tb_frame").ok(); + if let Some(f) = frame { + let code = f.getattr("f_code").ok(); + if let Some(c) = code { + if let Ok(filename) = + c.getattr("co_filename").and_then(|x| x.extract::()) + { + let lineno = t + .getattr("tb_lineno") + .and_then(|x| x.extract::()) + .unwrap_or(0); + file = Some(filename); + line = Some(lineno); + } + } + } + curr = t.getattr("tb_next").ok().and_then(|n| n.extract().ok()); + } + } + let tb_str = err .traceback(py) .and_then(|t| t.format().ok()) .unwrap_or_default(); - if tb.is_empty() { - format!("{ctx}: {err}") - } else { - format!("{ctx}: {err}\n{tb}") + let message = format!("{ctx}: {err}"); + ScriptError { + message, + file, + line, + traceback: tb_str, } } @@ -634,6 +894,7 @@ pub struct Pyo3Backend { class: String, instance: Option>, last_error: Option, + last_script_error: Option, host: Option>, pending_signals: Vec<(String, Vec)>, } @@ -647,6 +908,7 @@ impl Pyo3Backend { class: String::new(), instance: None, last_error: None, + last_script_error: None, host: None, pending_signals: Vec::new(), } @@ -660,6 +922,17 @@ impl Pyo3Backend { self.pending_signals.clear(); } } + if let Some(inst) = &self.instance { + Python::attach(|py| { + if let Ok(pos_attr) = inst.bind(py).getattr("position") { + if let Ok(mut vec2_bound) = + pos_attr.extract::>() + { + vec2_bound.set_target(host.clone(), self.node_path.clone()); + } + } + }); + } self.host = Some(host); } @@ -775,6 +1048,13 @@ impl Pyo3Backend { let owned: Py = instance.into(); owned.bind(py).setattr("node_path", &self.node_path)?; if let Some(host) = &self.host { + if let Ok(pos_attr) = owned.bind(py).getattr("position") { + if let Ok(mut vec2_bound) = + pos_attr.extract::>() + { + vec2_bound.set_target(host.clone(), self.node_path.clone()); + } + } host.register(self.node_path.clone(), owned.clone_ref(py)); } self.instance = Some(owned); @@ -807,7 +1087,9 @@ impl Pyo3Backend { None => callable.call0(), }; if let Err(err) = result { - let msg = format_error(py, err, &ctx); + let script_err = extract_script_error(py, &err, &ctx); + let msg = script_err.message.clone(); + self.last_script_error = Some(script_err); self.last_error = Some(msg.clone()); anyhow::bail!("{msg}"); } @@ -821,6 +1103,7 @@ impl ScriptBackend for Pyo3Backend { self.file = PathBuf::from(path); self.class = class.to_string(); self.last_error = None; + self.last_script_error = None; self.exec() } @@ -860,13 +1143,15 @@ impl ScriptBackend for Pyo3Backend { fn position(&self) -> Option<(f64, f64)> { Python::attach(|py| { - self.instance - .as_ref()? - .bind(py) - .getattr("position") - .ok()? - .extract() - .ok() + let inst = self.instance.as_ref()?.bind(py); + let attr = inst.getattr("position").ok()?; + if let Ok(v) = attr.extract::() { + Some((v.x(), v.y())) + } else if let Ok(t) = attr.extract::<(f64, f64)>() { + Some(t) + } else { + None + } }) } @@ -880,13 +1165,15 @@ impl ScriptBackend for Pyo3Backend { fn velocity(&self) -> Option<(f64, f64)> { Python::attach(|py| { - self.instance - .as_ref()? - .bind(py) - .getattr("velocity") - .ok()? - .extract() - .ok() + let inst = self.instance.as_ref()?.bind(py); + let attr = inst.getattr("velocity").ok()?; + if let Ok(v) = attr.extract::() { + Some((v.x(), v.y())) + } else if let Ok(t) = attr.extract::<(f64, f64)>() { + Some(t) + } else { + None + } }) } @@ -944,13 +1231,15 @@ impl ScriptBackend for Pyo3Backend { fn size(&self) -> Option<(f64, f64)> { Python::attach(|py| { - self.instance - .as_ref()? - .bind(py) - .getattr("size") - .ok()? - .extract() - .ok() + let inst = self.instance.as_ref()?.bind(py); + let attr = inst.getattr("size").ok()?; + if let Ok(v) = attr.extract::() { + Some((v.x(), v.y())) + } else if let Ok(t) = attr.extract::<(f64, f64)>() { + Some(t) + } else { + None + } }) } @@ -985,6 +1274,10 @@ impl ScriptBackend for Pyo3Backend { fn last_error(&self) -> Option { self.last_error.clone() } + + fn last_script_error(&self) -> Option { + self.last_script_error.clone() + } } #[cfg(test)] @@ -1231,10 +1524,10 @@ class Sizer(pite.Button): import pite class Sensor(pite.Node2D): - tripped: pite.signal(int, "str") + tripped = pite.signal(int, "str") def _process(self, delta): - if pite.pressed("T-Trip"): + if pite.pressed.space: self.tripped.emit(7, "hi") "#; @@ -1290,8 +1583,9 @@ class Listener(pite.Node2D): listener.load(&listener_file, "Listener").unwrap(); listener.call_ready().unwrap(); + crate::input_clear(); crate::input_begin_frame(); - crate::input_set_key("T-Trip", true); + crate::input_set_key("Space", true); sensor.call_process(0.016).unwrap(); assert_eq!(listener.position(), Some((7.0, 0.0))); Python::attach(|py| { @@ -1324,10 +1618,69 @@ class Listener(pite.Node2D): }); host.disconnect_node("l"); + crate::input_clear(); crate::input_begin_frame(); - crate::input_set_key("T-Trip", true); + crate::input_set_key("Space", true); sensor.call_process(0.016).unwrap(); assert_eq!(listener.position(), Some((7.0, 0.0))); std::fs::remove_dir_all(&dir).ok(); } + + #[test] + fn api_redesign_vec2_input_and_descriptors() { + let _guard = crate::host::HOST_SERIAL.lock().unwrap(); + crate::python::ensure_init(); + + pyo3::Python::attach(|py| { + // 1. Vec2 math and operations + let v = crate::vec2::Vec2::new(10.0, 20.0); + assert_eq!(v.x(), 10.0); + assert_eq!(v.y(), 20.0); + + let v_obj = pyo3::Bound::new(py, v).unwrap(); + let sum: crate::vec2::Vec2 = v_obj + .call_method1("__add__", ((5.0, 5.0),)) + .unwrap() + .extract() + .unwrap(); + assert_eq!(sum.x(), 15.0); + assert_eq!(sum.y(), 25.0); + + // Unpacking x, y = vec + let iter = v_obj.try_iter().unwrap(); + let items: Vec = iter + .map(|item| item.unwrap().extract::().unwrap()) + .collect(); + assert_eq!(items, vec![10.0, 20.0]); + + // 2. Input queries (held, pressed, released) + crate::input_begin_frame(); + crate::input_set_key("Space", true); + + let held_query = pyo3::Bound::new( + py, + PyInputQuery { + kind: InputQueryKind::Held, + }, + ) + .unwrap(); + let is_space_held: bool = held_query.getattr("space").unwrap().extract().unwrap(); + assert!(is_space_held); + + // Calling pite.held("Space") raises TypeError + let err = held_query.call1(("Space",)).unwrap_err(); + assert!(err.is_instance_of::(py)); + assert!(err.to_string().contains("attribute-based accessor")); + + let pressed_query = pyo3::Bound::new( + py, + PyInputQuery { + kind: InputQueryKind::Pressed, + }, + ) + .unwrap(); + let is_space_pressed: bool = pressed_query.getattr("space").unwrap().extract().unwrap(); + assert!(is_space_pressed); + }); + } } diff --git a/pite-script/src/vec2.rs b/pite-script/src/vec2.rs new file mode 100644 index 0000000..832e4e7 --- /dev/null +++ b/pite-script/src/vec2.rs @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use pyo3::prelude::*; +use std::sync::Arc; + +#[pyclass(name = "Vec2", subclass)] +#[derive(Clone)] +pub struct Vec2 { + pub(crate) x: f64, + pub(crate) y: f64, + pub(crate) target: Option<(Arc, String)>, +} + +impl std::fmt::Debug for Vec2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Vec2") + .field("x", &self.x()) + .field("y", &self.y()) + .finish() + } +} + +impl Vec2 { + pub fn x(&self) -> f64 { + if let Some((host, node)) = &self.target { + if let Some((x, _)) = host.get_node_position(node) { + return x; + } + } + self.x + } + + pub fn y(&self) -> f64 { + if let Some((host, node)) = &self.target { + if let Some((_, y)) = host.get_node_position(node) { + return y; + } + } + self.y + } + + pub fn set_target(&mut self, host: Arc, node: String) { + if let Some((x, y)) = host.get_node_position(&node) { + self.x = x; + self.y = y; + } + self.target = Some((host, node)); + } +} + +impl<'py> pyo3::FromPyObject<'_, 'py> for Vec2 { + type Error = PyErr; + fn extract(ob: pyo3::Borrowed<'_, 'py, pyo3::PyAny>) -> Result { + let b: pyo3::PyRef<'py, Vec2> = ob.extract()?; + Ok(b.clone()) + } +} + +#[pymethods] +impl Vec2 { + #[new] + #[pyo3(signature = (x = 0.0, y = 0.0))] + pub fn new(x: f64, y: f64) -> Self { + Self { x, y, target: None } + } + + #[getter] + pub fn get_x(&self) -> f64 { + self.x() + } + + #[setter] + pub fn set_x(&mut self, val: f64) { + self.x = val; + if let Some((host, node)) = &self.target { + let cur_y = self.y(); + self.y = cur_y; + host.set_node_position(node, val, cur_y); + } + } + + #[getter] + pub fn get_y(&self) -> f64 { + self.y() + } + + #[setter] + pub fn set_y(&mut self, val: f64) { + self.y = val; + if let Some((host, node)) = &self.target { + let cur_x = self.x(); + self.x = cur_x; + host.set_node_position(node, cur_x, val); + } + } + + fn __repr__(&self) -> String { + format!("Vec2({}, {})", self.x(), self.y()) + } + + fn __eq__(&self, other: &Bound<'_, PyAny>) -> bool { + let (ox, oy) = if let Ok(other_vec) = other.extract::() { + (other_vec.x(), other_vec.y()) + } else if let Ok((x, y)) = other.extract::<(f64, f64)>() { + (x, y) + } else { + return false; + }; + (self.x() - ox).abs() < 1e-6 && (self.y() - oy).abs() < 1e-6 + } + + fn __add__(&self, other: &Bound<'_, PyAny>) -> PyResult { + let (ox, oy) = if let Ok(other_vec) = other.extract::() { + (other_vec.x(), other_vec.y()) + } else if let Ok(t) = other.extract::<(f64, f64)>() { + t + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "unsupported operand type for +: 'Vec2' and non-vector", + )); + }; + Ok(Vec2 { + x: self.x() + ox, + y: self.y() + oy, + target: None, + }) + } + + fn __sub__(&self, other: &Bound<'_, PyAny>) -> PyResult { + let (ox, oy) = if let Ok(other_vec) = other.extract::() { + (other_vec.x(), other_vec.y()) + } else if let Ok(t) = other.extract::<(f64, f64)>() { + t + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "unsupported operand type for -: 'Vec2' and non-vector", + )); + }; + Ok(Vec2 { + x: self.x() - ox, + y: self.y() - oy, + target: None, + }) + } + + fn __mul__(&self, scalar: f64) -> Vec2 { + Vec2 { + x: self.x() * scalar, + y: self.y() * scalar, + target: None, + } + } + + fn __rmul__(&self, scalar: f64) -> Vec2 { + Vec2 { + x: self.x() * scalar, + y: self.y() * scalar, + target: None, + } + } + + fn __truediv__(&self, scalar: f64) -> PyResult { + if scalar == 0.0 { + return Err(pyo3::exceptions::PyZeroDivisionError::new_err( + "division by zero", + )); + } + Ok(Vec2 { + x: self.x() / scalar, + y: self.y() / scalar, + target: None, + }) + } + + fn __iadd__(&mut self, other: &Bound<'_, PyAny>) -> PyResult<()> { + let (ox, oy) = if let Ok(other_vec) = other.extract::() { + (other_vec.x(), other_vec.y()) + } else if let Ok(t) = other.extract::<(f64, f64)>() { + t + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "unsupported operand type for +=: 'Vec2' and non-vector", + )); + }; + let cur_x = self.x(); + let cur_y = self.y(); + self.set_x(cur_x + ox); + self.set_y(cur_y + oy); + Ok(()) + } + + fn __isub__(&mut self, other: &Bound<'_, PyAny>) -> PyResult<()> { + let (ox, oy) = if let Ok(other_vec) = other.extract::() { + (other_vec.x(), other_vec.y()) + } else if let Ok(t) = other.extract::<(f64, f64)>() { + t + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "unsupported operand type for -=: 'Vec2' and non-vector", + )); + }; + let cur_x = self.x(); + let cur_y = self.y(); + self.set_x(cur_x - ox); + self.set_y(cur_y - oy); + Ok(()) + } + + fn __imul__(&mut self, scalar: f64) { + let cur_x = self.x(); + let cur_y = self.y(); + self.set_x(cur_x * scalar); + self.set_y(cur_y * scalar); + } + + fn __itruediv__(&mut self, scalar: f64) -> PyResult<()> { + if scalar == 0.0 { + return Err(pyo3::exceptions::PyZeroDivisionError::new_err( + "division by zero", + )); + } + let cur_x = self.x(); + let cur_y = self.y(); + self.set_x(cur_x / scalar); + self.set_y(cur_y / scalar); + Ok(()) + } + + fn __len__(&self) -> usize { + 2 + } + + fn __getitem__(&self, idx: isize) -> PyResult { + match idx { + 0 | -2 => Ok(self.x()), + 1 | -1 => Ok(self.y()), + _ => Err(pyo3::exceptions::PyIndexError::new_err( + "Vec2 index out of range", + )), + } + } + + fn __setitem__(&mut self, idx: isize, val: f64) -> PyResult<()> { + match idx { + 0 | -2 => { + self.set_x(val); + Ok(()) + } + 1 | -1 => { + self.set_y(val); + Ok(()) + } + _ => Err(pyo3::exceptions::PyIndexError::new_err( + "Vec2 index out of range", + )), + } + } + + fn __iter__(slf: Bound<'_, Self>) -> PyResult> { + let x = slf.borrow().x(); + let y = slf.borrow().y(); + let tuple = pyo3::types::PyTuple::new(slf.py(), [x, y])?; + tuple.try_iter() + } + + fn length(&self) -> f64 { + let x = self.x(); + let y = self.y(); + (x * x + y * y).sqrt() + } + + fn normalized(&self) -> Vec2 { + let len = self.length(); + if len > 1e-6 { + let x = self.x(); + let y = self.y(); + Vec2 { + x: x / len, + y: y / len, + target: None, + } + } else { + Vec2 { + x: 0.0, + y: 0.0, + target: None, + } + } + } + + fn to_tuple(&self) -> (f64, f64) { + (self.x(), self.y()) + } +} + +impl From<(f64, f64)> for Vec2 { + fn from(t: (f64, f64)) -> Self { + Self { + x: t.0, + y: t.1, + target: None, + } + } +} diff --git a/python/pite/pite.pyi b/python/pite/pite.pyi index 435b4a6..96ce4bd 100644 --- a/python/pite/pite.pyi +++ b/python/pite/pite.pyi @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MIT OR Apache-2.0 from __future__ import annotations -from typing import Any +from typing import Any, Iterator, Sequence class Signal: def emit(self, *values: object) -> None: ... @@ -9,31 +9,55 @@ class Signal: def signal(*types: object) -> Signal: ... +class Vec2: + x: float + y: float + def __init__(self, x: float = 0.0, y: float = 0.0) -> None: ... + def __add__(self, other: Vec2 | tuple[float, float]) -> Vec2: ... + def __sub__(self, other: Vec2 | tuple[float, float]) -> Vec2: ... + def __mul__(self, scalar: float) -> Vec2: ... + def __rmul__(self, scalar: float) -> Vec2: ... + def __truediv__(self, scalar: float) -> Vec2: ... + def __iadd__(self, other: Vec2 | tuple[float, float]) -> Vec2: ... + def __isub__(self, other: Vec2 | tuple[float, float]) -> Vec2: ... + def __imul__(self, scalar: float) -> Vec2: ... + def __itruediv__(self, scalar: float) -> Vec2: ... + def __len__(self) -> int: ... + def __getitem__(self, idx: int) -> float: ... + def __setitem__(self, idx: int, val: float) -> None: ... + def __iter__(self) -> Iterator[float]: ... + def length(self) -> float: ... + def normalized(self) -> Vec2: ... + def to_tuple(self) -> tuple[float, float]: ... + +class InputQuery: + def __getattr__(self, name: str) -> bool: ... + +held: InputQuery +pressed: InputQuery +released: InputQuery + +def mouse() -> Vec2: ... +def frames(atlas: str) -> list[str]: ... +def play(path: str, volume: float = ...) -> int: ... +def stop(id: int) -> None: ... +def set_volume(id: int, volume: float) -> None: ... +def is_playing(id: int) -> bool: ... +def child(name: str) -> Any: ... +def node(path: str) -> Any: ... +def raycast(from_pos: tuple[float, float] | Vec2, to_pos: tuple[float, float] | Vec2, mask: int = ...) -> dict[str, Any] | None: ... + class Node: node_path: str def get_node(self, path: str) -> Node: ... def _ready(self) -> None: ... def _process(self, delta: float) -> None: ... - # Anything undeclared here is dynamically typed instead of a checker - # error: cross-node members, custom signals, script methods. A miss - # still raises at runtime; declared members keep completing and checking. - # Both directions: reads go through `__getattr__`, writes (like driving - # a label's `text`) through `__setattr__`. + def _physics_process(self, delta: float) -> None: ... def __getattr__(self, name: str) -> Any: ... def __setattr__(self, name: str, value: Any) -> None: ... -def held(key: str) -> bool: ... -def pressed(key: str) -> bool: ... -def released(key: str) -> bool: ... -def mouse() -> tuple[float, float]: ... -def frames(atlas: str) -> list[str]: ... -def play(path: str, volume: float = ...) -> int: ... -def stop(id: int) -> None: ... -def set_volume(id: int, volume: float) -> None: ... -def is_playing(id: int) -> bool: ... - class Node2D(Node): - position: tuple[float, float] + position: Vec2 class Sprite2D(Node2D): texture: str @@ -51,5 +75,45 @@ class Label(Node): class Button(Node2D): text: str - size: tuple[float, float] + size: Vec2 pressed: Signal + +class StaticBody2D(Node2D): + collision_layer: int + collision_mask: int + +class CharacterBody2D(Node2D): + velocity: Vec2 + up_direction: Vec2 + collision_layer: int + collision_mask: int + def is_on_floor(self) -> bool: ... + def is_on_wall(self) -> bool: ... + def is_on_ceiling(self) -> bool: ... + def move_and_slide(self) -> bool: ... + +class RigidBody2D(Node2D): + mass: float + gravity_scale: float + restitution: float + friction: float + lock_rotation: bool + collision_layer: int + collision_mask: int + def apply_impulse(self, impulse: tuple[float, float] | Vec2) -> None: ... + def apply_force(self, force: tuple[float, float] | Vec2) -> None: ... + +class Area2D(Node2D): + collision_layer: int + collision_mask: int + body_entered: Signal + body_exited: Signal + area_entered: Signal + area_exited: Signal + +class CollisionShape2D(Node2D): + shape: str + size: tuple[float, float] + radius: float + height: float + disabled: bool