thread_engine/engine.rs
1//! The one-call embedder facade: [`Loom`].
2//!
3//! [`crate::navigator::Navigator`] is the engine, but it asks you to thread a
4//! [`WorldLoader`] through every call. `Loom` owns the loader for you: construct
5//! it once with your renderer's loader, `open()` a Locator, and pump
6//! `update(dt, player_pos)` each frame. That's the whole browser loop. Everything
7//! a browser needs to *decide* — resolve, arm, veilwalk, focus — is inside; you
8//! supply only how to *draw* (the loader) and where the player is.
9//!
10//! ```no_run
11//! use thread_engine::{Loom, WorldLoader};
12//! use thread_engine::glam::Vec3;
13//! # fn demo<L: WorldLoader>(my_loader: L) {
14//! let mut loom = Loom::new("worlds", my_loader);
15//! loom.open("thread://pixygon.io", Vec3::ZERO);
16//! loop {
17//! if let Some(spawn) = loom.update(1.0 / 60.0, /* player_pos */ Vec3::ZERO) {
18//! // teleport the player to `spawn`
19//! }
20//! // draw loom.portals(), react to loom.focused(pos), etc.
21//! }
22//! # }
23//! ```
24//!
25//! Use this when your renderer's loader can be *owned* by the engine (an embedded
26//! view, a headless tool, a from-scratch browser). When the loader must borrow
27//! shared renderer state per frame — as Infinite's does, since its `SceneRenderer`
28//! is shared with the rest of the app — drive [`Navigator`] directly instead and
29//! pass a fresh loader each frame.
30
31use std::path::{Path, PathBuf};
32
33use glam::Vec3;
34
35use crate::navigator::{Focus, Navigator, PortalNav, SkyNav, WorldLoader};
36
37/// A ready-to-pump browser core: a [`Navigator`] wired to an owned [`WorldLoader`].
38pub struct Loom<L: WorldLoader> {
39 nav: Navigator,
40 loader: L,
41}
42
43impl<L: WorldLoader> Loom<L> {
44 /// Build a browser core over `loader`. `worlds_root` is the local dev-resolver
45 /// root (where `thread://host/path` falls back to `<root>/<path>/world.json`).
46 pub fn new(worlds_root: impl Into<PathBuf>, loader: L) -> Self {
47 Self {
48 nav: Navigator::new(worlds_root),
49 loader,
50 }
51 }
52
53 /// Open a world by Locator (the entry to the live Thread). The world loads on
54 /// a subsequent [`update`](Self::update) once the async resolve+fetch lands.
55 pub fn open(&mut self, locator: &str, anchor: Vec3) {
56 self.nav.open(locator, anchor);
57 }
58
59 /// Load a local `world.json` directly (the offline / startup path). Returns
60 /// the spawn to place the player at.
61 pub fn load_file(&mut self, path: &Path, anchor: Vec3) -> Option<Vec3> {
62 self.nav.load_file(&mut self.loader, path, anchor)
63 }
64
65 /// Advance one frame: finish an in-flight veilwalk, or begin one if the player
66 /// stepped into a veil. Returns `Some(spawn)` when the player should be moved.
67 pub fn update(&mut self, dt: f32, player_pos: Vec3) -> Option<Vec3> {
68 self.nav.update(dt, &mut self.loader, player_pos)
69 }
70
71 // --- read-through accessors (the browser's HUD/UI reads these) ---
72
73 /// Title of the active world.
74 pub fn title(&self) -> Option<&str> {
75 self.nav.title()
76 }
77 /// The active world's one-line description (may be empty) — arrival cards.
78 pub fn description(&self) -> Option<&str> {
79 self.nav.world_description()
80 }
81 /// The active world's stable id (its presence room key).
82 pub fn world_id(&self) -> &str {
83 self.nav.world_id()
84 }
85 /// The active world's sky/atmosphere.
86 pub fn sky(&self) -> Option<SkyNav> {
87 self.nav.sky()
88 }
89 /// The veils in the active world (render as doorways / minimap / walk toward).
90 pub fn portals(&self) -> &[PortalNav] {
91 self.nav.portals()
92 }
93 /// The active world's presence relays (primary first, then fallbacks).
94 pub fn presence_relays(&self) -> &[String] {
95 self.nav.presence_relays()
96 }
97 /// The inspectable placement the player is standing at, if any.
98 pub fn focused(&self, player_pos: Vec3) -> Option<Focus> {
99 self.nav.focused(player_pos)
100 }
101 /// The veil the player is standing in front of, as `(label, destination)`.
102 pub fn near_portal(&self, player_pos: Vec3) -> Option<(&str, &str)> {
103 self.nav.near_portal(player_pos)
104 }
105 /// Whether a veilwalk is in flight (show a spinner).
106 pub fn is_traveling(&self) -> bool {
107 self.nav.is_traveling()
108 }
109 /// Whether the engine is idle + armed (a veil will fire on contact).
110 pub fn armed(&self) -> bool {
111 self.nav.armed()
112 }
113 /// The most recent veilwalk failure, if any.
114 pub fn last_error(&self) -> Option<&str> {
115 self.nav.last_error()
116 }
117 /// The active world's Locator (the address bar), if it arrived by one.
118 pub fn locator(&self) -> Option<&str> {
119 self.nav.locator()
120 }
121 /// The active world's raw manifest text (view-source).
122 pub fn world_source(&self) -> Option<&str> {
123 self.nav.world_source()
124 }
125
126 // --- behaviors (the sandboxed interactivity layer) ---
127
128 /// The player interacted with whatever they're standing at → the [`Action`]s
129 /// (see [`crate::behavior`]) its bound behavior module asks the browser to
130 /// perform. Empty when nothing is bound (fall back to Codex/web focus).
131 pub fn interact(&mut self, player_pos: Vec3) -> Vec<crate::behavior::Action> {
132 self.nav.interact(player_pos)
133 }
134 /// Advance the active world's ticking behavior modules — the heartbeat.
135 /// Returns the Actions ticking Weft behaviors request.
136 pub fn tick_behaviors(&mut self, dt: f32) -> Vec<crate::behavior::Action> {
137 self.nav.tick_behaviors(dt)
138 }
139
140 // --- session history (the web's back/forward/home, engine-level) ---
141
142 /// Veilwalk back to the previous world. Returns whether a walk began.
143 pub fn back(&mut self, anchor: Vec3) -> bool {
144 self.nav.back(anchor)
145 }
146 /// Veilwalk forward again after `back()`.
147 pub fn forward(&mut self, anchor: Vec3) -> bool {
148 self.nav.forward(anchor)
149 }
150 /// Veilwalk to the session's home world (by default, where it opened).
151 pub fn go_home(&mut self, anchor: Vec3) -> bool {
152 self.nav.go_home(anchor)
153 }
154
155 /// Borrow the loader (e.g. to read what it recorded) …
156 pub fn loader(&self) -> &L {
157 &self.loader
158 }
159 /// … or the navigator, for the full read API.
160 pub fn navigator(&self) -> &Navigator {
161 &self.nav
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::navigator::LoadedWorldMeta;
169 use infinite_manifest::WorldManifest;
170
171 struct CountLoader(u32);
172 impl WorldLoader for CountLoader {
173 fn load(
174 &mut self,
175 m: &WorldManifest,
176 _assets: &crate::assets::AssetSource,
177 anchor: Vec3,
178 ) -> LoadedWorldMeta {
179 self.0 += 1;
180 LoadedWorldMeta {
181 title: m.world.title.clone(),
182 world_id: m.world.id.clone(),
183 spawn: anchor,
184 portals: vec![],
185 interactables: vec![],
186 sky: SkyNav::default(),
187 presence_relays: vec![],
188 colliders: vec![],
189 }
190 }
191 }
192
193 #[test]
194 fn facade_loads_a_local_world_through_the_owned_loader() {
195 // Author a tiny world on disk in a temp dir, then open it via the facade.
196 let dir = std::env::temp_dir().join("loom_facade_test");
197 let _ = std::fs::create_dir_all(&dir);
198 let file = dir.join("world.json");
199 std::fs::write(
200 &file,
201 r#"{ "thread": "thread/0.1", "world": { "id": "w", "title": "Facade World" },
202 "spawns": [{ "name": "entry", "position": [0, 0, 2] }] }"#,
203 )
204 .unwrap();
205
206 let mut loom = Loom::new(&dir, CountLoader(0));
207 let spawn = loom.load_file(&file, Vec3::new(1.0, 0.0, 1.0));
208 // Arrivals land STANDING: floor anchor + the capsule's feet drop.
209 assert_eq!(
210 spawn,
211 Some(Vec3::new(1.0, crate::navigator::FEET_DROP, 1.0))
212 );
213 assert_eq!(loom.title(), Some("Facade World"));
214 assert_eq!(loom.loader().0, 1, "the facade drove the owned loader");
215 }
216}