refactor: unify cross-platform asset loading, avoid hard-coding with folder-based asset embedding for desktop

This commit is contained in:
Ryan Walters
2025-09-11 01:11:00 -05:00
parent 43532dac56
commit 00a65954e6
9 changed files with 193 additions and 71 deletions

View File

@@ -5,6 +5,7 @@ use std::borrow::Cow;
use std::iter;
use crate::audio::Sound;
use crate::error::AssetError;
/// Enumeration of all game assets with cross-platform loading support.
///
@@ -66,7 +67,6 @@ impl Asset {
/// Paths are consistent across platforms and used by the Emscripten backend
/// for filesystem loading. Desktop builds embed assets directly and don't
/// use these paths at runtime.
#[allow(dead_code)]
pub fn path(&self) -> &str {
use Asset::*;
match self {
@@ -84,34 +84,54 @@ impl Asset {
Font => "TerminalVector.ttf",
}
}
}
mod imp {
use super::*;
use crate::error::AssetError;
use crate::platform;
use tracing::trace;
/// Loads asset bytes using the appropriate platform-specific method.
///
/// On desktop platforms, returns embedded compile-time data via `include_bytes!`.
/// On desktop platforms, returns embedded compile-time data via `rust-embed`.
/// On Emscripten, loads from the filesystem using the asset's path. The returned
/// `Cow` allows zero-copy access to embedded data while supporting owned data
/// when loaded from disk.
///
/// # Errors
///
/// Returns `AssetError::NotFound` if the asset file cannot be located (Emscripten only),
/// Returns `AssetError::NotFound` if the asset file cannot be located,
/// or `AssetError::Io` for filesystem I/O failures.
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
trace!(asset = ?asset, "Loading game asset");
let result = platform::get_asset_bytes(asset);
pub fn get_bytes(&self) -> Result<Cow<'static, [u8]>, AssetError> {
use tracing::trace;
trace!(asset = ?self, "Loading game asset");
let result = self.get_bytes_platform();
match &result {
Ok(bytes) => trace!(asset = ?asset, size_bytes = bytes.len(), "Asset loaded successfully"),
Err(e) => trace!(asset = ?asset, error = ?e, "Asset loading failed"),
Ok(bytes) => trace!(asset = ?self, size_bytes = bytes.len(), "Asset loaded successfully"),
Err(e) => trace!(asset = ?self, error = ?e, "Asset loading failed"),
}
result
}
}
pub use imp::get_asset_bytes;
#[cfg(not(target_os = "emscripten"))]
fn get_bytes_platform(&self) -> Result<Cow<'static, [u8]>, AssetError> {
#[derive(rust_embed::Embed)]
#[folder = "assets/game/"]
struct EmbeddedAssets;
let path = self.path();
EmbeddedAssets::get(path)
.map(|file| file.data)
.ok_or_else(|| AssetError::NotFound(path.to_string()))
}
#[cfg(target_os = "emscripten")]
fn get_bytes_platform(&self) -> Result<Cow<'static, [u8]>, AssetError> {
use sdl2::rwops::RWops;
use std::io::{self, Read};
let path = format!("assets/game/{}", self.path());
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(self.path().to_string()))?;
let len = rwops.len().ok_or_else(|| AssetError::NotFound(self.path().to_string()))?;
let mut buf = vec![0u8; len];
rwops.read_exact(&mut buf).map_err(|e| AssetError::Io(io::Error::other(e)))?;
Ok(Cow::Owned(buf))
}
}

View File

@@ -1,7 +1,7 @@
//! This module handles the audio playback for the game.
use std::collections::HashMap;
use crate::asset::{get_asset_bytes, Asset};
use crate::asset::Asset;
use sdl2::{
mixer::{self, Chunk, InitFlag, LoaderRWops, AUDIO_S16LSB, DEFAULT_CHANNELS},
rwops::RWops,
@@ -117,12 +117,13 @@ impl Audio {
// Try to load sounds, but don't panic if any fail
let mut sounds = HashMap::new();
for (i, asset) in Sound::iter().enumerate() {
match get_asset_bytes(Asset::SoundFile(asset)) {
for (i, sound_type) in Sound::iter().enumerate() {
let asset = Asset::SoundFile(sound_type);
match asset.get_bytes() {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => {
sounds.insert(asset, chunk);
sounds.insert(sound_type, chunk);
}
Err(e) => {
tracing::warn!("Failed to load sound {} from asset API: {}", i + 1, e);
@@ -138,7 +139,7 @@ impl Audio {
}
}
let death_sound = match get_asset_bytes(Asset::SoundFile(Sound::PacmanDeath)) {
let death_sound = match Asset::SoundFile(Sound::PacmanDeath).get_bytes() {
Ok(data) => match RWops::from_bytes(&data) {
Ok(rwops) => match rwops.load_wav() {
Ok(chunk) => Some(chunk),

View File

@@ -46,8 +46,6 @@ pub enum AssetError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
// This error is only possible on Emscripten, as the assets are loaded from a 'filesystem' of sorts (while on Desktop, they are included in the binary at compile time)
#[allow(dead_code)]
#[error("Asset not found: {0}")]
NotFound(String),
}

View File

@@ -40,7 +40,7 @@ use sdl2::video::{Window, WindowContext};
use sdl2::EventPump;
use crate::{
asset::{get_asset_bytes, Asset},
asset::Asset,
events::GameCommand,
map::render::MapRenderer,
systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource},
@@ -247,7 +247,7 @@ impl Game {
debug_texture.set_blend_mode(BlendMode::Blend);
debug_texture.set_scale_mode(ScaleMode::Nearest);
let font_data: &'static [u8] = get_asset_bytes(Asset::Font)?.to_vec().leak();
let font_data: &'static [u8] = Asset::Font.get_bytes()?.to_vec().leak();
let font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?;
let debug_font = ttf_context
.load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE)
@@ -261,7 +261,7 @@ impl Game {
fn load_atlas_and_map_tiles(texture_creator: &TextureCreator<WindowContext>) -> GameResult<(SpriteAtlas, Vec<AtlasTile>)> {
trace!("Loading atlas image from embedded assets");
let atlas_bytes = get_asset_bytes(Asset::AtlasImage)?;
let atlas_bytes = Asset::AtlasImage.get_bytes()?;
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
GameError::Texture(crate::error::TextureError::InvalidFormat(format!(

View File

@@ -1,13 +1,15 @@
//! Desktop platform implementation.
use std::borrow::Cow;
use std::time::Duration;
use rand::rngs::ThreadRng;
use rust_embed::Embed;
use crate::asset::Asset;
use crate::audio::Sound;
use crate::error::{AssetError, PlatformError};
use crate::error::PlatformError;
#[derive(Embed)]
#[folder = "assets/game/"]
struct EmbeddedAssets;
/// Desktop platform implementation.
pub fn sleep(duration: Duration, focused: bool) {
@@ -58,25 +60,6 @@ pub fn init_console() -> Result<(), PlatformError> {
Ok(())
}
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
match asset {
Asset::SoundFile(sound) => match sound {
Sound::Waka(0) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/1.ogg"))),
Sound::Waka(1) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/2.ogg"))),
Sound::Waka(2) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/3.ogg"))),
Sound::Waka(3..=u8::MAX) => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/waka/4.ogg"))),
Sound::PacmanDeath => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/death.ogg"))),
Sound::ExtraLife => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/extra_life.ogg"))),
Sound::Fruit => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/fruit.ogg"))),
Sound::Ghost => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/pacman/ghost.ogg"))),
Sound::Beginning => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/begin.ogg"))),
Sound::Intermission => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/intermission.ogg"))),
},
Asset::AtlasImage => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
Asset::Font => Ok(Cow::Borrowed(include_bytes!("../../assets/game/TerminalVector.ttf"))),
}
}
pub fn rng() -> ThreadRng {
rand::rng()
}

View File

@@ -1,13 +1,10 @@
//! Emscripten platform implementation.
use crate::asset::Asset;
use crate::error::{AssetError, PlatformError};
use crate::error::PlatformError;
use crate::formatter::CustomFormatter;
use rand::{rngs::SmallRng, SeedableRng};
use sdl2::rwops::RWops;
use std::borrow::Cow;
use std::ffi::CString;
use std::io::{self, Read, Write};
use std::io::{self, Write};
use std::time::Duration;
// Emscripten FFI functions
@@ -62,18 +59,6 @@ impl Write for EmscriptenConsoleWriter {
}
}
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
let path = format!("assets/game/{}", asset.path());
let mut rwops = RWops::from_file(&path, "rb").map_err(|_| AssetError::NotFound(asset.path().to_string()))?;
let len = rwops.len().ok_or_else(|| AssetError::NotFound(asset.path().to_string()))?;
let mut buf = vec![0u8; len];
rwops.read_exact(&mut buf).map_err(|e| AssetError::Io(io::Error::other(e)))?;
Ok(Cow::Owned(buf))
}
pub fn rng() -> SmallRng {
SmallRng::from_os_rng()
}