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#![forbid(unsafe_code)]
11
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use std::sync::mpsc::{Receiver, channel};
15
16use anyhow::{Context, Result};
17use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
18
19pub struct Assets {
20    root: PathBuf,
21    watcher: Option<RecommendedWatcher>,
22    rx: Option<Receiver<PathBuf>>,
23    pending: HashSet<PathBuf>,
24}
25
26impl Assets {
27    /// Create with `root` as the asset directory. Path is canonicalized eagerly to make
28    /// matching against `notify` events deterministic.
29    pub fn new(root: impl AsRef<Path>) -> Result<Self> {
30        let root = root.as_ref();
31        let canon = root
32            .canonicalize()
33            .with_context(|| format!("asset root {} not found", root.display()))?;
34        Ok(Self {
35            root: canon,
36            watcher: None,
37            rx: None,
38            pending: HashSet::new(),
39        })
40    }
41
42    pub fn root(&self) -> &Path {
43        &self.root
44    }
45
46    pub fn resolve(&self, rel: impl AsRef<Path>) -> PathBuf {
47        let rel = rel.as_ref();
48        if rel.is_absolute() {
49            rel.to_path_buf()
50        } else {
51            self.root.join(rel)
52        }
53    }
54
55    pub fn read(&self, rel: impl AsRef<Path>) -> Result<Vec<u8>> {
56        let p = self.resolve(rel.as_ref());
57        std::fs::read(&p).with_context(|| format!("read {}", p.display()))
58    }
59
60    /// Start watching the asset root recursively. Idempotent.
61    pub fn watch(&mut self) -> Result<()> {
62        if self.watcher.is_some() {
63            return Ok(());
64        }
65        let (tx, rx) = channel::<PathBuf>();
66        let mut watcher =
67            notify::recommended_watcher(move |res: notify::Result<Event>| match res {
68                Ok(event) => {
69                    if matches!(
70                        event.kind,
71                        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
72                    ) {
73                        for p in event.paths {
74                            let _ = tx.send(p);
75                        }
76                    }
77                }
78                Err(e) => log::warn!("notify error: {e}"),
79            })?;
80        watcher.watch(&self.root, RecursiveMode::Recursive)?;
81        self.watcher = Some(watcher);
82        self.rx = Some(rx);
83        Ok(())
84    }
85
86    /// Drain all queued filesystem changes since last call. Paths are canonicalized when
87    /// possible. Idempotent across re-saves within one frame (HashSet-deduped).
88    pub fn drain_changes(&mut self) -> Vec<PathBuf> {
89        if let Some(rx) = self.rx.as_ref() {
90            while let Ok(p) = rx.try_recv() {
91                let canon = p.canonicalize().unwrap_or(p);
92                self.pending.insert(canon);
93            }
94        }
95        self.pending.drain().collect()
96    }
97}