Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
9 changes: 8 additions & 1 deletion docs/scene-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
31 changes: 31 additions & 0 deletions docs/scripting-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
102 changes: 102 additions & 0 deletions pite-cli/src/cmd_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
&registry,
&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);
}
}
3 changes: 2 additions & 1 deletion pite-core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub type NodeFactory = Box<dyn Fn(NodeId) -> 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<String, NodeFactory>,
}
Expand All @@ -33,6 +33,7 @@ impl NodeTypeRegistry {
"RigidBody2D",
"Area2D",
"CollisionShape2D",
"TileMap2D",
] {
factories.insert(
type_name.to_string(),
Expand Down
126 changes: 126 additions & 0 deletions pite-editor/src/app/inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}
6 changes: 6 additions & 0 deletions pite-editor/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ pub struct EditorApp {
pub(super) tab: Tab,
pub(super) recent: Vec<PathBuf>,
pub(super) about_open: bool,
pub(super) tilemap_active_layer: usize,
pub(super) tilemap_selected_frame: Option<String>,
pub(super) tilemap_eraser: bool,
}

impl EditorApp {
Expand Down Expand Up @@ -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,
})
}

Expand Down
Loading
Loading