From 6217f050794c97e7bdaa801eea9f40fba6031d3d Mon Sep 17 00:00:00 2001 From: Mahan Khalili Date: Thu, 5 Nov 2026 11:53:51 +0000 Subject: [PATCH] feat: add TileMap2D support with multi-layer editing and physics collisions - Register TileMap2D node in pite-core. - Add .ptmap TOML schema, coordinate math, and serialization in pite-scene. - Implement greedy 2D rectangle collider merger and Rapier2D static bodies in pite-physics. - Add frustum-culled rendering, hot reload, and collision loading in pite-runtime. - Implement Python scripting API (pite.TileMap2D) in pite-script. - Add tilemap validation and atlas frame usage verification to pite check in pite-cli. - Add interactive tile palette, layer switcher, and viewport painting/erasing in pite-editor. - Update documentation in docs/scene-format.md, docs/scripting-api.md, and docs/editor.md. - Add unit and integration tests across crates. Signed-off-by: Mahan Khalili --- CHANGELOG.md | 9 + Cargo.lock | 1 + docs/editor.md | 13 ++ docs/scene-format.md | 9 +- docs/scripting-api.md | 31 ++++ pite-cli/src/cmd_check.rs | 102 ++++++++++ pite-core/src/registry.rs | 3 +- pite-editor/src/app/inspector.rs | 126 +++++++++++++ pite-editor/src/app/mod.rs | 6 + pite-editor/src/app/viewport.rs | 120 ++++++++++++ pite-editor/src/ops.rs | 7 + pite-physics/src/lib.rs | 2 + pite-physics/src/tilemap.rs | 151 +++++++++++++++ pite-physics/src/world.rs | 44 +++++ pite-runtime/src/lib.rs | 233 +++++++++++++++++++++++ pite-scene/src/lib.rs | 5 + pite-scene/src/tilemap.rs | 310 +++++++++++++++++++++++++++++++ pite-script/Cargo.toml | 1 + pite-script/src/host.rs | 78 ++++++++ pite-script/src/python.rs | 254 +++++++++++++++++++++++++ 20 files changed, 1503 insertions(+), 2 deletions(-) create mode 100644 pite-physics/src/tilemap.rs create mode 100644 pite-scene/src/tilemap.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 51371c4..c4fd312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- 2D tilemap subsystem (`TileMap2D`): + - Canonical `.ptmap` TOML sidecars: multi-layer tilemaps stored in human-readable TOML referencing atlas frames using sparse `[col, row, "frame"]` coordinates. + - Greedy 2D physics collider merging: adjacent solid tiles coalesce into maximal rectangular static box colliders in `rapier2d`, eliminating internal edge catching for character controllers and minimizing collider count. + - Frustum-culled batch rendering: camera viewport frustum culling automatically culls invisible tiles, batching visible tiles sharing an atlas into GPU draw calls. + - Python scripting API: `pite.TileMap2D` exposes `get_cell`, `set_cell`, `erase_cell`, `clear`, coordinate conversions `world_to_map` / `map_to_world`, layer introspection (`layers`, `get_used_cells`), and `tile_size`. + - Interactive editor tools: inspector tile palette with layer switcher and brush/eraser toggles; viewport interactive painting with snapped grid preview, left-click paint, and right-click erase with auto-saving. + - Project verification: `pite check` validates `.ptmap` files, confirming atlas references and tile frame usage. + + ## v0.3.0 - Hot reload reliability and asset watching: diff --git a/Cargo.lock b/Cargo.lock index b2698bb..a3b9488 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3285,6 +3285,7 @@ dependencies = [ "pite-audio", "pite-core", "pite-physics", + "pite-scene", "pyo3", ] diff --git a/docs/editor.md b/docs/editor.md index 4f9a799..30ab019 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -28,6 +28,19 @@ before each `session.frame()`, with the cursor rescaled into offscreen pixels. Input stays out of the way — it only forwards while the pointer is over the viewport, so the code pane and shortcuts keep priority. +## Tilemap editing + +When a `TileMap2D` node is selected in the scene tree: +- The Inspector displays the Tilemap Editor panel: + - Active layer switcher and layer toggles (`visible`, `collision_enabled`). + - Tool mode switcher: **Brush** (paint selected tile) vs **Eraser** (erase tile). + - Tile palette displaying all named frames in the referenced atlas. +- The Viewport provides interactive painting: + - Hovering over the viewport renders a snapped tile preview rectangle at the grid cell. + - Left-clicking (or dragging) paints the selected tile into the active layer. + - Right-clicking (or dragging) erases tiles. + - Edits automatically update the scene, refresh Rapier static physics colliders, and save the `.ptmap` file. + ## Save scene `Scene` > `Save Scene`, the save icon button, or `Ctrl+S` / `Cmd+S` diff --git a/docs/scene-format.md b/docs/scene-format.md index 25aafab..27f75dd 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`, `StaticBody2D`, `CharacterBody2D`, `RigidBody2D`, `Area2D`, `CollisionShape2D`); anything else is a `check` error. +- `type` must be registered (`Node`, `Node2D`, `Sprite2D`, `Camera2D`, `Timer`, `Label`, `Button`, `StaticBody2D`, `CharacterBody2D`, `RigidBody2D`, `Area2D`, `CollisionShape2D`, `TileMap2D`); 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. @@ -80,3 +80,10 @@ Saving preserves `[[instance]]` entries rather than inlining the subtree, and re - `Button` draws a `color` box (`size = [w, h]` in world units, default `[120, 40]`) centered on the node's global position, with its `text` captioned on top like a `Label`. - Clicks are press-inside plus release-inside (mouse position maps through the camera). The engine declares and fires the node's `pressed` signal itself — just connect it like any signal, no declaration needed (re-declaring is harmless). + +## Tilemaps + +- `TileMap2D` renders 2D tilemaps defined in an external `.ptmap` TOML sidecar (`tilemap = "res://maps/level.ptmap"`). +- The `.ptmap` format specifies the atlas (`atlas = "res://...atlas.json"`), tile size (`tile_size = [w, h]`), and layers (`[[layer]]`). +- Each layer defines `name`, `visible` (bool), `collision_enabled` (bool), `collision_layer`, `collision_mask`, optional `solid_frames`, and sparse tiles `tiles = [[col, row, "frame"], ...]`. +- When collisions are enabled on a layer, solid tiles are greedily merged into minimal rectangular static box colliders in Rapier2D, preventing internal edge snagging. diff --git a/docs/scripting-api.md b/docs/scripting-api.md index fabce0a..655cad7 100644 --- a/docs/scripting-api.md +++ b/docs/scripting-api.md @@ -91,6 +91,37 @@ 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. +## Tilemaps + +```python +class Level(pite.TileMap2D): + def _ready(self): + # Cell queries and mutations + tile = self.get_cell(0, 0, layer=0) + self.set_cell(0, 0, "grass", layer=0) + self.erase_cell(0, 0, layer=0) + self.clear(layer=None) + + # Coordinate math + col, row = self.world_to_map(pite.mouse()) + pos = self.map_to_world(col, row) + + # Tilemap properties & introspection + tile_w, tile_h = self.tile_size + layers = self.layers + cells = self.get_used_cells(layer=0) +``` + +- `tile_size -> tuple[int, int]`: Tile dimensions `(width, height)` in pixels. +- `layers -> list[str]`: List of layer names in the tilemap. +- `get_cell(col, row, layer=0) -> str | None`: Returns the atlas frame name at `(col, row)`. +- `set_cell(col, row, frame, layer=0)`: Places an atlas frame at `(col, row)`. +- `erase_cell(col, row, layer=0) -> bool`: Erases cell at `(col, row)`. +- `clear(layer=None)`: Clears all cells in the specified layer (or all layers if omitted). +- `world_to_map(pos_or_x, y=None) -> tuple[int, int]`: Converts world position to grid cell `(col, row)`. +- `map_to_world(col, row) -> Vec2`: Converts grid cell `(col, row)` to world center position. +- `get_used_cells(layer=0) -> list[tuple[int, int]]`: Returns list of populated `(col, row)` coordinates. + ## 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. diff --git a/pite-cli/src/cmd_check.rs b/pite-cli/src/cmd_check.rs index 829681b..c70311f 100644 --- a/pite-cli/src/cmd_check.rs +++ b/pite-cli/src/cmd_check.rs @@ -350,6 +350,37 @@ fn check_scene_ref( )), } } + if node.type_name == "TileMap2D" { + if let Some(map_ref) = node.props.get("tilemap").and_then(|v| v.as_str()) { + let map_path = if let Some(rel) = map_ref.strip_prefix("res://") { + root.join(rel) + } else { + root.join(map_ref) + }; + if map_path.is_file() { + match pite_scene::load_tilemap(&map_path) { + Ok(doc) => { + referenced.insert(normalize_asset_ref(&doc.atlas)); + for layer in &doc.layers { + for tile in &layer.tiles { + atlas_uses.push(pite_assets::AtlasUse { + scene: path.to_string_lossy().into_owned(), + node: node.id.clone(), + atlas: doc.atlas.clone(), + frame: tile.2.clone(), + }); + } + } + } + Err(e) => errors.push(format!( + "scene {}: node {:?} invalid tilemap {map_ref:?}: {e:#}", + path.display(), + node.id + )), + } + } + } + } for value in node.props.values() { let Some(s) = value.as_str() else { continue; @@ -836,4 +867,75 @@ texture = "res://assets/gone.png" assert!(warnings[0].contains("\"numpy\""), "got: {warnings:?}"); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn check_validates_tilemap_assets() { + let root = fixture_root(6); + std::fs::create_dir_all(root.join("maps")).expect("create maps dir"); + std::fs::write( + root.join("scenes").join("main.ptscn"), + r#"format_version = 1 +root = "root" + +[[node]] +id = "root" +type = "Node2D" +name = "Main" + +[[node]] +id = "map" +type = "TileMap2D" +name = "TileMap" +parent = "root" + +[node.props] +tilemap = "res://maps/level.ptmap" +"#, + ) + .expect("write scene"); + + std::fs::write( + root.join("maps").join("level.ptmap"), + r#"atlas = "res://tiles.atlas.json" +tile_size = [16, 16] + +[[layer]] +name = "ground" +tiles = [[0, 0, "grass"], [1, 0, "dirt"]] +"#, + ) + .expect("write tilemap"); + + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + let mut cache = Vec::new(); + let mut atlas_uses = Vec::new(); + let mut visited = HashSet::new(); + let mut referenced = HashSet::new(); + let registry = pite_assets::UidRegistry::default(); + let by_path: HashMap<&str, &pite_assets::ScannedFile> = HashMap::new(); + let by_hash: HashMap<&str, &str> = HashMap::new(); + + check_scene_ref( + &root, + "res://scenes/main.ptscn", + &mut errors, + &mut warnings, + &mut cache, + &mut atlas_uses, + ®istry, + &by_path, + &by_hash, + &mut visited, + &mut referenced, + ); + + assert!(errors.is_empty(), "got: {errors:?}"); + assert!(referenced.contains("res://maps/level.ptmap")); + assert!(referenced.contains("res://tiles.atlas.json")); + assert_eq!(atlas_uses.len(), 2); + assert_eq!(atlas_uses[0].frame, "grass"); + assert_eq!(atlas_uses[1].frame, "dirt"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/pite-core/src/registry.rs b/pite-core/src/registry.rs index e3c076c..c9a9904 100644 --- a/pite-core/src/registry.rs +++ b/pite-core/src/registry.rs @@ -12,7 +12,7 @@ pub type NodeFactory = Box Node + Send + Sync>; /// /// Registered set: `Node`, `Node2D`, `Sprite2D`, `Camera2D`, `Timer`, /// `Label`, `Button`, `StaticBody2D`, `CharacterBody2D`, `RigidBody2D`, -/// `Area2D`, `CollisionShape2D`. +/// `Area2D`, `CollisionShape2D`, `TileMap2D`. pub struct NodeTypeRegistry { factories: HashMap, } @@ -33,6 +33,7 @@ impl NodeTypeRegistry { "RigidBody2D", "Area2D", "CollisionShape2D", + "TileMap2D", ] { factories.insert( type_name.to_string(), diff --git a/pite-editor/src/app/inspector.rs b/pite-editor/src/app/inspector.rs index 3534ea9..1b6dcf6 100644 --- a/pite-editor/src/app/inspector.rs +++ b/pite-editor/src/app/inspector.rs @@ -122,6 +122,10 @@ impl EditorApp { ("height", PropValue::Num(32.0)), ("disabled", PropValue::Bool(false)), ], + "TileMap2D" => &[ + ("position", PropValue::Vec2(0.0, 0.0)), + ("tilemap", PropValue::Str(String::new())), + ], _ => &[], }; let missing: Vec<(&str, PropValue)> = known_props @@ -154,5 +158,127 @@ impl EditorApp { ui.label("no script attached."); } } + + if node.type_name == "TileMap2D" { + self.show_tilemap_inspector(ui, &node_id); + } + } + + fn show_tilemap_inspector(&mut self, ui: &mut egui::Ui, node_id: &NodeId) { + let doc_opt = self.session.host().get_tilemap_doc(node_id.as_str()); + let Some(mut doc) = doc_opt else { + ui.add_space(8.0); + ui.label("No tilemap loaded. Set `tilemap` property to a valid .ptmap file."); + return; + }; + + ui.add_space(8.0); + ui.separator(); + ui.heading("Tilemap Editor"); + + let mut changed = false; + + // Layer selection + ui.horizontal(|ui| { + ui.label("Active Layer:"); + let layer_count = doc.layers.len(); + if layer_count == 0 { + ui.label("None"); + } else { + if self.tilemap_active_layer >= layer_count { + self.tilemap_active_layer = 0; + } + let current_name = doc.layers[self.tilemap_active_layer].name.clone(); + egui::ComboBox::from_id_salt("tilemap_layers") + .selected_text(format!("[{}] {}", self.tilemap_active_layer, current_name)) + .show_ui(ui, |ui| { + for (i, layer) in doc.layers.iter().enumerate() { + ui.selectable_value( + &mut self.tilemap_active_layer, + i, + format!("[{i}] {}", layer.name), + ); + } + }); + } + }); + + // Layer properties + if let Some(layer) = doc.layers.get_mut(self.tilemap_active_layer) { + ui.horizontal(|ui| { + if ui.checkbox(&mut layer.visible, "Visible").changed() { + changed = true; + self.viewport_dirty = true; + } + if ui + .checkbox(&mut layer.collision_enabled, "Collisions") + .changed() + { + changed = true; + self.session.rebuild_physics(); + self.viewport_dirty = true; + } + }); + } + + // Tool toggles: Brush vs Eraser + ui.horizontal(|ui| { + let brush_active = !self.tilemap_eraser && self.tilemap_selected_frame.is_some(); + if ui.selectable_label(brush_active, "🖌 Brush").clicked() { + self.tilemap_eraser = false; + } + if ui + .selectable_label(self.tilemap_eraser, "🧹 Eraser") + .clicked() + { + self.tilemap_eraser = true; + } + }); + + if let Some(frame) = &self.tilemap_selected_frame { + ui.label(format!("Brush tile: {frame}")); + } else if self.tilemap_eraser { + ui.label("Brush: Erase mode"); + } else { + ui.label("Select a tile below to paint"); + } + + // Palette + ui.add_space(4.0); + ui.label("Tile Palette:"); + let sidecar_path = self.resolve_res(&doc.atlas); + if let Ok(atlas) = pite_render::atlas::load_atlas(&sidecar_path) { + egui::ScrollArea::vertical() + .id_salt("tile_palette") + .max_height(140.0) + .show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for name in atlas.names() { + let selected = !self.tilemap_eraser + && self.tilemap_selected_frame.as_deref() == Some(name); + if ui.selectable_label(selected, name).clicked() { + self.tilemap_selected_frame = Some(name.to_string()); + self.tilemap_eraser = false; + } + } + }); + }); + } else { + ui.label(format!("Cannot load atlas: {}", doc.atlas)); + } + + if changed { + let node_path = self.session.host().with_tree(|t| { + t.get(node_id).and_then(|n| match n.props.get("tilemap") { + Some(pite_core::PropValue::Str(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + }) + }); + if let Some(map_ref) = node_path { + let fs_path = self.resolve_res(&map_ref); + let _ = pite_scene::save_tilemap(&fs_path, &doc); + } + self.session.host().set_tilemap_doc(node_id.as_str(), doc); + } } } diff --git a/pite-editor/src/app/mod.rs b/pite-editor/src/app/mod.rs index a4925d3..e7851bb 100644 --- a/pite-editor/src/app/mod.rs +++ b/pite-editor/src/app/mod.rs @@ -104,6 +104,9 @@ pub struct EditorApp { pub(super) tab: Tab, pub(super) recent: Vec, pub(super) about_open: bool, + pub(super) tilemap_active_layer: usize, + pub(super) tilemap_selected_frame: Option, + pub(super) tilemap_eraser: bool, } impl EditorApp { @@ -160,6 +163,9 @@ impl EditorApp { tab: Tab::Viewport, recent: Self::load_recent(), about_open: false, + tilemap_active_layer: 0, + tilemap_selected_frame: None, + tilemap_eraser: false, }) } diff --git a/pite-editor/src/app/viewport.rs b/pite-editor/src/app/viewport.rs index e033a6f..a41be02 100644 --- a/pite-editor/src/app/viewport.rs +++ b/pite-editor/src/app/viewport.rs @@ -268,9 +268,129 @@ impl EditorApp { } } if !self.playing { + self.handle_tilemap_viewport(ui, &painter, view_rect, (view_w, view_h)); draw_collision_overlays(&self.session, &painter, view_rect, (view_w, view_h)); } } + + fn handle_tilemap_viewport( + &mut self, + ui: &mut egui::Ui, + painter: &egui::Painter, + view_rect: egui::Rect, + view_size: (u32, u32), + ) { + let Some(selected_id) = self.selected.clone() else { + return; + }; + let node_id = pite_core::NodeId::from(selected_id.clone()); + let is_tilemap = self + .session + .host() + .with_tree(|t| t.get(&node_id).is_some_and(|n| n.type_name == "TileMap2D")); + if !is_tilemap { + return; + } + + let doc_opt = self.session.host().get_tilemap_doc(&selected_id); + let Some(mut doc) = doc_opt else { + return; + }; + + let origin = self + .session + .host() + .with_tree(|t| pite_core::global_position(t, &node_id)); + let tile_size = (doc.tile_size[0] as f64, doc.tile_size[1] as f64); + let ((cam_x, cam_y), _cam_zoom) = self.session.camera_view(); + let eff_zoom = self.session.effective_zoom(); + + let hover_pos = ui.input(|i| i.pointer.hover_pos()); + let Some(cursor_pos) = hover_pos else { + return; + }; + if !view_rect.contains(cursor_pos) { + return; + } + + let Some(offscreen_coords) = viewport_cursor(view_rect, view_size, cursor_pos) else { + return; + }; + let world = pite_render::screen_to_world( + (offscreen_coords.0 as f32, offscreen_coords.1 as f32), + (cam_x, cam_y), + eff_zoom, + view_size, + ); + let cell = pite_scene::world_to_map(world, origin, tile_size); + + // Draw preview grid cell outline + let cell_tl_world = ( + origin.0 + cell.0 as f64 * tile_size.0, + origin.1 + cell.1 as f64 * tile_size.1, + ); + let cell_br_world = (cell_tl_world.0 + tile_size.0, cell_tl_world.1 + tile_size.1); + let s_tl = pite_render::world_to_screen(cell_tl_world, (cam_x, cam_y), eff_zoom, view_size); + let s_br = pite_render::world_to_screen(cell_br_world, (cam_x, cam_y), eff_zoom, view_size); + + let to_egui = |s: (f32, f32)| { + egui::Pos2::new( + view_rect.min.x + s.0 / view_size.0 as f32 * view_rect.width(), + view_rect.min.y + s.1 / view_size.1 as f32 * view_rect.height(), + ) + }; + let egui_rect = egui::Rect::from_min_max(to_egui(s_tl), to_egui(s_br)); + + let preview_color = if self.tilemap_eraser { + egui::Color32::from_rgba_unmultiplied(220, 50, 50, 160) + } else { + egui::Color32::from_rgba_unmultiplied(11, 147, 135, 180) + }; + painter.rect_stroke( + egui_rect, + 0.0, + egui::Stroke::new(1.5, preview_color), + egui::StrokeKind::Middle, + ); + + // Handle paint clicks / drags + let left_down = ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary)); + let right_down = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary)); + + let mut modified = false; + if left_down { + if self.tilemap_eraser { + if doc.erase_cell(self.tilemap_active_layer, cell.0, cell.1) { + modified = true; + } + } else if let Some(frame) = &self.tilemap_selected_frame { + if doc.get_cell(self.tilemap_active_layer, cell.0, cell.1) != Some(frame.as_str()) { + doc.set_cell(self.tilemap_active_layer, cell.0, cell.1, frame.clone()); + modified = true; + } + } + } else if right_down { + if doc.erase_cell(self.tilemap_active_layer, cell.0, cell.1) { + modified = true; + } + } + + if modified { + let map_ref_opt = self.session.host().with_tree(|t| { + t.get(&node_id).and_then(|n| match n.props.get("tilemap") { + Some(PropValue::Str(s)) if !s.is_empty() => Some(s.clone()), + _ => None, + }) + }); + if let Some(map_ref) = map_ref_opt { + let fs_path = self.resolve_res(&map_ref); + let _ = pite_scene::save_tilemap(&fs_path, &doc); + } + self.session.host().set_tilemap_doc(&selected_id, doc); + self.session.rebuild_physics(); + self.viewport_dirty = true; + } + } } struct CollisionGizmo { diff --git a/pite-editor/src/ops.rs b/pite-editor/src/ops.rs index d31c389..6ddfc8d 100644 --- a/pite-editor/src/ops.rs +++ b/pite-editor/src/ops.rs @@ -27,6 +27,7 @@ pub fn registered_types() -> Vec { "RigidBody2D", "Area2D", "CollisionShape2D", + "TileMap2D", ] .iter() .map(ToString::to_string) @@ -108,6 +109,12 @@ pub fn add_node( node.props .insert("disabled".to_string(), PropValue::Bool(false)); } + "TileMap2D" => { + node.props + .insert("position".to_string(), PropValue::Vec2(0.0, 0.0)); + node.props + .insert("tilemap".to_string(), PropValue::Str(String::new())); + } _ => {} } let parent = match parent { diff --git a/pite-physics/src/lib.rs b/pite-physics/src/lib.rs index f9224ac..a3f17cb 100644 --- a/pite-physics/src/lib.rs +++ b/pite-physics/src/lib.rs @@ -6,10 +6,12 @@ pub mod body; pub mod character; pub mod query; pub mod shape; +pub mod tilemap; pub mod world; pub use body::BodyKind; pub use character::CharacterMoveResult; pub use query::RaycastHit; pub use shape::ShapeConfig; +pub use tilemap::{merge_tile_cells, MergedTileRect}; pub use world::{PhysicsEvent, PhysicsWorld}; diff --git a/pite-physics/src/tilemap.rs b/pite-physics/src/tilemap.rs new file mode 100644 index 0000000..0314062 --- /dev/null +++ b/pite-physics/src/tilemap.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +//! Tilemap collision meshing: greedy 2D rectangle merger for Rapier2D static colliders. +//! +//! Coalesces adjacent solid grid cells into maximum rectangular bounding boxes, +//! eliminating internal edge catching for character controllers (`move_and_slide()`) +//! and reducing collider counts by an order of magnitude. + +use std::collections::HashSet; + +/// A merged 2D rectangle of cells: `(col, row, width, height)` in cell units. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MergedTileRect { + pub col: i32, + pub row: i32, + pub width: u32, + pub height: u32, +} + +/// Greedily merges contiguous 2D grid cells into a minimal set of non-overlapping +/// rectangles, preventing internal collision edge catching in Rapier2D. +pub fn merge_tile_cells(cells: &[(i32, i32)]) -> Vec { + if cells.is_empty() { + return Vec::new(); + } + + let mut unvisited: HashSet<(i32, i32)> = cells.iter().copied().collect(); + let mut sorted_cells: Vec<(i32, i32)> = cells.to_vec(); + // Sort primarily by row (Y), secondarily by col (X) + sorted_cells.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + sorted_cells.dedup(); + + let mut rects = Vec::new(); + + for &(x, y) in &sorted_cells { + if !unvisited.contains(&(x, y)) { + continue; + } + + // 1. Expand width to the right as far as possible + let mut w = 1; + while unvisited.contains(&(x + w, y)) { + w += 1; + } + + // 2. Expand height downwards as far as possible + let mut h = 1; + 'outer: loop { + let next_y = y + h; + for dx in 0..w { + if !unvisited.contains(&(x + dx, next_y)) { + break 'outer; + } + } + h += 1; + } + + // 3. Mark all cells in this rectangle as visited + for dy in 0..h { + for dx in 0..w { + unvisited.remove(&(x + dx, y + dy)); + } + } + + rects.push(MergedTileRect { + col: x, + row: y, + width: w as u32, + height: h as u32, + }); + } + + rects +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_and_single() { + assert_eq!(merge_tile_cells(&[]), vec![]); + + let single = merge_tile_cells(&[(5, 10)]); + assert_eq!( + single, + vec![MergedTileRect { + col: 5, + row: 10, + width: 1, + height: 1 + }] + ); + } + + #[test] + fn test_contiguous_rectangle() { + // 3x2 rectangle + let cells = vec![(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]; + let merged = merge_tile_cells(&cells); + assert_eq!( + merged, + vec![MergedTileRect { + col: 0, + row: 0, + width: 3, + height: 2 + }] + ); + } + + #[test] + fn test_l_shape() { + // L-shape: + // [X, X, X] + // [X, ., .] + let cells = vec![(0, 0), (1, 0), (2, 0), (0, 1)]; + let merged = merge_tile_cells(&cells); + assert_eq!( + merged, + vec![ + MergedTileRect { + col: 0, + row: 0, + width: 3, + height: 1 + }, + MergedTileRect { + col: 0, + row: 1, + width: 1, + height: 1 + }, + ] + ); + } + + #[test] + fn test_negative_coordinates() { + let cells = vec![(-2, -3), (-1, -3), (-2, -2), (-1, -2)]; + let merged = merge_tile_cells(&cells); + assert_eq!( + merged, + vec![MergedTileRect { + col: -2, + row: -3, + width: 2, + height: 2 + }] + ); + } +} diff --git a/pite-physics/src/world.rs b/pite-physics/src/world.rs index ef1a5c9..fe75f7c 100644 --- a/pite-physics/src/world.rs +++ b/pite-physics/src/world.rs @@ -256,6 +256,50 @@ impl PhysicsWorld { } } + /// Add static merged colliders for a TileMap2D layer. + pub fn add_tilemap_colliders( + &mut self, + tilemap_node_id: &NodeId, + layer_idx: usize, + origin: (f32, f32), + tile_size: (f32, f32), + solid_cells: &[(i32, i32)], + collision_layer: u32, + collision_mask: u32, + ) { + let merged = crate::tilemap::merge_tile_cells(solid_cells); + if merged.is_empty() { + return; + } + + let layer_body_id = NodeId::from(format!("{tilemap_node_id}_layer_{layer_idx}")); + self.add_static_body(layer_body_id.clone(), origin, 0.0); + + for (i, rect) in merged.into_iter().enumerate() { + let shape_id = NodeId::from(format!("{layer_body_id}_rect_{i}")); + let w_world = rect.width as f32 * tile_size.0; + let h_world = rect.height as f32 * tile_size.1; + let offset_x = (rect.col as f32 + rect.width as f32 * 0.5) * tile_size.0; + let offset_y = (rect.row as f32 + rect.height as f32 * 0.5) * tile_size.1; + + self.attach_shape( + &layer_body_id, + shape_id, + ShapeConfig::Box { + width: w_world, + height: h_world, + }, + (offset_x, offset_y), + 0.0, + false, + collision_layer, + collision_mask, + 0.0, + 0.0, + ); + } + } + /// 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) { diff --git a/pite-runtime/src/lib.rs b/pite-runtime/src/lib.rs index 3006b87..562ff9f 100644 --- a/pite-runtime/src/lib.rs +++ b/pite-runtime/src/lib.rs @@ -303,6 +303,50 @@ impl GameSession { } } } + let tilemap_nodes: Vec<(String, (f64, f64))> = self.host.with_tree(|tree| { + tree.iter() + .filter(|n| n.type_name == "TileMap2D") + .map(|n| (n.id.to_string(), global_position(tree, &n.id))) + .collect() + }); + for (id, pos) in tilemap_nodes { + if let Some(doc) = self.host.get_tilemap_doc(&id) { + let sidecar = self.resolve_path(&doc.atlas); + let sheet = pite_render::atlas::sheet_for_sidecar(&sidecar); + let sheet_str = sheet.to_string_lossy(); + let tile_w = doc.tile_size[0] as f64; + let tile_h = doc.tile_size[1] as f64; + let zoom = self.effective_zoom(); + let half_w = (self.viewport.0 as f64 / 2.0) / zoom; + let half_h = (self.viewport.1 as f64 / 2.0) / zoom; + let min_x = cam.0 - half_w; + let max_x = cam.0 + half_w; + let min_y = cam.1 - half_h; + let max_y = cam.1 + half_h; + + let min_col = ((min_x - pos.0) / tile_w).floor() as i32 - 1; + let max_col = ((max_x - pos.0) / tile_w).ceil() as i32 + 1; + let min_row = ((min_y - pos.1) / tile_h).floor() as i32 - 1; + let max_row = ((max_y - pos.1) / tile_h).ceil() as i32 + 1; + + for layer in &doc.layers { + if !layer.visible { + continue; + } + for tile in &layer.tiles { + if tile.0 >= min_col + && tile.0 <= max_col + && tile.1 >= min_row + && tile.1 <= max_row + { + let cx = pos.0 + (tile.0 as f64 + 0.5) * tile_w; + let cy = pos.1 + (tile.1 as f64 + 0.5) * tile_h; + r.draw_sprite_frame(&sheet_str, Some(&tile.2), cx, cy)?; + } + } + } + } + } let labels: Vec<(String, f32, [u8; 4], (f64, f64))> = self.host.with_tree(|tree| { tree.iter() .filter(|n| n.type_name == "Label") @@ -457,6 +501,30 @@ impl GameSession { } } + let tilemaps: Vec<(String, String)> = self.host.with_tree(|tree| { + tree.iter() + .filter(|n| n.type_name == "TileMap2D") + .filter_map(|n| { + let map_path = match n.props.get("tilemap") { + Some(PropValue::Str(s)) if !s.is_empty() => s.clone(), + _ => return None, + }; + Some((n.id.to_string(), map_path)) + }) + .collect() + }); + for (id, map_path) in tilemaps { + let resolved = self.resolve_path(&map_path); + match pite_scene::load_tilemap(&resolved) { + Ok(doc) => { + self.host.set_tilemap_doc(&id, doc); + } + Err(e) => { + tracing::error!(node = %id, "cannot load tilemap {}: {e:#}", resolved.display()); + } + } + } + self.rebuild_physics(); self.timers.clear(); let mut olds: HashMap = HashMap::new(); @@ -602,6 +670,9 @@ impl GameSession { scene_touched = true; } } + Some("ptmap") => { + scene_touched = true; + } Some("png") | Some("json") => { assets_touched.insert(path); } @@ -678,6 +749,25 @@ impl GameSession { "Area2D" => { world.add_area(node.id.clone(), (pos.0 as f32, pos.1 as f32), rot); } + "TileMap2D" => { + if let Some(doc) = self.host.get_tilemap_doc(node.id.as_str()) { + let tile_size = (doc.tile_size[0] as f32, doc.tile_size[1] as f32); + for (idx, layer) in doc.layers.iter().enumerate() { + if layer.collision_enabled { + let solid_cells = doc.solid_cells(idx); + world.add_tilemap_colliders( + &node.id, + idx, + (pos.0 as f32, pos.1 as f32), + tile_size, + &solid_cells, + layer.collision_layer, + layer.collision_mask, + ); + } + } + } + } _ => {} } } @@ -2224,4 +2314,147 @@ class Zone(pite.Area2D): std::fs::remove_dir_all(&dir).ok(); } + + #[test] + fn tilemap_loading_and_physics_e2e() { + let _guard = SERIAL.lock().unwrap(); + + let dir = std::env::temp_dir().join(format!("pite-tilemap-e2e-{}", std::process::id())); + 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::create_dir_all(dir.join("maps")).unwrap(); + std::fs::create_dir_all(dir.join("assets")).unwrap(); + std::fs::write( + dir.join("pite.toml"), + "[project]\nname = \"tilemap-test\"\n", + ) + .unwrap(); + + let atlas = r#"{ + "texture": "res://assets/tiles.png", + "size": [64, 64], + "frames": { + "grass": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "wall": { "x": 32, "y": 0, "w": 32, "h": 32 } + } +}"#; + std::fs::write(dir.join("assets").join("tiles.atlas.json"), atlas).unwrap(); + + let ptmap = r#"atlas = "res://assets/tiles.atlas.json" +tile_size = [32, 32] + +[[layer]] +name = "ground" +visible = true +collision_enabled = true +collision_layer = 1 +collision_mask = 1 +tiles = [ + [-1, 0, "wall"], + [0, 0, "wall"], + [1, 0, "wall"], +] +"#; + std::fs::write(dir.join("maps").join("level.ptmap"), ptmap).unwrap(); + + let scene = r#"format_version = 1 +root = "root" + +[[node]] +id = "root" +type = "Node2D" +name = "Root" + +[[node]] +id = "tilemap" +type = "TileMap2D" +name = "Map" +parent = "root" + +[node.props] +tilemap = "res://maps/level.ptmap" +position = [0.0, 100.0] + +[node.script] +path = "res://scripts/map.py" +class = "MapScript" + +[[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 map_script = r#"import pite + +class MapScript(pite.TileMap2D): + def _ready(self): + val = self.get_cell(0, 0) + assert val == "wall", f"expected wall, got {val}" + + layers = self.get_layers() + assert layers == ["ground"], f"got layers {layers}" + + self.set_cell(2, 0, "grass") + assert self.get_cell(2, 0) == "grass" + + col, row = self.world_to_map((0.0, 100.0)) + assert (col, row) == (0, 0), f"got ({col}, {row})" + world = self.map_to_world(0, 0) + assert world.x == 16.0 and world.y == 116.0, f"got {world.x}, {world.y}" +"#; + std::fs::write(dir.join("scripts").join("map.py"), map_script).unwrap(); + + let mut session = GameSession::open(&dir.join("scenes").join("main.ptscn"), true).unwrap(); + for _ in 0..40 { + 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 - 90.0).abs() < 2.0, + "Player should rest on tilemap floor at y=90, got {player_y}" + ); + + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/pite-scene/src/lib.rs b/pite-scene/src/lib.rs index 7234c15..cae18f7 100644 --- a/pite-scene/src/lib.rs +++ b/pite-scene/src/lib.rs @@ -14,8 +14,13 @@ use pite_core::{NodeDesc, NodeId, NodeTree, PropValue, Props, ScriptRef}; use serde::{Deserialize, Serialize}; pub mod cache; +pub mod tilemap; pub use cache::{CacheStatus, CACHE_VERSION}; +pub use tilemap::{ + load_tilemap, map_to_world, parse_tilemap_str, save_tilemap, world_to_map, TileCell, + TileMapDoc, TileMapLayer, +}; /// Current scene format version. Bump = migrate or error loudly, never silent. pub const FORMAT_VERSION: u32 = 1; diff --git a/pite-scene/src/tilemap.rs b/pite-scene/src/tilemap.rs new file mode 100644 index 0000000..ae7a91d --- /dev/null +++ b/pite-scene/src/tilemap.rs @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +//! `tilemap`: TileMap document schema, TOML serialization, coordinate math, +//! and layer management. +//! +//! Stores sparse tile layers referencing atlas frames. TOML is canonical +//! and human-readable, consistent with `.ptscn`. + +use std::collections::HashSet; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +fn default_true() -> bool { + true +} + +fn default_one() -> u32 { + 1 +} + +/// One tile cell in a sparse layer: `[col, row, "frame_name"]`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TileCell(pub i32, pub i32, pub String); + +impl TileCell { + pub fn new(col: i32, row: i32, frame: impl Into) -> Self { + Self(col, row, frame.into()) + } + + pub fn col(&self) -> i32 { + self.0 + } + + pub fn row(&self) -> i32 { + self.1 + } + + pub fn frame(&self) -> &str { + &self.2 + } +} + +/// One layer in a tilemap. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TileMapLayer { + pub name: String, + #[serde(default = "default_true")] + pub visible: bool, + #[serde(default)] + pub collision_enabled: bool, + #[serde(default = "default_one")] + pub collision_layer: u32, + #[serde(default = "default_one")] + pub collision_mask: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub solid_frames: Option>, + #[serde(default)] + pub tiles: Vec, +} + +impl TileMapLayer { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + visible: true, + collision_enabled: false, + collision_layer: 1, + collision_mask: 1, + solid_frames: None, + tiles: Vec::new(), + } + } +} + +/// Canonical `.ptmap` document. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TileMapDoc { + pub atlas: String, + pub tile_size: [u32; 2], + #[serde(default, rename = "layer")] + pub layers: Vec, +} + +impl TileMapDoc { + pub fn new(atlas: impl Into, tile_w: u32, tile_h: u32) -> Self { + Self { + atlas: atlas.into(), + tile_size: [tile_w, tile_h], + layers: Vec::new(), + } + } + + pub fn layer(&self, idx: usize) -> Option<&TileMapLayer> { + self.layers.get(idx) + } + + pub fn layer_mut(&mut self, idx: usize) -> Option<&mut TileMapLayer> { + self.layers.get_mut(idx) + } + + pub fn layer_names(&self) -> Vec<&str> { + self.layers.iter().map(|l| l.name.as_str()).collect() + } + + pub fn get_cell(&self, layer_idx: usize, col: i32, row: i32) -> Option<&str> { + let layer = self.layers.get(layer_idx)?; + layer + .tiles + .iter() + .find(|t| t.0 == col && t.1 == row) + .map(|t| t.2.as_str()) + } + + pub fn set_cell(&mut self, layer_idx: usize, col: i32, row: i32, frame: impl Into) { + if let Some(layer) = self.layers.get_mut(layer_idx) { + let frame = frame.into(); + if let Some(cell) = layer.tiles.iter_mut().find(|t| t.0 == col && t.1 == row) { + cell.2 = frame; + } else { + layer.tiles.push(TileCell(col, row, frame)); + } + } + } + + pub fn erase_cell(&mut self, layer_idx: usize, col: i32, row: i32) -> bool { + if let Some(layer) = self.layers.get_mut(layer_idx) { + if let Some(pos) = layer.tiles.iter().position(|t| t.0 == col && t.1 == row) { + layer.tiles.remove(pos); + return true; + } + } + false + } + + pub fn clear_layer(&mut self, layer_idx: usize) { + if let Some(layer) = self.layers.get_mut(layer_idx) { + layer.tiles.clear(); + } + } + + pub fn used_cells(&self, layer_idx: usize) -> Vec<(i32, i32)> { + let Some(layer) = self.layers.get(layer_idx) else { + return Vec::new(); + }; + layer.tiles.iter().map(|t| (t.0, t.1)).collect() + } + + /// Solid cells for a layer taking `collision_enabled` and `solid_frames` into account. + pub fn solid_cells(&self, layer_idx: usize) -> Vec<(i32, i32)> { + let Some(layer) = self.layers.get(layer_idx) else { + return Vec::new(); + }; + if !layer.collision_enabled { + return Vec::new(); + } + match &layer.solid_frames { + Some(solids) => { + let solid_set: HashSet<&str> = solids.iter().map(String::as_str).collect(); + layer + .tiles + .iter() + .filter(|t| solid_set.contains(t.2.as_str())) + .map(|t| (t.0, t.1)) + .collect() + } + None => layer.tiles.iter().map(|t| (t.0, t.1)).collect(), + } + } +} + +/// Convert world position to tilemap grid coordinate `(col, row)`. +pub fn world_to_map(world: (f64, f64), origin: (f64, f64), tile_size: (f64, f64)) -> (i32, i32) { + let w = if tile_size.0 <= 0.0 { + 16.0 + } else { + tile_size.0 + }; + let h = if tile_size.1 <= 0.0 { + 16.0 + } else { + tile_size.1 + }; + let col = ((world.0 - origin.0) / w).floor() as i32; + let row = ((world.1 - origin.1) / h).floor() as i32; + (col, row) +} + +/// Convert grid coordinate `(col, row)` to world center position `(x, y)`. +pub fn map_to_world(cell: (i32, i32), origin: (f64, f64), tile_size: (f64, f64)) -> (f64, f64) { + let w = if tile_size.0 <= 0.0 { + 16.0 + } else { + tile_size.0 + }; + let h = if tile_size.1 <= 0.0 { + 16.0 + } else { + tile_size.1 + }; + ( + origin.0 + (cell.0 as f64 + 0.5) * w, + origin.1 + (cell.1 as f64 + 0.5) * h, + ) +} + +pub fn parse_tilemap_str(text: &str) -> Result { + toml::from_str(text).context("invalid tilemap TOML") +} + +pub fn load_tilemap(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read tilemap {}", path.display()))?; + parse_tilemap_str(&text).with_context(|| format!("cannot parse tilemap {}", path.display())) +} + +pub fn save_tilemap(path: &Path, doc: &TileMapDoc) -> Result<()> { + let text = toml::to_string(doc).context("cannot serialize tilemap")?; + std::fs::write(path, text).with_context(|| format!("cannot write tilemap {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_tilemap_toml() { + let toml_src = r#"atlas = "res://assets/tileset.atlas.json" +tile_size = [16, 16] + +[[layer]] +name = "ground" +visible = true +collision_enabled = false +collision_layer = 1 +collision_mask = 1 +tiles = [ + [0, 0, "grass"], + [1, 0, "grass"], + [2, 0, "path"], +] + +[[layer]] +name = "walls" +visible = true +collision_enabled = true +collision_layer = 2 +collision_mask = 1 +solid_frames = ["wall_brick"] +tiles = [ + [0, 1, "wall_brick"], + [1, 1, "wall_window"], +] +"#; + let doc = parse_tilemap_str(toml_src).unwrap(); + assert_eq!(doc.atlas, "res://assets/tileset.atlas.json"); + assert_eq!(doc.tile_size, [16, 16]); + assert_eq!(doc.layers.len(), 2); + assert_eq!(doc.get_cell(0, 0, 0), Some("grass")); + assert_eq!(doc.get_cell(0, 2, 0), Some("path")); + assert_eq!(doc.get_cell(0, 3, 0), None); + + // Check collision query: only wall_brick is solid on layer 1 + let solids = doc.solid_cells(1); + assert_eq!(solids, vec![(0, 1)]); + + // Reserialize + let serialized = toml::to_string(&doc).unwrap(); + let doc2 = parse_tilemap_str(&serialized).unwrap(); + assert_eq!(doc, doc2); + } + + #[test] + fn coordinate_conversion() { + let origin = (100.0, 200.0); + let size = (16.0, 16.0); + + let cell = (2, 3); + let world = map_to_world(cell, origin, size); + assert_eq!(world, (100.0 + 2.5 * 16.0, 200.0 + 3.5 * 16.0)); + + let back_cell = world_to_map(world, origin, size); + assert_eq!(back_cell, cell); + + // Negative coordinates + let neg_cell = (-3, -5); + let neg_world = map_to_world(neg_cell, origin, size); + let back_neg = world_to_map(neg_world, origin, size); + assert_eq!(back_neg, neg_cell); + } + + #[test] + fn mutations() { + let mut doc = TileMapDoc::new("res://tiles.atlas.json", 16, 16); + doc.layers.push(TileMapLayer::new("main")); + assert_eq!(doc.get_cell(0, 0, 0), None); + + doc.set_cell(0, 0, 0, "stone"); + assert_eq!(doc.get_cell(0, 0, 0), Some("stone")); + + // Overwrite + doc.set_cell(0, 0, 0, "wood"); + assert_eq!(doc.get_cell(0, 0, 0), Some("wood")); + + // Erase + assert!(doc.erase_cell(0, 0, 0)); + assert_eq!(doc.get_cell(0, 0, 0), None); + assert!(!doc.erase_cell(0, 0, 0)); + } +} diff --git a/pite-script/Cargo.toml b/pite-script/Cargo.toml index 1f5f0ca..20ba0ca 100644 --- a/pite-script/Cargo.toml +++ b/pite-script/Cargo.toml @@ -23,4 +23,5 @@ pite-assets.workspace = true pite-audio.workspace = true pite-core.workspace = true pite-physics.workspace = true +pite-scene.workspace = true pyo3 = { version = "0.30", features = ["auto-initialize"] } diff --git a/pite-script/src/host.rs b/pite-script/src/host.rs index e5078ee..a16a616 100644 --- a/pite-script/src/host.rs +++ b/pite-script/src/host.rs @@ -42,6 +42,7 @@ pub struct ScriptHost { connections: Mutex>>, callable_connections: Mutex>>>, proxy_cache: Mutex>>, + tilemaps: Mutex>, } pub const SIGNAL_TYPES: &[&str] = &["int", "float", "str", "bool"]; @@ -63,6 +64,7 @@ impl ScriptHost { connections: Mutex::new(HashMap::new()), callable_connections: Mutex::new(HashMap::new()), proxy_cache: Mutex::new(HashMap::new()), + tilemaps: Mutex::new(HashMap::new()), } } @@ -102,6 +104,82 @@ impl ScriptHost { } } + pub fn get_tilemap_cell( + &self, + node_id: &str, + layer: usize, + col: i32, + row: i32, + ) -> Option { + let maps = self.tilemaps.lock().unwrap(); + let doc = maps.get(node_id)?; + doc.get_cell(layer, col, row).map(|s| s.to_string()) + } + + pub fn set_tilemap_cell(&self, node_id: &str, layer: usize, col: i32, row: i32, frame: &str) { + let mut maps = self.tilemaps.lock().unwrap(); + if let Some(doc) = maps.get_mut(node_id) { + doc.set_cell(layer, col, row, frame); + } + } + + pub fn erase_tilemap_cell(&self, node_id: &str, layer: usize, col: i32, row: i32) -> bool { + let mut maps = self.tilemaps.lock().unwrap(); + if let Some(doc) = maps.get_mut(node_id) { + doc.erase_cell(layer, col, row) + } else { + false + } + } + + pub fn clear_tilemap(&self, node_id: &str, layer: Option) { + let mut maps = self.tilemaps.lock().unwrap(); + if let Some(doc) = maps.get_mut(node_id) { + if let Some(l) = layer { + doc.clear_layer(l); + } else { + for l in 0..doc.layers.len() { + doc.clear_layer(l); + } + } + } + } + + pub fn get_tilemap_used_cells(&self, node_id: &str, layer: usize) -> Vec<(i32, i32)> { + let maps = self.tilemaps.lock().unwrap(); + let Some(doc) = maps.get(node_id) else { + return Vec::new(); + }; + doc.used_cells(layer) + } + + pub fn get_tilemap_layers(&self, node_id: &str) -> Vec { + let maps = self.tilemaps.lock().unwrap(); + let Some(doc) = maps.get(node_id) else { + return Vec::new(); + }; + doc.layers.iter().map(|l| l.name.clone()).collect() + } + + pub fn get_tilemap_tile_size(&self, node_id: &str) -> (f64, f64) { + let maps = self.tilemaps.lock().unwrap(); + if let Some(doc) = maps.get(node_id) { + (doc.tile_size[0] as f64, doc.tile_size[1] as f64) + } else { + (16.0, 16.0) + } + } + + pub fn set_tilemap_doc(&self, node_id: &str, doc: pite_scene::TileMapDoc) { + let mut maps = self.tilemaps.lock().unwrap(); + maps.insert(node_id.to_string(), doc); + } + + pub fn get_tilemap_doc(&self, node_id: &str) -> Option { + let maps = self.tilemaps.lock().unwrap(); + maps.get(node_id).cloned() + } + pub fn resolve_proxy( self: &std::sync::Arc, py: Python<'_>, diff --git a/pite-script/src/python.rs b/pite-script/src/python.rs index 66c112e..c9589ba 100644 --- a/pite-script/src/python.rs +++ b/pite-script/src/python.rs @@ -525,6 +525,166 @@ impl PyCollisionShape2D { } } +#[pyclass(extends = PyNode2D, subclass, name = "TileMap2D")] +struct PyTileMap2D { + #[pyo3(get, set)] + tilemap: String, +} + +#[pymethods] +impl PyTileMap2D { + #[new] + 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(PyTileMap2D { + tilemap: String::new(), + })) + } + + #[pyo3(signature = (col, row, layer = 0))] + fn get_cell( + slf: &Bound<'_, Self>, + col: i32, + row: i32, + layer: usize, + ) -> PyResult> { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + Ok(host.get_tilemap_cell(&node_path, layer, col, row)) + } else { + Ok(None) + } + } + + #[pyo3(signature = (col, row, frame, layer = 0))] + fn set_cell( + slf: &Bound<'_, Self>, + col: i32, + row: i32, + frame: &str, + layer: usize, + ) -> PyResult<()> { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + host.set_tilemap_cell(&node_path, layer, col, row, frame); + } + Ok(()) + } + + #[pyo3(signature = (col, row, layer = 0))] + fn erase_cell(slf: &Bound<'_, Self>, col: i32, row: i32, layer: usize) -> PyResult { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + Ok(host.erase_tilemap_cell(&node_path, layer, col, row)) + } else { + Ok(false) + } + } + + #[pyo3(signature = (layer = None))] + fn clear(slf: &Bound<'_, Self>, layer: Option) -> PyResult<()> { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + host.clear_tilemap(&node_path, layer); + } + Ok(()) + } + + #[pyo3(signature = (layer = 0))] + fn get_used_cells(slf: &Bound<'_, Self>, layer: usize) -> PyResult> { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + Ok(host.get_tilemap_used_cells(&node_path, layer)) + } else { + Ok(Vec::new()) + } + } + + #[getter] + fn tile_size(slf: &Bound<'_, Self>) -> PyResult<(u32, u32)> { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + let (w, h) = host.get_tilemap_tile_size(&node_path); + Ok((w as u32, h as u32)) + } else { + Ok((16, 16)) + } + } + + #[getter] + fn layers(slf: &Bound<'_, Self>) -> PyResult> { + let node_path: String = slf.getattr("node_path")?.extract()?; + if let Some(host) = crate::host::current_host() { + Ok(host.get_tilemap_layers(&node_path)) + } else { + Ok(Vec::new()) + } + } + + #[pyo3(signature = (*args))] + fn world_to_map( + slf: &Bound<'_, Self>, + args: &Bound<'_, pyo3::types::PyTuple>, + ) -> PyResult<(i32, i32)> { + let (wx, wy) = if args.len() == 2 { + let x: f64 = args.get_item(0)?.extract()?; + let y: f64 = args.get_item(1)?.extract()?; + (x, y) + } else if args.len() == 1 { + let item = args.get_item(0)?; + if let Ok(v) = item.extract::() { + (v.x(), v.y()) + } else if let Ok(t) = item.extract::<(f64, f64)>() { + t + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "pos must be Vec2 or (x, y) tuple", + )); + } + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "world_to_map expects 1 or 2 arguments (pos: Vec2 or x, y)", + )); + }; + let _py = slf.py(); + let node_path: String = slf.getattr("node_path")?.extract()?; + let origin = if let Some(host) = crate::host::current_host() { + host.get_node_position(&node_path).unwrap_or((0.0, 0.0)) + } else { + let pos_attr = slf.getattr("position")?; + let v = pos_attr.extract::()?; + (v.x(), v.y()) + }; + let tile_size = if let Some(host) = crate::host::current_host() { + host.get_tilemap_tile_size(&node_path) + } else { + (16.0, 16.0) + }; + Ok(pite_scene::world_to_map((wx, wy), origin, tile_size)) + } + + fn map_to_world(slf: &Bound<'_, Self>, col: i32, row: i32) -> PyResult> { + let py = slf.py(); + let node_path: String = slf.getattr("node_path")?.extract()?; + let origin = if let Some(host) = crate::host::current_host() { + host.get_node_position(&node_path).unwrap_or((0.0, 0.0)) + } else { + let pos_attr = slf.getattr("position")?; + let v = pos_attr.extract::()?; + (v.x(), v.y()) + }; + let tile_size = if let Some(host) = crate::host::current_host() { + host.get_tilemap_tile_size(&node_path) + } else { + (16.0, 16.0) + }; + let (wx, wy) = pite_scene::map_to_world((col, row), origin, tile_size); + Py::new(py, crate::vec2::Vec2::new(wx, wy)) + } +} + #[pyfunction] #[pyo3(signature = (from, to, mask = 0xFFFFFFFF))] fn raycast( @@ -585,6 +745,7 @@ 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::()?; @@ -1720,4 +1881,97 @@ class Listener(pite.Node2D): assert!(is_space_pressed); }); } + + #[test] + fn tilemap2d_python_api_methods() { + let _guard = crate::host::HOST_SERIAL.lock().unwrap(); + crate::python::ensure_init(); + + pyo3::Python::attach(|py| { + let host = + std::sync::Arc::new(crate::host::ScriptHost::new(pite_core::NodeTree::new())); + crate::host::set_current_host(host.clone()); + + let mut doc = pite_scene::tilemap::TileMapDoc::new("res://tiles.atlas.json", 16, 16); + let layer = pite_scene::tilemap::TileMapLayer::new("ground"); + doc.layers.push(layer); + doc.set_cell(0, 1, 2, "grass"); + host.set_tilemap_doc("Root/Map", doc); + + let pite_mod = py.import("pite").unwrap(); + let tilemap_cls = pite_mod.getattr("TileMap2D").unwrap(); + let node = tilemap_cls.call0().unwrap(); + node.setattr("node_path", "Root/Map").unwrap(); + + // tile_size + let ts: (u32, u32) = node.getattr("tile_size").unwrap().extract().unwrap(); + assert_eq!(ts, (16, 16)); + + // layers + let layers: Vec = node.getattr("layers").unwrap().extract().unwrap(); + assert_eq!(layers, vec!["ground"]); + + // get_cell + let cell: Option = node + .call_method1("get_cell", (1, 2)) + .unwrap() + .extract() + .unwrap(); + assert_eq!(cell, Some("grass".to_string())); + + // set_cell + node.call_method1("set_cell", (3, 4, "stone")).unwrap(); + let cell2: Option = node + .call_method1("get_cell", (3, 4)) + .unwrap() + .extract() + .unwrap(); + assert_eq!(cell2, Some("stone".to_string())); + + // get_used_cells + let used: Vec<(i32, i32)> = node + .call_method1("get_used_cells", (0,)) + .unwrap() + .extract() + .unwrap(); + assert!(used.contains(&(1, 2))); + assert!(used.contains(&(3, 4))); + + // erase_cell + let erased: bool = node + .call_method1("erase_cell", (1, 2)) + .unwrap() + .extract() + .unwrap(); + assert!(erased); + let cell_erased: Option = node + .call_method1("get_cell", (1, 2)) + .unwrap() + .extract() + .unwrap(); + assert_eq!(cell_erased, None); + + // world_to_map / map_to_world + let coord: (i32, i32) = node + .call_method1("world_to_map", (32.0, 48.0)) + .unwrap() + .extract() + .unwrap(); + assert_eq!(coord, (2, 3)); + let wpos: pyo3::Bound<'_, pyo3::PyAny> = + node.call_method1("map_to_world", (2, 3)).unwrap(); + let wx: f32 = wpos.getattr("x").unwrap().extract().unwrap(); + let wy: f32 = wpos.getattr("y").unwrap().extract().unwrap(); + assert_eq!((wx, wy), (40.0, 56.0)); + + // clear + node.call_method0("clear").unwrap(); + let used_after: Vec<(i32, i32)> = node + .call_method1("get_used_cells", (0,)) + .unwrap() + .extract() + .unwrap(); + assert!(used_after.is_empty()); + }); + } }