From b953ea57543175c762b83d15667f5c5a579a14d1 Mon Sep 17 00:00:00 2001 From: Mahan Khalili Date: Mon, 2 Nov 2026 11:49:39 +0000 Subject: [PATCH] feat: add 2D physics subsystem with rapier2d - add crate pite-physics implementing 2D physics via pure-Rust rapier2d - support StaticBody2D, CharacterBody2D, RigidBody2D, Area2D, CollisionShape2D - kinematic character controller with move_and_slide(), floor/wall/ceiling detection - dynamic rigid body simulation with forces, impulses, gravity scaling, mass - Area2D trigger zones with body_entered/exited and area_entered/exited signals - pite.raycast spatial queries returning intersection point, normal, distance, collider - fixed 60 Hz physics loop in GameSession with _physics_process dispatch - editor viewport wireframe overlays for box, circle, capsule shapes and inspector support - comprehensive unit and integration test coverage --- AGENTS.md | 4 +- CHANGELOG.md | 9 +- CONTRIBUTING.md | 2 +- Cargo.lock | 282 ++++++++++- Cargo.toml | 3 + README.md | 1 + docs/scene-format.md | 2 +- docs/scripting-api.md | 84 ++++ pite-core/src/registry.rs | 16 +- pite-editor/src/app/inspector.rs | 59 ++- pite-editor/src/app/viewport.rs | 110 ++++ pite-editor/src/ops.rs | 50 +- pite-physics/Cargo.toml | 15 + pite-physics/src/body.rs | 35 ++ pite-physics/src/character.rs | 23 + pite-physics/src/lib.rs | 15 + pite-physics/src/query.rs | 11 + pite-physics/src/shape.rs | 31 ++ pite-physics/src/world.rs | 802 ++++++++++++++++++++++++++++++ pite-runtime/Cargo.toml | 1 + pite-runtime/src/lib.rs | 513 ++++++++++++++++++- pite-script/Cargo.toml | 1 + pite-script/src/host.rs | 36 ++ pite-script/src/lib.rs | 18 +- pite-script/src/physics_bridge.rs | 19 + pite-script/src/python.rs | 354 +++++++++++++ 26 files changed, 2483 insertions(+), 13 deletions(-) create mode 100644 pite-physics/Cargo.toml create mode 100644 pite-physics/src/body.rs create mode 100644 pite-physics/src/character.rs create mode 100644 pite-physics/src/lib.rs create mode 100644 pite-physics/src/query.rs create mode 100644 pite-physics/src/shape.rs create mode 100644 pite-physics/src/world.rs create mode 100644 pite-script/src/physics_bridge.rs diff --git a/AGENTS.md b/AGENTS.md index a8f067f..aeaa80d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ cargo run --locked -p pite-cli -- check ## Architecture (crate → role) -`pite-cli` (the `pite` binary) → `pite-runtime` (window, main loop, hot reload) → `pite-core` (node tree, handles, lifecycle) + `pite-scene` (TOML load/validate/cache) + `pite-script` (embedded `pite` module) + `pite-render` (`Renderer2D` wgpu seam) + `pite-assets` (`Importer` trait, uid registry) + `pite-audio` (WAV/OGG/MP3/FLAC, silent-simulation fallback) + `pite-project` (`pite.toml`, `res://`, templates) + `pite-export` (binary + resolved content) + `pite-player` (lean run-only binary — the runtime without the editor) + `pite-editor` (egui panels, LSP client). Docs in `docs/` are authoritative per topic: `scene-format`, `scripting-api`, `assets`, `editor`, `manifest`, `export`, `lsp`. +`pite-cli` (the `pite` binary) → `pite-runtime` (window, main loop, hot reload) → `pite-core` (node tree, handles, lifecycle) + `pite-scene` (TOML load/validate/cache) + `pite-script` (embedded `pite` module) + `pite-physics` (pure-Rust rapier2d 2D physics subsystem) + `pite-render` (`Renderer2D` wgpu seam) + `pite-assets` (`Importer` trait, uid registry) + `pite-audio` (WAV/OGG/MP3/FLAC, silent-simulation fallback) + `pite-project` (`pite.toml`, `res://`, templates) + `pite-export` (binary + resolved content) + `pite-player` (lean run-only binary — the runtime without the editor) + `pite-editor` (egui panels, LSP client). Docs in `docs/` are authoritative per topic: `scene-format`, `scripting-api`, `assets`, `editor`, `manifest`, `export`, `lsp`. ## Gotchas agents miss @@ -36,4 +36,4 @@ cargo run --locked -p pite-cli -- check - Conventional commits (`feat:`/`fix:`/`docs:`/`chore:`), one focused change per commit/PR; never commit secrets, `target/`, or local paths. - New Rust files start with `// SPDX-License-Identifier: MIT OR Apache-2.0` (Python: `# …`); `examples/` game scripts stay header-free. -- Pinned values bump in multi-file lockstep (12 internal path-dep pins, scene `FORMAT_VERSION`/`CACHE_VERSION`, the pyright pin in `lsp.rs` + docs + CI, `python_bundle.rs` pins) — checklist in `CONTRIBUTING.md`. +- Pinned values bump in multi-file lockstep (13 internal path-dep pins, scene `FORMAT_VERSION`/`CACHE_VERSION`, the pyright pin in `lsp.rs` + docs + CI, `python_bundle.rs` pins) — checklist in `CONTRIBUTING.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index edc125f..b377ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,14 @@ - Editor code pane opens config text files (`toml`/`cfg`/`ini`/`json`/`yaml`/`yml`) for plain editing; LSP completion, hover, and diagnostics stay Python-only. - Editor code pane highlights Python and config text (keywords, strings, numbers, comments, `[...]` headers) in palette colors. - Editor menus reorganized: `Scene` (new/open/recent/save/save-as/quit, native dialogs, `Ctrl+N/O/Shift+S/Q` shortcuts), `Project`, and `Help` (repo/issues links, about, clipboard debug info); the `Run` menu is gone, transport buttons remain. -- MacOS support (both dor editor and games) with cross-compile added. +- MacOS support (both dor editor and games) with cross-compile added. +- 2D physics subsystem with Rapier2D: pure-Rust 2D physics powered by `rapier2d 0.23` in a new workspace crate `pite-physics`, running in a fixed 60 Hz simulation loop (`dt = 1/60`) and driving `_physics_process(delta)` in Python scripts. + - New node types: `StaticBody2D` (immobile bodies), `CharacterBody2D` (kinematic controller with `move_and_slide()`), `RigidBody2D` (dynamic forces and gravity), `Area2D` (trigger zones), and `CollisionShape2D` (`box`, `circle`, and `capsule` geometric colliders). + - Kinematic character controller: `move_and_slide()` computes sliding obstacle collision response against solid geometry while ignoring trigger sensors, automatically updating velocity and contact flags (`is_on_floor()`, `is_on_wall()`, `is_on_ceiling()`) relative to `up_direction`. + - Dynamic rigid bodies: `apply_impulse((x, y))` and `apply_force((x, y))` for simulation, with configurable `mass`, `gravity_scale`, and `lock_rotation`. + - Area trigger signals: `Area2D` emits engine-owned `body_entered`, `body_exited`, `area_entered`, and `area_exited` signals with node ID arguments. + - Spatial raycasts: `pite.raycast(from, to, mask=1)` performs line intersection queries against solid obstacles, returning intersection point, surface normal, distance, collider node, and body node. + - Editor visualization and inspection: `pite-editor` paints semi-transparent collision shape wireframe overlays (`box`, `circle`, `capsule`) directly in the viewport in design mode, with shape dropdowns and physics property controls in the Inspector. ## v0.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b513a0..76465ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ cargo run -p pite-cli -- check The `v*` tag flow automates almost nothing — bump these by hand, every release: -- `Cargo.toml`: `version` + all 12 internal `pite-*` path-dep pins (nothing enforces lockstep; `cargo publish` breaks otherwise). +- `Cargo.toml`: `version` + all 13 internal `pite-*` path-dep pins (nothing enforces lockstep; `cargo publish` breaks otherwise). - `CHANGELOG.md`: move notes from `## Unreleased` to the release heading. - MSRV proof re-runs automatically (weekly + manual); if it goes red, fix code or raise `rust-version`. - Only when the toolchain changes: `python_bundle.rs` (`PINNED_PYTHON`, `PINNED_BUILD`, filenames, checksums). diff --git a/Cargo.lock b/Cargo.lock index 2f71bb9..f498f08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,6 +272,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -515,9 +524,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + [[package]] name = "bit-vec" version = "0.9.1" @@ -900,6 +915,56 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1170,6 +1235,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "endi" version = "1.1.1" @@ -2115,6 +2189,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2219,6 +2303,33 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "nalgebra" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2301,6 +2412,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2321,6 +2442,26 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2831,6 +2972,37 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" +[[package]] +name = "parry2d" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd0ac62e76d6143e008a4988d795550d47aba40a50929d6b7b85e4230784403" +dependencies = [ + "approx", + "arrayvec", + "bitflags 2.13.2", + "downcast-rs", + "either", + "ena", + "log", + "nalgebra", + "num-derive", + "num-traits", + "ordered-float", + "rustc-hash 2.1.3", + "simba", + "slab", + "smallvec", + "spade", + "thiserror 1.0.69", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "peniko" version = "0.6.1" @@ -3025,6 +3197,17 @@ dependencies = [ "zip", ] +[[package]] +name = "pite-physics" +version = "0.2.0" +dependencies = [ + "anyhow", + "pite-core", + "rapier2d", + "serde", + "tracing", +] + [[package]] name = "pite-player" version = "0.2.0" @@ -3072,6 +3255,7 @@ dependencies = [ "notify", "pite-audio", "pite-core", + "pite-physics", "pite-project", "pite-render", "pite-scene", @@ -3100,6 +3284,7 @@ dependencies = [ "pite-assets", "pite-audio", "pite-core", + "pite-physics", "pyo3", ] @@ -3226,6 +3411,19 @@ name = "profiling" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] [[package]] name = "pxfm" @@ -3332,6 +3530,30 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rapier2d" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd9ef3dbd7ab80f038f37a6952b00146a771ddde72244b0745ac43c2a4ad30c1" +dependencies = [ + "approx", + "arrayvec", + "bit-vec 0.7.0", + "bitflags 2.13.2", + "crossbeam", + "downcast-rs", + "log", + "nalgebra", + "num-derive", + "num-traits", + "ordered-float", + "parry2d", + "profiling", + "rustc-hash 2.1.3", + "simba", + "thiserror 1.0.69", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3350,6 +3572,12 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "read-fonts" version = "0.41.0" @@ -3444,6 +3672,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + [[package]] name = "roxmltree" version = "0.21.2" @@ -3506,6 +3740,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3657,6 +3900,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3788,6 +4044,18 @@ dependencies = [ "serde", ] +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -4685,7 +4953,7 @@ checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", "bit-set", - "bit-vec", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -4830,6 +5098,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index b89b4db..1446bd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "pite-script", "pite-audio", "pite-export", + "pite-physics", "pite-player", ] exclude = ["examples", "python"] @@ -38,6 +39,7 @@ pite-scene = { path = "pite-scene", version = "0.2.0" } pite-script = { path = "pite-script", version = "0.2.0" } pite-audio = { path = "pite-audio", version = "0.2.0" } pite-export = { path = "pite-export", version = "0.2.0" } +pite-physics = { path = "pite-physics", version = "0.2.0" } pite-player = { path = "pite-player", version = "0.2.0" } # external @@ -49,6 +51,7 @@ crc32fast = "1" hound = "3.5" image = { version = "0.25", default-features = false, features = ["png"] } pollster = "1.0" +rapier2d = "0.23" raw-window-handle = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index b8aaeba..dbf49a2 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ No audio device? Silent simulation. Change a script? Hot reload. Type a method n | `pite-player` | Lean export player: runs a scene, no editor | | `pite-runtime` | Window, main loop, script driving, hot reload | | `pite-core` | Node tree, handles, props, lifecycle dispatch | +| `pite-physics` | Pure-Rust 2D physics subsystem (`rapier2d`): rigid bodies, character controller, triggers, raycasts | | `pite-render` | `Renderer2D` trait (wgpu seam) | | `pite-script` | Embedded Python (`pite` module) | | `pite-scene` | TOML scenes, instantiation, validation, binary cache | diff --git a/docs/scene-format.md b/docs/scene-format.md index f61a33a..25aafab 100644 --- a/docs/scene-format.md +++ b/docs/scene-format.md @@ -30,7 +30,7 @@ class = "Player" - `format_version` starts at 1. An unsupported version fails loudly at parse — never silently reinterpreted. - `root` must name a node in the list. A node whose `parent` names nothing is tolerated at load and reported by `pite check` as an error. -- `type` must be registered (`Node`, `Node2D`, `Sprite2D`, `Camera2D`, `Timer`, `Label`, `Button`); anything else is a `check` error. +- `type` must be registered (`Node`, `Node2D`, `Sprite2D`, `Camera2D`, `Timer`, `Label`, `Button`, `StaticBody2D`, `CharacterBody2D`, `RigidBody2D`, `Area2D`, `CollisionShape2D`); anything else is a `check` error. - `props` is open: scalars map to typed values, a 2-number array maps to `Vec2`, anything else is kept as text. **Unknown props warn and survive a save** (forward compat). - `script` is optional. A missing script file is a `check` warning and loads as a placeholder node — never a load failure. - All paths are project-relative (`res://…`); absolute paths are forbidden. diff --git a/docs/scripting-api.md b/docs/scripting-api.md index 0fb8b9e..6532189 100644 --- a/docs/scripting-api.md +++ b/docs/scripting-api.md @@ -85,6 +85,90 @@ pite.stop(voice) - `play(path, volume=1.0)` decodes the file and returns a voice id; `.wav` (8/16/24/32-bit int and float), `.ogg` (Vorbis), `.mp3`, and `.flac` are supported. Output goes to the OS default audio device; on machines with no audio device playback is simulated (a warning is logged; same API, no sound). A missing or undecodable file raises loudly — the game keeps running. `stop(id)` on an unknown id is a silent no-op, as is `set_volume` (clamped to `0.0`–`1.0`); `is_playing` on unknown ids is `False`. - `pite check` validates every `play("…")` literal in project scripts: the file must exist and decode. +## Physics + +Pite includes a pure-Rust 2D physics subsystem built on `rapier2d`. Simulation runs in a fixed 60 Hz loop (`dt = 1/60`), executing `_physics_process(delta)` across active scripts. + +### Nodes + +- `StaticBody2D`: Non-moving collision body (floors, walls). +- `CharacterBody2D`: Kinematic character controller with `move_and_slide()`. +- `RigidBody2D`: Dynamic simulation body affected by gravity and forces. +- `Area2D`: Trigger zones with entry/exit overlap signals. +- `CollisionShape2D`: Child of any physics body or area defining geometric collider (`shape`: `"box"`, `"circle"`, `"capsule"`). + +### Character Movement + +```python +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 + else: + vx = 0.0 + + # Gravity + vy += 980.0 * delta + + if pite.pressed("Space") and self.is_on_floor(): + vy = -400.0 + + self.velocity = (vx, vy) + self.move_and_slide() +``` + +- `move_and_slide() -> bool`: Advances shape along `velocity * dt` using obstacle sliding collision resolution (ignoring sensor areas). Updates `velocity`, `position`, and collision contact flags. +- `is_on_floor() -> bool`, `is_on_wall() -> bool`, `is_on_ceiling() -> bool`: Collision contact queries relative to `up_direction` (default `(0.0, -1.0)`). + +### RigidBody2D Dynamics + +```python +class Ball(pite.RigidBody2D): + def _ready(self): + self.apply_impulse((0.0, -200.0)) +``` + +- `apply_impulse((x, y))`: Instantaneous momentum change scaled by mass. +- `apply_force((x, y))`: Continuous force application over current step. +- Configurable properties: `mass: float`, `gravity_scale: float`, `lock_rotation: bool`. + +### Area2D Triggers + +```python +class Coin(pite.Area2D): + def _ready(self): + self.body_entered.connect(self.on_body) + + def on_body(self, body_id: str): + print(f"Collected by {body_id}") +``` + +Built-in signals: +- `body_entered(body_id: str)` +- `body_exited(body_id: str)` +- `area_entered(area_id: str)` +- `area_exited(area_id: str)` + +### Spatial Queries (Raycasts) + +```python +hit = pite.raycast((x, y), (target_x, target_y), mask=1) +if hit is not None: + print(f"Hit {hit['body']} at {hit['point']} with normal {hit['normal']}") +``` + +Returns `None` if no solid obstacle intersects, or a dict containing: +- `point`: `(float, float)` world coordinate of intersection. +- `normal`: `(float, float)` surface normal at intersection point. +- `distance`: `float` distance from ray origin. +- `collider`: `str` node ID of `CollisionShape2D`. +- `body`: `str` node ID of parent physics body. + ## Errors and reload - A script exception is reported with node path, file, and line; the node keeps its last good state and the game keeps running. Repeat offenders are parked after the first error (see the console). diff --git a/pite-core/src/registry.rs b/pite-core/src/registry.rs index 0269117..e3c076c 100644 --- a/pite-core/src/registry.rs +++ b/pite-core/src/registry.rs @@ -11,7 +11,8 @@ pub type NodeFactory = Box Node + Send + Sync>; /// never a core match-statement edit. /// /// Registered set: `Node`, `Node2D`, `Sprite2D`, `Camera2D`, `Timer`, -/// `Label`, `Button`. +/// `Label`, `Button`, `StaticBody2D`, `CharacterBody2D`, `RigidBody2D`, +/// `Area2D`, `CollisionShape2D`. pub struct NodeTypeRegistry { factories: HashMap, } @@ -20,7 +21,18 @@ impl NodeTypeRegistry { pub fn new() -> Self { let mut factories: HashMap = HashMap::new(); for type_name in [ - "Node", "Node2D", "Sprite2D", "Camera2D", "Timer", "Label", "Button", + "Node", + "Node2D", + "Sprite2D", + "Camera2D", + "Timer", + "Label", + "Button", + "StaticBody2D", + "CharacterBody2D", + "RigidBody2D", + "Area2D", + "CollisionShape2D", ] { factories.insert( type_name.to_string(), diff --git a/pite-editor/src/app/inspector.rs b/pite-editor/src/app/inspector.rs index 76d7263..3534ea9 100644 --- a/pite-editor/src/app/inspector.rs +++ b/pite-editor/src/app/inspector.rs @@ -57,7 +57,25 @@ impl EditorApp { let mut v = s; ui.horizontal(|ui| { ui.label(&key); - if ui.text_edit_singleline(&mut v).changed() { + if key == "shape" && node.type_name == "CollisionShape2D" { + let mut selected = v.clone(); + egui::ComboBox::from_id_salt("shape_select") + .selected_text(&selected) + .show_ui(ui, |ui| { + for opt in ["box", "circle", "capsule"] { + if ui + .selectable_value(&mut selected, opt.to_string(), opt) + .changed() + { + self.set_prop( + &node_id, + &key, + PropValue::Str(selected.clone()), + ); + } + } + }); + } else if ui.text_edit_singleline(&mut v).changed() { self.set_prop(&node_id, &key, PropValue::Str(v)); } }); @@ -84,6 +102,45 @@ impl EditorApp { } } } + let known_props: &[(&str, PropValue)] = match node.type_name.as_str() { + "StaticBody2D" | "Area2D" => &[("position", PropValue::Vec2(0.0, 0.0))], + "CharacterBody2D" => &[ + ("position", PropValue::Vec2(0.0, 0.0)), + ("velocity", PropValue::Vec2(0.0, 0.0)), + ], + "RigidBody2D" => &[ + ("position", PropValue::Vec2(0.0, 0.0)), + ("mass", PropValue::Num(1.0)), + ("gravity_scale", PropValue::Num(1.0)), + ("lock_rotation", PropValue::Bool(false)), + ], + "CollisionShape2D" => &[ + ("position", PropValue::Vec2(0.0, 0.0)), + ("shape", PropValue::Str("box".to_string())), + ("size", PropValue::Vec2(32.0, 32.0)), + ("radius", PropValue::Num(16.0)), + ("height", PropValue::Num(32.0)), + ("disabled", PropValue::Bool(false)), + ], + _ => &[], + }; + let missing: Vec<(&str, PropValue)> = known_props + .iter() + .filter(|(k, _)| !node.props.0.contains_key(*k)) + .cloned() + .collect(); + if !missing.is_empty() { + ui.add_space(4.0); + ui.label("Add property:"); + ui.horizontal_wrapped(|ui| { + for (k, default_val) in missing { + if ui.button(format!("+ {k}")).clicked() { + self.set_prop(&node_id, k, default_val); + } + } + }); + } + match &node.script { Some(script) => { ui.label(format!("script: {} ({})", script.path, script.class_name)); diff --git a/pite-editor/src/app/viewport.rs b/pite-editor/src/app/viewport.rs index ee66c54..d986da9 100644 --- a/pite-editor/src/app/viewport.rs +++ b/pite-editor/src/app/viewport.rs @@ -266,5 +266,115 @@ impl EditorApp { ); } } + if !self.playing { + draw_collision_overlays(&self.session, &painter, view_rect, (view_w, view_h)); + } + } +} + +struct CollisionGizmo { + shape_type: String, + center: (f64, f64), + size: (f64, f64), + radius: f64, + height: f64, + disabled: bool, +} + +fn draw_collision_overlays( + session: &GameSession, + painter: &egui::Painter, + view_rect: egui::Rect, + view_size: (u32, u32), +) { + let gizmos: Vec = session.host().with_tree(|tree| { + tree.iter() + .filter(|n| n.type_name == "CollisionShape2D") + .map(|n| { + let shape_type = match n.props.get("shape") { + Some(PropValue::Str(s)) => s.clone(), + _ => "box".to_string(), + }; + let size = match n.props.get("size") { + Some(PropValue::Vec2(w, h)) => (*w, *h), + _ => (32.0, 32.0), + }; + let radius = match n.props.get("radius") { + Some(PropValue::Num(r)) => *r, + _ => 16.0, + }; + let height = match n.props.get("height") { + Some(PropValue::Num(h)) => *h, + _ => 32.0, + }; + let disabled = match n.props.get("disabled") { + Some(PropValue::Bool(b)) => *b, + _ => false, + }; + let center = pite_core::global_position(tree, &n.id); + CollisionGizmo { + shape_type, + center, + size, + radius, + height, + disabled, + } + }) + .collect() + }); + + if gizmos.is_empty() { + return; + } + + let (cam, _) = session.camera_view(); + let zoom = session.effective_zoom(); + let (view_w, view_h) = view_size; + let scale = (view_rect.width() / view_w as f32) * zoom as f32; + + for gizmo in gizmos { + let (sx, sy) = pite_render::world_to_screen(gizmo.center, cam, zoom, (view_w, view_h)); + let vp_x = view_rect.min.x + (sx / view_w as f32) * view_rect.width(); + let vp_y = view_rect.min.y + (sy / view_h as f32) * view_rect.height(); + let center_pos = egui::pos2(vp_x, vp_y); + + let (fill_col, stroke_col) = if gizmo.disabled { + ( + egui::Color32::from_rgba_unmultiplied(160, 160, 160, 35), + egui::Color32::from_rgb(160, 160, 160), + ) + } else { + ( + egui::Color32::from_rgba_unmultiplied(72, 220, 140, 50), + egui::Color32::from_rgb(72, 220, 140), + ) + }; + let stroke = egui::Stroke::new(1.5, stroke_col); + + match gizmo.shape_type.as_str() { + "circle" => { + let r = (gizmo.radius as f32 * scale).max(1.0); + painter.circle_filled(center_pos, r, fill_col); + painter.circle_stroke(center_pos, r, stroke); + } + "capsule" => { + let r = (gizmo.radius as f32 * scale).max(1.0); + let half_h = (gizmo.height as f32 * 0.5 * scale).max(0.0); + let rect = egui::Rect::from_center_size( + center_pos, + egui::vec2(r * 2.0, half_h * 2.0 + r * 2.0), + ); + painter.rect_filled(rect, r, fill_col); + painter.rect_stroke(rect, r, stroke, egui::StrokeKind::Middle); + } + _ => { + let w = (gizmo.size.0 as f32 * scale).max(2.0); + let h = (gizmo.size.1 as f32 * scale).max(2.0); + let rect = egui::Rect::from_center_size(center_pos, egui::vec2(w, h)); + painter.rect_filled(rect, 0.0, fill_col); + painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Middle); + } + } } } diff --git a/pite-editor/src/ops.rs b/pite-editor/src/ops.rs index 9e0c587..d31c389 100644 --- a/pite-editor/src/ops.rs +++ b/pite-editor/src/ops.rs @@ -15,7 +15,18 @@ use pite_scene::{SceneDoc, SceneInstance, SceneNode, SceneScript, FORMAT_VERSION /// the registry exposes no listing API and the editor must not touch core. pub fn registered_types() -> Vec { [ - "Node", "Node2D", "Sprite2D", "Camera2D", "Timer", "Label", "Button", + "Node", + "Node2D", + "Sprite2D", + "Camera2D", + "Timer", + "Label", + "Button", + "StaticBody2D", + "CharacterBody2D", + "RigidBody2D", + "Area2D", + "CollisionShape2D", ] .iter() .map(ToString::to_string) @@ -62,6 +73,43 @@ pub fn add_node( .create(id.clone(), type_name) .map_err(|e| anyhow::anyhow!("{e}"))?; node.name = name.to_string(); + + match type_name { + "StaticBody2D" | "Area2D" => { + node.props + .insert("position".to_string(), PropValue::Vec2(0.0, 0.0)); + } + "CharacterBody2D" => { + node.props + .insert("position".to_string(), PropValue::Vec2(0.0, 0.0)); + node.props + .insert("velocity".to_string(), PropValue::Vec2(0.0, 0.0)); + } + "RigidBody2D" => { + node.props + .insert("position".to_string(), PropValue::Vec2(0.0, 0.0)); + node.props.insert("mass".to_string(), PropValue::Num(1.0)); + node.props + .insert("gravity_scale".to_string(), PropValue::Num(1.0)); + node.props + .insert("lock_rotation".to_string(), PropValue::Bool(false)); + } + "CollisionShape2D" => { + node.props + .insert("position".to_string(), PropValue::Vec2(0.0, 0.0)); + node.props + .insert("shape".to_string(), PropValue::Str("box".to_string())); + node.props + .insert("size".to_string(), PropValue::Vec2(32.0, 32.0)); + node.props + .insert("radius".to_string(), PropValue::Num(16.0)); + node.props + .insert("height".to_string(), PropValue::Num(32.0)); + node.props + .insert("disabled".to_string(), PropValue::Bool(false)); + } + _ => {} + } let parent = match parent { Some(p) => { if !tree.contains(&p) { diff --git a/pite-physics/Cargo.toml b/pite-physics/Cargo.toml new file mode 100644 index 0000000..8ab7860 --- /dev/null +++ b/pite-physics/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pite-physics" +description = "2D physics simulation, kinematic character controller, and collision queries for Pite." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +rapier2d.workspace = true +serde.workspace = true +tracing.workspace = true +pite-core.workspace = true diff --git a/pite-physics/src/body.rs b/pite-physics/src/body.rs new file mode 100644 index 0000000..fd00eec --- /dev/null +++ b/pite-physics/src/body.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BodyKind { + Static, + Character, + Rigid, + Area, +} + +#[derive(Debug, Clone)] +pub struct RigidBodyConfig { + pub mass: f32, + pub gravity_scale: f32, + pub restitution: f32, + pub friction: f32, + pub linear_damping: f32, + pub angular_damping: f32, + pub lock_rotation: bool, +} + +impl Default for RigidBodyConfig { + fn default() -> Self { + Self { + mass: 1.0, + gravity_scale: 1.0, + restitution: 0.0, + friction: 0.5, + linear_damping: 0.0, + angular_damping: 0.0, + lock_rotation: false, + } + } +} diff --git a/pite-physics/src/character.rs b/pite-physics/src/character.rs new file mode 100644 index 0000000..72a4f23 --- /dev/null +++ b/pite-physics/src/character.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct CharacterMoveResult { + pub movement: (f32, f32), + pub velocity: (f32, f32), + pub is_on_floor: bool, + pub is_on_wall: bool, + pub is_on_ceiling: bool, +} + +impl Default for CharacterMoveResult { + fn default() -> Self { + Self { + movement: (0.0, 0.0), + velocity: (0.0, 0.0), + is_on_floor: false, + is_on_wall: false, + is_on_ceiling: false, + } + } +} diff --git a/pite-physics/src/lib.rs b/pite-physics/src/lib.rs new file mode 100644 index 0000000..f9224ac --- /dev/null +++ b/pite-physics/src/lib.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +//! `pite-physics`: 2D physics simulation, kinematic character controller, +//! collision shapes, and queries powered by pure-Rust `rapier2d`. + +pub mod body; +pub mod character; +pub mod query; +pub mod shape; +pub mod world; + +pub use body::BodyKind; +pub use character::CharacterMoveResult; +pub use query::RaycastHit; +pub use shape::ShapeConfig; +pub use world::{PhysicsEvent, PhysicsWorld}; diff --git a/pite-physics/src/query.rs b/pite-physics/src/query.rs new file mode 100644 index 0000000..6821087 --- /dev/null +++ b/pite-physics/src/query.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use pite_core::NodeId; + +#[derive(Debug, Clone, PartialEq)] +pub struct RaycastHit { + pub point: (f32, f32), + pub normal: (f32, f32), + pub distance: f32, + pub collider_node: NodeId, + pub body_node: Option, +} diff --git a/pite-physics/src/shape.rs b/pite-physics/src/shape.rs new file mode 100644 index 0000000..11afd08 --- /dev/null +++ b/pite-physics/src/shape.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use rapier2d::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ShapeConfig { + Box { width: f32, height: f32 }, + Circle { radius: f32 }, + Capsule { radius: f32, height: f32 }, +} + +impl ShapeConfig { + pub fn to_shared_shape(&self) -> SharedShape { + match self { + Self::Box { width, height } => { + let hw = (width.abs() * 0.5).max(0.001); + let hh = (height.abs() * 0.5).max(0.001); + SharedShape::cuboid(hw, hh) + } + Self::Circle { radius } => { + let r = radius.abs().max(0.001); + SharedShape::ball(r) + } + Self::Capsule { radius, height } => { + let r = radius.abs().max(0.001); + let hh = (height.abs() * 0.5).max(0.001); + SharedShape::capsule_y(hh, r) + } + } + } +} diff --git a/pite-physics/src/world.rs b/pite-physics/src/world.rs new file mode 100644 index 0000000..ef1a5c9 --- /dev/null +++ b/pite-physics/src/world.rs @@ -0,0 +1,802 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; + +use pite_core::NodeId; +use rapier2d::control::{CharacterLength, KinematicCharacterController}; +use rapier2d::dynamics::CCDSolver; +use rapier2d::prelude::*; + +use crate::character::CharacterMoveResult; +use crate::query::RaycastHit; +use crate::shape::ShapeConfig; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PhysicsEvent { + BodyEntered { + area_id: NodeId, + body_id: NodeId, + }, + BodyExited { + area_id: NodeId, + body_id: NodeId, + }, + AreaEntered { + area_id: NodeId, + other_area_id: NodeId, + }, + AreaExited { + area_id: NodeId, + other_area_id: NodeId, + }, +} + +#[derive(Default)] +struct PhysicsEventCollector { + events: Mutex>, +} + +impl EventHandler for PhysicsEventCollector { + fn handle_collision_event( + &self, + _bodies: &RigidBodySet, + _colliders: &ColliderSet, + event: CollisionEvent, + _: Option<&ContactPair>, + ) { + if let Ok(mut lock) = self.events.lock() { + lock.push(event); + } + } + + fn handle_contact_force_event( + &self, + _dt: Real, + _bodies: &RigidBodySet, + _colliders: &ColliderSet, + _contact_pair: &ContactPair, + _total_force_magnitude: Real, + ) { + } +} + +pub struct PhysicsWorld { + pub gravity: Vector, + integration_parameters: IntegrationParameters, + physics_pipeline: PhysicsPipeline, + island_manager: IslandManager, + broad_phase: DefaultBroadPhase, + narrow_phase: NarrowPhase, + rigid_body_set: RigidBodySet, + collider_set: ColliderSet, + impulse_joint_set: ImpulseJointSet, + multibody_joint_set: MultibodyJointSet, + ccd_solver: CCDSolver, + query_pipeline: QueryPipeline, + character_controller: KinematicCharacterController, + event_collector: PhysicsEventCollector, + + // Node <-> Handle mappings + node_to_body: HashMap, + body_to_node: HashMap, + node_to_collider: HashMap, + collider_to_node: HashMap, + collider_to_body: HashMap, + + // Shape configs and offsets + shape_configs: HashMap)>, + + // Body kinds & tags + area_nodes: HashSet, + character_nodes: HashSet, + rigid_nodes: HashSet, + static_nodes: HashSet, +} + +impl Default for PhysicsWorld { + fn default() -> Self { + Self::new() + } +} + +impl PhysicsWorld { + pub fn new() -> Self { + let mut character_controller = KinematicCharacterController::default(); + character_controller.up = -Vector::y_axis(); // Standard 2D coordinate system: -Y is up + character_controller.offset = CharacterLength::Absolute(0.01); + character_controller.slide = true; + + let mut integration_parameters = IntegrationParameters::default(); + integration_parameters.dt = 1.0 / 60.0; + + Self { + gravity: Vector::new(0.0, 980.0), // 980 pixels/sec^2 + integration_parameters, + physics_pipeline: PhysicsPipeline::new(), + island_manager: IslandManager::new(), + broad_phase: DefaultBroadPhase::new(), + narrow_phase: NarrowPhase::new(), + rigid_body_set: RigidBodySet::new(), + collider_set: ColliderSet::new(), + impulse_joint_set: ImpulseJointSet::new(), + multibody_joint_set: MultibodyJointSet::new(), + ccd_solver: CCDSolver::new(), + query_pipeline: QueryPipeline::new(), + character_controller, + event_collector: PhysicsEventCollector::default(), + + node_to_body: HashMap::new(), + body_to_node: HashMap::new(), + node_to_collider: HashMap::new(), + collider_to_node: HashMap::new(), + collider_to_body: HashMap::new(), + + shape_configs: HashMap::new(), + + area_nodes: HashSet::new(), + character_nodes: HashSet::new(), + rigid_nodes: HashSet::new(), + static_nodes: HashSet::new(), + } + } + + /// Add a StaticBody2D to the physics world. + pub fn add_static_body(&mut self, id: NodeId, position: (f32, f32), rotation: f32) { + self.remove_node(&id); + let rb = RigidBodyBuilder::fixed() + .translation(Vector::new(position.0, position.1)) + .rotation(rotation) + .build(); + let handle = self.rigid_body_set.insert(rb); + self.node_to_body.insert(id.clone(), handle); + self.body_to_node.insert(handle, id.clone()); + self.static_nodes.insert(id); + } + + /// Add a CharacterBody2D (kinematic position-based) to the physics world. + pub fn add_character_body(&mut self, id: NodeId, position: (f32, f32), rotation: f32) { + self.remove_node(&id); + let rb = RigidBodyBuilder::kinematic_position_based() + .translation(Vector::new(position.0, position.1)) + .rotation(rotation) + .build(); + let handle = self.rigid_body_set.insert(rb); + self.node_to_body.insert(id.clone(), handle); + self.body_to_node.insert(handle, id.clone()); + self.character_nodes.insert(id); + } + + /// Add a RigidBody2D (dynamic) to the physics world. + pub fn add_rigid_body( + &mut self, + id: NodeId, + position: (f32, f32), + rotation: f32, + mass: f32, + gravity_scale: f32, + lock_rotation: bool, + ) { + self.remove_node(&id); + let mut builder = RigidBodyBuilder::dynamic() + .translation(Vector::new(position.0, position.1)) + .rotation(rotation) + .gravity_scale(gravity_scale) + .additional_mass(mass.max(0.001)); + if lock_rotation { + builder = builder.lock_rotations(); + } + let handle = self.rigid_body_set.insert(builder.build()); + self.node_to_body.insert(id.clone(), handle); + self.body_to_node.insert(handle, id.clone()); + self.rigid_nodes.insert(id); + } + + /// Add an Area2D to the physics world. + pub fn add_area(&mut self, id: NodeId, position: (f32, f32), rotation: f32) { + self.remove_node(&id); + let rb = RigidBodyBuilder::fixed() + .translation(Vector::new(position.0, position.1)) + .rotation(rotation) + .build(); + let handle = self.rigid_body_set.insert(rb); + self.node_to_body.insert(id.clone(), handle); + self.body_to_node.insert(handle, id.clone()); + self.area_nodes.insert(id); + } + + /// Attach a CollisionShape2D collider to a parent body or area. + pub fn attach_shape( + &mut self, + body_id: &NodeId, + shape_node_id: NodeId, + config: ShapeConfig, + offset: (f32, f32), + rotation: f32, + is_sensor: bool, + collision_layer: u32, + collision_mask: u32, + friction: f32, + restitution: f32, + ) { + if let Some(&body_handle) = self.node_to_body.get(body_id) { + let shared_shape = config.to_shared_shape(); + let mut collider_builder = ColliderBuilder::new(shared_shape) + .translation(Vector::new(offset.0, offset.1)) + .rotation(rotation) + .friction(friction) + .restitution(restitution) + .active_events(ActiveEvents::COLLISION_EVENTS) + .active_collision_types(ActiveCollisionTypes::all()); + + if is_sensor || self.area_nodes.contains(body_id) { + collider_builder = collider_builder.sensor(true); + } + + // Set collision filter groups + let memberships = Group::from_bits_truncate(collision_layer); + let filter = Group::from_bits_truncate(collision_mask); + collider_builder = + collider_builder.collision_groups(InteractionGroups::new(memberships, filter)); + + let collider = collider_builder.build(); + let collider_handle = self.collider_set.insert_with_parent( + collider, + body_handle, + &mut self.rigid_body_set, + ); + + self.node_to_collider + .insert(shape_node_id.clone(), collider_handle); + self.collider_to_node + .insert(collider_handle, shape_node_id.clone()); + self.collider_to_body + .insert(collider_handle, body_id.clone()); + self.shape_configs + .insert(shape_node_id, (config, Vector::new(offset.0, offset.1))); + } + } + + /// Remove a node (body or shape) from the physics world. + pub fn remove_node(&mut self, id: &NodeId) { + if let Some(body_handle) = self.node_to_body.remove(id) { + self.body_to_node.remove(&body_handle); + self.area_nodes.remove(id); + self.character_nodes.remove(id); + self.rigid_nodes.remove(id); + self.static_nodes.remove(id); + self.rigid_body_set.remove( + body_handle, + &mut self.island_manager, + &mut self.collider_set, + &mut self.impulse_joint_set, + &mut self.multibody_joint_set, + true, + ); + } + if let Some(collider_handle) = self.node_to_collider.remove(id) { + self.collider_to_node.remove(&collider_handle); + self.collider_to_body.remove(&collider_handle); + self.shape_configs.remove(id); + self.collider_set.remove( + collider_handle, + &mut self.island_manager, + &mut self.rigid_body_set, + true, + ); + } + } + + /// Step the simulation by the fixed timestep. + pub fn step(&mut self) { + let physics_hooks = (); + self.physics_pipeline.step( + &self.gravity, + &self.integration_parameters, + &mut self.island_manager, + &mut self.broad_phase, + &mut self.narrow_phase, + &mut self.rigid_body_set, + &mut self.collider_set, + &mut self.impulse_joint_set, + &mut self.multibody_joint_set, + &mut self.ccd_solver, + Some(&mut self.query_pipeline), + &physics_hooks, + &self.event_collector, + ); + } + + /// Execute `move_and_slide` for a `CharacterBody2D`. + pub fn move_and_slide( + &mut self, + character_id: &NodeId, + desired_velocity: (f32, f32), + dt: f32, + up_direction: (f32, f32), + ) -> CharacterMoveResult { + let Some(&body_handle) = self.node_to_body.get(character_id) else { + return CharacterMoveResult::default(); + }; + + let (rb_pos, first_collider) = match self.rigid_body_set.get(body_handle) { + Some(b) => { + let t = *b.translation(); + let c = b.colliders().first().copied(); + (t, c) + } + None => return CharacterMoveResult::default(), + }; + + let Some(collider_handle) = first_collider else { + // No colliders attached: simply move without collision + let movement = (desired_velocity.0 * dt, desired_velocity.1 * dt); + if let Some(b) = self.rigid_body_set.get_mut(body_handle) { + b.set_translation( + Vector::new(rb_pos.x + movement.0, rb_pos.y + movement.1), + true, + ); + } + return CharacterMoveResult { + movement, + velocity: desired_velocity, + is_on_floor: false, + is_on_wall: false, + is_on_ceiling: false, + }; + }; + + let collider = match self.collider_set.get(collider_handle) { + Some(c) => c, + None => return CharacterMoveResult::default(), + }; + + let shape = collider.shape(); + let current_pos = collider.position(); + let desired_translation = Vector::new(desired_velocity.0 * dt, desired_velocity.1 * dt); + + let up_vec = Vector::new(up_direction.0, up_direction.1); + let up = UnitVector::try_new(up_vec, 0.0001).unwrap_or_else(|| -Vector::y_axis()); + self.character_controller.up = up; + + self.query_pipeline.update(&self.collider_set); + + let filter = QueryFilter::default() + .exclude_rigid_body(body_handle) + .exclude_sensors(); + + let mut is_on_floor = false; + let mut is_on_wall = false; + let mut is_on_ceiling = false; + + let movement = self.character_controller.move_shape( + dt, + &self.rigid_body_set, + &self.collider_set, + &self.query_pipeline, + shape, + current_pos, + desired_translation, + filter, + |collision| { + let normal = collision.hit.normal2; + let dot = normal.dot(up.as_ref()); + if dot > 0.7 { + is_on_floor = true; + } else if dot < -0.7 { + is_on_ceiling = true; + } else { + is_on_wall = true; + } + }, + ); + + if movement.grounded { + is_on_floor = true; + } + + // Apply effective translation to character rigid body + let actual_translation = movement.translation; + let new_pos = Vector::new( + rb_pos.x + actual_translation.x, + rb_pos.y + actual_translation.y, + ); + if let Some(b) = self.rigid_body_set.get_mut(body_handle) { + b.set_translation(new_pos, true); + } + + let effective_vel = if dt > 0.0 { + (actual_translation.x / dt, actual_translation.y / dt) + } else { + desired_velocity + }; + + CharacterMoveResult { + movement: (actual_translation.x, actual_translation.y), + velocity: effective_vel, + is_on_floor, + is_on_wall, + is_on_ceiling, + } + } + + /// Apply an impulse to a RigidBody2D. + pub fn apply_impulse(&mut self, id: &NodeId, impulse: (f32, f32)) { + if let Some(&body_handle) = self.node_to_body.get(id) { + if let Some(body) = self.rigid_body_set.get_mut(body_handle) { + body.apply_impulse(Vector::new(impulse.0, impulse.1), true); + } + } + } + + /// Apply a continuous force to a RigidBody2D. + pub fn apply_force(&mut self, id: &NodeId, force: (f32, f32)) { + if let Some(&body_handle) = self.node_to_body.get(id) { + if let Some(body) = self.rigid_body_set.get_mut(body_handle) { + body.add_force(Vector::new(force.0, force.1), true); + } + } + } + + pub fn is_rigid_body(&self, id: &NodeId) -> bool { + self.rigid_nodes.contains(id) + } + + /// Retrieve the current global position of a body. + pub fn get_body_position(&self, id: &NodeId) -> Option<(f64, f64)> { + let handle = self.node_to_body.get(id)?; + let body = self.rigid_body_set.get(*handle)?; + let t = body.translation(); + Some((t.x as f64, t.y as f64)) + } + + /// Set the position of a body. + pub fn set_body_position(&mut self, id: &NodeId, pos: (f64, f64)) { + if let Some(&handle) = self.node_to_body.get(id) { + if let Some(body) = self.rigid_body_set.get_mut(handle) { + body.set_translation(Vector::new(pos.0 as f32, pos.1 as f32), true); + } + } + } + + /// Retrieve the current rotation of a body. + pub fn get_body_rotation(&self, id: &NodeId) -> Option { + let handle = self.node_to_body.get(id)?; + let body = self.rigid_body_set.get(*handle)?; + Some(body.rotation().angle() as f64) + } + + /// Perform a 2D raycast query. + pub fn raycast(&self, from: (f32, f32), to: (f32, f32), mask: u32) -> Option { + let ray_origin = Point::new(from.0, from.1); + let dir = Vector::new(to.0 - from.0, to.1 - from.1); + let max_toi = dir.norm(); + if max_toi <= 0.0001 { + return None; + } + let ray_dir = dir / max_toi; + let ray = Ray::new(ray_origin, ray_dir); + + let groups = InteractionGroups::new(Group::all(), Group::from_bits_truncate(mask)); + let filter = QueryFilter::default().groups(groups); + + let (handle, intersection) = self.query_pipeline.cast_ray_and_get_normal( + &self.rigid_body_set, + &self.collider_set, + &ray, + max_toi, + true, + filter, + )?; + + let collider_node = self.collider_to_node.get(&handle)?.clone(); + let body_node = self.collider_to_body.get(&handle).cloned(); + let hit_point = ray.point_at(intersection.time_of_impact); + let normal = intersection.normal; + + Some(RaycastHit { + point: (hit_point.x, hit_point.y), + normal: (normal.x, normal.y), + distance: intersection.time_of_impact, + collider_node, + body_node, + }) + } + + /// Drain queued collision events (body/area enter and exit). + pub fn drain_events(&mut self) -> Vec { + let mut raw_events = Vec::new(); + if let Ok(mut lock) = self.event_collector.events.lock() { + raw_events.append(&mut *lock); + } + + let mut events = Vec::new(); + for ev in raw_events { + match ev { + CollisionEvent::Started(h1, h2, _flags) => { + self.resolve_collision_pair(h1, h2, true, &mut events); + } + CollisionEvent::Stopped(h1, h2, _flags) => { + self.resolve_collision_pair(h1, h2, false, &mut events); + } + } + } + events + } + + fn resolve_collision_pair( + &self, + h1: ColliderHandle, + h2: ColliderHandle, + started: bool, + out: &mut Vec, + ) { + let b1 = self.collider_to_body.get(&h1); + let b2 = self.collider_to_body.get(&h2); + let Some(node1) = b1 else { return }; + let Some(node2) = b2 else { return }; + + let is_area1 = self.area_nodes.contains(node1); + let is_area2 = self.area_nodes.contains(node2); + + if is_area1 && is_area2 { + if started { + out.push(PhysicsEvent::AreaEntered { + area_id: node1.clone(), + other_area_id: node2.clone(), + }); + out.push(PhysicsEvent::AreaEntered { + area_id: node2.clone(), + other_area_id: node1.clone(), + }); + } else { + out.push(PhysicsEvent::AreaExited { + area_id: node1.clone(), + other_area_id: node2.clone(), + }); + out.push(PhysicsEvent::AreaExited { + area_id: node2.clone(), + other_area_id: node1.clone(), + }); + } + } else if is_area1 && !is_area2 { + if started { + out.push(PhysicsEvent::BodyEntered { + area_id: node1.clone(), + body_id: node2.clone(), + }); + } else { + out.push(PhysicsEvent::BodyExited { + area_id: node1.clone(), + body_id: node2.clone(), + }); + } + } else if !is_area1 && is_area2 { + if started { + out.push(PhysicsEvent::BodyEntered { + area_id: node2.clone(), + body_id: node1.clone(), + }); + } else { + out.push(PhysicsEvent::BodyExited { + area_id: node2.clone(), + body_id: node1.clone(), + }); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_character_moves_and_hits_floor() { + let mut world = PhysicsWorld::new(); + + // Floor at y = 100, box 200 x 20 + let floor_id = NodeId::from("Floor"); + world.add_static_body(floor_id.clone(), (0.0, 100.0), 0.0); + world.attach_shape( + &floor_id, + NodeId::from("FloorShape"), + ShapeConfig::Box { + width: 200.0, + height: 20.0, + }, + (0.0, 0.0), + 0.0, + false, + 1, + 1, + 0.5, + 0.0, + ); + + // Character starting at y = 70 with a box 20 x 20 (bottom at y = 80) + let player_id = NodeId::from("Player"); + world.add_character_body(player_id.clone(), (0.0, 70.0), 0.0); + world.attach_shape( + &player_id, + NodeId::from("PlayerShape"), + ShapeConfig::Box { + width: 20.0, + height: 20.0, + }, + (0.0, 0.0), + 0.0, + false, + 1, + 1, + 0.0, + 0.0, + ); + + // Move downward by 50 units (trying to reach y = 120, but floor top is at 90) + let res = world.move_and_slide(&player_id, (0.0, 50.0), 1.0, (0.0, -1.0)); + assert!(res.is_on_floor); + let pos = world.get_body_position(&player_id).unwrap(); + // Should have stopped around y = 80 (since player half-height = 10, floor top = 90) + assert!((pos.1 - 80.0).abs() < 1.0, "Expected y ~ 80, got {}", pos.1); + } + + #[test] + fn test_rigidbody_falls_under_gravity() { + let mut world = PhysicsWorld::new(); + let crate_id = NodeId::from("Crate"); + world.add_rigid_body(crate_id.clone(), (0.0, 0.0), 0.0, 1.0, 1.0, false); + world.attach_shape( + &crate_id, + NodeId::from("CrateShape"), + ShapeConfig::Box { + width: 10.0, + height: 10.0, + }, + (0.0, 0.0), + 0.0, + false, + 1, + 1, + 0.5, + 0.0, + ); + + // Step for 10 frames (1/60s each) + for _ in 0..10 { + world.step(); + } + + let pos = world.get_body_position(&crate_id).unwrap(); + assert!(pos.1 > 0.0, "RigidBody should have fallen under gravity"); + } + + #[test] + fn test_area2d_signals_on_overlap() { + let mut world = PhysicsWorld::new(); + + let area_id = NodeId::from("Coin"); + world.add_area(area_id.clone(), (0.0, 0.0), 0.0); + world.attach_shape( + &area_id, + NodeId::from("CoinShape"), + ShapeConfig::Circle { radius: 20.0 }, + (0.0, 0.0), + 0.0, + true, + 1, + 1, + 0.0, + 0.0, + ); + + let body_id = NodeId::from("Ball"); + world.add_rigid_body(body_id.clone(), (0.0, 0.0), 0.0, 1.0, 0.0, false); + world.attach_shape( + &body_id, + NodeId::from("BallShape"), + ShapeConfig::Circle { radius: 10.0 }, + (0.0, 0.0), + 0.0, + false, + 1, + 1, + 0.0, + 0.0, + ); + + world.step(); + let events = world.drain_events(); + assert!( + events.contains(&PhysicsEvent::BodyEntered { + area_id: area_id.clone(), + body_id: body_id.clone() + }), + "Expected BodyEntered event for area and body" + ); + } + + #[test] + fn test_character_triggers_area() { + let mut world = PhysicsWorld::new(); + let area_id = NodeId::from("Area"); + world.add_area(area_id.clone(), (0.0, 20.0), 0.0); + world.attach_shape( + &area_id, + NodeId::from("AreaShape"), + ShapeConfig::Box { + width: 50.0, + height: 50.0, + }, + (0.0, 0.0), + 0.0, + true, + 1, + 1, + 0.0, + 0.0, + ); + + let char_id = NodeId::from("Char"); + world.add_character_body(char_id.clone(), (0.0, 0.0), 0.0); + world.attach_shape( + &char_id, + NodeId::from("CharShape"), + ShapeConfig::Box { + width: 20.0, + height: 20.0, + }, + (0.0, 0.0), + 0.0, + false, + 1, + 1, + 0.0, + 0.0, + ); + + world.move_and_slide(&char_id, (0.0, 200.0), 1.0 / 60.0, (0.0, -1.0)); + world.step(); + let events = world.drain_events(); + println!("Events: {events:?}"); + assert!(!events.is_empty()); + } + + #[test] + fn test_raycast_detects_solid() { + let mut world = PhysicsWorld::new(); + + let wall_id = NodeId::from("Wall"); + world.add_static_body(wall_id.clone(), (100.0, 0.0), 0.0); + world.attach_shape( + &wall_id, + NodeId::from("WallShape"), + ShapeConfig::Box { + width: 20.0, + height: 100.0, + }, + (0.0, 0.0), + 0.0, + false, + 1, + 1, + 0.0, + 0.0, + ); + + world.step(); // update query pipeline + + // Cast ray from (0, 0) to (200, 0) + let hit = world + .raycast((0.0, 0.0), (200.0, 0.0), 1) + .expect("ray should hit wall"); + assert_eq!(hit.collider_node, NodeId::from("WallShape")); + assert_eq!(hit.body_node, Some(wall_id)); + // Wall left face is at x = 90 + assert!((hit.point.0 - 90.0).abs() < 1.0); + assert_eq!(hit.normal, (-1.0, 0.0)); + + // Cast ray with non-matching mask + let miss = world.raycast((0.0, 0.0), (200.0, 0.0), 2); + assert!(miss.is_none()); + } +} diff --git a/pite-runtime/Cargo.toml b/pite-runtime/Cargo.toml index 3d263d7..d9062f1 100644 --- a/pite-runtime/Cargo.toml +++ b/pite-runtime/Cargo.toml @@ -15,6 +15,7 @@ winit.workspace = true notify = "8" pite-audio.workspace = true pite-core.workspace = true +pite-physics.workspace = true pite-scene.workspace = true pite-render.workspace = true pite-script.workspace = true diff --git a/pite-runtime/src/lib.rs b/pite-runtime/src/lib.rs index eae4176..b088763 100644 --- a/pite-runtime/src/lib.rs +++ b/pite-runtime/src/lib.rs @@ -5,12 +5,13 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::Instant; use anyhow::{Context, Result}; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use pite_core::{global_position, NodeId, NodeTree, PropValue}; +use pite_physics::{PhysicsEvent, PhysicsWorld, ShapeConfig}; use pite_render::{Renderer2D, WgpuRenderer}; use pite_script::{ input_mouse, input_pressed, input_released, Pyo3Backend, ScriptBackend, ScriptHost, @@ -100,6 +101,8 @@ pub struct GameSession { viewport: (u32, u32), armed: HashSet, timers: std::collections::HashMap, + physics: Arc>, + physics_accumulator: f64, } impl GameSession { @@ -109,6 +112,7 @@ impl GameSession { .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")); let project_dir = pite_project::find_project_root(scene); + let physics = Arc::new(Mutex::new(PhysicsWorld::new())); let mut session = Self { host: Arc::new(ScriptHost::new(NodeTree::new())), slots: Vec::new(), @@ -121,8 +125,11 @@ impl GameSession { viewport: (800, 600), armed: HashSet::new(), timers: std::collections::HashMap::new(), + physics, + physics_accumulator: 0.0, }; pite_script::set_current_host(session.host.clone()); + pite_script::set_current_physics(Some(session.physics.clone())); pite_audio::set_project_dir(session.project_dir.clone()); pite_script::set_project_dir(session.project_dir.clone()); session.rebuild()?; @@ -388,6 +395,26 @@ impl GameSession { tracing::error!(node = %id, "cannot declare builtin timeout: {e:#}"); } } + + let areas: Vec = self.host.with_tree(|tree| { + tree.iter() + .filter(|n| n.type_name == "Area2D") + .map(|n| n.id.to_string()) + .collect() + }); + for id in &areas { + let signals = vec![ + ("body_entered".to_string(), vec!["str".to_string()]), + ("body_exited".to_string(), vec!["str".to_string()]), + ("area_entered".to_string(), vec!["str".to_string()]), + ("area_exited".to_string(), vec!["str".to_string()]), + ]; + if let Err(e) = self.host.declare_signals(id, signals) { + tracing::error!(node = %id, "cannot declare builtin area signals: {e:#}"); + } + } + + self.rebuild_physics(); self.timers.clear(); let mut olds: HashMap = HashMap::new(); for slot in self.slots.drain(..) { @@ -533,9 +560,269 @@ impl GameSession { } } + pub fn rebuild_physics(&mut self) { + let mut world = PhysicsWorld::new(); + self.host.with_tree(|tree| { + // 1. Add bodies & areas + for node in tree.iter() { + let pos = global_position(tree, &node.id); + let rot = match node.props.get("rotation") { + Some(PropValue::Num(r)) => *r as f32, + _ => 0.0, + }; + match node.type_name.as_str() { + "StaticBody2D" => { + world.add_static_body(node.id.clone(), (pos.0 as f32, pos.1 as f32), rot); + } + "CharacterBody2D" => { + world.add_character_body( + node.id.clone(), + (pos.0 as f32, pos.1 as f32), + rot, + ); + } + "RigidBody2D" => { + let mass = match node.props.get("mass") { + Some(PropValue::Num(m)) => *m as f32, + _ => 1.0, + }; + let grav = match node.props.get("gravity_scale") { + Some(PropValue::Num(g)) => *g as f32, + _ => 1.0, + }; + let lock_rot = match node.props.get("lock_rotation") { + Some(PropValue::Bool(b)) => *b, + _ => false, + }; + world.add_rigid_body( + node.id.clone(), + (pos.0 as f32, pos.1 as f32), + rot, + mass, + grav, + lock_rot, + ); + } + "Area2D" => { + world.add_area(node.id.clone(), (pos.0 as f32, pos.1 as f32), rot); + } + _ => {} + } + } + + // 2. Attach collision shapes + for node in tree.iter().filter(|n| n.type_name == "CollisionShape2D") { + let disabled = match node.props.get("disabled") { + Some(PropValue::Bool(b)) => *b, + _ => false, + }; + if disabled { + continue; + } + + // Find parent body + let mut parent_id = node.parent.clone(); + let mut parent_body: Option<&pite_core::Node> = None; + while let Some(pid) = parent_id { + if let Some(pnode) = tree.get(&pid) { + if matches!( + pnode.type_name.as_str(), + "StaticBody2D" | "CharacterBody2D" | "RigidBody2D" | "Area2D" + ) { + parent_body = Some(pnode); + break; + } + parent_id = pnode.parent.clone(); + } else { + break; + } + } + + let Some(body) = parent_body else { + continue; + }; + + let body_pos = global_position(tree, &body.id); + let shape_pos = global_position(tree, &node.id); + let offset = ( + (shape_pos.0 - body_pos.0) as f32, + (shape_pos.1 - body_pos.1) as f32, + ); + + let shape_rot = match node.props.get("rotation") { + Some(PropValue::Num(r)) => *r as f32, + _ => 0.0, + }; + + let shape_type = match node.props.get("shape") { + Some(PropValue::Str(s)) => s.as_str(), + _ => "box", + }; + + let config = match shape_type { + "circle" => { + let radius = match node.props.get("radius") { + Some(PropValue::Num(r)) => *r as f32, + _ => 16.0, + }; + ShapeConfig::Circle { radius } + } + "capsule" => { + let radius = match node.props.get("radius") { + Some(PropValue::Num(r)) => *r as f32, + _ => 16.0, + }; + let height = match node.props.get("height") { + Some(PropValue::Num(h)) => *h as f32, + _ => 32.0, + }; + ShapeConfig::Capsule { radius, height } + } + _ => { + let (w, h) = match node.props.get("size") { + Some(PropValue::Vec2(w, h)) => (*w as f32, *h as f32), + _ => (32.0, 32.0), + }; + ShapeConfig::Box { + width: w, + height: h, + } + } + }; + + let layer = match body.props.get("collision_layer") { + Some(PropValue::Int(l)) => *l as u32, + _ => 1, + }; + let mask = match body.props.get("collision_mask") { + Some(PropValue::Int(m)) => *m as u32, + _ => 1, + }; + let friction = match body.props.get("friction") { + Some(PropValue::Num(f)) => *f as f32, + _ => 0.5, + }; + let restitution = match body.props.get("restitution") { + Some(PropValue::Num(r)) => *r as f32, + _ => 0.0, + }; + + let is_sensor = body.type_name == "Area2D"; + + world.attach_shape( + &body.id, + node.id.clone(), + config, + offset, + shape_rot, + is_sensor, + layer, + mask, + friction, + restitution, + ); + } + }); + + *self.physics.lock().unwrap() = world; + pite_script::set_current_physics(Some(self.physics.clone())); + } + pub fn update(&mut self, delta: f64) { self.poll_buttons(); pite_audio::poll_audio(); + + // 60 Hz fixed physics stepping + const FIXED_STEP: f64 = 1.0 / 60.0; + self.physics_accumulator += delta; + if self.physics_accumulator > 0.25 { + self.physics_accumulator = 0.25; + } + + while self.physics_accumulator >= FIXED_STEP { + for slot in &mut self.slots { + if slot.errored { + continue; + } + if let Err(e) = slot.backend.call_physics_process(FIXED_STEP) { + tracing::error!(node = %slot.id, "physics process error: {e:#}"); + slot.errored = true; + } + if let Some((x, y)) = slot.backend.position() { + let mut lock = self.physics.lock().unwrap(); + lock.set_body_position(&slot.id, (x, y)); + } + } + + let events = { + let mut lock = self.physics.lock().unwrap(); + lock.step(); + lock.drain_events() + }; + + // Sync dynamic physics positions (RigidBody2D) back into tree & slots + { + let lock = self.physics.lock().unwrap(); + for slot in &mut self.slots { + if lock.is_rigid_body(&slot.id) { + if let Some(pos) = lock.get_body_position(&slot.id) { + slot.backend.set_position(pos.0, pos.1); + self.host.with_tree_mut(|tree| { + if let Some(node) = tree.get_mut(&slot.id) { + node.props.insert( + "position".to_string(), + PropValue::Vec2(pos.0, pos.1), + ); + } + }); + } + } + } + } + + // Dispatch Area2D signals + for ev in events { + match ev { + PhysicsEvent::BodyEntered { area_id, body_id } => { + let _ = pite_script::fire_body_entered( + &self.host, + area_id.as_str(), + body_id.as_str(), + ); + } + PhysicsEvent::BodyExited { area_id, body_id } => { + let _ = pite_script::fire_body_exited( + &self.host, + area_id.as_str(), + body_id.as_str(), + ); + } + PhysicsEvent::AreaEntered { + area_id, + other_area_id, + } => { + let _ = pite_script::fire_area_entered( + &self.host, + area_id.as_str(), + other_area_id.as_str(), + ); + } + PhysicsEvent::AreaExited { + area_id, + other_area_id, + } => { + let _ = pite_script::fire_area_exited( + &self.host, + area_id.as_str(), + other_area_id.as_str(), + ); + } + } + } + + self.physics_accumulator -= FIXED_STEP; + } + for slot in &mut self.slots { if slot.errored { continue; @@ -1586,4 +1873,228 @@ mod tests { "enemy should recoil to x=-5, got {after}" ); } + + #[test] + fn physics_character_and_raycast_e2e() { + let _guard = SERIAL.lock().unwrap(); + + let dir = std::env::temp_dir().join("pite-physics-e2e"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("scenes")).unwrap(); + std::fs::create_dir_all(dir.join("scripts")).unwrap(); + std::fs::write( + dir.join("pite.toml"), + "[project]\nname = \"physics-test\"\n", + ) + .unwrap(); + + let scene = r#"format_version = 1 +root = "root" + +[[node]] +id = "root" +type = "Node2D" +name = "Root" + +[[node]] +id = "floor" +type = "StaticBody2D" +name = "Floor" +parent = "root" + +[node.props] +position = [0.0, 100.0] + +[[node]] +id = "floor_shape" +type = "CollisionShape2D" +name = "Shape" +parent = "floor" + +[node.props] +shape = "box" +size = [200.0, 20.0] + +[[node]] +id = "player" +type = "CharacterBody2D" +name = "Player" +parent = "root" + +[node.props] +position = [0.0, 0.0] + +[node.script] +path = "res://scripts/player.py" +class = "Player" + +[[node]] +id = "player_shape" +type = "CollisionShape2D" +name = "Shape" +parent = "player" + +[node.props] +shape = "box" +size = [20.0, 20.0] +"#; + std::fs::write(dir.join("scenes").join("main.ptscn"), scene).unwrap(); + + let script = r#"import pite + +class Player(pite.CharacterBody2D): + def _physics_process(self, dt): + self.velocity = (0.0, 300.0) + self.move_and_slide() + hit = pite.raycast((0.0, 0.0), (0.0, 150.0)) + if hit is not None and hit.get("body") == "floor": + pass +"#; + std::fs::write(dir.join("scripts").join("player.py"), script).unwrap(); + + let mut session = GameSession::open(&dir.join("scenes").join("main.ptscn"), true).unwrap(); + for _ in 0..30 { + session.update(0.016); + } + + let player_y = + session + .host + .with_tree(|t| match t.get(&NodeId::from("player".to_string())) { + Some(n) => match n.props.get("position") { + Some(PropValue::Vec2(_, y)) => *y, + _ => f64::NAN, + }, + None => f64::NAN, + }); + + assert!( + (player_y - 80.0).abs() < 1.0, + "Player should rest on floor at y=80, got {player_y}" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn physics_area2d_signal_e2e() { + let _guard = SERIAL.lock().unwrap(); + + let dir = std::env::temp_dir().join("pite-physics-area-e2e"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("scenes")).unwrap(); + std::fs::create_dir_all(dir.join("scripts")).unwrap(); + std::fs::write(dir.join("pite.toml"), "[project]\nname = \"area-test\"\n").unwrap(); + + let scene = r#"format_version = 1 +root = "root" + +[[node]] +id = "root" +type = "Node2D" +name = "Root" + +[[node]] +id = "zone" +type = "Area2D" +name = "Zone" +parent = "root" + +[node.props] +position = [0.0, 60.0] + +[node.script] +path = "res://scripts/zone.py" +class = "Zone" + +[[node]] +id = "zone_shape" +type = "CollisionShape2D" +name = "Shape" +parent = "zone" + +[node.props] +shape = "box" +size = [60.0, 60.0] + +[[node]] +id = "player" +type = "CharacterBody2D" +name = "Player" +parent = "root" + +[node.props] +position = [0.0, 0.0] + +[node.script] +path = "res://scripts/player.py" +class = "Player" + +[[node]] +id = "player_shape" +type = "CollisionShape2D" +name = "Shape" +parent = "player" + +[node.props] +shape = "box" +size = [20.0, 20.0] +"#; + std::fs::write(dir.join("scenes").join("main.ptscn"), scene).unwrap(); + + let player_script = r#"import pite + +class Player(pite.CharacterBody2D): + def _physics_process(self, dt): + self.velocity = (0.0, 200.0) + self.move_and_slide() +"#; + std::fs::write(dir.join("scripts").join("player.py"), player_script).unwrap(); + + let zone_script = r#"import pite + +class Zone(pite.Area2D): + def _ready(self): + self.body_entered.connect(self.on_body) + + def on_body(self, body_id): + self.position = (999.0, 999.0) +"#; + std::fs::write(dir.join("scripts").join("zone.py"), zone_script).unwrap(); + + let mut session = GameSession::open(&dir.join("scenes").join("main.ptscn"), true).unwrap(); + for _ in 0..30 { + session.update(0.016); + let zp = session + .host + .with_tree(|t| match t.get(&NodeId::from("zone".to_string())) { + Some(n) => match n.props.get("position") { + Some(PropValue::Vec2(x, y)) => (*x, *y), + _ => (0.0, 0.0), + }, + None => (0.0, 0.0), + }); + if zp == (999.0, 999.0) { + break; + } + } + + let zone_pos = session + .host + .with_tree(|t| match t.get(&NodeId::from("zone".to_string())) { + Some(n) => match n.props.get("position") { + Some(PropValue::Vec2(x, y)) => (*x, *y), + _ => (0.0, 0.0), + }, + None => (0.0, 0.0), + }); + + assert_eq!( + zone_pos, + (999.0, 999.0), + "Area2D body_entered signal should move zone to (999, 999)" + ); + + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/pite-script/Cargo.toml b/pite-script/Cargo.toml index b54a016..103cc71 100644 --- a/pite-script/Cargo.toml +++ b/pite-script/Cargo.toml @@ -12,4 +12,5 @@ anyhow.workspace = true pite-assets.workspace = true pite-audio.workspace = true pite-core.workspace = true +pite-physics.workspace = true pyo3 = { version = "0.30", features = ["auto-initialize"] } diff --git a/pite-script/src/host.rs b/pite-script/src/host.rs index 6ff95d1..64d2f66 100644 --- a/pite-script/src/host.rs +++ b/pite-script/src/host.rs @@ -464,3 +464,39 @@ pub fn fire_pressed(host: &ScriptHost, id: &str) -> anyhow::Result<()> { pub fn fire_timeout(host: &ScriptHost, id: &str) -> anyhow::Result<()> { Python::attach(|py| host.emit(py, id, "timeout", &PyTuple::empty(py))) } + +pub fn fire_body_entered(host: &ScriptHost, area_id: &str, body_id: &str) -> anyhow::Result<()> { + Python::attach(|py| { + let arg = PyTuple::new(py, [body_id])?; + host.emit(py, area_id, "body_entered", &arg) + }) +} + +pub fn fire_body_exited(host: &ScriptHost, area_id: &str, body_id: &str) -> anyhow::Result<()> { + Python::attach(|py| { + let arg = PyTuple::new(py, [body_id])?; + host.emit(py, area_id, "body_exited", &arg) + }) +} + +pub fn fire_area_entered( + host: &ScriptHost, + area_id: &str, + other_area_id: &str, +) -> anyhow::Result<()> { + Python::attach(|py| { + let arg = PyTuple::new(py, [other_area_id])?; + host.emit(py, area_id, "area_entered", &arg) + }) +} + +pub fn fire_area_exited( + host: &ScriptHost, + area_id: &str, + other_area_id: &str, +) -> anyhow::Result<()> { + Python::attach(|py| { + let arg = PyTuple::new(py, [other_area_id])?; + host.emit(py, area_id, "area_exited", &arg) + }) +} diff --git a/pite-script/src/lib.rs b/pite-script/src/lib.rs index 7cb2db2..1a7af93 100644 --- a/pite-script/src/lib.rs +++ b/pite-script/src/lib.rs @@ -5,17 +5,20 @@ mod audio; mod host; mod input; +pub mod physics_bridge; mod python; use anyhow::Result; pub use host::{ - fire_pressed, fire_timeout, proxy_for, resolve_caller, set_current_host, NodeProxy, ScriptHost, + 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, }; 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; @@ -40,6 +43,9 @@ pub trait ScriptBackend { fn load(&mut self, path: &str, class: &str) -> Result<()>; fn call_ready(&mut self) -> Result<()>; fn call_process(&mut self, delta: f64) -> Result<()>; + fn call_physics_process(&mut self, _delta: f64) -> Result<()> { + Ok(()) + } fn reload(&mut self) -> Result<()>; fn position(&self) -> Option<(f64, f64)> { @@ -48,6 +54,16 @@ pub trait ScriptBackend { fn set_position(&mut self, _x: f64, _y: f64) {} + fn velocity(&self) -> Option<(f64, f64)> { + None + } + + fn set_velocity(&mut self, _vx: f64, _vy: f64) {} + + fn set_on_floor(&mut self, _on_floor: bool) {} + fn set_on_wall(&mut self, _on_wall: bool) {} + fn set_on_ceiling(&mut self, _on_ceiling: bool) {} + fn text(&self) -> Option { None } diff --git a/pite-script/src/physics_bridge.rs b/pite-script/src/physics_bridge.rs new file mode 100644 index 0000000..3bb6088 --- /dev/null +++ b/pite-script/src/physics_bridge.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use pite_physics::PhysicsWorld; +use std::sync::{Arc, Mutex, RwLock}; + +static CURRENT_PHYSICS: RwLock>>> = RwLock::new(None); + +pub fn set_current_physics(physics: Option>>) { + *CURRENT_PHYSICS.write().unwrap() = physics; +} + +pub fn current_physics() -> Option>> { + CURRENT_PHYSICS.read().unwrap().clone() +} + +pub fn with_physics(f: impl FnOnce(&mut PhysicsWorld) -> R) -> Option { + let arc = current_physics()?; + let mut lock = arc.lock().ok()?; + Some(f(&mut lock)) +} diff --git a/pite-script/src/python.rs b/pite-script/src/python.rs index 398b5f1..82c6217 100644 --- a/pite-script/src/python.rs +++ b/pite-script/src/python.rs @@ -143,6 +143,306 @@ impl PyButton { } } +#[pyclass(extends = PyNode2D, subclass, name = "StaticBody2D")] +struct PyStaticBody2D { + #[pyo3(get, set)] + collision_layer: u32, + #[pyo3(get, set)] + collision_mask: u32, +} + +#[pymethods] +impl PyStaticBody2D { + #[new] + fn new() -> PyClassInitializer { + PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { + position: (0.0, 0.0), + }) + .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), + #[pyo3(get, set)] + collision_layer: u32, + #[pyo3(get, set)] + collision_mask: u32, + #[pyo3(get, set)] + _is_on_floor: bool, + #[pyo3(get, set)] + _is_on_wall: bool, + #[pyo3(get, set)] + _is_on_ceiling: bool, +} + +#[pymethods] +impl PyCharacterBody2D { + #[new] + fn new() -> PyClassInitializer { + PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { + position: (0.0, 0.0), + }) + .add_subclass(PyCharacterBody2D { + velocity: (0.0, 0.0), + up_direction: (0.0, -1.0), + collision_layer: 1, + collision_mask: 1, + _is_on_floor: false, + _is_on_wall: false, + _is_on_ceiling: false, + }) + } + + fn is_on_floor(&self) -> bool { + self._is_on_floor + } + + fn is_on_wall(&self) -> bool { + self._is_on_wall + } + + fn is_on_ceiling(&self) -> bool { + self._is_on_ceiling + } + + fn move_and_slide(slf: &Bound<'_, Self>) -> PyResult { + 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 dt = 1.0 / 60.0; + let res = crate::with_physics(|world| { + world.move_and_slide( + &node_id, + (vel.0 as f32, vel.1 as f32), + dt as f32, + (up.0 as f32, up.1 as f32), + ) + }); + + if let Some(res) = res { + { + 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; + } + if let Some(Some(new_pos)) = crate::with_physics(|w| w.get_body_position(&node_id)) { + slf.setattr("position", new_pos)?; + } + Ok(true) + } else { + Ok(false) + } + } +} + +#[pyclass(extends = PyNode2D, subclass, name = "RigidBody2D")] +struct PyRigidBody2D { + #[pyo3(get, set)] + mass: f64, + #[pyo3(get, set)] + gravity_scale: f64, + #[pyo3(get, set)] + restitution: f64, + #[pyo3(get, set)] + friction: f64, + #[pyo3(get, set)] + lock_rotation: bool, + #[pyo3(get, set)] + collision_layer: u32, + #[pyo3(get, set)] + collision_mask: u32, +} + +#[pymethods] +impl PyRigidBody2D { + #[new] + fn new() -> PyClassInitializer { + PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { + position: (0.0, 0.0), + }) + .add_subclass(PyRigidBody2D { + mass: 1.0, + gravity_scale: 1.0, + restitution: 0.0, + friction: 0.5, + lock_rotation: false, + collision_layer: 1, + collision_mask: 1, + }) + } + + fn apply_impulse(slf: &Bound<'_, Self>, impulse: (f64, f64)) -> PyResult<()> { + let node_path: String = slf.getattr("node_path")?.extract()?; + let node_id = pite_core::NodeId::from(node_path); + crate::with_physics(|w| w.apply_impulse(&node_id, (impulse.0 as f32, impulse.1 as f32))); + Ok(()) + } + + fn apply_force(slf: &Bound<'_, Self>, force: (f64, f64)) -> PyResult<()> { + let node_path: String = slf.getattr("node_path")?.extract()?; + let node_id = pite_core::NodeId::from(node_path); + crate::with_physics(|w| w.apply_force(&node_id, (force.0 as f32, force.1 as f32))); + Ok(()) + } +} + +#[pyclass(extends = PyNode2D, subclass, name = "Area2D")] +struct PyArea2D { + #[pyo3(get, set)] + collision_layer: u32, + #[pyo3(get, set)] + collision_mask: u32, +} + +#[pymethods] +impl PyArea2D { + #[new] + fn new() -> PyClassInitializer { + PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { + position: (0.0, 0.0), + }) + .add_subclass(PyArea2D { + collision_layer: 1, + collision_mask: 1, + }) + } + + #[getter] + fn body_entered(slf: &Bound<'_, Self>) -> PyResult { + let node_path: String = slf.getattr("node_path")?.extract()?; + let host = crate::host::current_host().ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host") + })?; + Ok(crate::host::BoundSignal::with_host( + host, + node_path, + "body_entered".to_string(), + )) + } + + #[getter] + fn body_exited(slf: &Bound<'_, Self>) -> PyResult { + let node_path: String = slf.getattr("node_path")?.extract()?; + let host = crate::host::current_host().ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host") + })?; + Ok(crate::host::BoundSignal::with_host( + host, + node_path, + "body_exited".to_string(), + )) + } + + #[getter] + fn area_entered(slf: &Bound<'_, Self>) -> PyResult { + let node_path: String = slf.getattr("node_path")?.extract()?; + let host = crate::host::current_host().ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host") + })?; + Ok(crate::host::BoundSignal::with_host( + host, + node_path, + "area_entered".to_string(), + )) + } + + #[getter] + fn area_exited(slf: &Bound<'_, Self>) -> PyResult { + let node_path: String = slf.getattr("node_path")?.extract()?; + let host = crate::host::current_host().ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host") + })?; + Ok(crate::host::BoundSignal::with_host( + host, + node_path, + "area_exited".to_string(), + )) + } +} + +#[pyclass(extends = PyNode2D, subclass, name = "CollisionShape2D")] +struct PyCollisionShape2D { + #[pyo3(get, set)] + shape: String, + #[pyo3(get, set)] + size: (f64, f64), + #[pyo3(get, set)] + radius: f64, + #[pyo3(get, set)] + height: f64, + #[pyo3(get, set)] + disabled: bool, +} + +#[pymethods] +impl PyCollisionShape2D { + #[new] + fn new() -> PyClassInitializer { + PyClassInitializer::from(PyNode::new()) + .add_subclass(PyNode2D { + position: (0.0, 0.0), + }) + .add_subclass(PyCollisionShape2D { + shape: "box".to_string(), + size: (32.0, 32.0), + radius: 16.0, + height: 32.0, + disabled: false, + }) + } +} + +#[pyfunction] +#[pyo3(signature = (from, to, mask = 0xFFFFFFFF))] +fn raycast( + py: Python<'_>, + from: (f64, f64), + to: (f64, f64), + mask: u32, +) -> PyResult>> { + let hit = crate::with_physics(|w| { + w.raycast( + (from.0 as f32, from.1 as f32), + (to.0 as f32, to.1 as f32), + mask, + ) + }) + .flatten(); + + if let Some(h) = hit { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("point", (h.point.0 as f64, h.point.1 as f64))?; + dict.set_item("normal", (h.normal.0 as f64, h.normal.1 as f64))?; + dict.set_item("distance", h.distance as f64)?; + dict.set_item("collider", h.collider_node.as_str())?; + dict.set_item( + "body", + h.body_node.as_ref().map(|b| b.as_str()).unwrap_or(""), + )?; + Ok(Some(dict.into_any().unbind())) + } else { + Ok(None) + } +} + /// Frame names in a sprite-sheet atlas (`res://…atlas.json`). #[pyfunction] fn frames(atlas_ref: &str) -> PyResult> { @@ -165,6 +465,12 @@ 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_class::()?; + 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)?)?; @@ -526,6 +832,10 @@ impl ScriptBackend for Pyo3Backend { self.call_opt("_process", Some(delta)) } + fn call_physics_process(&mut self, delta: f64) -> Result<()> { + self.call_opt("_physics_process", Some(delta)) + } + fn reload(&mut self) -> Result<()> { let pos = self.position(); let text = self.text(); @@ -568,6 +878,50 @@ impl ScriptBackend for Pyo3Backend { }); } + fn velocity(&self) -> Option<(f64, f64)> { + Python::attach(|py| { + self.instance + .as_ref()? + .bind(py) + .getattr("velocity") + .ok()? + .extract() + .ok() + }) + } + + fn set_velocity(&mut self, vx: f64, vy: f64) { + Python::attach(|py| { + if let Some(inst) = &self.instance { + let _ = inst.bind(py).setattr("velocity", (vx, vy)); + } + }); + } + + fn set_on_floor(&mut self, on_floor: bool) { + Python::attach(|py| { + if let Some(inst) = &self.instance { + let _ = inst.bind(py).setattr("_is_on_floor", on_floor); + } + }); + } + + fn set_on_wall(&mut self, on_wall: bool) { + Python::attach(|py| { + if let Some(inst) = &self.instance { + let _ = inst.bind(py).setattr("_is_on_wall", on_wall); + } + }); + } + + fn set_on_ceiling(&mut self, on_ceiling: bool) { + Python::attach(|py| { + if let Some(inst) = &self.instance { + let _ = inst.bind(py).setattr("_is_on_ceiling", on_ceiling); + } + }); + } + fn text(&self) -> Option { Python::attach(|py| { self.instance