Skip to main content

game_toolkit_assets/
lib.rs

1//! Asset filesystem helpers + optional hot-reload via `notify`.
2//!
3//! Asset paths are relative to a base directory (defaults to `assets/` next to the executable
4//! or the current working directory). Reads return raw bytes; decoding is the caller's job —
5//! `game_toolkit_gfx` decodes PNGs, `game_toolkit_audio` decodes ogg/wav, etc.
6//!
7//! Hot-reload is opt-in: call [`Assets::watch`] to start a `notify` watcher on the asset root,
8//! then drain change events each frame with [`Assets::drain_changes`].
9//!
10//! # Web
11//!
12//! The browser has no filesystem and can only fetch asynchronously, so on `wasm32` the asset
13//! root is a URL prefix relative to the page and [`Assets::read`] serves bytes from memory. The
14//! toolkit's core fills that store with `Assets::preload` before `Game::init` runs, from the
15//! list in `manifest.txt` (one asset path per line) under the asset root. Hot-reload is not
16//! available there: [`Assets::watch`] is a logged no-op.
17//!
18//! On every platform, bytes added with [`Assets::insert`] take precedence over the filesystem.
19
20#![forbid(unsafe_code)]
21
22use std::collections::{HashMap, HashSet};
23use std::path::{Path, PathBuf};
24#[cfg(not(target_arch = "wasm32"))]
25use std::sync::mpsc::{Receiver, channel};
26
27#[cfg(not(target_arch = "wasm32"))]
28use anyhow::Context;
29use anyhow::Result;
30#[cfg(not(target_arch = "wasm32"))]
31use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
32
33#[cfg(target_arch = "wasm32")]
34mod web;
35
36/// Name of the asset list the web build preloads, relative to the asset root.
37pub const MANIFEST_FILE: &str = "manifest.txt";
38
39pub struct Assets {
40    root: PathBuf,
41    /// In-memory files keyed by resolved path; consulted before the filesystem.
42    mem: HashMap<PathBuf, Vec<u8>>,
43    #[cfg(not(target_arch = "wasm32"))]
44    watcher: Option<RecommendedWatcher>,
45    #[cfg(not(target_arch = "wasm32"))]
46    rx: Option<Receiver<PathBuf>>,
47    pending: HashSet<PathBuf>,
48}
49
50impl Assets {
51    /// Create with `root` as the asset directory. Path is canonicalized eagerly to make
52    /// matching against `notify` events deterministic. On the web, `root` is a URL path
53    /// relative to the page and is used as-is.
54    pub fn new(root: impl AsRef<Path>) -> Result<Self> {
55        let root = root.as_ref();
56        #[cfg(not(target_arch = "wasm32"))]
57        let root = root
58            .canonicalize()
59            .with_context(|| format!("asset root {} not found", root.display()))?;
60        #[cfg(target_arch = "wasm32")]
61        let root = root.to_path_buf();
62        Ok(Self {
63            root,
64            mem: HashMap::new(),
65            #[cfg(not(target_arch = "wasm32"))]
66            watcher: None,
67            #[cfg(not(target_arch = "wasm32"))]
68            rx: None,
69            pending: HashSet::new(),
70        })
71    }
72
73    pub fn root(&self) -> &Path {
74        &self.root
75    }
76
77    pub fn resolve(&self, rel: impl AsRef<Path>) -> PathBuf {
78        let rel = rel.as_ref();
79        if rel.is_absolute() {
80            rel.to_path_buf()
81        } else {
82            self.root.join(rel)
83        }
84    }
85
86    /// Read an asset's bytes: from memory if it was inserted or preloaded, otherwise from disk
87    /// (native only).
88    pub fn read(&self, rel: impl AsRef<Path>) -> Result<Vec<u8>> {
89        let p = self.resolve(rel.as_ref());
90        if let Some(bytes) = self.mem.get(&p) {
91            return Ok(bytes.clone());
92        }
93        #[cfg(not(target_arch = "wasm32"))]
94        {
95            std::fs::read(&p).with_context(|| format!("read {}", p.display()))
96        }
97        #[cfg(target_arch = "wasm32")]
98        {
99            anyhow::bail!(
100                "asset {} was not preloaded; list it in {}/{MANIFEST_FILE}",
101                p.display(),
102                self.root.display()
103            )
104        }
105    }
106
107    /// Store `bytes` under `rel`, shadowing any file at that path.
108    pub fn insert(&mut self, rel: impl AsRef<Path>, bytes: impl Into<Vec<u8>>) {
109        let p = self.resolve(rel);
110        self.mem.insert(p, bytes.into());
111    }
112
113    /// Whether `rel` is available in memory (inserted or preloaded).
114    pub fn is_loaded(&self, rel: impl AsRef<Path>) -> bool {
115        self.mem.contains_key(&self.resolve(rel))
116    }
117
118    /// Start watching the asset root recursively. Idempotent. On the web this logs that
119    /// hot-reload is unavailable and returns `Ok`.
120    #[cfg(not(target_arch = "wasm32"))]
121    pub fn watch(&mut self) -> Result<()> {
122        if self.watcher.is_some() {
123            return Ok(());
124        }
125        let (tx, rx) = channel::<PathBuf>();
126        let mut watcher =
127            notify::recommended_watcher(move |res: notify::Result<Event>| match res {
128                Ok(event) => {
129                    if matches!(
130                        event.kind,
131                        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
132                    ) {
133                        for p in event.paths {
134                            let _ = tx.send(p);
135                        }
136                    }
137                }
138                Err(e) => log::warn!("notify error: {e}"),
139            })?;
140        watcher.watch(&self.root, RecursiveMode::Recursive)?;
141        self.watcher = Some(watcher);
142        self.rx = Some(rx);
143        Ok(())
144    }
145
146    /// Start watching the asset root recursively. Idempotent. On the web this logs that
147    /// hot-reload is unavailable and returns `Ok`.
148    #[cfg(target_arch = "wasm32")]
149    pub fn watch(&mut self) -> Result<()> {
150        log::info!("asset hot-reload is not available on the web");
151        Ok(())
152    }
153
154    /// Drain all queued filesystem changes since last call. Paths are canonicalized when
155    /// possible. Idempotent across re-saves within one frame (HashSet-deduped). Always empty
156    /// on the web.
157    pub fn drain_changes(&mut self) -> Vec<PathBuf> {
158        #[cfg(not(target_arch = "wasm32"))]
159        if let Some(rx) = self.rx.as_ref() {
160            while let Ok(p) = rx.try_recv() {
161                let canon = p.canonicalize().unwrap_or(p);
162                self.pending.insert(canon);
163            }
164        }
165        self.pending.drain().collect()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn inserted_bytes_shadow_the_filesystem() {
175        let mut assets = Assets::new(".").unwrap();
176        assert!(!assets.is_loaded("Cargo.toml"));
177        assets.insert("Cargo.toml", b"in memory".to_vec());
178        assert!(assets.is_loaded("Cargo.toml"));
179        assert_eq!(assets.read("Cargo.toml").unwrap(), b"in memory");
180    }
181
182    #[test]
183    fn falls_back_to_disk() {
184        let assets = Assets::new(".").unwrap();
185        assert!(assets.read("Cargo.toml").unwrap().starts_with(b"[package]"));
186        assert!(assets.read("does-not-exist.bin").is_err());
187    }
188}