1use 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#[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
815pub(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
831pub 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 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 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 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 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 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}