Skip to main content

pite_script/
python.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::path::{Path, PathBuf};
3use std::sync::Once;
4
5use anyhow::{Context, Result};
6use pyo3::prelude::*;
7use pyo3::PyClassInitializer;
8
9use std::collections::HashMap;
10
11use crate::host::NodeProxy;
12use crate::ScriptBackend;
13
14#[pyclass(subclass, name = "Node")]
15struct PyNode {
16    #[pyo3(get, set)]
17    node_path: String,
18}
19
20#[pymethods]
21impl PyNode {
22    #[new]
23    fn new() -> Self {
24        Self {
25            node_path: String::new(),
26        }
27    }
28
29    fn get_node(slf: &Bound<'_, Self>, path: &str) -> PyResult<Py<NodeProxy>> {
30        let py = slf.py();
31        let node_path = slf.borrow().node_path.clone();
32        crate::resolve_caller_py(py, &node_path, path)
33            .map_err(|e| pyo3::exceptions::PyKeyError::new_err(e.to_string()))
34    }
35}
36
37#[pyclass(extends = PyNode, subclass, name = "Node2D")]
38struct PyNode2D {
39    position: Py<crate::vec2::Vec2>,
40}
41
42#[pymethods]
43impl PyNode2D {
44    #[new]
45    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
46        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
47        Ok(PyClassInitializer::from(PyNode::new()).add_subclass(PyNode2D { position: pos }))
48    }
49
50    #[getter]
51    fn position(&self, py: Python<'_>) -> Py<crate::vec2::Vec2> {
52        self.position.clone_ref(py)
53    }
54
55    #[setter]
56    fn set_position(&self, py: Python<'_>, val: &Bound<'_, PyAny>) -> PyResult<()> {
57        let mut b = self.position.borrow_mut(py);
58        if let Ok(v) = val.extract::<crate::vec2::Vec2>() {
59            b.set_x(v.x());
60            b.set_y(v.y());
61        } else if let Ok((x, y)) = val.extract::<(f64, f64)>() {
62            b.set_x(x);
63            b.set_y(y);
64        } else {
65            return Err(pyo3::exceptions::PyTypeError::new_err(
66                "position must be Vec2 or (x, y) tuple",
67            ));
68        }
69        Ok(())
70    }
71}
72
73#[pyclass(extends = PyNode2D, subclass, name = "Sprite2D")]
74struct PySprite2D {
75    #[pyo3(get, set)]
76    texture: String,
77    #[pyo3(get, set)]
78    atlas: String,
79    #[pyo3(get, set)]
80    frame: String,
81}
82
83#[pymethods]
84impl PySprite2D {
85    #[new]
86    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
87        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
88        Ok(PyClassInitializer::from(PyNode::new())
89            .add_subclass(PyNode2D { position: pos })
90            .add_subclass(PySprite2D {
91                texture: String::new(),
92                atlas: String::new(),
93                frame: String::new(),
94            }))
95    }
96}
97
98#[pyclass(extends = PyNode2D, subclass, name = "Camera2D")]
99struct PyCamera2D;
100
101#[pymethods]
102impl PyCamera2D {
103    #[new]
104    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
105        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
106        Ok(PyClassInitializer::from(PyNode::new())
107            .add_subclass(PyNode2D { position: pos })
108            .add_subclass(PyCamera2D))
109    }
110}
111
112#[pyclass(extends = PyNode, subclass, name = "Timer")]
113struct PyTimer {
114    #[pyo3(get, set)]
115    wait_time: f64,
116}
117
118#[pymethods]
119impl PyTimer {
120    #[new]
121    fn new() -> PyClassInitializer<Self> {
122        PyClassInitializer::from(PyNode::new()).add_subclass(PyTimer { wait_time: 1.0 })
123    }
124}
125
126#[pyclass(extends = PyNode, subclass, name = "Label")]
127struct PyLabel {
128    #[pyo3(get)]
129    text: String,
130}
131
132#[pymethods]
133impl PyLabel {
134    #[new]
135    fn new() -> PyClassInitializer<Self> {
136        PyClassInitializer::from(PyNode::new()).add_subclass(PyLabel {
137            text: String::new(),
138        })
139    }
140
141    #[setter]
142    fn set_text(slf: &Bound<'_, Self>, text: String) -> PyResult<()> {
143        let node_path: String = slf.getattr("node_path")?.extract()?;
144        if let Some(host) = crate::host::current_host() {
145            host.set_node_text(&node_path, &text);
146        }
147        let mut b = slf.borrow_mut();
148        b.text = text;
149        Ok(())
150    }
151}
152
153#[pyclass(extends = PyNode2D, subclass, name = "Button")]
154struct PyButton {
155    #[pyo3(get)]
156    text: String,
157    size: Py<crate::vec2::Vec2>,
158}
159
160#[pymethods]
161impl PyButton {
162    #[new]
163    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
164        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
165        let size = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
166        Ok(PyClassInitializer::from(PyNode::new())
167            .add_subclass(PyNode2D { position: pos })
168            .add_subclass(PyButton {
169                text: String::new(),
170                size,
171            }))
172    }
173
174    #[setter]
175    fn set_text(slf: &Bound<'_, Self>, text: String) -> PyResult<()> {
176        let node_path: String = slf.getattr("node_path")?.extract()?;
177        if let Some(host) = crate::host::current_host() {
178            host.set_node_text(&node_path, &text);
179        }
180        let mut b = slf.borrow_mut();
181        b.text = text;
182        Ok(())
183    }
184
185    #[getter]
186    fn size(&self, py: Python<'_>) -> Py<crate::vec2::Vec2> {
187        self.size.clone_ref(py)
188    }
189
190    #[setter]
191    fn set_size(slf: &Bound<'_, Self>, val: &Bound<'_, PyAny>) -> PyResult<()> {
192        let (w, h) = if let Ok(v) = val.extract::<crate::vec2::Vec2>() {
193            (v.x(), v.y())
194        } else if let Ok(t) = val.extract::<(f64, f64)>() {
195            t
196        } else {
197            return Err(pyo3::exceptions::PyTypeError::new_err(
198                "size must be Vec2 or (w, h) tuple",
199            ));
200        };
201        let node_path: String = slf.getattr("node_path")?.extract()?;
202        if let Some(host) = crate::host::current_host() {
203            host.set_node_size(&node_path, w, h);
204        }
205        let py = slf.py();
206        let b = slf.borrow();
207        let mut s = b.size.borrow_mut(py);
208        s.set_x(w);
209        s.set_y(h);
210        Ok(())
211    }
212}
213
214#[pyclass(extends = PyNode2D, subclass, name = "StaticBody2D")]
215struct PyStaticBody2D {
216    #[pyo3(get, set)]
217    collision_layer: u32,
218    #[pyo3(get, set)]
219    collision_mask: u32,
220}
221
222#[pymethods]
223impl PyStaticBody2D {
224    #[new]
225    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
226        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
227        Ok(PyClassInitializer::from(PyNode::new())
228            .add_subclass(PyNode2D { position: pos })
229            .add_subclass(PyStaticBody2D {
230                collision_layer: 1,
231                collision_mask: 1,
232            }))
233    }
234}
235
236#[pyclass(extends = PyNode2D, subclass, name = "CharacterBody2D")]
237struct PyCharacterBody2D {
238    velocity: Py<crate::vec2::Vec2>,
239    up_direction: Py<crate::vec2::Vec2>,
240    #[pyo3(get, set)]
241    collision_layer: u32,
242    #[pyo3(get, set)]
243    collision_mask: u32,
244    #[pyo3(get, set)]
245    _is_on_floor: bool,
246    #[pyo3(get, set)]
247    _is_on_wall: bool,
248    #[pyo3(get, set)]
249    _is_on_ceiling: bool,
250}
251
252#[pymethods]
253impl PyCharacterBody2D {
254    #[new]
255    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
256        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
257        let vel = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
258        let up = Py::new(py, crate::vec2::Vec2::new(0.0, -1.0))?;
259        Ok(PyClassInitializer::from(PyNode::new())
260            .add_subclass(PyNode2D { position: pos })
261            .add_subclass(PyCharacterBody2D {
262                velocity: vel,
263                up_direction: up,
264                collision_layer: 1,
265                collision_mask: 1,
266                _is_on_floor: false,
267                _is_on_wall: false,
268                _is_on_ceiling: false,
269            }))
270    }
271
272    #[getter]
273    fn velocity(&self, py: Python<'_>) -> Py<crate::vec2::Vec2> {
274        self.velocity.clone_ref(py)
275    }
276
277    #[setter]
278    fn set_velocity(&self, py: Python<'_>, val: &Bound<'_, PyAny>) -> PyResult<()> {
279        let mut b = self.velocity.borrow_mut(py);
280        if let Ok(v) = val.extract::<crate::vec2::Vec2>() {
281            b.set_x(v.x());
282            b.set_y(v.y());
283        } else if let Ok((vx, vy)) = val.extract::<(f64, f64)>() {
284            b.set_x(vx);
285            b.set_y(vy);
286        } else {
287            return Err(pyo3::exceptions::PyTypeError::new_err(
288                "velocity must be Vec2 or (vx, vy) tuple",
289            ));
290        }
291        Ok(())
292    }
293
294    #[getter]
295    fn up_direction(&self, py: Python<'_>) -> Py<crate::vec2::Vec2> {
296        self.up_direction.clone_ref(py)
297    }
298
299    #[setter]
300    fn set_up_direction(&self, py: Python<'_>, val: &Bound<'_, PyAny>) -> PyResult<()> {
301        let mut b = self.up_direction.borrow_mut(py);
302        if let Ok(v) = val.extract::<crate::vec2::Vec2>() {
303            b.set_x(v.x());
304            b.set_y(v.y());
305        } else if let Ok((ux, uy)) = val.extract::<(f64, f64)>() {
306            b.set_x(ux);
307            b.set_y(uy);
308        } else {
309            return Err(pyo3::exceptions::PyTypeError::new_err(
310                "up_direction must be Vec2 or (ux, uy) tuple",
311            ));
312        }
313        Ok(())
314    }
315
316    fn is_on_floor(&self) -> bool {
317        self._is_on_floor
318    }
319
320    fn is_on_wall(&self) -> bool {
321        self._is_on_wall
322    }
323
324    fn is_on_ceiling(&self) -> bool {
325        self._is_on_ceiling
326    }
327
328    fn move_and_slide(slf: &Bound<'_, Self>) -> PyResult<bool> {
329        let py = slf.py();
330        let node_path: String = slf.getattr("node_path")?.extract()?;
331        let node_id = pite_core::NodeId::from(node_path);
332        let (vel, up) = {
333            let b = slf.borrow();
334            let v = b.velocity.borrow(py);
335            let u = b.up_direction.borrow(py);
336            ((v.x(), v.y()), (u.x(), u.y()))
337        };
338
339        let dt = 1.0 / 60.0;
340        let res = crate::with_physics(|world| {
341            world.move_and_slide(
342                &node_id,
343                (vel.0 as f32, vel.1 as f32),
344                dt as f32,
345                (up.0 as f32, up.1 as f32),
346            )
347        });
348
349        if let Some(res) = res {
350            {
351                let b = slf.borrow();
352                let mut v = b.velocity.borrow_mut(py);
353                v.set_x(res.velocity.0 as f64);
354                v.set_y(res.velocity.1 as f64);
355            }
356            {
357                let mut b = slf.borrow_mut();
358                b._is_on_floor = res.is_on_floor;
359                b._is_on_wall = res.is_on_wall;
360                b._is_on_ceiling = res.is_on_ceiling;
361            }
362            if let Some(Some(new_pos)) = crate::with_physics(|w| w.get_body_position(&node_id)) {
363                slf.setattr("position", new_pos)?;
364            }
365            Ok(true)
366        } else {
367            Ok(false)
368        }
369    }
370}
371
372#[pyclass(extends = PyNode2D, subclass, name = "RigidBody2D")]
373struct PyRigidBody2D {
374    #[pyo3(get, set)]
375    mass: f64,
376    #[pyo3(get, set)]
377    gravity_scale: f64,
378    #[pyo3(get, set)]
379    restitution: f64,
380    #[pyo3(get, set)]
381    friction: f64,
382    #[pyo3(get, set)]
383    lock_rotation: bool,
384    #[pyo3(get, set)]
385    collision_layer: u32,
386    #[pyo3(get, set)]
387    collision_mask: u32,
388}
389
390#[pymethods]
391impl PyRigidBody2D {
392    #[new]
393    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
394        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
395        Ok(PyClassInitializer::from(PyNode::new())
396            .add_subclass(PyNode2D { position: pos })
397            .add_subclass(PyRigidBody2D {
398                mass: 1.0,
399                gravity_scale: 1.0,
400                restitution: 0.0,
401                friction: 0.5,
402                lock_rotation: false,
403                collision_layer: 1,
404                collision_mask: 1,
405            }))
406    }
407
408    fn apply_impulse(slf: &Bound<'_, Self>, impulse: (f64, f64)) -> PyResult<()> {
409        let node_path: String = slf.getattr("node_path")?.extract()?;
410        let node_id = pite_core::NodeId::from(node_path);
411        crate::with_physics(|w| w.apply_impulse(&node_id, (impulse.0 as f32, impulse.1 as f32)));
412        Ok(())
413    }
414
415    fn apply_force(slf: &Bound<'_, Self>, force: (f64, f64)) -> PyResult<()> {
416        let node_path: String = slf.getattr("node_path")?.extract()?;
417        let node_id = pite_core::NodeId::from(node_path);
418        crate::with_physics(|w| w.apply_force(&node_id, (force.0 as f32, force.1 as f32)));
419        Ok(())
420    }
421}
422
423#[pyclass(extends = PyNode2D, subclass, name = "Area2D")]
424struct PyArea2D {
425    #[pyo3(get, set)]
426    collision_layer: u32,
427    #[pyo3(get, set)]
428    collision_mask: u32,
429}
430
431#[pymethods]
432impl PyArea2D {
433    #[new]
434    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
435        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
436        Ok(PyClassInitializer::from(PyNode::new())
437            .add_subclass(PyNode2D { position: pos })
438            .add_subclass(PyArea2D {
439                collision_layer: 1,
440                collision_mask: 1,
441            }))
442    }
443
444    #[getter]
445    fn body_entered(slf: &Bound<'_, Self>) -> PyResult<crate::host::BoundSignal> {
446        let node_path: String = slf.getattr("node_path")?.extract()?;
447        let host = crate::host::current_host().ok_or_else(|| {
448            pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host")
449        })?;
450        Ok(crate::host::BoundSignal::with_host(
451            host,
452            node_path,
453            "body_entered".to_string(),
454        ))
455    }
456
457    #[getter]
458    fn body_exited(slf: &Bound<'_, Self>) -> PyResult<crate::host::BoundSignal> {
459        let node_path: String = slf.getattr("node_path")?.extract()?;
460        let host = crate::host::current_host().ok_or_else(|| {
461            pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host")
462        })?;
463        Ok(crate::host::BoundSignal::with_host(
464            host,
465            node_path,
466            "body_exited".to_string(),
467        ))
468    }
469
470    #[getter]
471    fn area_entered(slf: &Bound<'_, Self>) -> PyResult<crate::host::BoundSignal> {
472        let node_path: String = slf.getattr("node_path")?.extract()?;
473        let host = crate::host::current_host().ok_or_else(|| {
474            pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host")
475        })?;
476        Ok(crate::host::BoundSignal::with_host(
477            host,
478            node_path,
479            "area_entered".to_string(),
480        ))
481    }
482
483    #[getter]
484    fn area_exited(slf: &Bound<'_, Self>) -> PyResult<crate::host::BoundSignal> {
485        let node_path: String = slf.getattr("node_path")?.extract()?;
486        let host = crate::host::current_host().ok_or_else(|| {
487            pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host")
488        })?;
489        Ok(crate::host::BoundSignal::with_host(
490            host,
491            node_path,
492            "area_exited".to_string(),
493        ))
494    }
495}
496
497#[pyclass(extends = PyNode2D, subclass, name = "CollisionShape2D")]
498struct PyCollisionShape2D {
499    #[pyo3(get, set)]
500    shape: String,
501    #[pyo3(get, set)]
502    size: (f64, f64),
503    #[pyo3(get, set)]
504    radius: f64,
505    #[pyo3(get, set)]
506    height: f64,
507    #[pyo3(get, set)]
508    disabled: bool,
509}
510
511#[pymethods]
512impl PyCollisionShape2D {
513    #[new]
514    fn new(py: Python<'_>) -> PyResult<PyClassInitializer<Self>> {
515        let pos = Py::new(py, crate::vec2::Vec2::new(0.0, 0.0))?;
516        Ok(PyClassInitializer::from(PyNode::new())
517            .add_subclass(PyNode2D { position: pos })
518            .add_subclass(PyCollisionShape2D {
519                shape: "box".to_string(),
520                size: (32.0, 32.0),
521                radius: 16.0,
522                height: 32.0,
523                disabled: false,
524            }))
525    }
526}
527
528#[pyfunction]
529#[pyo3(signature = (from, to, mask = 0xFFFFFFFF))]
530fn raycast(
531    py: Python<'_>,
532    from: (f64, f64),
533    to: (f64, f64),
534    mask: u32,
535) -> PyResult<Option<Py<PyAny>>> {
536    let hit = crate::with_physics(|w| {
537        w.raycast(
538            (from.0 as f32, from.1 as f32),
539            (to.0 as f32, to.1 as f32),
540            mask,
541        )
542    })
543    .flatten();
544
545    if let Some(h) = hit {
546        let dict = pyo3::types::PyDict::new(py);
547        dict.set_item("point", (h.point.0 as f64, h.point.1 as f64))?;
548        dict.set_item("normal", (h.normal.0 as f64, h.normal.1 as f64))?;
549        dict.set_item("distance", h.distance as f64)?;
550        dict.set_item("collider", h.collider_node.as_str())?;
551        dict.set_item(
552            "body",
553            h.body_node.as_ref().map(|b| b.as_str()).unwrap_or(""),
554        )?;
555        Ok(Some(dict.into_any().unbind()))
556    } else {
557        Ok(None)
558    }
559}
560
561/// Frame names in a sprite-sheet atlas (`res://…atlas.json`).
562#[pyfunction]
563fn frames(atlas_ref: &str) -> PyResult<Vec<String>> {
564    let root = crate::project_dir().ok_or_else(|| {
565        pyo3::exceptions::PyRuntimeError::new_err(
566            "no project root known; pite.frames() needs res:// inside a project",
567        )
568    })?;
569    let atlas = pite_assets::load_sidecar(&root, atlas_ref)
570        .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e:#}")))?;
571    Ok(atlas.names().into_iter().map(str::to_string).collect())
572}
573
574#[pymodule]
575fn pite(m: &Bound<'_, PyModule>) -> PyResult<()> {
576    m.add_class::<PyNode>()?;
577    m.add_class::<PyNode2D>()?;
578    m.add_class::<PySprite2D>()?;
579    m.add_class::<PyCamera2D>()?;
580    m.add_class::<PyTimer>()?;
581    m.add_class::<PyLabel>()?;
582    m.add_class::<PyButton>()?;
583    m.add_class::<PyStaticBody2D>()?;
584    m.add_class::<PyCharacterBody2D>()?;
585    m.add_class::<PyRigidBody2D>()?;
586    m.add_class::<PyArea2D>()?;
587    m.add_class::<PyCollisionShape2D>()?;
588    m.add_class::<crate::vec2::Vec2>()?;
589    m.add_class::<PyInputQuery>()?;
590    m.add_class::<crate::descriptor::PyChildDescriptor>()?;
591    m.add_class::<crate::descriptor::PyNodeDescriptor>()?;
592    m.add(
593        "held",
594        PyInputQuery {
595            kind: InputQueryKind::Held,
596        },
597    )?;
598    m.add(
599        "pressed",
600        PyInputQuery {
601            kind: InputQueryKind::Pressed,
602        },
603    )?;
604    m.add(
605        "released",
606        PyInputQuery {
607            kind: InputQueryKind::Released,
608        },
609    )?;
610    m.add_function(wrap_pyfunction!(raycast, m)?)?;
611    m.add_function(wrap_pyfunction!(mouse, m)?)?;
612    m.add_function(wrap_pyfunction!(signal, m)?)?;
613    m.add_class::<PySignalDecl>()?;
614    m.add_function(wrap_pyfunction!(frames, m)?)?;
615    m.add_function(wrap_pyfunction!(crate::descriptor::child, m)?)?;
616    m.add_function(wrap_pyfunction!(crate::descriptor::node, m)?)?;
617    m.add_function(wrap_pyfunction!(crate::audio::play, m)?)?;
618    m.add_function(wrap_pyfunction!(crate::audio::stop, m)?)?;
619    m.add_function(wrap_pyfunction!(crate::audio::set_volume, m)?)?;
620    m.add_function(wrap_pyfunction!(crate::audio::is_playing, m)?)?;
621    Ok(())
622}
623
624fn map_key_attr(attr: &str) -> &'static str {
625    match attr {
626        "space" => "Space",
627        "enter" | "return" => "Enter",
628        "escape" | "esc" => "Escape",
629        "tab" => "Tab",
630        "backspace" => "Backspace",
631        "shift" => "Shift",
632        "ctrl" | "control" => "Control",
633        "alt" => "Alt",
634        "left" | "arrow_left" => "ArrowLeft",
635        "right" | "arrow_right" => "ArrowRight",
636        "up" | "arrow_up" => "ArrowUp",
637        "down" | "arrow_down" => "ArrowDown",
638        "mouse_left" | "mouse1" => "MouseLeft",
639        "mouse_right" | "mouse2" => "MouseRight",
640        "mouse_middle" | "mouse3" => "MouseMiddle",
641        "a" => "A",
642        "b" => "B",
643        "c" => "C",
644        "d" => "D",
645        "e" => "E",
646        "f" => "F",
647        "g" => "G",
648        "h" => "H",
649        "i" => "I",
650        "j" => "J",
651        "k" => "K",
652        "l" => "L",
653        "m" => "M",
654        "n" => "N",
655        "o" => "O",
656        "p" => "P",
657        "q" => "Q",
658        "r" => "R",
659        "s" => "S",
660        "t" => "T",
661        "u" => "U",
662        "v" => "V",
663        "w" => "W",
664        "x" => "X",
665        "y" => "Y",
666        "z" => "Z",
667        "num0" | "zero" => "0",
668        "num1" | "one" => "1",
669        "num2" | "two" => "2",
670        "num3" | "three" => "3",
671        "num4" | "four" => "4",
672        "num5" | "five" => "5",
673        "num6" | "six" => "6",
674        "num7" | "seven" => "7",
675        "num8" | "eight" => "8",
676        "num9" | "nine" => "9",
677        _ => "",
678    }
679}
680
681#[derive(Debug, Clone, Copy, PartialEq, Eq)]
682pub enum InputQueryKind {
683    Held,
684    Pressed,
685    Released,
686}
687
688impl InputQueryKind {
689    fn name(&self) -> &'static str {
690        match self {
691            Self::Held => "held",
692            Self::Pressed => "pressed",
693            Self::Released => "released",
694        }
695    }
696}
697
698#[pyclass(name = "InputQuery")]
699pub struct PyInputQuery {
700    pub(crate) kind: InputQueryKind,
701}
702
703#[pymethods]
704impl PyInputQuery {
705    fn __getattr__(&self, name: &str) -> bool {
706        let key = map_key_attr(name);
707        let key_str = if key.is_empty() { name } else { key };
708        match self.kind {
709            InputQueryKind::Held => crate::input_held(key_str),
710            InputQueryKind::Pressed => crate::input_pressed(key_str),
711            InputQueryKind::Released => crate::input_released(key_str),
712        }
713    }
714
715    #[pyo3(signature = (*_args, **_kwargs))]
716    fn __call__(
717        &self,
718        _args: &Bound<'_, pyo3::types::PyTuple>,
719        _kwargs: Option<&Bound<'_, pyo3::types::PyDict>>,
720    ) -> PyResult<()> {
721        let n = self.kind.name();
722        Err(pyo3::exceptions::PyTypeError::new_err(format!(
723            "pite.{n} is an attribute-based accessor. Use 'pite.{n}.<key>' instead (e.g. pite.{n}.space, pite.{n}.right). Passing strings is no longer supported."
724        )))
725    }
726}
727
728#[pyfunction]
729fn mouse() -> crate::vec2::Vec2 {
730    let (x, y) = crate::input_mouse();
731    crate::vec2::Vec2::new(x, y)
732}
733
734#[pyfunction]
735#[pyo3(signature = (*types))]
736fn signal(types: Vec<Bound<'_, PyAny>>) -> PyResult<PySignalDecl> {
737    Ok(PySignalDecl {
738        payload: read_payload_types(&types)?,
739        attr: String::new(),
740    })
741}
742
743pub(crate) fn read_payload_types(types: &[Bound<'_, PyAny>]) -> PyResult<Vec<String>> {
744    let mut out = Vec::with_capacity(types.len());
745    for ty in types {
746        if let Ok(s) = ty.extract::<String>() {
747            out.push(s);
748        } else if ty.is_instance_of::<pyo3::types::PyType>() {
749            let type_name: String = ty.getattr("__name__")?.extract()?;
750            match type_name.as_str() {
751                "int" | "float" | "str" | "bool" => out.push(type_name),
752                _ => {
753                    return Err(pyo3::exceptions::PyTypeError::new_err(
754                        "signal payload type must be int, float, str or bool",
755                    ));
756                }
757            }
758        } else {
759            return Err(pyo3::exceptions::PyTypeError::new_err(
760                "signal payload type must be int, float, str or bool",
761            ));
762        }
763    }
764    Ok(out)
765}
766
767#[pyclass(name = "Signal")]
768#[derive(Clone)]
769pub(crate) struct PySignalDecl {
770    #[pyo3(get)]
771    payload: Vec<String>,
772    #[pyo3(get, set)]
773    attr: String,
774}
775
776#[pymethods]
777impl PySignalDecl {
778    fn __set_name__(&mut self, _owner: Bound<'_, PyAny>, name: String) {
779        self.attr = name;
780    }
781
782    fn __get__(
783        &self,
784        py: Python<'_>,
785        obj: Option<Bound<'_, PyAny>>,
786        _owner: Option<Bound<'_, PyAny>>,
787    ) -> PyResult<Py<PyAny>> {
788        match obj {
789            None => Ok(Bound::new(py, self.clone())?.into_any().unbind()),
790            Some(obj) => {
791                let node_path: String = obj.getattr("node_path")?.extract()?;
792                let host = crate::host::current_host().ok_or_else(|| {
793                    pyo3::exceptions::PyRuntimeError::new_err("signal access has no script host")
794                })?;
795                Ok(Bound::new(
796                    py,
797                    crate::host::BoundSignal::with_host(host, node_path, self.attr.clone()),
798                )?
799                .into_any()
800                .unbind())
801            }
802        }
803    }
804}
805
806static INIT: Once = Once::new();
807
808fn ensure_init() {
809    INIT.call_once(|| {
810        pyo3::append_to_inittab!(pite);
811        Python::initialize();
812    });
813}
814
815/// Sorted wheel files inside `vendor_dir`. Missing dir reads as empty so
816/// projects without vendored packages pay nothing.
817pub(crate) fn vendor_wheels(vendor_dir: &std::path::Path) -> Vec<std::path::PathBuf> {
818    let mut out = Vec::new();
819    if let Ok(entries) = std::fs::read_dir(vendor_dir) {
820        for entry in entries.filter_map(|e| e.ok()) {
821            let path = entry.path();
822            if path.extension().is_some_and(|e| e == "whl") {
823                out.push(path);
824            }
825        }
826    }
827    out.sort_unstable();
828    out
829}
830
831/// True when `name` is a top-level stdlib module of the running interpreter
832/// (`sys.stdlib_module_names`). Asked live so the answer tracks the pinned
833/// CPython instead of a hand list; `pite check` uses it to tell stdlib
834/// (ships in the export zip) from third-party (dropped) imports.
835pub fn is_stdlib_module(name: &str) -> bool {
836    ensure_init();
837    Python::attach(|py| {
838        py.import("sys")
839            .and_then(|sys| sys.getattr("stdlib_module_names"))
840            .and_then(|names| names.contains(name))
841            .unwrap_or(false)
842    })
843}
844
845#[derive(Debug, Clone)]
846pub struct ScriptError {
847    pub message: String,
848    pub file: Option<String>,
849    pub line: Option<usize>,
850    pub traceback: String,
851}
852
853pub fn extract_script_error(py: Python<'_>, err: &PyErr, ctx: &str) -> ScriptError {
854    let mut file = None;
855    let mut line = None;
856    if let Some(tb) = err.traceback(py) {
857        let mut curr = Some(tb);
858        while let Some(t) = curr {
859            let frame = t.getattr("tb_frame").ok();
860            if let Some(f) = frame {
861                let code = f.getattr("f_code").ok();
862                if let Some(c) = code {
863                    if let Ok(filename) =
864                        c.getattr("co_filename").and_then(|x| x.extract::<String>())
865                    {
866                        let lineno = t
867                            .getattr("tb_lineno")
868                            .and_then(|x| x.extract::<usize>())
869                            .unwrap_or(0);
870                        file = Some(filename);
871                        line = Some(lineno);
872                    }
873                }
874            }
875            curr = t.getattr("tb_next").ok().and_then(|n| n.extract().ok());
876        }
877    }
878    let tb_str = err
879        .traceback(py)
880        .and_then(|t| t.format().ok())
881        .unwrap_or_default();
882    let message = format!("{ctx}: {err}");
883    ScriptError {
884        message,
885        file,
886        line,
887        traceback: tb_str,
888    }
889}
890
891pub struct Pyo3Backend {
892    node_path: String,
893    file: PathBuf,
894    class: String,
895    instance: Option<Py<PyAny>>,
896    last_error: Option<String>,
897    last_script_error: Option<ScriptError>,
898    host: Option<std::sync::Arc<crate::ScriptHost>>,
899    pending_signals: Vec<(String, Vec<String>)>,
900}
901
902impl Pyo3Backend {
903    pub fn new(node_path: impl Into<String>) -> Self {
904        ensure_init();
905        Self {
906            node_path: node_path.into(),
907            file: PathBuf::new(),
908            class: String::new(),
909            instance: None,
910            last_error: None,
911            last_script_error: None,
912            host: None,
913            pending_signals: Vec::new(),
914        }
915    }
916
917    pub fn set_host(&mut self, host: std::sync::Arc<crate::ScriptHost>) {
918        if !self.pending_signals.is_empty() {
919            if let Err(e) = host.declare_signals(&self.node_path, self.pending_signals.clone()) {
920                self.last_error = Some(e.to_string());
921            } else {
922                self.pending_signals.clear();
923            }
924        }
925        if let Some(inst) = &self.instance {
926            Python::attach(|py| {
927                if let Ok(pos_attr) = inst.bind(py).getattr("position") {
928                    if let Ok(mut vec2_bound) =
929                        pos_attr.extract::<pyo3::PyRefMut<crate::vec2::Vec2>>()
930                    {
931                        vec2_bound.set_target(host.clone(), self.node_path.clone());
932                    }
933                }
934            });
935        }
936        self.host = Some(host);
937    }
938
939    fn read_signal_defs(
940        py: Python<'_>,
941        cls: &Bound<'_, PyAny>,
942    ) -> Result<Vec<(String, Vec<String>)>> {
943        let mut defs: Vec<(String, Vec<String>)> = Vec::new();
944        let mut seen = std::collections::HashSet::new();
945        let mut consider = |name: String, value: Bound<'_, PyAny>| {
946            if !seen.insert(name.clone()) {
947                return;
948            }
949            if !value.is_instance_of::<PySignalDecl>() {
950                return;
951            }
952            let Ok(payload) = value.getattr("payload") else {
953                return;
954            };
955            let Ok(types) = payload.extract::<Vec<String>>() else {
956                return;
957            };
958            defs.push((name, types));
959        };
960        for name in cls
961            .dir()
962            .map(|list| {
963                list.iter()
964                    .filter_map(|item| item.extract::<String>().ok())
965                    .collect::<Vec<_>>()
966            })
967            .unwrap_or_default()
968        {
969            if let Ok(value) = cls.getattr(name.as_str()) {
970                consider(name.to_string(), value);
971            }
972        }
973        if let Ok(ann) = cls.getattr("__annotations__") {
974            if !ann.is_none() {
975                if let Ok(map) = ann.extract::<HashMap<String, Bound<'_, PyAny>>>() {
976                    for (name, value) in map {
977                        if value.is_instance_of::<PySignalDecl>() {
978                            let _ = value.setattr("attr", name.as_str());
979                            let _ = cls.setattr(name.as_str(), &value);
980                        }
981                        consider(name, value);
982                    }
983                }
984            }
985        }
986        let _ = py;
987        Ok(defs)
988    }
989
990    fn exec(&mut self) -> Result<()> {
991        let src = std::fs::read_to_string(&self.file)
992            .with_context(|| format!("cannot read script {}", self.file.display()))?;
993        let dir = self
994            .file
995            .parent()
996            .map(Path::to_path_buf)
997            .unwrap_or_else(|| PathBuf::from("."));
998        let stem = self
999            .file
1000            .file_stem()
1001            .map(|s| s.to_string_lossy().into_owned())
1002            .unwrap_or_else(|| "script".to_string());
1003        Python::attach(|py| {
1004            let sys = py.import("sys")?;
1005            let path = sys.getattr("path")?;
1006            // Vendored third-party packages resolve in dev and export alike:
1007            // the export stages vendor/ whole, so the same entries work.
1008            // Unpacked trees import from the directory itself; each zipped
1009            // wheel needs its own entry (a dir of wheels imports nothing).
1010            if let Some(root) = crate::project_dir() {
1011                let vendor = root.join("vendor");
1012                for (i, wheel) in vendor_wheels(&vendor).iter().enumerate() {
1013                    path.call_method1("insert", (i + 1, wheel.to_string_lossy().as_ref()))?;
1014                }
1015                path.call_method1("insert", (0, vendor.to_string_lossy().as_ref()))?;
1016            }
1017            path.call_method1("insert", (0, dir.to_string_lossy().as_ref()))?;
1018            let modules = sys.getattr("modules")?;
1019            let _ = modules.call_method1("pop", (stem.as_str(), py.None()));
1020            let code = std::ffi::CString::new(src.clone())?;
1021            let filename = std::ffi::CString::new(format!("{stem}.py"))?;
1022            let modname = std::ffi::CString::new(stem.clone())?;
1023            let module = PyModule::from_code(py, &code, &filename, &modname)
1024                .with_context(|| format!("cannot exec script {}", self.file.display()))?;
1025            let cls = module.getattr(self.class.as_str()).with_context(|| {
1026                format!(
1027                    "script {} has no class {:?}",
1028                    self.file.display(),
1029                    self.class
1030                )
1031            })?;
1032            let defs = Self::read_signal_defs(py, &cls).with_context(|| {
1033                format!(
1034                    "script {} class {} has bad signals declaration",
1035                    self.file.display(),
1036                    self.class
1037                )
1038            })?;
1039            if let Some(host) = &self.host {
1040                host.declare_signals(&self.node_path, defs.clone())?;
1041            }
1042            self.pending_signals = defs;
1043            let instance = cls.call0().with_context(|| {
1044                format!(
1045                    "cannot instantiate {} in {}",
1046                    self.class,
1047                    self.file.display()
1048                )
1049            })?;
1050            let owned: Py<PyAny> = instance.into();
1051            owned.bind(py).setattr("node_path", &self.node_path)?;
1052            if let Some(host) = &self.host {
1053                if let Ok(pos_attr) = owned.bind(py).getattr("position") {
1054                    if let Ok(mut vec2_bound) =
1055                        pos_attr.extract::<pyo3::PyRefMut<crate::vec2::Vec2>>()
1056                    {
1057                        vec2_bound.set_target(host.clone(), self.node_path.clone());
1058                    }
1059                }
1060                host.register(self.node_path.clone(), owned.clone_ref(py));
1061            }
1062            self.instance = Some(owned);
1063            Ok(())
1064        })
1065    }
1066
1067    fn call_opt(&mut self, method: &str, arg: Option<f64>) -> Result<()> {
1068        let ctx = format!(
1069            "{} ({}:{}.{})",
1070            self.node_path,
1071            self.file.display(),
1072            self.class,
1073            method
1074        );
1075        Python::attach(|py| {
1076            let Some(inst) = &self.instance else {
1077                return Ok(());
1078            };
1079            let bound = inst.bind(py);
1080            let callable = match bound.getattr(method) {
1081                Ok(attr) => attr,
1082                Err(_) => return Ok(()),
1083            };
1084            if callable.is_none() || !callable.is_callable() {
1085                return Ok(());
1086            }
1087            let result = match arg {
1088                Some(delta) => callable.call1((delta,)),
1089                None => callable.call0(),
1090            };
1091            if let Err(err) = result {
1092                let script_err = extract_script_error(py, &err, &ctx);
1093                let msg = script_err.message.clone();
1094                self.last_script_error = Some(script_err);
1095                self.last_error = Some(msg.clone());
1096                anyhow::bail!("{msg}");
1097            }
1098            Ok(())
1099        })
1100    }
1101}
1102
1103impl ScriptBackend for Pyo3Backend {
1104    fn load(&mut self, path: &str, class: &str) -> Result<()> {
1105        self.file = PathBuf::from(path);
1106        self.class = class.to_string();
1107        self.last_error = None;
1108        self.last_script_error = None;
1109        self.exec()
1110    }
1111
1112    fn call_ready(&mut self) -> Result<()> {
1113        self.call_opt("_ready", None)
1114    }
1115
1116    fn call_process(&mut self, delta: f64) -> Result<()> {
1117        self.call_opt("_process", Some(delta))
1118    }
1119
1120    fn call_physics_process(&mut self, delta: f64) -> Result<()> {
1121        self.call_opt("_physics_process", Some(delta))
1122    }
1123
1124    fn reload(&mut self) -> Result<()> {
1125        let pos = self.position();
1126        let vel = self.velocity();
1127        let text = self.text();
1128        let size = self.size();
1129        let wait = self.wait_time();
1130        let old_dict: Option<std::collections::HashMap<String, Py<PyAny>>> = Python::attach(|py| {
1131            let inst = self.instance.as_ref()?.bind(py);
1132            let dict = inst.getattr("__dict__").ok()?;
1133            let py_dict = dict.cast::<pyo3::types::PyDict>().ok()?;
1134            let mut attrs = std::collections::HashMap::new();
1135            for (k, v) in py_dict {
1136                if let Ok(key) = k.extract::<String>() {
1137                    if key != "node_path"
1138                        && key != "_descriptor_cache"
1139                        && key != "position"
1140                        && key != "velocity"
1141                    {
1142                        attrs.insert(key, v.into());
1143                    }
1144                }
1145            }
1146            Some(attrs)
1147        });
1148        self.last_error = None;
1149        self.exec()?;
1150        if let Some((x, y)) = pos {
1151            self.set_position(x, y);
1152        }
1153        if let Some((vx, vy)) = vel {
1154            self.set_velocity(vx, vy);
1155        }
1156        if let Some(text) = text {
1157            self.set_text(&text);
1158        }
1159        if let Some((w, h)) = size {
1160            self.set_size(w, h);
1161        }
1162        if let Some(wait) = wait {
1163            self.set_wait_time(wait);
1164        }
1165        if let Some(attrs) = old_dict {
1166            Python::attach(|py| {
1167                if let Some(inst) = &self.instance {
1168                    if let Ok(dict) = inst.bind(py).getattr("__dict__") {
1169                        if let Ok(py_dict) = dict.cast::<pyo3::types::PyDict>() {
1170                            for (k, v) in attrs {
1171                                let _ = py_dict.set_item(k, v);
1172                            }
1173                        }
1174                    }
1175                }
1176            });
1177        }
1178        Ok(())
1179    }
1180
1181    fn position(&self) -> Option<(f64, f64)> {
1182        Python::attach(|py| {
1183            let inst = self.instance.as_ref()?.bind(py);
1184            let attr = inst.getattr("position").ok()?;
1185            if let Ok(v) = attr.extract::<crate::vec2::Vec2>() {
1186                Some((v.x(), v.y()))
1187            } else if let Ok(t) = attr.extract::<(f64, f64)>() {
1188                Some(t)
1189            } else {
1190                None
1191            }
1192        })
1193    }
1194
1195    fn set_position(&mut self, x: f64, y: f64) {
1196        Python::attach(|py| {
1197            if let Some(inst) = &self.instance {
1198                let _ = inst.bind(py).setattr("position", (x, y));
1199            }
1200        });
1201    }
1202
1203    fn velocity(&self) -> Option<(f64, f64)> {
1204        Python::attach(|py| {
1205            let inst = self.instance.as_ref()?.bind(py);
1206            let attr = inst.getattr("velocity").ok()?;
1207            if let Ok(v) = attr.extract::<crate::vec2::Vec2>() {
1208                Some((v.x(), v.y()))
1209            } else if let Ok(t) = attr.extract::<(f64, f64)>() {
1210                Some(t)
1211            } else {
1212                None
1213            }
1214        })
1215    }
1216
1217    fn set_velocity(&mut self, vx: f64, vy: f64) {
1218        Python::attach(|py| {
1219            if let Some(inst) = &self.instance {
1220                let _ = inst.bind(py).setattr("velocity", (vx, vy));
1221            }
1222        });
1223    }
1224
1225    fn set_on_floor(&mut self, on_floor: bool) {
1226        Python::attach(|py| {
1227            if let Some(inst) = &self.instance {
1228                let _ = inst.bind(py).setattr("_is_on_floor", on_floor);
1229            }
1230        });
1231    }
1232
1233    fn set_on_wall(&mut self, on_wall: bool) {
1234        Python::attach(|py| {
1235            if let Some(inst) = &self.instance {
1236                let _ = inst.bind(py).setattr("_is_on_wall", on_wall);
1237            }
1238        });
1239    }
1240
1241    fn set_on_ceiling(&mut self, on_ceiling: bool) {
1242        Python::attach(|py| {
1243            if let Some(inst) = &self.instance {
1244                let _ = inst.bind(py).setattr("_is_on_ceiling", on_ceiling);
1245            }
1246        });
1247    }
1248
1249    fn text(&self) -> Option<String> {
1250        Python::attach(|py| {
1251            self.instance
1252                .as_ref()?
1253                .bind(py)
1254                .getattr("text")
1255                .ok()?
1256                .extract()
1257                .ok()
1258        })
1259    }
1260
1261    fn set_text(&mut self, text: &str) {
1262        Python::attach(|py| {
1263            if let Some(inst) = &self.instance {
1264                let _ = inst.bind(py).setattr("text", text);
1265            }
1266        });
1267    }
1268
1269    fn size(&self) -> Option<(f64, f64)> {
1270        Python::attach(|py| {
1271            let inst = self.instance.as_ref()?.bind(py);
1272            let attr = inst.getattr("size").ok()?;
1273            if let Ok(v) = attr.extract::<crate::vec2::Vec2>() {
1274                Some((v.x(), v.y()))
1275            } else if let Ok(t) = attr.extract::<(f64, f64)>() {
1276                Some(t)
1277            } else {
1278                None
1279            }
1280        })
1281    }
1282
1283    fn set_size(&mut self, w: f64, h: f64) {
1284        Python::attach(|py| {
1285            if let Some(inst) = &self.instance {
1286                let _ = inst.bind(py).setattr("size", (w, h));
1287            }
1288        });
1289    }
1290
1291    fn wait_time(&self) -> Option<f64> {
1292        Python::attach(|py| {
1293            self.instance
1294                .as_ref()?
1295                .bind(py)
1296                .getattr("wait_time")
1297                .ok()?
1298                .extract()
1299                .ok()
1300        })
1301    }
1302
1303    fn set_wait_time(&mut self, wait: f64) {
1304        Python::attach(|py| {
1305            if let Some(inst) = &self.instance {
1306                let _ = inst.bind(py).setattr("wait_time", wait);
1307            }
1308        });
1309    }
1310
1311    fn last_error(&self) -> Option<String> {
1312        self.last_error.clone()
1313    }
1314
1315    fn last_script_error(&self) -> Option<ScriptError> {
1316        self.last_script_error.clone()
1317    }
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322    use super::*;
1323
1324    fn fixture(dir: &Path, name: &str, text: &str) -> String {
1325        let path = dir.join(name);
1326        std::fs::write(&path, text).unwrap();
1327        path.to_string_lossy().into_owned()
1328    }
1329
1330    fn test_dir(tag: &str) -> PathBuf {
1331        let dir = std::env::temp_dir().join(format!("pite-py-{tag}-{}", std::process::id()));
1332        std::fs::create_dir_all(&dir).unwrap();
1333        dir
1334    }
1335
1336    #[test]
1337    fn vendor_wheels_lists_sorted_whls() {
1338        let dir = test_dir("wheels");
1339        assert!(vendor_wheels(&dir.join("missing")).is_empty());
1340        std::fs::write(dir.join("b-1.0-py3-none-any.whl"), b"w").unwrap();
1341        std::fs::write(dir.join("a-2.0-py3-none-any.whl"), b"w").unwrap();
1342        std::fs::write(dir.join("notes.txt"), b"w").unwrap();
1343        let got = vendor_wheels(&dir);
1344        assert_eq!(
1345            got.iter()
1346                .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1347                .collect::<Vec<_>>(),
1348            vec![
1349                "a-2.0-py3-none-any.whl".to_string(),
1350                "b-1.0-py3-none-any.whl".to_string()
1351            ]
1352        );
1353        std::fs::remove_dir_all(&dir).ok();
1354    }
1355
1356    const MOVER: &str = r#"
1357import pite
1358
1359class Player(pite.Node2D):
1360    speed: float = 200.0
1361
1362    def _ready(self):
1363        self.position = (100.0, 200.0)
1364
1365    def _process(self, delta: float):
1366        x, y = self.position
1367        self.position = (x + self.speed * delta, y)
1368"#;
1369
1370    const BOOM: &str = r#"
1371import pite
1372
1373class Boom(pite.Node2D):
1374    def _ready(self):
1375        raise RuntimeError("ready exploded")
1376
1377    def _process(self, delta: float):
1378        raise RuntimeError("process exploded")
1379"#;
1380
1381    #[test]
1382    fn python_moves_sprite_and_vec2_crosses() {
1383        let dir = test_dir("mover");
1384        let file = fixture(&dir, "player.py", MOVER);
1385        let mut backend = Pyo3Backend::new("/root/Player");
1386        backend.load(&file, "Player").unwrap();
1387        backend.set_position(5.0, 6.0);
1388        assert_eq!(backend.position(), Some((5.0, 6.0)));
1389        backend.call_ready().unwrap();
1390        assert_eq!(backend.position(), Some((100.0, 200.0)));
1391        backend.call_process(0.5).unwrap();
1392        assert_eq!(backend.position(), Some((200.0, 200.0)));
1393        assert!(backend.last_error().is_none());
1394        std::fs::remove_dir_all(&dir).ok();
1395    }
1396
1397    #[test]
1398    fn exception_is_reported_and_state_survives() {
1399        let dir = test_dir("boom");
1400        let file = fixture(&dir, "boom.py", BOOM);
1401        let mut backend = Pyo3Backend::new("/root/Boom");
1402        backend.load(&file, "Boom").unwrap();
1403        backend.set_position(1.0, 2.0);
1404        let err = backend.call_ready().unwrap_err();
1405        assert!(err.to_string().contains("ready exploded"));
1406        assert!(err.to_string().contains("boom.py"));
1407        assert_eq!(backend.position(), Some((1.0, 2.0)));
1408        assert!(backend.last_error().is_some());
1409        let err = backend.call_process(0.016).unwrap_err();
1410        assert!(err.to_string().contains("process exploded"));
1411        std::fs::remove_dir_all(&dir).ok();
1412    }
1413
1414    #[test]
1415    fn missing_method_is_noop() {
1416        let dir = test_dir("plain");
1417        let file = fixture(
1418            &dir,
1419            "plain.py",
1420            "import pite\n\nclass Plain(pite.Node):\n    pass\n",
1421        );
1422        let mut backend = Pyo3Backend::new("/root/Plain");
1423        backend.load(&file, "Plain").unwrap();
1424        backend.call_ready().unwrap();
1425        backend.call_process(0.016).unwrap();
1426        std::fs::remove_dir_all(&dir).ok();
1427    }
1428
1429    const VICTIM: &str = r#"
1430import pite
1431
1432class Victim(pite.Node2D):
1433    def _ready(self):
1434        self.hp = 3
1435
1436    def take_damage(self, amount):
1437        self.hp = self.hp - amount
1438"#;
1439
1440    const ATTACKER: &str = r#"
1441import pite
1442
1443class Attacker(pite.Node2D):
1444    def _process(self, delta):
1445        if not getattr(self, "_hit", False):
1446            self._hit = True
1447            self.get_node("../Victim").take_damage(1)
1448"#;
1449
1450    #[test]
1451    fn get_node_direct_call_hits_sibling() {
1452        let _guard = crate::host::HOST_SERIAL.lock().unwrap();
1453        use pite_core::{NodeDesc, NodeId, NodeTree};
1454        use std::sync::Arc;
1455
1456        let dir = test_dir("getnode");
1457        let victim_file = fixture(&dir, "victim.py", VICTIM);
1458        let attacker_file = fixture(&dir, "attacker.py", ATTACKER);
1459
1460        let mut tree = NodeTree::new();
1461        let mut root = NodeDesc::new(NodeId::from("root".to_string()), "Node");
1462        root.name = "Root".to_string();
1463        tree.insert(root).unwrap();
1464        for (id, name) in [("a", "Attacker"), ("v", "Victim")] {
1465            let mut d = NodeDesc::new(NodeId::from(id.to_string()), "Node2D");
1466            d.name = name.to_string();
1467            d.parent = Some(NodeId::from("root".to_string()));
1468            tree.insert(d).unwrap();
1469        }
1470        let host = Arc::new(crate::ScriptHost::new(tree));
1471        crate::set_current_host(host.clone());
1472
1473        let mut victim = Pyo3Backend::new("v");
1474        victim.set_host(host.clone());
1475        victim.load(&victim_file, "Victim").unwrap();
1476        victim.call_ready().unwrap();
1477
1478        let mut attacker = Pyo3Backend::new("a");
1479        attacker.set_host(host.clone());
1480        attacker.load(&attacker_file, "Attacker").unwrap();
1481        attacker.call_process(0.016).unwrap();
1482
1483        Python::attach(|py| {
1484            let inst = host.lookup_instance(py, "v").unwrap();
1485            let hp: i64 = inst.bind(py).getattr("hp").unwrap().extract().unwrap();
1486            assert_eq!(hp, 2);
1487        });
1488        std::fs::remove_dir_all(&dir).ok();
1489    }
1490
1491    const LABELER: &str = r#"
1492import pite
1493
1494class Hud(pite.Label):
1495    def _ready(self):
1496        self.text = "HP: 3"
1497
1498    def _process(self, delta):
1499        self.text = "HP: 2"
1500"#;
1501
1502    #[test]
1503    fn label_text_syncs_both_ways() {
1504        let dir = test_dir("label");
1505        let file = fixture(&dir, "hud.py", LABELER);
1506        let mut backend = Pyo3Backend::new("hud");
1507        backend.load(&file, "Hud").unwrap();
1508        backend.set_text("seed");
1509        assert_eq!(backend.text().as_deref(), Some("seed"));
1510        backend.call_ready().unwrap();
1511        assert_eq!(backend.text().as_deref(), Some("HP: 3"));
1512        backend.call_process(0.016).unwrap();
1513        assert_eq!(backend.text().as_deref(), Some("HP: 2"));
1514        std::fs::remove_dir_all(&dir).ok();
1515    }
1516
1517    #[test]
1518    fn plain_node_has_no_text_channel() {
1519        let dir = test_dir("notext");
1520        let file = fixture(
1521            &dir,
1522            "plain.py",
1523            "import pite\n\nclass Plain(pite.Node):\n    pass\n",
1524        );
1525        let mut backend = Pyo3Backend::new("plain");
1526        backend.load(&file, "Plain").unwrap();
1527        assert_eq!(backend.text(), None);
1528        backend.set_text("ignored");
1529        backend.call_process(0.016).unwrap();
1530        std::fs::remove_dir_all(&dir).ok();
1531    }
1532
1533    const SIZER: &str = r#"
1534import pite
1535
1536class Sizer(pite.Button):
1537    def _ready(self):
1538        self.size = (120.0, 40.0)
1539
1540    def _process(self, delta):
1541        w, h = self.size
1542        self.size = (w + 1.0, h)
1543"#;
1544
1545    #[test]
1546    fn button_size_syncs_both_ways() {
1547        let dir = test_dir("size");
1548        let file = fixture(&dir, "sizer.py", SIZER);
1549        let mut backend = Pyo3Backend::new("sizer");
1550        backend.load(&file, "Sizer").unwrap();
1551        backend.set_size(10.0, 20.0);
1552        assert_eq!(backend.size(), Some((10.0, 20.0)));
1553        backend.call_ready().unwrap();
1554        assert_eq!(backend.size(), Some((120.0, 40.0)));
1555        backend.call_process(0.016).unwrap();
1556        assert_eq!(backend.size(), Some((121.0, 40.0)));
1557        std::fs::remove_dir_all(&dir).ok();
1558    }
1559
1560    const SENSOR: &str = r#"
1561import pite
1562
1563class Sensor(pite.Node2D):
1564    tripped = pite.signal(int, "str")
1565
1566    def _process(self, delta):
1567        if pite.pressed.space:
1568            self.tripped.emit(7, "hi")
1569"#;
1570
1571    const LISTENER: &str = r#"
1572import pite
1573
1574class Listener(pite.Node2D):
1575    def _ready(self):
1576        self.get_node("../Sensor").tripped.connect(self.on_tripped)
1577
1578    def on_tripped(self, n, msg):
1579        self.position = (float(n), 0.0)
1580        self.last_msg = msg
1581"#;
1582
1583    const BAD_DECL: &str =
1584        "import pite\n\nclass Bad(pite.Node2D):\n    bad = pite.signal(\"wat\")\n";
1585
1586    #[test]
1587    fn signals_declare_connect_emit_drop() {
1588        let _guard = crate::host::HOST_SERIAL.lock().unwrap();
1589        use pite_core::{NodeDesc, NodeId, NodeTree};
1590        use std::sync::Arc;
1591
1592        let dir = test_dir("signals");
1593        let sensor_file = fixture(&dir, "sensor.py", SENSOR);
1594        let listener_file = fixture(&dir, "listener.py", LISTENER);
1595        let bad_file = fixture(&dir, "bad.py", BAD_DECL);
1596
1597        let mut tree = NodeTree::new();
1598        let mut root = NodeDesc::new(NodeId::from("root".to_string()), "Node");
1599        root.name = "Root".to_string();
1600        tree.insert(root).unwrap();
1601        for (id, name) in [("s", "Sensor"), ("l", "Listener")] {
1602            let mut d = NodeDesc::new(NodeId::from(id.to_string()), "Node2D");
1603            d.name = name.to_string();
1604            d.parent = Some(NodeId::from("root".to_string()));
1605            tree.insert(d).unwrap();
1606        }
1607        let host = Arc::new(crate::ScriptHost::new(tree));
1608        crate::set_current_host(host.clone());
1609
1610        let mut bad = Pyo3Backend::new("bad");
1611        bad.set_host(host.clone());
1612        let err = bad.load(&bad_file, "Bad").unwrap_err();
1613        assert!(err.to_string().contains("unknown payload type"));
1614
1615        let mut sensor = Pyo3Backend::new("s");
1616        sensor.set_host(host.clone());
1617        sensor.load(&sensor_file, "Sensor").unwrap();
1618        let mut listener = Pyo3Backend::new("l");
1619        listener.set_host(host.clone());
1620        listener.load(&listener_file, "Listener").unwrap();
1621        listener.call_ready().unwrap();
1622
1623        crate::input_clear();
1624        crate::input_begin_frame();
1625        crate::input_set_key("Space", true);
1626        sensor.call_process(0.016).unwrap();
1627        assert_eq!(listener.position(), Some((7.0, 0.0)));
1628        Python::attach(|py| {
1629            let inst = host.lookup_instance(py, "l").unwrap();
1630            let msg: String = inst
1631                .bind(py)
1632                .getattr("last_msg")
1633                .unwrap()
1634                .extract()
1635                .unwrap();
1636            assert_eq!(msg, "hi");
1637        });
1638
1639        Python::attach(|py| {
1640            let wrong = pyo3::types::PyTuple::new(
1641                py,
1642                vec![
1643                    "nope".into_pyobject(py).unwrap(),
1644                    "hi".into_pyobject(py).unwrap(),
1645                ],
1646            )
1647            .unwrap();
1648            let err = host.emit(py, "s", "tripped", &wrong).unwrap_err();
1649            assert!(err.to_string().contains("wants int"));
1650            let empty = pyo3::types::PyTuple::empty(py);
1651            let err = host.emit(py, "s", "missing", &empty).unwrap_err();
1652            assert!(err.to_string().contains("unknown signal"));
1653            let err = host.connect("s", "missing", "l", "on_tripped").unwrap_err();
1654            assert!(err.to_string().contains("unknown signal"));
1655        });
1656
1657        host.disconnect_node("l");
1658        crate::input_clear();
1659        crate::input_begin_frame();
1660        crate::input_set_key("Space", true);
1661        sensor.call_process(0.016).unwrap();
1662        assert_eq!(listener.position(), Some((7.0, 0.0)));
1663        std::fs::remove_dir_all(&dir).ok();
1664    }
1665
1666    #[test]
1667    fn api_redesign_vec2_input_and_descriptors() {
1668        let _guard = crate::host::HOST_SERIAL.lock().unwrap();
1669        crate::python::ensure_init();
1670
1671        pyo3::Python::attach(|py| {
1672            // 1. Vec2 math and operations
1673            let v = crate::vec2::Vec2::new(10.0, 20.0);
1674            assert_eq!(v.x(), 10.0);
1675            assert_eq!(v.y(), 20.0);
1676
1677            let v_obj = pyo3::Bound::new(py, v).unwrap();
1678            let sum: crate::vec2::Vec2 = v_obj
1679                .call_method1("__add__", ((5.0, 5.0),))
1680                .unwrap()
1681                .extract()
1682                .unwrap();
1683            assert_eq!(sum.x(), 15.0);
1684            assert_eq!(sum.y(), 25.0);
1685
1686            // Unpacking x, y = vec
1687            let iter = v_obj.try_iter().unwrap();
1688            let items: Vec<f64> = iter
1689                .map(|item| item.unwrap().extract::<f64>().unwrap())
1690                .collect();
1691            assert_eq!(items, vec![10.0, 20.0]);
1692
1693            // 2. Input queries (held, pressed, released)
1694            crate::input_begin_frame();
1695            crate::input_set_key("Space", true);
1696
1697            let held_query = pyo3::Bound::new(
1698                py,
1699                PyInputQuery {
1700                    kind: InputQueryKind::Held,
1701                },
1702            )
1703            .unwrap();
1704            let is_space_held: bool = held_query.getattr("space").unwrap().extract().unwrap();
1705            assert!(is_space_held);
1706
1707            // Calling pite.held("Space") raises TypeError
1708            let err = held_query.call1(("Space",)).unwrap_err();
1709            assert!(err.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
1710            assert!(err.to_string().contains("attribute-based accessor"));
1711
1712            let pressed_query = pyo3::Bound::new(
1713                py,
1714                PyInputQuery {
1715                    kind: InputQueryKind::Pressed,
1716                },
1717            )
1718            .unwrap();
1719            let is_space_pressed: bool = pressed_query.getattr("space").unwrap().extract().unwrap();
1720            assert!(is_space_pressed);
1721        });
1722    }
1723}