Skip to main content

pite_runtime/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `pite-runtime`: thin orchestrator wiring scene + render + script.
3//! Opens a window, loads the scene file into a node tree, runs the loop.
4
5use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7use std::sync::mpsc::{self, Receiver};
8use std::sync::{Arc, Mutex};
9use std::time::Instant;
10
11use anyhow::{Context, Result};
12use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
13use pite_core::{global_position, NodeId, NodeTree, PropValue};
14use pite_physics::{PhysicsEvent, PhysicsWorld, ShapeConfig};
15use pite_render::{Renderer2D, WgpuRenderer};
16use pite_script::{
17    input_mouse, input_pressed, input_released, Pyo3Backend, ScriptBackend, ScriptHost,
18};
19use winit::application::ApplicationHandler;
20use winit::event::{ElementState, MouseButton, WindowEvent};
21use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
22use winit::keyboard::{KeyCode, PhysicalKey};
23use winit::window::{Window, WindowId};
24
25fn key_name(code: KeyCode) -> Option<&'static str> {
26    Some(match code {
27        KeyCode::ArrowLeft => "ArrowLeft",
28        KeyCode::ArrowRight => "ArrowRight",
29        KeyCode::ArrowUp => "ArrowUp",
30        KeyCode::ArrowDown => "ArrowDown",
31        KeyCode::Space => "Space",
32        KeyCode::Enter => "Enter",
33        KeyCode::Escape => "Escape",
34        KeyCode::Tab => "Tab",
35        KeyCode::Backspace => "Backspace",
36        KeyCode::ShiftLeft | KeyCode::ShiftRight => "Shift",
37        KeyCode::ControlLeft | KeyCode::ControlRight => "Control",
38        KeyCode::AltLeft | KeyCode::AltRight => "Alt",
39        KeyCode::KeyA => "A",
40        KeyCode::KeyB => "B",
41        KeyCode::KeyC => "C",
42        KeyCode::KeyD => "D",
43        KeyCode::KeyE => "E",
44        KeyCode::KeyF => "F",
45        KeyCode::KeyG => "G",
46        KeyCode::KeyH => "H",
47        KeyCode::KeyI => "I",
48        KeyCode::KeyJ => "J",
49        KeyCode::KeyK => "K",
50        KeyCode::KeyL => "L",
51        KeyCode::KeyM => "M",
52        KeyCode::KeyN => "N",
53        KeyCode::KeyO => "O",
54        KeyCode::KeyP => "P",
55        KeyCode::KeyQ => "Q",
56        KeyCode::KeyR => "R",
57        KeyCode::KeyS => "S",
58        KeyCode::KeyT => "T",
59        KeyCode::KeyU => "U",
60        KeyCode::KeyV => "V",
61        KeyCode::KeyW => "W",
62        KeyCode::KeyX => "X",
63        KeyCode::KeyY => "Y",
64        KeyCode::KeyZ => "Z",
65        KeyCode::Digit0 => "0",
66        KeyCode::Digit1 => "1",
67        KeyCode::Digit2 => "2",
68        KeyCode::Digit3 => "3",
69        KeyCode::Digit4 => "4",
70        KeyCode::Digit5 => "5",
71        KeyCode::Digit6 => "6",
72        KeyCode::Digit7 => "7",
73        KeyCode::Digit8 => "8",
74        KeyCode::Digit9 => "9",
75        _ => return None,
76    })
77}
78
79#[derive(Debug, Clone)]
80pub struct RunOptions {
81    pub scene: PathBuf,
82    pub no_reload: bool,
83}
84
85struct ScriptSlot {
86    id: NodeId,
87    file: PathBuf,
88    backend: Box<dyn ScriptBackend>,
89    errored: bool,
90}
91
92pub struct GameSession {
93    host: Arc<ScriptHost>,
94    slots: Vec<ScriptSlot>,
95    scene: PathBuf,
96    scene_dir: PathBuf,
97    project_dir: Option<PathBuf>,
98    no_reload: bool,
99    _watcher: Option<RecommendedWatcher>,
100    watch_rx: Option<Receiver<std::result::Result<notify::Event, notify::Error>>>,
101    viewport: (u32, u32),
102    armed: HashSet<String>,
103    timers: std::collections::HashMap<String, f64>,
104    physics: Arc<Mutex<PhysicsWorld>>,
105    physics_accumulator: f64,
106    pub pause_on_error: bool,
107    pub last_error: Option<pite_script::ScriptError>,
108    pub dirty_assets: HashSet<PathBuf>,
109}
110
111impl GameSession {
112    pub fn open(scene: &Path, no_reload: bool) -> Result<Self> {
113        let scene_dir = scene
114            .parent()
115            .map(Path::to_path_buf)
116            .unwrap_or_else(|| PathBuf::from("."));
117        let project_dir = pite_project::find_project_root(scene);
118        let physics = Arc::new(Mutex::new(PhysicsWorld::new()));
119        let mut session = Self {
120            host: Arc::new(ScriptHost::new(NodeTree::new())),
121            slots: Vec::new(),
122            scene: scene.to_path_buf(),
123            scene_dir,
124            project_dir,
125            no_reload,
126            _watcher: None,
127            watch_rx: None,
128            viewport: (800, 600),
129            armed: HashSet::new(),
130            timers: std::collections::HashMap::new(),
131            physics,
132            physics_accumulator: 0.0,
133            pause_on_error: false,
134            last_error: None,
135            dirty_assets: HashSet::new(),
136        };
137        pite_script::set_current_host(session.host.clone());
138        pite_script::set_current_physics(Some(session.physics.clone()));
139        pite_audio::set_project_dir(session.project_dir.clone());
140        pite_script::set_project_dir(session.project_dir.clone());
141        session.rebuild()?;
142        if !session.no_reload {
143            session.start_watcher()?;
144        }
145        Ok(session)
146    }
147
148    pub fn host(&self) -> &Arc<ScriptHost> {
149        &self.host
150    }
151
152    pub fn set_pause_on_error(&mut self, pause: bool) {
153        self.pause_on_error = pause;
154    }
155
156    pub fn take_last_error(&mut self) -> Option<pite_script::ScriptError> {
157        self.last_error.take()
158    }
159
160    pub fn resolve_path(&self, res_path: &str) -> PathBuf {
161        resolve_script(res_path, &self.scene_dir, self.project_dir.as_deref())
162            .unwrap_or_else(|| PathBuf::from(res_path))
163    }
164
165    pub fn errors(&self) -> Vec<String> {
166        self.slots
167            .iter()
168            .filter_map(|s| s.backend.last_error())
169            .collect()
170    }
171
172    pub fn reload_script_path(&mut self, path: &Path) -> bool {
173        let mut reloaded = false;
174        for slot in self.slots.iter_mut().filter(|s| same_file(&s.file, path)) {
175            match slot.backend.reload() {
176                Ok(()) => {
177                    slot.errored = false;
178                    self.host.invalidate_proxy_cache(slot.id.as_str());
179                    reloaded = true;
180                    tracing::info!(node = %slot.id, "script reloaded");
181                }
182                Err(e) => {
183                    tracing::error!(node = %slot.id, "reload failed, keeping last good state: {e:#}")
184                }
185            }
186        }
187        reloaded
188    }
189
190    pub fn is_scene_affected(&self, path: &Path) -> bool {
191        if same_file(path, &self.scene) {
192            return true;
193        }
194        if let Ok(doc) = pite_scene::load_scene_cached(&self.scene) {
195            for inst in &doc.instance {
196                if let Ok(inst_path) = pite_scene::resolve_scene_ref(
197                    &inst.scene,
198                    &self.scene_dir,
199                    self.project_dir.as_deref(),
200                ) {
201                    if same_file(path, &inst_path) {
202                        return true;
203                    }
204                }
205            }
206        }
207        false
208    }
209
210    pub fn tree_len(&self) -> usize {
211        self.host.with_tree(|t| t.len())
212    }
213
214    pub fn script_count(&self) -> usize {
215        self.slots.len()
216    }
217
218    pub fn set_viewport(&mut self, w: u32, h: u32) {
219        if w > 0 && h > 0 {
220            self.viewport = (w, h);
221        }
222    }
223
224    /// Project window size: explicit `window_width/height` when set, else the
225    /// engine default. Doubles as the design resolution the game scales from
226    /// and as the window's restore size when maximized.
227    pub fn design_size(&self) -> Option<(u32, u32)> {
228        let dir = self.project_dir.as_deref()?;
229        pite_project::load_manifest(dir)
230            .ok()
231            .map(|m| (m.project.window_width, m.project.window_height))
232    }
233
234    /// Camera zoom composed with the contain-fit scale, so content keeps the
235    /// design aspect at any window size. `button_at` shares it, keeping
236    /// clicks on what is drawn.
237    pub fn effective_zoom(&self) -> f64 {
238        let ((_, _), zoom) = self.camera_view();
239        let design = self.design_size().unwrap_or(self.viewport);
240        zoom * pite_render::fit_scale(design, self.viewport)
241    }
242
243    pub fn camera_view(&self) -> ((f64, f64), f64) {
244        self.host.with_tree(|tree| {
245            tree.iter()
246                .find(|n| n.type_name == "Camera2D")
247                .map(|cam| {
248                    let zoom = match cam.props.get("zoom") {
249                        Some(PropValue::Num(z)) => *z,
250                        _ => 1.0,
251                    };
252                    (global_position(tree, &cam.id), zoom)
253                })
254                .unwrap_or(((0.0, 0.0), 1.0))
255        })
256    }
257
258    pub fn draw_into(&mut self, r: &mut dyn Renderer2D) -> Result<()> {
259        if !self.dirty_assets.is_empty() {
260            for p in self.dirty_assets.drain() {
261                r.reload_asset(&p.to_string_lossy());
262            }
263        }
264        let (cam, _) = self.camera_view();
265        r.set_camera(cam.0, cam.1, self.effective_zoom());
266        let sprite_nodes: Vec<(String, String, Option<String>, Option<String>, (f64, f64))> =
267            self.host.with_tree(|tree| {
268                tree.iter()
269                    .filter(|n| n.type_name == "Sprite2D")
270                    .map(|n| {
271                        let prop = |key: &str| match n.props.get(key) {
272                            Some(PropValue::Str(s)) => Some(s.clone()),
273                            _ => None,
274                        };
275                        (
276                            n.id.to_string(),
277                            prop("texture").unwrap_or_default(),
278                            prop("atlas"),
279                            prop("frame"),
280                            global_position(tree, &n.id),
281                        )
282                    })
283                    .collect()
284            });
285        for (id, texture, atlas, frame, pos) in sprite_nodes {
286            let has_texture = !texture.is_empty();
287            if has_texture && atlas.is_some() {
288                anyhow::bail!("node {id}: `texture` and `atlas` are mutually exclusive");
289            }
290            match (atlas, frame) {
291                (Some(atlas), Some(frame)) => {
292                    let sidecar = self.resolve_path(&atlas);
293                    let sheet = pite_render::atlas::sheet_for_sidecar(&sidecar);
294                    r.draw_sprite_frame(&sheet.to_string_lossy(), Some(&frame), pos.0, pos.1)?;
295                }
296                (Some(_), None) => anyhow::bail!("node {id}: `atlas` requires a `frame`"),
297                (None, Some(_)) => anyhow::bail!("node {id}: `frame` requires an `atlas`"),
298                (None, None) => {
299                    if has_texture {
300                        let resolved = self.resolve_path(&texture);
301                        r.draw_sprite(&resolved.to_string_lossy(), pos.0, pos.1)?;
302                    }
303                }
304            }
305        }
306        let labels: Vec<(String, f32, [u8; 4], (f64, f64))> = self.host.with_tree(|tree| {
307            tree.iter()
308                .filter(|n| n.type_name == "Label")
309                .filter_map(|n| {
310                    let text = match n.props.get("text") {
311                        Some(PropValue::Str(s)) if !s.is_empty() => s.clone(),
312                        _ => return None,
313                    };
314                    let size = match n.props.get("font_size") {
315                        Some(PropValue::Num(s)) => *s as f32,
316                        Some(PropValue::Int(s)) => *s as f32,
317                        _ => 16.0,
318                    };
319                    let color = match n.props.get("color") {
320                        Some(PropValue::Str(s)) => pite_render::text::parse_color(s),
321                        _ => [255, 255, 255, 255],
322                    };
323                    Some((text, size, color, global_position(tree, &n.id)))
324                })
325                .collect()
326        });
327        for (text, size, color, pos) in labels {
328            r.draw_text(&text, pos.0, pos.1, size, color)?;
329        }
330        let buttons: Vec<(String, f32, [u8; 4], (f64, f64), (f64, f64))> =
331            self.host.with_tree(|tree| {
332                tree.iter()
333                    .filter(|n| n.type_name == "Button")
334                    .map(|n| {
335                        let text = match n.props.get("text") {
336                            Some(PropValue::Str(s)) => s.clone(),
337                            _ => String::new(),
338                        };
339                        let size = match n.props.get("font_size") {
340                            Some(PropValue::Num(s)) => *s as f32,
341                            Some(PropValue::Int(s)) => *s as f32,
342                            _ => 16.0,
343                        };
344                        let color = match n.props.get("color") {
345                            Some(PropValue::Str(s)) => pite_render::text::parse_color(s),
346                            _ => [51, 65, 85, 255],
347                        };
348                        let (w, h) = button_size(n);
349                        (text, size, color, global_position(tree, &n.id), (w, h))
350                    })
351                    .collect()
352            });
353        for (text, size, color, pos, (w, h)) in buttons {
354            r.draw_rect(pos.0, pos.1, w, h, color)?;
355            if !text.is_empty() {
356                r.draw_text(&text, pos.0, pos.1, size, [255, 255, 255, 255])?;
357            }
358        }
359        Ok(())
360    }
361
362    pub fn button_at(&self, screen: (f64, f64)) -> Option<String> {
363        let ((cx, cy), _) = self.camera_view();
364        let zoom = self.effective_zoom();
365        let (world_x, world_y) = pite_render::screen_to_world(
366            (screen.0 as f32, screen.1 as f32),
367            (cx, cy),
368            zoom,
369            self.viewport,
370        );
371        self.host.with_tree(|tree| {
372            tree.iter()
373                .filter(|n| n.type_name == "Button")
374                .find(|n| {
375                    let (w, h) = button_size(n);
376                    let (px, py) = global_position(tree, &n.id);
377                    (world_x - px).abs() <= w / 2.0 && (world_y - py).abs() <= h / 2.0
378                })
379                .map(|n| n.id.to_string())
380        })
381    }
382
383    fn fire_pressed(&self, id: &str) {
384        if !self.host.has_signal(id, "pressed") {
385            return;
386        }
387        if let Err(e) = pite_script::fire_pressed(&self.host, id) {
388            tracing::error!(node = %id, "pressed handlers failed: {e:#}");
389        }
390    }
391
392    fn fire_timeout(&self, id: &str) {
393        if !self.host.has_signal(id, "timeout") {
394            return;
395        }
396        if let Err(e) = pite_script::fire_timeout(&self.host, id) {
397            tracing::error!(node = %id, "timeout handlers failed: {e:#}");
398        }
399    }
400
401    fn rebuild(&mut self) -> Result<()> {
402        let doc = pite_scene::load_scene_cached(&self.scene)
403            .with_context(|| format!("cannot load scene {}", self.scene.display()))?;
404        let tree = pite_scene::build_tree(&doc, &self.scene_dir, self.project_dir.as_deref())?;
405        self.host.with_tree_mut(|t| *t = tree);
406        // `pressed` belongs to the Button type itself (Godot-style), not to
407        // each script: the engine fires it, so it must be declared BEFORE
408        // any `_ready` runs — scripts connect to it during `_ready`.
409        // Re-declaring it in a script is a harmless overwrite.
410        let buttons: Vec<String> = self.host.with_tree(|tree| {
411            tree.iter()
412                .filter(|n| n.type_name == "Button")
413                .map(|n| n.id.to_string())
414                .collect()
415        });
416        for id in &buttons {
417            if let Err(e) = self
418                .host
419                .declare_signals(id, vec![("pressed".to_string(), Vec::new())])
420            {
421                tracing::error!(node = %id, "cannot declare builtin pressed: {e:#}");
422            }
423        }
424        // `timeout` belongs to the Timer type itself, like `pressed` to
425        // Button: the engine fires it, so it must be declared BEFORE any
426        // `_ready` runs — scripts connect to it during `_ready`.
427        let timers: Vec<String> = self.host.with_tree(|tree| {
428            tree.iter()
429                .filter(|n| n.type_name == "Timer")
430                .map(|n| n.id.to_string())
431                .collect()
432        });
433        for id in &timers {
434            if let Err(e) = self
435                .host
436                .declare_signals(id, vec![("timeout".to_string(), Vec::new())])
437            {
438                tracing::error!(node = %id, "cannot declare builtin timeout: {e:#}");
439            }
440        }
441
442        let areas: Vec<String> = self.host.with_tree(|tree| {
443            tree.iter()
444                .filter(|n| n.type_name == "Area2D")
445                .map(|n| n.id.to_string())
446                .collect()
447        });
448        for id in &areas {
449            let signals = vec![
450                ("body_entered".to_string(), vec!["str".to_string()]),
451                ("body_exited".to_string(), vec!["str".to_string()]),
452                ("area_entered".to_string(), vec!["str".to_string()]),
453                ("area_exited".to_string(), vec!["str".to_string()]),
454            ];
455            if let Err(e) = self.host.declare_signals(id, signals) {
456                tracing::error!(node = %id, "cannot declare builtin area signals: {e:#}");
457            }
458        }
459
460        self.rebuild_physics();
461        self.timers.clear();
462        let mut olds: HashMap<String, ScriptSlot> = HashMap::new();
463        for slot in self.slots.drain(..) {
464            olds.insert(slot.id.to_string(), slot);
465        }
466        let wanted: Vec<(NodeId, PathBuf, String)> = self.host.with_tree(|tree| {
467            tree.iter()
468                .filter_map(|node| {
469                    let script = node.script.as_ref()?;
470                    let path =
471                        resolve_script(&script.path, &self.scene_dir, self.project_dir.as_deref())?;
472                    Some((node.id.clone(), path, script.class_name.clone()))
473                })
474                .collect()
475        });
476        for (id, path, class) in wanted {
477            if let Some(mut slot) = olds.remove(&id.to_string()) {
478                if slot.file != path {
479                    slot.file = path;
480                    if let Err(e) = slot.backend.load(&slot.file.to_string_lossy(), &class) {
481                        tracing::error!(node = %id, "cannot reload script: {e:#}");
482                        slot.errored = true;
483                    }
484                }
485                let seeds = self.host.with_tree(|tree| {
486                    (
487                        seed_position(tree, &id),
488                        seed_text(tree, &id),
489                        seed_size(tree, &id),
490                        seed_wait(tree, &id),
491                    )
492                });
493                if let Some((x, y)) = seeds.0 {
494                    slot.backend.set_position(x, y);
495                }
496                if let Some(text) = seeds.1 {
497                    slot.backend.set_text(&text);
498                }
499                if let Some((w, h)) = seeds.2 {
500                    slot.backend.set_size(w, h);
501                }
502                if let Some(wait) = seeds.3 {
503                    slot.backend.set_wait_time(wait);
504                }
505                self.slots.push(slot);
506                continue;
507            }
508            if !path.is_file() {
509                tracing::warn!(node = %id, script = %path.display(), "missing script, keeping placeholder node");
510                continue;
511            }
512            let mut backend = Box::new(Pyo3Backend::new(id.to_string()));
513            backend.set_host(self.host.clone());
514            match backend.load(&path.to_string_lossy(), &class) {
515                Ok(()) => {
516                    let seeds = self.host.with_tree(|tree| {
517                        (
518                            seed_position(tree, &id),
519                            seed_text(tree, &id),
520                            seed_size(tree, &id),
521                            seed_wait(tree, &id),
522                        )
523                    });
524                    if let Some((x, y)) = seeds.0 {
525                        backend.set_position(x, y);
526                    }
527                    if let Some(text) = seeds.1 {
528                        backend.set_text(&text);
529                    }
530                    if let Some((w, h)) = seeds.2 {
531                        backend.set_size(w, h);
532                    }
533                    if let Some(wait) = seeds.3 {
534                        backend.set_wait_time(wait);
535                    }
536                    let mut errored = false;
537                    if let Err(e) = backend.call_ready() {
538                        tracing::error!(node = %id, "{e:#}");
539                        errored = true;
540                    }
541                    self.slots.push(ScriptSlot {
542                        id,
543                        file: path,
544                        backend,
545                        errored,
546                    });
547                }
548                Err(e) => {
549                    tracing::error!(node = %id, "cannot load script: {e:#}");
550                }
551            }
552        }
553        for (id, _) in olds {
554            self.host.unregister(&id);
555            self.host.disconnect_node(&id);
556        }
557        tracing::info!(
558            nodes = self.tree_len(),
559            scripts = self.slots.len(),
560            "scene ready"
561        );
562        Ok(())
563    }
564
565    fn start_watcher(&mut self) -> Result<()> {
566        let root = self
567            .project_dir
568            .clone()
569            .unwrap_or_else(|| self.scene_dir.clone());
570        let (tx, rx) = mpsc::channel();
571        let mut watcher = RecommendedWatcher::new(tx, notify::Config::default())?;
572        watcher.watch(&root, RecursiveMode::Recursive)?;
573        self._watcher = Some(watcher);
574        self.watch_rx = Some(rx);
575        tracing::info!(watch = %root.display(), "hot reload watching");
576        Ok(())
577    }
578
579    pub fn poll_watch(&mut self) -> bool {
580        if self.no_reload {
581            return false;
582        }
583        let mut script_touched: HashSet<PathBuf> = HashSet::new();
584        let mut scene_touched = false;
585        let mut assets_touched: HashSet<PathBuf> = HashSet::new();
586        if let Some(rx) = &self.watch_rx {
587            while let Ok(result) = rx.try_recv() {
588                let Ok(event) = result else { continue };
589                if !matches!(
590                    event.kind,
591                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
592                ) {
593                    continue;
594                }
595                for path in event.paths {
596                    match path.extension().and_then(|e| e.to_str()) {
597                        Some("py") => {
598                            script_touched.insert(path);
599                        }
600                        Some("ptscn") => {
601                            if self.is_scene_affected(&path) {
602                                scene_touched = true;
603                            }
604                        }
605                        Some("png") | Some("json") => {
606                            assets_touched.insert(path);
607                        }
608                        _ => {}
609                    }
610                }
611            }
612        }
613        let mut changed = false;
614        for path in script_touched {
615            if self.reload_script_path(&path) {
616                changed = true;
617            }
618        }
619        if !assets_touched.is_empty() {
620            self.dirty_assets.extend(assets_touched);
621            changed = true;
622        }
623        if scene_touched {
624            match self.rebuild() {
625                Ok(()) => {
626                    changed = true;
627                    tracing::info!("scene reloaded");
628                }
629                Err(e) => tracing::error!("scene reload failed, keeping old tree: {e:#}"),
630            }
631        }
632        changed
633    }
634
635    pub fn rebuild_physics(&mut self) {
636        let mut world = PhysicsWorld::new();
637        self.host.with_tree(|tree| {
638            // 1. Add bodies & areas
639            for node in tree.iter() {
640                let pos = global_position(tree, &node.id);
641                let rot = match node.props.get("rotation") {
642                    Some(PropValue::Num(r)) => *r as f32,
643                    _ => 0.0,
644                };
645                match node.type_name.as_str() {
646                    "StaticBody2D" => {
647                        world.add_static_body(node.id.clone(), (pos.0 as f32, pos.1 as f32), rot);
648                    }
649                    "CharacterBody2D" => {
650                        world.add_character_body(
651                            node.id.clone(),
652                            (pos.0 as f32, pos.1 as f32),
653                            rot,
654                        );
655                    }
656                    "RigidBody2D" => {
657                        let mass = match node.props.get("mass") {
658                            Some(PropValue::Num(m)) => *m as f32,
659                            _ => 1.0,
660                        };
661                        let grav = match node.props.get("gravity_scale") {
662                            Some(PropValue::Num(g)) => *g as f32,
663                            _ => 1.0,
664                        };
665                        let lock_rot = match node.props.get("lock_rotation") {
666                            Some(PropValue::Bool(b)) => *b,
667                            _ => false,
668                        };
669                        world.add_rigid_body(
670                            node.id.clone(),
671                            (pos.0 as f32, pos.1 as f32),
672                            rot,
673                            mass,
674                            grav,
675                            lock_rot,
676                        );
677                    }
678                    "Area2D" => {
679                        world.add_area(node.id.clone(), (pos.0 as f32, pos.1 as f32), rot);
680                    }
681                    _ => {}
682                }
683            }
684
685            // 2. Attach collision shapes
686            for node in tree.iter().filter(|n| n.type_name == "CollisionShape2D") {
687                let disabled = match node.props.get("disabled") {
688                    Some(PropValue::Bool(b)) => *b,
689                    _ => false,
690                };
691                if disabled {
692                    continue;
693                }
694
695                // Find parent body
696                let mut parent_id = node.parent.clone();
697                let mut parent_body: Option<&pite_core::Node> = None;
698                while let Some(pid) = parent_id {
699                    if let Some(pnode) = tree.get(&pid) {
700                        if matches!(
701                            pnode.type_name.as_str(),
702                            "StaticBody2D" | "CharacterBody2D" | "RigidBody2D" | "Area2D"
703                        ) {
704                            parent_body = Some(pnode);
705                            break;
706                        }
707                        parent_id = pnode.parent.clone();
708                    } else {
709                        break;
710                    }
711                }
712
713                let Some(body) = parent_body else {
714                    continue;
715                };
716
717                let body_pos = global_position(tree, &body.id);
718                let shape_pos = global_position(tree, &node.id);
719                let offset = (
720                    (shape_pos.0 - body_pos.0) as f32,
721                    (shape_pos.1 - body_pos.1) as f32,
722                );
723
724                let shape_rot = match node.props.get("rotation") {
725                    Some(PropValue::Num(r)) => *r as f32,
726                    _ => 0.0,
727                };
728
729                let shape_type = match node.props.get("shape") {
730                    Some(PropValue::Str(s)) => s.as_str(),
731                    _ => "box",
732                };
733
734                let config = match shape_type {
735                    "circle" => {
736                        let radius = match node.props.get("radius") {
737                            Some(PropValue::Num(r)) => *r as f32,
738                            _ => 16.0,
739                        };
740                        ShapeConfig::Circle { radius }
741                    }
742                    "capsule" => {
743                        let radius = match node.props.get("radius") {
744                            Some(PropValue::Num(r)) => *r as f32,
745                            _ => 16.0,
746                        };
747                        let height = match node.props.get("height") {
748                            Some(PropValue::Num(h)) => *h as f32,
749                            _ => 32.0,
750                        };
751                        ShapeConfig::Capsule { radius, height }
752                    }
753                    _ => {
754                        let (w, h) = match node.props.get("size") {
755                            Some(PropValue::Vec2(w, h)) => (*w as f32, *h as f32),
756                            _ => (32.0, 32.0),
757                        };
758                        ShapeConfig::Box {
759                            width: w,
760                            height: h,
761                        }
762                    }
763                };
764
765                let layer = match body.props.get("collision_layer") {
766                    Some(PropValue::Int(l)) => *l as u32,
767                    _ => 1,
768                };
769                let mask = match body.props.get("collision_mask") {
770                    Some(PropValue::Int(m)) => *m as u32,
771                    _ => 1,
772                };
773                let friction = match body.props.get("friction") {
774                    Some(PropValue::Num(f)) => *f as f32,
775                    _ => 0.5,
776                };
777                let restitution = match body.props.get("restitution") {
778                    Some(PropValue::Num(r)) => *r as f32,
779                    _ => 0.0,
780                };
781
782                let is_sensor = body.type_name == "Area2D";
783
784                world.attach_shape(
785                    &body.id,
786                    node.id.clone(),
787                    config,
788                    offset,
789                    shape_rot,
790                    is_sensor,
791                    layer,
792                    mask,
793                    friction,
794                    restitution,
795                );
796            }
797        });
798
799        *self.physics.lock().unwrap() = world;
800        pite_script::set_current_physics(Some(self.physics.clone()));
801    }
802
803    pub fn update(&mut self, delta: f64) {
804        self.poll_buttons();
805        pite_audio::poll_audio();
806
807        // 60 Hz fixed physics stepping
808        const FIXED_STEP: f64 = 1.0 / 60.0;
809        self.physics_accumulator += delta;
810        if self.physics_accumulator > 0.25 {
811            self.physics_accumulator = 0.25;
812        }
813
814        while self.physics_accumulator >= FIXED_STEP {
815            for slot in &mut self.slots {
816                if slot.errored {
817                    continue;
818                }
819                if let Err(e) = slot.backend.call_physics_process(FIXED_STEP) {
820                    tracing::error!(node = %slot.id, "physics process error: {e:#}");
821                    slot.errored = true;
822                    if self.pause_on_error && self.last_error.is_none() {
823                        self.last_error = slot.backend.last_script_error();
824                    }
825                }
826                if let Some((x, y)) = slot.backend.position() {
827                    let mut lock = self.physics.lock().unwrap();
828                    lock.set_body_position(&slot.id, (x, y));
829                }
830            }
831
832            let events = {
833                let mut lock = self.physics.lock().unwrap();
834                lock.step();
835                lock.drain_events()
836            };
837
838            // Sync dynamic physics positions (RigidBody2D) back into tree & slots
839            {
840                let lock = self.physics.lock().unwrap();
841                for slot in &mut self.slots {
842                    if lock.is_rigid_body(&slot.id) {
843                        if let Some(pos) = lock.get_body_position(&slot.id) {
844                            slot.backend.set_position(pos.0, pos.1);
845                            self.host.with_tree_mut(|tree| {
846                                if let Some(node) = tree.get_mut(&slot.id) {
847                                    node.props.insert(
848                                        "position".to_string(),
849                                        PropValue::Vec2(pos.0, pos.1),
850                                    );
851                                }
852                            });
853                        }
854                    }
855                }
856            }
857
858            // Dispatch Area2D signals
859            for ev in events {
860                match ev {
861                    PhysicsEvent::BodyEntered { area_id, body_id } => {
862                        let _ = pite_script::fire_body_entered(
863                            &self.host,
864                            area_id.as_str(),
865                            body_id.as_str(),
866                        );
867                    }
868                    PhysicsEvent::BodyExited { area_id, body_id } => {
869                        let _ = pite_script::fire_body_exited(
870                            &self.host,
871                            area_id.as_str(),
872                            body_id.as_str(),
873                        );
874                    }
875                    PhysicsEvent::AreaEntered {
876                        area_id,
877                        other_area_id,
878                    } => {
879                        let _ = pite_script::fire_area_entered(
880                            &self.host,
881                            area_id.as_str(),
882                            other_area_id.as_str(),
883                        );
884                    }
885                    PhysicsEvent::AreaExited {
886                        area_id,
887                        other_area_id,
888                    } => {
889                        let _ = pite_script::fire_area_exited(
890                            &self.host,
891                            area_id.as_str(),
892                            other_area_id.as_str(),
893                        );
894                    }
895                }
896            }
897
898            self.physics_accumulator -= FIXED_STEP;
899        }
900
901        for slot in &mut self.slots {
902            if slot.errored {
903                continue;
904            }
905            if let Err(e) = slot.backend.call_process(delta) {
906                tracing::error!(node = %slot.id, "{e:#}");
907                slot.errored = true;
908                if self.pause_on_error && self.last_error.is_none() {
909                    self.last_error = slot.backend.last_script_error();
910                }
911            }
912            if let Some(wait) = slot.backend.wait_time() {
913                self.host.with_tree_mut(|tree| {
914                    if let Some(node) = tree.get_mut(&slot.id) {
915                        node.props
916                            .insert("wait_time".to_string(), PropValue::Num(wait));
917                    }
918                });
919            }
920        }
921        self.tick_timers(delta);
922    }
923
924    /// Advance every Timer node; fire `timeout` on each elapsed `wait_time`
925    /// and restart the clock (looping). Non-positive waits never fire.
926    fn tick_timers(&mut self, delta: f64) {
927        let timers: Vec<(String, f64)> = self.host.with_tree(|tree| {
928            tree.iter()
929                .filter(|n| n.type_name == "Timer")
930                .filter_map(|n| {
931                    let wait = match n.props.get("wait_time") {
932                        Some(PropValue::Num(f)) => *f,
933                        Some(PropValue::Int(i)) => *i as f64,
934                        _ => return None,
935                    };
936                    (wait > 0.0).then(|| (n.id.to_string(), wait))
937                })
938                .collect()
939        });
940        for (id, wait) in &timers {
941            let fire = {
942                let elapsed = self.timers.entry(id.clone()).or_insert(0.0);
943                *elapsed += delta;
944                if *elapsed >= *wait {
945                    *elapsed = 0.0;
946                    true
947                } else {
948                    false
949                }
950            };
951            if fire {
952                self.fire_timeout(id);
953            }
954        }
955        let live: std::collections::HashSet<String> =
956            timers.into_iter().map(|(id, _)| id).collect();
957        self.timers.retain(|id, _| live.contains(id));
958    }
959
960    fn poll_buttons(&mut self) {
961        let mouse = input_mouse();
962        let hovered = self.button_at(mouse);
963        if input_pressed("MouseLeft") {
964            if let Some(id) = hovered.clone() {
965                self.armed.insert(id);
966            }
967        }
968        if input_released("MouseLeft") {
969            match hovered {
970                Some(id) if self.armed.contains(&id) => self.fire_pressed(&id),
971                _ => {}
972            }
973            self.armed.clear();
974        }
975    }
976
977    /// One frame: pump watchers, run scripts, then retire edge input.
978    ///
979    /// `input_begin_frame()` MUST come last. OS/window events arrive
980    /// between frames, so clearing edges before `update()` wipes presses
981    /// and releases scripts have not consumed yet (`pite.pressed()` would
982    /// read false forever and buttons would never arm). Edges set since
983    /// the previous frame stay visible for exactly one `update()`.
984    pub fn frame(&mut self, delta: f64) {
985        self.poll_watch();
986        self.update(delta);
987        pite_script::input_begin_frame();
988    }
989}
990
991fn button_size(node: &pite_core::Node) -> (f64, f64) {
992    match node.props.get("size") {
993        Some(PropValue::Vec2(w, h)) => (*w, *h),
994        _ => (120.0, 40.0),
995    }
996}
997
998fn same_file(a: &Path, b: &Path) -> bool {
999    if a == b {
1000        return true;
1001    }
1002    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
1003        (Ok(x), Ok(y)) => x == y,
1004        _ => false,
1005    }
1006}
1007
1008struct App {
1009    window: Option<Arc<Window>>,
1010    renderer: Option<WgpuRenderer>,
1011    title: String,
1012    session: GameSession,
1013    last_frame: Instant,
1014    render_errors: u32,
1015}
1016
1017pub const DEFAULT_ICON_PNG: &[u8] = include_bytes!("../assets/icon.png");
1018
1019fn window_icon() -> Option<winit::window::Icon> {
1020    let img =
1021        image::load_from_memory_with_format(DEFAULT_ICON_PNG, image::ImageFormat::Png).ok()?;
1022    let rgba = img.to_rgba8();
1023    let (width, height) = (rgba.width(), rgba.height());
1024    winit::window::Icon::from_rgba(rgba.into_raw(), width, height).ok()
1025}
1026
1027impl ApplicationHandler for App {
1028    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1029        if self.window.is_none() {
1030            let mut attrs = Window::default_attributes().with_title(self.title.clone());
1031            // Maximized like the editor; the design size is the restore size.
1032            attrs = attrs.with_maximized(true);
1033            if let Some((w, h)) = self.session.design_size() {
1034                attrs = attrs.with_inner_size(winit::dpi::PhysicalSize::new(w, h));
1035            }
1036            if let Some(icon) = window_icon() {
1037                attrs = attrs.with_window_icon(Some(icon));
1038            }
1039            match event_loop.create_window(attrs) {
1040                Ok(window) => {
1041                    let window = Arc::new(window);
1042                    match WgpuRenderer::new(window.clone()) {
1043                        Ok(renderer) => {
1044                            self.window = Some(window);
1045                            self.renderer = Some(renderer);
1046                        }
1047                        Err(e) => {
1048                            tracing::error!("cannot init renderer: {e:#}");
1049                            event_loop.exit();
1050                        }
1051                    }
1052                }
1053                Err(e) => {
1054                    tracing::error!("cannot create window: {e}");
1055                    event_loop.exit();
1056                }
1057            }
1058        }
1059    }
1060
1061    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
1062        match event {
1063            WindowEvent::CloseRequested => event_loop.exit(),
1064            WindowEvent::Resized(size) => {
1065                if let Some(renderer) = &mut self.renderer {
1066                    renderer.resize(size.width, size.height);
1067                }
1068            }
1069            WindowEvent::KeyboardInput { event, .. } => {
1070                if let PhysicalKey::Code(code) = event.physical_key {
1071                    if let Some(name) = key_name(code) {
1072                        pite_script::input_set_key(name, event.state == ElementState::Pressed);
1073                    }
1074                }
1075            }
1076            WindowEvent::CursorMoved { position, .. } => {
1077                // Physical pixels: the renderer's viewport (`inner_size`)
1078                // is physical too, and `button_at` maps the cursor through
1079                // it. Dividing by the scale factor here once made every
1080                // click miss on scaled displays while the button drew fine.
1081                pite_script::input_set_mouse(position.x, position.y);
1082            }
1083            WindowEvent::MouseInput { state, button, .. } => {
1084                let name = match button {
1085                    MouseButton::Left => "MouseLeft",
1086                    MouseButton::Right => "MouseRight",
1087                    MouseButton::Middle => "MouseMiddle",
1088                    _ => return,
1089                };
1090                pite_script::input_set_key(name, state == ElementState::Pressed);
1091            }
1092            WindowEvent::RedrawRequested => {
1093                let delta = self.session_delta();
1094                if let Some(renderer) = &self.renderer {
1095                    let (w, h) = renderer.size();
1096                    self.session.set_viewport(w, h);
1097                }
1098                self.session.frame(delta);
1099                self.render_frame();
1100                if let Some(window) = &self.window {
1101                    window.request_redraw();
1102                }
1103            }
1104            _ => {}
1105        }
1106    }
1107}
1108
1109impl App {
1110    fn render_frame(&mut self) {
1111        let Some(renderer) = &mut self.renderer else {
1112            return;
1113        };
1114        let result = (|| -> Result<()> {
1115            renderer.begin_frame()?;
1116            self.session.draw_into(renderer)?;
1117            renderer.end_frame()?;
1118            Ok(())
1119        })();
1120        if let Err(e) = result {
1121            self.render_errors += 1;
1122            if self.render_errors <= 3 {
1123                tracing::error!("render frame failed: {e:#}");
1124            }
1125        }
1126    }
1127}
1128
1129impl App {
1130    fn session_delta(&mut self) -> f64 {
1131        let delta = self.last_frame.elapsed().as_secs_f64().min(0.1);
1132        self.last_frame = Instant::now();
1133        delta
1134    }
1135}
1136
1137/// Resolve a scene argument to an absolute path. `res://` resolves against
1138/// the enclosing project root, everything else against the current dir.
1139/// Shared by `pite run` and `pite-player` so both accept the same paths.
1140pub fn resolve_scene_arg(scene: &str) -> Result<PathBuf> {
1141    let cwd = std::env::current_dir()?;
1142    if let Some(rel) = scene.strip_prefix("res://") {
1143        let root = pite_project::find_project_root(&cwd)
1144            .with_context(|| "scene uses res:// but no pite.toml found above cwd")?;
1145        return Ok(root.join(rel));
1146    }
1147    let p = PathBuf::from(scene);
1148    Ok(if p.is_absolute() { p } else { cwd.join(p) })
1149}
1150
1151/// Default scene when scene argument is omitted: the enclosing project's
1152/// `main_scene`. Same rule `pite edit` uses with no scene; outside a
1153/// project it fails loudly instead of guessing.
1154pub fn resolve_default_scene() -> Result<PathBuf> {
1155    let cwd = std::env::current_dir()?;
1156    let root = pite_project::find_project_root(&cwd)
1157        .with_context(|| "no scene given and no pite.toml found above cwd")?;
1158    let main = pite_project::load_manifest(&root)?.project.main_scene;
1159    Ok(match main.strip_prefix("res://") {
1160        Some(rel) => root.join(rel),
1161        None => root.join(main),
1162    })
1163}
1164
1165pub fn run_scene(scene: &Path, no_reload: bool) -> Result<()> {
1166    run_with_options(&RunOptions {
1167        scene: scene.to_path_buf(),
1168        no_reload,
1169    })
1170}
1171
1172pub fn run_with_options(options: &RunOptions) -> Result<()> {
1173    let session = GameSession::open(&options.scene, options.no_reload)?;
1174    let title = format!(
1175        "Pite — {} ({} nodes)",
1176        options.scene.display(),
1177        session.tree_len()
1178    );
1179    tracing::info!(
1180        scene = %options.scene.display(),
1181        nodes = session.tree_len(),
1182        scripts = session.script_count(),
1183        no_reload = options.no_reload,
1184        "scene loaded"
1185    );
1186
1187    let event_loop = EventLoop::new().context("cannot create event loop")?;
1188    event_loop.set_control_flow(ControlFlow::Wait);
1189    let mut app = App {
1190        window: None,
1191        renderer: None,
1192        title,
1193        session,
1194        last_frame: Instant::now(),
1195        render_errors: 0,
1196    };
1197    event_loop.run_app(&mut app).context("event loop failed")?;
1198    Ok(())
1199}
1200
1201fn seed_position(tree: &NodeTree, id: &NodeId) -> Option<(f64, f64)> {
1202    match tree.get(id)?.props.get("position") {
1203        Some(PropValue::Vec2(x, y)) => Some((*x, *y)),
1204        _ => None,
1205    }
1206}
1207
1208fn seed_text(tree: &NodeTree, id: &NodeId) -> Option<String> {
1209    match tree.get(id)?.props.get("text") {
1210        Some(PropValue::Str(s)) => Some(s.clone()),
1211        _ => None,
1212    }
1213}
1214
1215fn seed_wait(tree: &NodeTree, id: &NodeId) -> Option<f64> {
1216    match tree.get(id)?.props.get("wait_time") {
1217        Some(PropValue::Num(f)) => Some(*f),
1218        Some(PropValue::Int(i)) => Some(*i as f64),
1219        _ => None,
1220    }
1221}
1222
1223fn seed_size(tree: &NodeTree, id: &NodeId) -> Option<(f64, f64)> {
1224    match tree.get(id)?.props.get("size") {
1225        Some(PropValue::Vec2(w, h)) => Some((*w, *h)),
1226        _ => None,
1227    }
1228}
1229
1230fn resolve_script(
1231    script_ref: &str,
1232    scene_dir: &Path,
1233    project_dir: Option<&Path>,
1234) -> Option<PathBuf> {
1235    if let Some(rel) = script_ref.strip_prefix("res://") {
1236        project_dir.map(|root| root.join(rel))
1237    } else {
1238        let p = PathBuf::from(script_ref);
1239        if p.is_absolute() {
1240            Some(p)
1241        } else {
1242            Some(scene_dir.join(p))
1243        }
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250
1251    static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
1252
1253    fn make_project(tag: &str, class: &str, script_src: &str) -> (PathBuf, PathBuf) {
1254        let dir = std::env::temp_dir().join(format!("pite-sess-{tag}-{}", std::process::id()));
1255        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1256        std::fs::create_dir_all(dir.join("scripts")).unwrap();
1257        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"t\"\n").unwrap();
1258        std::fs::write(
1259            dir.join("scenes").join("main.ptscn"),
1260            format!("format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"mover\"\ntype = \"Sprite2D\"\nname = \"Mover\"\nparent = \"root\"\n\n[node.script]\npath = \"res://scripts/mover.py\"\nclass = \"{class}\"\n"),
1261        )
1262        .unwrap();
1263        let script = dir.join("scripts").join("mover.py");
1264        std::fs::write(&script, script_src).unwrap();
1265        (dir, script)
1266    }
1267
1268    const V1: &str = "import pite\n\nclass Mover(pite.Node2D):\n    def _process(self, delta):\n        x, y = self.position\n        self.position = (x + 100.0 * delta, y)\n";
1269    const V2: &str = "import pite\n\nclass Mover(pite.Node2D):\n    def _process(self, delta):\n        x, y = self.position\n        self.position = (x + 400.0 * delta, y)\n";
1270
1271    fn drive_x(session: &mut GameSession) -> f64 {
1272        session.update(0.1);
1273        session
1274            .host
1275            .with_tree(|t| match t.get(&NodeId::from("mover".to_string())) {
1276                Some(n) => match n.props.get("position") {
1277                    Some(PropValue::Vec2(x, _)) => *x,
1278                    _ => f64::NAN,
1279                },
1280                None => f64::NAN,
1281            })
1282    }
1283
1284    #[test]
1285    fn editing_script_changes_behavior_without_restart() {
1286        let _guard = SERIAL.lock().unwrap();
1287        let (dir, script) = make_project("reload", "Mover", V1);
1288        let scene = dir.join("scenes").join("main.ptscn");
1289        let mut session = GameSession::open(&scene, false).unwrap();
1290        assert_eq!(session.script_count(), 1);
1291        let x1 = drive_x(&mut session);
1292        assert!((x1 - 10.0).abs() < 1e-6);
1293
1294        std::fs::write(&script, V2).unwrap();
1295        let mut reloaded = false;
1296        for _ in 0..100 {
1297            session.poll_watch();
1298            let before = drive_x(&mut session);
1299            let after = drive_x(&mut session);
1300            if (after - before - 40.0).abs() < 1e-6 {
1301                reloaded = true;
1302                break;
1303            }
1304            std::thread::sleep(std::time::Duration::from_millis(50));
1305        }
1306        assert!(reloaded, "v2 behavior never took effect");
1307        std::fs::remove_dir_all(&dir).ok();
1308    }
1309
1310    #[test]
1311    fn scene_edit_adds_nodes_live() {
1312        let _guard = SERIAL.lock().unwrap();
1313        let (dir, _) = make_project("scene", "Mover", V1);
1314        let scene = dir.join("scenes").join("main.ptscn");
1315        let mut session = GameSession::open(&scene, false).unwrap();
1316        assert_eq!(session.tree_len(), 2);
1317        std::fs::write(
1318            &scene,
1319            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"extra\"\ntype = \"Timer\"\nname = \"Extra\"\nparent = \"root\"\n",
1320        )
1321        .unwrap();
1322        let mut grown = false;
1323        for _ in 0..100 {
1324            session.poll_watch();
1325            if session.tree_len() == 2 && session.script_count() == 0 {
1326                grown = true;
1327                break;
1328            }
1329            std::thread::sleep(std::time::Duration::from_millis(50));
1330        }
1331        assert!(grown, "rebuilt tree never applied");
1332        std::fs::remove_dir_all(&dir).ok();
1333    }
1334
1335    #[test]
1336    fn no_reload_disables_watching() {
1337        let _guard = SERIAL.lock().unwrap();
1338        let (dir, _) = make_project("noreload", "Mover", V1);
1339        let scene = dir.join("scenes").join("main.ptscn");
1340        let session = GameSession::open(&scene, true).unwrap();
1341        assert!(session.watch_rx.is_none());
1342        std::fs::remove_dir_all(&dir).ok();
1343    }
1344
1345    #[test]
1346    fn unrelated_scene_edit_does_not_rebuild() {
1347        let _guard = SERIAL.lock().unwrap();
1348        let (dir, _) = make_project("unrelated", "Mover", V1);
1349        let scene = dir.join("scenes").join("main.ptscn");
1350        let other = dir.join("scenes").join("other.ptscn");
1351        std::fs::write(
1352            &other,
1353            "format_version = 1\nroot = \"other\"\n\n[[node]]\nid = \"other\"\ntype = \"Node2D\"\nname = \"Other\"\n",
1354        )
1355        .unwrap();
1356        let mut session = GameSession::open(&scene, false).unwrap();
1357        assert_eq!(session.tree_len(), 2);
1358
1359        std::fs::write(
1360            &other,
1361            "format_version = 1\nroot = \"other2\"\n\n[[node]]\nid = \"other2\"\ntype = \"Node2D\"\nname = \"Other2\"\n",
1362        )
1363        .unwrap();
1364        for _ in 0..10 {
1365            session.poll_watch();
1366            std::thread::sleep(std::time::Duration::from_millis(20));
1367        }
1368        assert_eq!(session.tree_len(), 2);
1369        assert_eq!(session.script_count(), 1);
1370        std::fs::remove_dir_all(&dir).ok();
1371    }
1372
1373    #[test]
1374    fn script_reload_preserves_custom_attributes_and_velocity() {
1375        let _guard = SERIAL.lock().unwrap();
1376        let src1 = "import pite\n\nclass Char(pite.CharacterBody2D):\n    def _ready(self):\n        self.score = 42\n        self.velocity = (123.0, 456.0)\n";
1377        let src2 = "import pite\n\nclass Char(pite.CharacterBody2D):\n    def _process(self, delta):\n        pass\n";
1378        let (dir, script) = make_project("char-preserve", "Char", src1);
1379        let scene = dir.join("scenes").join("main.ptscn");
1380        std::fs::write(
1381            &scene,
1382            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"mover\"\ntype = \"CharacterBody2D\"\nname = \"Char\"\nparent = \"root\"\n\n[node.script]\npath = \"res://scripts/mover.py\"\nclass = \"Char\"\n",
1383        )
1384        .unwrap();
1385        let mut session = GameSession::open(&scene, false).unwrap();
1386        session.update(0.016);
1387
1388        use pite_script::pyo3::{self, types::PyAnyMethods};
1389        pyo3::Python::attach(|py| {
1390            let inst = session.host.lookup_instance(py, "mover").unwrap();
1391            let score: i32 = inst.bind(py).getattr("score").unwrap().extract().unwrap();
1392            assert_eq!(score, 42);
1393        });
1394        assert_eq!(session.slots[0].backend.velocity(), Some((123.0, 456.0)));
1395
1396        std::fs::write(&script, src2).unwrap();
1397        let mut reloaded = false;
1398        for _ in 0..100 {
1399            if session.poll_watch() {
1400                reloaded = true;
1401                break;
1402            }
1403            std::thread::sleep(std::time::Duration::from_millis(50));
1404        }
1405        assert!(reloaded, "script reload never detected");
1406
1407        pyo3::Python::attach(|py| {
1408            let inst = session.host.lookup_instance(py, "mover").unwrap();
1409            let score: i32 = inst.bind(py).getattr("score").unwrap().extract().unwrap();
1410            assert_eq!(score, 42);
1411        });
1412        assert_eq!(session.slots[0].backend.velocity(), Some((123.0, 456.0)));
1413        std::fs::remove_dir_all(&dir).ok();
1414    }
1415
1416    const HUD: &str = "import pite\n\nclass Hud(pite.Label):\n    def _process(self, delta):\n        self.text = \"HP: 2\"\n";
1417    fn hud_text(session: &GameSession) -> String {
1418        session
1419            .host
1420            .with_tree(|t| match t.get(&NodeId::from("hud".to_string())) {
1421                Some(n) => match n.props.get("text") {
1422                    Some(PropValue::Str(s)) => s.clone(),
1423                    _ => String::new(),
1424                },
1425                None => String::new(),
1426            })
1427    }
1428
1429    #[test]
1430    fn script_text_reaches_tree_and_renderer() {
1431        let _guard = SERIAL.lock().unwrap();
1432        let dir = std::env::temp_dir().join(format!("pite-sess-hud-{}", std::process::id()));
1433        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1434        std::fs::create_dir_all(dir.join("scripts")).unwrap();
1435        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"t\"\n").unwrap();
1436        std::fs::write(
1437            dir.join("scenes").join("main.ptscn"),
1438            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"hud\"\ntype = \"Label\"\nname = \"Hud\"\nparent = \"root\"\n\n[node.props]\ntext = \"HP: 3\"\nfont_size = 20.0\n\n[node.script]\npath = \"res://scripts/hud.py\"\nclass = \"Hud\"\n",
1439        )
1440        .unwrap();
1441        std::fs::write(dir.join("scripts").join("hud.py"), HUD).unwrap();
1442        let scene = dir.join("scenes").join("main.ptscn");
1443        let mut session = GameSession::open(&scene, true).unwrap();
1444        assert_eq!(hud_text(&session), "HP: 3");
1445        session.update(0.016);
1446        assert_eq!(hud_text(&session), "HP: 2");
1447        std::fs::remove_dir_all(&dir).ok();
1448    }
1449
1450    const PINGER: &str = "import pite\n\nclass Pinger(pite.Node2D):\n    ping = pite.signal(int)\n    def _process(self, delta):\n        if not getattr(self, \"_sent\", False):\n            self._sent = True\n            self.ping.emit(1)\n";
1451    const PONGER: &str = "import pite\n\nclass Ponger(pite.Node2D):\n    def _ready(self):\n        self.get_node(\"../Pinger\").ping.connect(self.on_ping)\n    def on_ping(self, n):\n        self.get_node(\"../Hud\").text = \"hit!\"\n";
1452
1453    #[test]
1454    fn signal_reaches_label_through_proxy() {
1455        let _guard = SERIAL.lock().unwrap();
1456        let dir = std::env::temp_dir().join(format!("pite-sess-siglbl-{}", std::process::id()));
1457        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1458        std::fs::create_dir_all(dir.join("scripts")).unwrap();
1459        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"t\"\n").unwrap();
1460        std::fs::write(
1461            dir.join("scenes").join("main.ptscn"),
1462            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"pinger\"\ntype = \"Node2D\"\nname = \"Pinger\"\nparent = \"root\"\n\n[node.script]\npath = \"res://scripts/pinger.py\"\nclass = \"Pinger\"\n\n[[node]]\nid = \"ponger\"\ntype = \"Node2D\"\nname = \"Ponger\"\nparent = \"root\"\n\n[node.script]\npath = \"res://scripts/ponger.py\"\nclass = \"Ponger\"\n\n[[node]]\nid = \"hud\"\ntype = \"Label\"\nname = \"Hud\"\nparent = \"root\"\n\n[node.props]\ntext = \"waiting\"\n",
1463        )
1464        .unwrap();
1465        std::fs::write(dir.join("scripts").join("pinger.py"), PINGER).unwrap();
1466        std::fs::write(dir.join("scripts").join("ponger.py"), PONGER).unwrap();
1467        let scene = dir.join("scenes").join("main.ptscn");
1468        let mut session = GameSession::open(&scene, true).unwrap();
1469        assert_eq!(hud_text(&session), "waiting");
1470        session.update(0.016);
1471        assert_eq!(hud_text(&session), "hit!");
1472        std::fs::remove_dir_all(&dir).ok();
1473    }
1474
1475    const TICKER: &str = "import pite\n\nclass Ticker(pite.Node):\n    def _ready(self):\n        self.get_node(\".\").timeout.connect(self.on_timeout)\n    def on_timeout(self):\n        hud = self.get_node(\"../Hud\")\n        hud.text = str(int(hud.text) + 1)\n";
1476
1477    fn make_timer_project(tag: &str) -> PathBuf {
1478        let dir = std::env::temp_dir().join(format!("pite-sess-{tag}-{}", std::process::id()));
1479        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1480        std::fs::create_dir_all(dir.join("scripts")).unwrap();
1481        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"t\"\n").unwrap();
1482        std::fs::write(
1483            dir.join("scenes").join("main.ptscn"),
1484            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"tick\"\ntype = \"Timer\"\nname = \"Tick\"\nparent = \"root\"\n\n[node.props]\nwait_time = 0.05\n\n[node.script]\npath = \"res://scripts/ticker.py\"\nclass = \"Ticker\"\n\n[[node]]\nid = \"hud\"\ntype = \"Label\"\nname = \"Hud\"\nparent = \"root\"\n\n[node.props]\ntext = \"0\"\n",
1485        )
1486        .unwrap();
1487        std::fs::write(dir.join("scripts").join("ticker.py"), TICKER).unwrap();
1488        dir
1489    }
1490
1491    #[test]
1492    fn timer_fires_and_loops() {
1493        let _guard = SERIAL.lock().unwrap();
1494        let dir = make_timer_project("timer-loop");
1495        let scene = dir.join("scenes").join("main.ptscn");
1496        let mut session = GameSession::open(&scene, true).unwrap();
1497        assert_eq!(hud_text(&session), "0");
1498        for _ in 0..3 {
1499            session.update(0.016);
1500        }
1501        assert_eq!(hud_text(&session), "0");
1502        session.update(0.016);
1503        assert_eq!(hud_text(&session), "1");
1504        for _ in 0..4 {
1505            session.update(0.016);
1506        }
1507        assert_eq!(hud_text(&session), "2");
1508        std::fs::remove_dir_all(&dir).ok();
1509    }
1510
1511    const COUNTER: &str = "import pite\n\nclass Counter(pite.Button):\n    pressed = pite.signal()\n    def _ready(self):\n        self.text = \"0\"\n        self.get_node(\".\").pressed.connect(self.on_pressed)\n    def on_pressed(self):\n        self.text = str(int(self.text) + 1)\n";
1512
1513    const COUNTER_BUILTIN: &str = "import pite\n\nclass Counter(pite.Button):\n    def _ready(self):\n        self.text = \"0\"\n        self.get_node(\".\").pressed.connect(self.on_pressed)\n    def on_pressed(self):\n        self.text = str(int(self.text) + 1)\n";
1514
1515    #[test]
1516    fn vendored_import_resolves_in_session() {
1517        let _guard = SERIAL.lock().unwrap();
1518        let dir = std::env::temp_dir().join(format!("pite-sess-vendor-{}", std::process::id()));
1519        let _ = std::fs::remove_dir_all(&dir);
1520        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1521        std::fs::create_dir_all(dir.join("scripts")).unwrap();
1522        std::fs::create_dir_all(dir.join("vendor").join("vpkg")).unwrap();
1523        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"t\"\n").unwrap();
1524        std::fs::write(
1525            dir.join("vendor").join("vpkg").join("__init__.py"),
1526            "VALUE = 7\n",
1527        )
1528        .unwrap();
1529        std::fs::write(
1530            dir.join("scenes").join("main.ptscn"),
1531            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"m\"\ntype = \"Label\"\nname = \"M\"\nparent = \"root\"\n\n[node.script]\npath = \"res://scripts/m.py\"\nclass = \"M\"\n",
1532        )
1533        .unwrap();
1534        std::fs::write(
1535            dir.join("scripts").join("m.py"),
1536            "import pite\nimport vpkg\n\nclass M(pite.Label):\n    def _ready(self):\n        self.text = str(vpkg.VALUE)\n",
1537        )
1538        .unwrap();
1539        let scene = dir.join("scenes").join("main.ptscn");
1540        let mut session = GameSession::open(&scene, true).unwrap();
1541        session.update(0.016);
1542        assert!(session.errors().is_empty(), "got: {:?}", session.errors());
1543        let text = session
1544            .host
1545            .with_tree(|t| match t.get(&NodeId::from("m".to_string())) {
1546                Some(n) => match n.props.get("text") {
1547                    Some(PropValue::Str(s)) => s.clone(),
1548                    _ => String::new(),
1549                },
1550                None => String::from("<gone>"),
1551            });
1552        assert_eq!(text, "7");
1553        std::fs::remove_dir_all(&dir).ok();
1554    }
1555
1556    #[test]
1557    fn zipped_wheel_import_resolves_in_session() {
1558        let _guard = SERIAL.lock().unwrap();
1559        let dir = std::env::temp_dir().join(format!("pite-sess-zip-{}", std::process::id()));
1560        let _ = std::fs::remove_dir_all(&dir);
1561        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1562        std::fs::create_dir_all(dir.join("scripts")).unwrap();
1563        std::fs::create_dir_all(dir.join("vendor")).unwrap();
1564        let crafted = std::process::Command::new("python3")
1565            .arg("-c")
1566            .arg(
1567                "import sys, zipfile; z = zipfile.ZipFile(sys.argv[1], 'w'); z.writestr('zpkg/__init__.py', 'VALUE = 9\\n'); z.close()",
1568            )
1569            .arg(dir.join("vendor").join("zpkg-1.0-py3-none-any.whl"))
1570            .output();
1571        if !crafted.as_ref().is_ok_and(|o| o.status.success()) {
1572            eprintln!("skipped: python3 with zipfile unavailable");
1573            return;
1574        }
1575        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"t\"\n").unwrap();
1576        std::fs::write(
1577            dir.join("scenes").join("main.ptscn"),
1578            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"m\"\ntype = \"Label\"\nname = \"M\"\nparent = \"root\"\n\n[node.script]\npath = \"res://scripts/m.py\"\nclass = \"M\"\n",
1579        )
1580        .unwrap();
1581        std::fs::write(
1582            dir.join("scripts").join("m.py"),
1583            "import pite\nimport zpkg\n\nclass M(pite.Label):\n    def _ready(self):\n        self.text = str(zpkg.VALUE)\n",
1584        )
1585        .unwrap();
1586        let scene = dir.join("scenes").join("main.ptscn");
1587        let mut session = GameSession::open(&scene, true).unwrap();
1588        session.update(0.016);
1589        assert!(session.errors().is_empty(), "got: {:?}", session.errors());
1590        let text = session
1591            .host
1592            .with_tree(|t| match t.get(&NodeId::from("m".to_string())) {
1593                Some(n) => match n.props.get("text") {
1594                    Some(PropValue::Str(s)) => s.clone(),
1595                    _ => String::new(),
1596                },
1597                None => String::from("<gone>"),
1598            });
1599        assert_eq!(text, "9");
1600        std::fs::remove_dir_all(&dir).ok();
1601    }
1602
1603    fn make_button_project(tag: &str) -> PathBuf {
1604        make_button_project_with(tag, COUNTER)
1605    }
1606
1607    fn make_button_project_with(tag: &str, script_src: &str) -> PathBuf {
1608        let dir = std::env::temp_dir().join(format!("pite-sess-{tag}-{}", std::process::id()));
1609        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1610        std::fs::create_dir_all(dir.join("scripts")).unwrap();
1611        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"t\"\n").unwrap();
1612        std::fs::write(
1613            dir.join("scenes").join("main.ptscn"),
1614            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"btn\"\ntype = \"Button\"\nname = \"HitBtn\"\nparent = \"root\"\n\n[node.props]\nposition = [100.0, 100.0]\nsize = [120.0, 40.0]\ntext = \"0\"\n\n[node.script]\npath = \"res://scripts/counter.py\"\nclass = \"Counter\"\n",
1615        )
1616        .unwrap();
1617        std::fs::write(dir.join("scripts").join("counter.py"), script_src).unwrap();
1618        dir
1619    }
1620
1621    fn btn_text(session: &GameSession) -> String {
1622        session
1623            .host
1624            .with_tree(|t| match t.get(&NodeId::from("btn".to_string())) {
1625                Some(n) => match n.props.get("text") {
1626                    Some(PropValue::Str(s)) => s.clone(),
1627                    _ => String::new(),
1628                },
1629                None => String::from("<gone>"),
1630            })
1631    }
1632
1633    fn click(session: &mut GameSession, down: (f64, f64), up: (f64, f64)) {
1634        use pite_script::{input_begin_frame, input_set_key, input_set_mouse};
1635        let (w, h) = session.design_size().unwrap_or((800, 600));
1636        session.set_viewport(w, h);
1637        input_set_mouse(down.0, down.1);
1638        input_set_key("MouseLeft", true);
1639        session.update(0.016);
1640        input_begin_frame();
1641        input_set_mouse(up.0, up.1);
1642        input_set_key("MouseLeft", false);
1643        session.update(0.016);
1644        input_begin_frame();
1645    }
1646
1647    #[test]
1648    fn click_inside_fires_once_per_click() {
1649        let _guard = SERIAL.lock().unwrap();
1650        let dir = make_button_project("btn-once");
1651        let scene = dir.join("scenes").join("main.ptscn");
1652        let mut session = GameSession::open(&scene, true).unwrap();
1653        assert_eq!(btn_text(&session), "0");
1654        click(&mut session, (740.0, 460.0), (740.0, 460.0));
1655        assert_eq!(btn_text(&session), "1");
1656        click(&mut session, (740.0, 460.0), (740.0, 460.0));
1657        assert_eq!(btn_text(&session), "2");
1658        std::fs::remove_dir_all(&dir).ok();
1659    }
1660
1661    #[test]
1662    fn button_pressed_needs_no_script_declaration() {
1663        let _guard = SERIAL.lock().unwrap();
1664        let dir = make_button_project_with("btn-builtin", COUNTER_BUILTIN);
1665        let scene = dir.join("scenes").join("main.ptscn");
1666        let mut session = GameSession::open(&scene, true).unwrap();
1667        assert!(session.host.has_signal("btn", "pressed"));
1668        assert_eq!(btn_text(&session), "0");
1669        click(&mut session, (740.0, 460.0), (740.0, 460.0));
1670        assert_eq!(btn_text(&session), "1");
1671        std::fs::remove_dir_all(&dir).ok();
1672    }
1673
1674    #[test]
1675    fn click_outside_is_ignored() {
1676        let _guard = SERIAL.lock().unwrap();
1677        let dir = make_button_project("btn-out");
1678        let scene = dir.join("scenes").join("main.ptscn");
1679        let mut session = GameSession::open(&scene, true).unwrap();
1680        // Screen points for the 1280x720 design center (640, 360): the
1681        // fixture button sits at world (100, 100), i.e. screen (740, 460).
1682        click(&mut session, (100.0, 100.0), (100.0, 100.0));
1683        assert_eq!(btn_text(&session), "0");
1684        std::fs::remove_dir_all(&dir).ok();
1685    }
1686
1687    #[test]
1688    fn press_inside_release_outside_is_ignored() {
1689        let _guard = SERIAL.lock().unwrap();
1690        let dir = make_button_project("btn-drag");
1691        let scene = dir.join("scenes").join("main.ptscn");
1692        let mut session = GameSession::open(&scene, true).unwrap();
1693        click(&mut session, (740.0, 460.0), (100.0, 100.0));
1694        assert_eq!(btn_text(&session), "0");
1695        std::fs::remove_dir_all(&dir).ok();
1696    }
1697
1698    #[test]
1699    fn dropped_button_disconnects_silently() {
1700        let _guard = SERIAL.lock().unwrap();
1701        let dir = make_button_project("btn-drop");
1702        let scene = dir.join("scenes").join("main.ptscn");
1703        let mut session = GameSession::open(&scene, true).unwrap();
1704        session.host.with_tree_mut(|t| {
1705            t.remove(&NodeId::from("btn".to_string())).unwrap();
1706        });
1707        session.host.disconnect_node("btn");
1708        assert!(!session.host.has_signal("btn", "pressed"));
1709        click(&mut session, (740.0, 460.0), (740.0, 460.0));
1710        assert_eq!(btn_text(&session), "<gone>");
1711        std::fs::remove_dir_all(&dir).ok();
1712    }
1713
1714    const WALKER: &str = "import pite\n\nclass Walker(pite.Node2D):\n    def _process(self, delta):\n        if pite.held.right:\n            self.position.x += 200.0 * delta\n        if pite.pressed.space:\n            self.position.x += 10.0\n";
1715
1716    fn walker_x(session: &GameSession) -> f64 {
1717        session
1718            .host
1719            .with_tree(|t| match t.get(&NodeId::from("mover".to_string())) {
1720                Some(n) => match n.props.get("position") {
1721                    Some(PropValue::Vec2(x, _)) => *x,
1722                    _ => f64::NAN,
1723                },
1724                None => f64::NAN,
1725            })
1726    }
1727
1728    #[test]
1729    fn keypress_moves_node_through_real_input() {
1730        let _guard = SERIAL.lock().unwrap();
1731        use pite_script::{input_begin_frame, input_set_key};
1732
1733        let (dir, _) = make_project("input", "Walker", WALKER);
1734        let scene = dir.join("scenes").join("main.ptscn");
1735        let mut session = GameSession::open(&scene, true).unwrap();
1736
1737        input_begin_frame();
1738        input_set_key("ArrowRight", true);
1739        session.update(0.5);
1740        assert!((walker_x(&session) - 100.0).abs() < 1e-6);
1741
1742        input_begin_frame();
1743        input_set_key("Space", true);
1744        session.update(0.5);
1745        let after_step = walker_x(&session);
1746        assert!((after_step - 210.0).abs() < 1e-6);
1747
1748        input_begin_frame();
1749        session.update(0.5);
1750        assert!((walker_x(&session) - 310.0).abs() < 1e-6);
1751        input_set_key("ArrowRight", false);
1752        input_set_key("Space", false);
1753        std::fs::remove_dir_all(&dir).ok();
1754    }
1755
1756    const STEPPER: &str = "import pite\n\nclass Walker(pite.Node2D):\n    def _process(self, delta):\n        if pite.pressed.space:\n            self.position.x += 10.0\n";
1757
1758    #[test]
1759    fn frame_delivers_production_order_edges_exactly_once() {
1760        let _guard = SERIAL.lock().unwrap();
1761        use pite_script::input_set_key;
1762
1763        let (dir, _) = make_project("edgeorder", "Walker", STEPPER);
1764        let scene = dir.join("scenes").join("main.ptscn");
1765        let mut session = GameSession::open(&scene, true).unwrap();
1766
1767        // Production order: the OS event lands between frames, so the
1768        // edge is set BEFORE frame() with no begin_frame in between.
1769        // (frame() itself retires edges at its end.)
1770        input_set_key("Space", true);
1771        session.frame(0.5);
1772        assert!((walker_x(&session) - 10.0).abs() < 1e-6);
1773        session.frame(0.5);
1774        assert!(
1775            (walker_x(&session) - 10.0).abs() < 1e-6,
1776            "edge must be consumed exactly once"
1777        );
1778        input_set_key("Space", false);
1779        pite_script::input_begin_frame();
1780        std::fs::remove_dir_all(&dir).ok();
1781    }
1782
1783    fn node_x(session: &GameSession, id: &str) -> f64 {
1784        session
1785            .host
1786            .with_tree(|t| match t.get(&NodeId::from(id.to_string())) {
1787                Some(n) => match n.props.get("position") {
1788                    Some(PropValue::Vec2(x, _)) => *x,
1789                    _ => f64::NAN,
1790                },
1791                None => f64::NAN,
1792            })
1793    }
1794
1795    /// Records what the draw path actually queued.
1796    #[derive(Default)]
1797    struct RecordingRenderer {
1798        calls: Vec<(String, Option<String>, (f64, f64))>,
1799    }
1800
1801    impl Renderer2D for RecordingRenderer {
1802        fn begin_frame(&mut self) -> Result<()> {
1803            Ok(())
1804        }
1805
1806        fn draw_sprite(&mut self, texture: &str, x: f64, y: f64) -> Result<()> {
1807            self.calls.push((texture.to_string(), None, (x, y)));
1808            Ok(())
1809        }
1810
1811        fn draw_sprite_frame(
1812            &mut self,
1813            texture: &str,
1814            frame: Option<&str>,
1815            x: f64,
1816            y: f64,
1817        ) -> Result<()> {
1818            self.calls
1819                .push((texture.to_string(), frame.map(str::to_string), (x, y)));
1820            Ok(())
1821        }
1822
1823        fn draw_text(
1824            &mut self,
1825            _text: &str,
1826            _x: f64,
1827            _y: f64,
1828            _size: f32,
1829            _color: [u8; 4],
1830        ) -> Result<()> {
1831            Ok(())
1832        }
1833
1834        fn draw_rect(&mut self, _x: f64, _y: f64, _w: f64, _h: f64, _color: [u8; 4]) -> Result<()> {
1835            Ok(())
1836        }
1837
1838        fn end_frame(&mut self) -> Result<()> {
1839            Ok(())
1840        }
1841    }
1842
1843    fn dogfood_scene() -> PathBuf {
1844        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1845            .join("..")
1846            .join("examples")
1847            .join("minimal-2d")
1848            .join("scenes")
1849            .join("main.ptscn")
1850    }
1851
1852    #[test]
1853    fn dogfood_sprites_draw_from_one_sheet() {
1854        let _guard = SERIAL.lock().unwrap();
1855        let mut session = GameSession::open(&dogfood_scene(), true).unwrap();
1856        let mut recorder = RecordingRenderer::default();
1857        session.draw_into(&mut recorder).unwrap();
1858
1859        let sheet = session
1860            .resolve_path("res://assets/sheet.atlas.json")
1861            .to_string_lossy()
1862            .into_owned();
1863        let sheet = PathBuf::from(&sheet)
1864            .with_file_name("sheet.png")
1865            .to_string_lossy()
1866            .into_owned();
1867        let frames: Vec<Option<&str>> = recorder
1868            .calls
1869            .iter()
1870            .filter(|(tex, _, _)| tex == &sheet)
1871            .map(|(_, frame, _)| frame.as_deref())
1872            .collect();
1873        assert!(
1874            frames.contains(&Some("player")),
1875            "player must draw from the sheet, got {:?}",
1876            recorder.calls
1877        );
1878        assert!(
1879            frames.contains(&Some("enemy")),
1880            "instanced enemy must draw from the sheet, got {:?}",
1881            recorder.calls
1882        );
1883        let sheets: std::collections::HashSet<&String> =
1884            recorder.calls.iter().map(|(tex, _, _)| tex).collect();
1885        assert_eq!(
1886            sheets.len(),
1887            1,
1888            "all frames share one texture, so they batch into one draw"
1889        );
1890    }
1891
1892    #[test]
1893    fn default_scene_resolves_main_scene_and_fails_outside_projects() {
1894        let _guard = SERIAL.lock().unwrap();
1895        let dir = std::env::temp_dir().join(format!("pite-defscene-{}", std::process::id()));
1896        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1897        std::fs::write(
1898            dir.join("pite.toml"),
1899            "[project]\nname = \"t\"\nmain_scene = \"res://scenes/main.ptscn\"\n",
1900        )
1901        .unwrap();
1902        let prev = std::env::current_dir().unwrap();
1903        std::env::set_current_dir(&dir).unwrap();
1904        let got = resolve_default_scene().unwrap();
1905        assert_eq!(got, dir.join("scenes").join("main.ptscn"));
1906        let bare = std::env::temp_dir().join(format!("pite-noproj-{}", std::process::id()));
1907        std::fs::create_dir_all(&bare).unwrap();
1908        std::env::set_current_dir(&bare).unwrap();
1909        let err = resolve_default_scene().expect_err("outside a project must fail loudly");
1910        assert!(err.to_string().contains("pite.toml"), "got {err:#}");
1911        std::env::set_current_dir(&prev).unwrap();
1912        std::fs::remove_dir_all(&dir).ok();
1913        std::fs::remove_dir_all(&bare).ok();
1914    }
1915
1916    #[test]
1917    fn texture_and_atlas_together_fail_loudly() {
1918        let _guard = SERIAL.lock().unwrap();
1919        let dir = std::env::temp_dir().join(format!("pite-atlas-draw-{}", std::process::id()));
1920        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1921        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"t\"\n").unwrap();
1922        std::fs::write(
1923            dir.join("scenes").join("main.ptscn"),
1924            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"both\"\ntype = \"Sprite2D\"\nname = \"Both\"\nparent = \"root\"\n\n[node.props]\ntexture = \"res://a.png\"\natlas = \"res://sheet.atlas.json\"\nframe = \"player\"\n",
1925        )
1926        .unwrap();
1927        let mut session = GameSession::open(&dir.join("scenes").join("main.ptscn"), true).unwrap();
1928        let mut recorder = RecordingRenderer::default();
1929        let err = session
1930            .draw_into(&mut recorder)
1931            .expect_err("both texture and atlas must fail loudly");
1932        assert!(
1933            err.to_string().contains("mutually exclusive"),
1934            "unexpected: {err:#}"
1935        );
1936        std::fs::remove_dir_all(&dir).ok();
1937    }
1938
1939    /// Child-process half of the bundled-Python probe. With
1940    /// `PITE_BUNDLE_PROBE=1` this initializes Python purely from whatever the
1941    /// environment provides (a bundled libpython + stdlib zip, no system
1942    /// Python) and prints `BUNDLE_PROBE_OK` only if a script really ran.
1943    #[test]
1944    fn bundle_probe_child() {
1945        if std::env::var("PITE_BUNDLE_PROBE").is_err() {
1946            eprintln!("skipped: set PITE_BUNDLE_PROBE=1 in the child process");
1947            return;
1948        }
1949        let dir = std::env::temp_dir().join("pite-bundle-probe-project");
1950        let _ = std::fs::remove_dir_all(&dir);
1951        std::fs::create_dir_all(dir.join("scenes")).unwrap();
1952        std::fs::create_dir_all(dir.join("scripts")).unwrap();
1953        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"probe\"\n").unwrap();
1954        std::fs::write(
1955            dir.join("scenes").join("main.ptscn"),
1956            "format_version = 1\nroot = \"root\"\n\n[[node]]\nid = \"root\"\ntype = \"Node2D\"\nname = \"Main\"\n\n[[node]]\nid = \"probe\"\ntype = \"Label\"\nname = \"Probe\"\nparent = \"root\"\n\n[node.props]\ntext = \"before\"\n\n[node.script]\npath = \"res://scripts/probe.py\"\nclass = \"Probe\"\n",
1957        )
1958        .unwrap();
1959        std::fs::write(
1960            dir.join("scripts").join("probe.py"),
1961            "import pite\n\n\nclass Probe(pite.Label):\n    def _process(self, delta):\n        self.text = \"bundled\"\n",
1962        )
1963        .unwrap();
1964        let mut session = GameSession::open(&dir.join("scenes").join("main.ptscn"), true).unwrap();
1965        session.update(0.016);
1966        let text = session
1967            .host
1968            .with_tree(|t| match t.get(&NodeId::from("probe".to_string())) {
1969                Some(n) => match n.props.get("text") {
1970                    Some(PropValue::Str(s)) => s.clone(),
1971                    _ => String::new(),
1972                },
1973                None => String::new(),
1974            });
1975        assert_eq!(text, "bundled", "script did not run under this interpreter");
1976        println!("BUNDLE_PROBE_OK");
1977        std::fs::remove_dir_all(&dir).ok();
1978    }
1979
1980    #[test]
1981    fn dogfood_player_hit_reaches_enemy() {
1982        let _guard = SERIAL.lock().unwrap();
1983        use pite_script::{input_begin_frame, input_set_key};
1984
1985        let scene = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1986            .join("..")
1987            .join("examples")
1988            .join("minimal-2d")
1989            .join("scenes")
1990            .join("main.ptscn");
1991        let mut session = GameSession::open(&scene, true).unwrap();
1992        assert_eq!(session.script_count(), 3);
1993        input_begin_frame();
1994        input_set_key("Space", true);
1995        session.update(0.016);
1996        input_set_key("Space", false);
1997        let after = node_x(&session, "e1_enemy");
1998        assert!(
1999            (after + 5.0).abs() < 1e-6,
2000            "enemy should recoil to x=-5, got {after}"
2001        );
2002    }
2003
2004    #[test]
2005    fn physics_character_and_raycast_e2e() {
2006        let _guard = SERIAL.lock().unwrap();
2007
2008        let dir = std::env::temp_dir().join("pite-physics-e2e");
2009        let _ = std::fs::remove_dir_all(&dir);
2010        std::fs::create_dir_all(dir.join("scenes")).unwrap();
2011        std::fs::create_dir_all(dir.join("scripts")).unwrap();
2012        std::fs::write(
2013            dir.join("pite.toml"),
2014            "[project]\nname = \"physics-test\"\n",
2015        )
2016        .unwrap();
2017
2018        let scene = r#"format_version = 1
2019root = "root"
2020
2021[[node]]
2022id = "root"
2023type = "Node2D"
2024name = "Root"
2025
2026[[node]]
2027id = "floor"
2028type = "StaticBody2D"
2029name = "Floor"
2030parent = "root"
2031
2032[node.props]
2033position = [0.0, 100.0]
2034
2035[[node]]
2036id = "floor_shape"
2037type = "CollisionShape2D"
2038name = "Shape"
2039parent = "floor"
2040
2041[node.props]
2042shape = "box"
2043size = [200.0, 20.0]
2044
2045[[node]]
2046id = "player"
2047type = "CharacterBody2D"
2048name = "Player"
2049parent = "root"
2050
2051[node.props]
2052position = [0.0, 0.0]
2053
2054[node.script]
2055path = "res://scripts/player.py"
2056class = "Player"
2057
2058[[node]]
2059id = "player_shape"
2060type = "CollisionShape2D"
2061name = "Shape"
2062parent = "player"
2063
2064[node.props]
2065shape = "box"
2066size = [20.0, 20.0]
2067"#;
2068        std::fs::write(dir.join("scenes").join("main.ptscn"), scene).unwrap();
2069
2070        let script = r#"import pite
2071
2072class Player(pite.CharacterBody2D):
2073    def _physics_process(self, dt):
2074        self.velocity = (0.0, 300.0)
2075        self.move_and_slide()
2076        hit = pite.raycast((0.0, 0.0), (0.0, 150.0))
2077        if hit is not None and hit.get("body") == "floor":
2078            pass
2079"#;
2080        std::fs::write(dir.join("scripts").join("player.py"), script).unwrap();
2081
2082        let mut session = GameSession::open(&dir.join("scenes").join("main.ptscn"), true).unwrap();
2083        for _ in 0..30 {
2084            session.update(0.016);
2085        }
2086
2087        let player_y =
2088            session
2089                .host
2090                .with_tree(|t| match t.get(&NodeId::from("player".to_string())) {
2091                    Some(n) => match n.props.get("position") {
2092                        Some(PropValue::Vec2(_, y)) => *y,
2093                        _ => f64::NAN,
2094                    },
2095                    None => f64::NAN,
2096                });
2097
2098        assert!(
2099            (player_y - 80.0).abs() < 1.0,
2100            "Player should rest on floor at y=80, got {player_y}"
2101        );
2102
2103        std::fs::remove_dir_all(&dir).ok();
2104    }
2105
2106    #[test]
2107    fn physics_area2d_signal_e2e() {
2108        let _guard = SERIAL.lock().unwrap();
2109
2110        let dir = std::env::temp_dir().join("pite-physics-area-e2e");
2111        let _ = std::fs::remove_dir_all(&dir);
2112        std::fs::create_dir_all(dir.join("scenes")).unwrap();
2113        std::fs::create_dir_all(dir.join("scripts")).unwrap();
2114        std::fs::write(dir.join("pite.toml"), "[project]\nname = \"area-test\"\n").unwrap();
2115
2116        let scene = r#"format_version = 1
2117root = "root"
2118
2119[[node]]
2120id = "root"
2121type = "Node2D"
2122name = "Root"
2123
2124[[node]]
2125id = "zone"
2126type = "Area2D"
2127name = "Zone"
2128parent = "root"
2129
2130[node.props]
2131position = [0.0, 60.0]
2132
2133[node.script]
2134path = "res://scripts/zone.py"
2135class = "Zone"
2136
2137[[node]]
2138id = "zone_shape"
2139type = "CollisionShape2D"
2140name = "Shape"
2141parent = "zone"
2142
2143[node.props]
2144shape = "box"
2145size = [60.0, 60.0]
2146
2147[[node]]
2148id = "player"
2149type = "CharacterBody2D"
2150name = "Player"
2151parent = "root"
2152
2153[node.props]
2154position = [0.0, 0.0]
2155
2156[node.script]
2157path = "res://scripts/player.py"
2158class = "Player"
2159
2160[[node]]
2161id = "player_shape"
2162type = "CollisionShape2D"
2163name = "Shape"
2164parent = "player"
2165
2166[node.props]
2167shape = "box"
2168size = [20.0, 20.0]
2169"#;
2170        std::fs::write(dir.join("scenes").join("main.ptscn"), scene).unwrap();
2171
2172        let player_script = r#"import pite
2173
2174class Player(pite.CharacterBody2D):
2175    def _physics_process(self, dt):
2176        self.velocity = (0.0, 200.0)
2177        self.move_and_slide()
2178"#;
2179        std::fs::write(dir.join("scripts").join("player.py"), player_script).unwrap();
2180
2181        let zone_script = r#"import pite
2182
2183class Zone(pite.Area2D):
2184    def _ready(self):
2185        self.body_entered.connect(self.on_body)
2186
2187    def on_body(self, body_id):
2188        self.position = (999.0, 999.0)
2189"#;
2190        std::fs::write(dir.join("scripts").join("zone.py"), zone_script).unwrap();
2191
2192        let mut session = GameSession::open(&dir.join("scenes").join("main.ptscn"), true).unwrap();
2193        for _ in 0..30 {
2194            session.update(0.016);
2195            let zp = session
2196                .host
2197                .with_tree(|t| match t.get(&NodeId::from("zone".to_string())) {
2198                    Some(n) => match n.props.get("position") {
2199                        Some(PropValue::Vec2(x, y)) => (*x, *y),
2200                        _ => (0.0, 0.0),
2201                    },
2202                    None => (0.0, 0.0),
2203                });
2204            if zp == (999.0, 999.0) {
2205                break;
2206            }
2207        }
2208
2209        let zone_pos = session
2210            .host
2211            .with_tree(|t| match t.get(&NodeId::from("zone".to_string())) {
2212                Some(n) => match n.props.get("position") {
2213                    Some(PropValue::Vec2(x, y)) => (*x, *y),
2214                    _ => (0.0, 0.0),
2215                },
2216                None => (0.0, 0.0),
2217            });
2218
2219        assert_eq!(
2220            zone_pos,
2221            (999.0, 999.0),
2222            "Area2D body_entered signal should move zone to (999, 999)"
2223        );
2224
2225        std::fs::remove_dir_all(&dir).ok();
2226    }
2227}