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