Compare commits

...

9 Commits

Author SHA1 Message Date
Ryan Walters
028ee28840 fix: remove redundant double canvas copy 2025-09-03 17:31:06 -05:00
Ryan Walters
a489bff0d1 chore: add timing demo bin 2025-09-03 17:31:06 -05:00
Ryan Walters
0907b5ebe7 chore: remove unused functions, add 'web' task to Justfile 2025-09-03 16:31:21 -05:00
Ryan Walters
4cc5816d1f refactor: use small_rng for Emscripten only, simplify platform to top-level functions only, no trait/struct 2025-09-03 11:11:04 -05:00
Ryan Walters
208ad3e733 chore: move spin-sleep to desktop only, rearrange Cargo dependencies 2025-09-03 11:04:06 -05:00
Ryan Walters
24e8b3e3bc fix: retain main SDL & audio contexts for application lifetime 2025-09-03 09:33:03 -05:00
dependabot[bot]
da0f4d856a chore(deps): bump actions/upload-pages-artifact (#5)
Bumps the dependencies group with 1 update: [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact).


Updates `actions/upload-pages-artifact` from 3 to 4
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 08:28:39 -05:00
Ryan Walters
aaf30efde7 fix: only run coverage upload if secret is available 2025-09-03 08:23:33 -05:00
Ryan Walters
89f1e71568 chore: add 'samply' profiling helper task to Justfile 2025-09-02 15:42:13 -05:00
15 changed files with 389 additions and 282 deletions

View File

@@ -151,7 +151,7 @@ jobs:
done
- name: Upload Artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v4
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
with:
path: "./dist/"

View File

@@ -9,6 +9,8 @@ env:
jobs:
coverage:
runs-on: ubuntu-latest
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -49,13 +51,13 @@ jobs:
just coverage
- name: Download Coveralls CLI
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
run: |
# use GitHub Releases URL instead of coveralls.io because they can't maintain their own files; it 404s
curl -L https://github.com/coverallsapp/coverage-reporter/releases/download/v0.6.15/coveralls-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin
- name: Upload coverage to Coveralls
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
if: ${{ env.COVERALLS_REPO_TOKEN != '' }}
run: |
if [ ! -f "lcov.info" ]; then
echo "Error: lcov.info file not found. Coverage generation may have failed."

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ coverage.html
# Profiling output
flamegraph.svg
/profile.*

40
Cargo.lock generated
View File

@@ -722,6 +722,15 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@@ -762,6 +771,17 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
@@ -1529,3 +1549,23 @@ name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zerocopy"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@@ -13,30 +13,51 @@ keywords = ["game", "pacman", "arcade", "sdl2"]
categories = ["games", "emulators"]
publish = false
exclude = ["/assets/unpacked/**", "/assets/site/**", "/bacon.toml", "/Justfile"]
default-run = "pacman"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
bevy_ecs = "0.16.1"
glam = "0.30.5"
pathfinding = "4.14"
tracing = { version = "0.1.41", features = ["max_level_debug", "release_max_level_debug"]}
tracing-error = "0.2.0"
tracing-subscriber = {version = "0.3.20", features = ["env-filter"]}
thiserror = "2.0.16"
anyhow = "1.0"
bevy_ecs = "0.16.1"
glam = "0.30.5"
rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] }
pathfinding = "4.14"
smallvec = "1.15.1"
bitflags = "2.9.4"
micromap = "0.1.0"
circular-buffer = "1.1.0"
parking_lot = "0.12.3"
spin_sleep = "1.3.2"
strum = "0.27.2"
strum_macros = "0.27.2"
phf = { version = "0.13.1", features = ["macros"] }
thousands = "0.2.0"
num-width = "0.1.0"
# While not actively used in code, `build.rs` generates code that relies on this. Keep the versions synchronized.
phf = { version = "0.13.1", features = ["macros"] }
# Windows-specific dependencies
[target.'cfg(target_os = "windows")'.dependencies]
# Used for customizing console output on Windows; both are required due to the `windows` crate having poor Result handling with `GetStdHandle`.
windows = { version = "0.61.3", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] }
windows-sys = { version = "0.60.2", features = ["Win32_System_Console"] }
# Desktop-specific dependencies
[target.'cfg(not(target_os = "emscripten"))'.dependencies]
# On desktop platforms, build SDL2 with cargo-vcpkg
sdl2 = { version = "0.38", default-features = false, features = ["image", "ttf", "gfx", "mixer", "unsafe_textures", "static-link", "use-vcpkg"] }
rand = { version = "0.9.2", default-features = false, features = ["thread_rng"] }
spin_sleep = "1.3.2"
# Browser-specific dependencies
[target.'cfg(target_os = "emscripten")'.dependencies]
# On Emscripten, we don't use cargo-vcpkg
sdl2 = { version = "0.38", default-features = false, features = ["image", "ttf", "gfx", "mixer", "unsafe_textures"] }
# TODO: Document why Emscripten cannot use `os_rng`.
rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] }
libc = "0.2.175" # TODO: Describe why this is required.
[dev-dependencies]
pretty_assertions = "1.4.1"
@@ -59,32 +80,16 @@ opt-level = 3
lto = false
panic = 'unwind'
# LTO optimizations, no unwinding on panic, optimize for size
# Optimized release profile for size
[profile.release]
opt-level = "z"
lto = true
panic = "abort"
opt-level = "z"
# Used for customizing console output on Windows; both are required due to the `windows` crate having poor Result handling with `GetStdHandle`.
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.61.3", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] }
windows-sys = { version = "0.60.2", features = ["Win32_System_Console"] }
# On desktop platforms, build SDL2 with cargo-vcpkg
[target.'cfg(not(target_os = "emscripten"))'.dependencies.sdl2]
version = "0.38"
default-features = false
features = ["image", "ttf", "gfx", "mixer", "unsafe_textures", "static-link", "use-vcpkg"]
# On Emscripten, we don't use cargo-vcpkg
[target.'cfg(target_os = "emscripten")'.dependencies]
sdl2 = { version = "0.38", default-features = false, features = ["image", "ttf", "gfx", "mixer", "unsafe_textures"] }
libc = "0.2.175" # TODO: Describe why this is required.
[package.metadata.vcpkg]
dependencies = ["sdl2", "sdl2-image", "sdl2-ttf", "sdl2-gfx", "sdl2-mixer"]
git = "https://github.com/microsoft/vcpkg"
rev = "2024.05.24" # release 2024.05.24 # to check for a new one, check https://github.com/microsoft/vcpkg/releases
rev = "2024.05.24" # to check for a new one, check https://github.com/microsoft/vcpkg/releases
[package.metadata.vcpkg.target]
x86_64-pc-windows-msvc = { triplet = "x64-windows-static-md" }

View File

@@ -5,6 +5,8 @@ set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# You can use src\\\\..., but the filename alone is acceptable too
coverage_exclude_pattern := "src\\\\app.rs|audio.rs|src\\\\error.rs|platform\\\\emscripten.rs"
binary_extension := if os() == "windows" { ".exe" } else { "" }
# !!! --ignore-filename-regex should be used on both reports & coverage testing
# !!! --remap-path-prefix prevents the absolute path from being used in the generated report
@@ -31,3 +33,12 @@ coverage:
--output-path lcov.info \
--profile coverage \
--no-fail-fast nextest
# Profile the project using 'samply'
samply:
cargo build --profile profile
samply record ./target/profile/pacman{{ binary_extension }}
# Build the project for Emscripten
web:
bun run web.build.ts

View File

@@ -4,7 +4,8 @@ use crate::error::{GameError, GameResult};
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game;
use crate::platform::get_platform;
use crate::platform;
use sdl2::{AudioSubsystem, Sdl};
/// Main application wrapper that manages SDL initialization, window lifecycle, and the game loop.
///
@@ -15,6 +16,9 @@ pub struct App {
pub game: Game,
last_tick: Instant,
focused: bool,
// Keep SDL alive for the app lifetime so subsystems (audio) are not shut down
_sdl_context: Sdl,
_audio_subsystem: AudioSubsystem,
}
impl App {
@@ -31,8 +35,8 @@ impl App {
pub fn new() -> GameResult<Self> {
let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
let _audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
let _ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
// TTF context is initialized within Game::new where it is leaked for font usage
let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?;
let window = video_subsystem
@@ -65,6 +69,8 @@ impl App {
game,
focused: true,
last_tick: Instant::now(),
_sdl_context: sdl_context,
_audio_subsystem: audio_subsystem,
})
}
@@ -95,7 +101,7 @@ impl App {
if start.elapsed() < LOOP_TIME {
let time = LOOP_TIME.saturating_sub(start.elapsed());
if time != Duration::ZERO {
get_platform().sleep(time, self.focused);
platform::sleep(time, self.focused);
}
}

View File

@@ -44,7 +44,7 @@ impl Asset {
mod imp {
use super::*;
use crate::error::AssetError;
use crate::platform::get_platform;
use crate::platform;
/// Loads asset bytes using the appropriate platform-specific method.
///
@@ -58,7 +58,7 @@ mod imp {
/// Returns `AssetError::NotFound` if the asset file cannot be located (Emscripten only),
/// or `AssetError::Io` for filesystem I/O failures.
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
get_platform().get_asset_bytes(asset)
platform::get_asset_bytes(asset)
}
}

91
src/bin/timing_demo.rs Normal file
View File

@@ -0,0 +1,91 @@
use circular_buffer::CircularBuffer;
use pacman::constants::CANVAS_SIZE;
use sdl2::event::Event;
use sdl2::keyboard::Keycode;
use sdl2::pixels::Color;
use std::time::{Duration, Instant};
fn main() -> Result<(), String> {
let sdl_context = sdl2::init()?;
let video_subsystem = sdl_context.video()?;
let window = video_subsystem
.window("SDL2 Timing Demo", CANVAS_SIZE.x, CANVAS_SIZE.y)
.opengl()
.position_centered()
.build()
.map_err(|e| e.to_string())?;
let mut canvas = window.into_canvas().accelerated().build().map_err(|e| e.to_string())?;
canvas
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| e.to_string())?;
let mut event_pump = sdl_context.event_pump()?;
// Store frame timings in milliseconds
let mut frame_timings = CircularBuffer::<20_000, f64>::new();
let mut last_report_time = Instant::now();
let report_interval = Duration::from_millis(500);
'running: loop {
let frame_start_time = Instant::now();
for event in event_pump.poll_iter() {
match event {
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape),
..
} => {
break 'running;
}
_ => {}
}
}
// Clear the screen
canvas.set_draw_color(Color::RGB(0, 0, 0));
canvas.clear();
canvas.present();
// Record timing
let frame_duration = frame_start_time.elapsed();
frame_timings.push_back(frame_duration.as_secs_f64());
// Report stats every `report_interval`
let elapsed = last_report_time.elapsed();
if elapsed >= report_interval {
if !frame_timings.is_empty() {
let count = frame_timings.len() as f64;
let sum: f64 = frame_timings.iter().sum();
let mean = sum / count;
let variance = frame_timings
.iter()
.map(|value| {
let diff = mean - value;
diff * diff
})
.sum::<f64>()
/ count;
let std_dev = variance.sqrt();
println!(
"Rendered {count} frames at {fps:.1} fps (last {elapsed:.2?}): mean={mean:.3?}, std_dev={std_dev:.3?}",
count = frame_timings.len(),
fps = count / elapsed.as_secs_f64(),
elapsed = elapsed,
mean = Duration::from_secs_f64(mean),
std_dev = Duration::from_secs_f64(std_dev),
);
}
// Reset for next interval
frame_timings.clear();
last_report_time = Instant::now();
}
}
Ok(())
}

View File

@@ -22,13 +22,12 @@ mod texture;
/// This function initializes SDL, the window, the game state, and then enters
/// the main game loop.
pub fn main() {
let platform = platform::get_platform();
if platform.requires_console() {
if platform::requires_console() {
// Setup buffered tracing subscriber that will buffer logs until console is ready
let switchable_writer = platform::tracing_buffer::setup_switchable_subscriber();
// Initialize platform-specific console
platform.init_console().expect("Could not initialize console");
platform::init_console().expect("Could not initialize console");
// Now that console is initialized, flush buffered logs and switch to direct output
debug!("Switching to direct logging mode and flushing buffer...");

View File

@@ -3,171 +3,167 @@
use std::borrow::Cow;
use std::time::Duration;
use rand::rngs::ThreadRng;
use crate::asset::Asset;
use crate::error::{AssetError, PlatformError};
use crate::platform::CommonPlatform;
/// Desktop platform implementation.
pub struct Platform;
pub fn sleep(duration: Duration, focused: bool) {
if focused {
spin_sleep::sleep(duration);
} else {
std::thread::sleep(duration);
}
}
impl CommonPlatform for Platform {
fn sleep(&self, duration: Duration, focused: bool) {
if focused {
spin_sleep::sleep(duration);
pub fn init_console() -> Result<(), PlatformError> {
#[cfg(windows)]
{
use tracing::{debug, info};
use windows::Win32::System::Console::GetConsoleWindow;
// Check if we already have a console window
if unsafe { !GetConsoleWindow().0.is_null() } {
debug!("Already have a console window");
return Ok(());
} else {
std::thread::sleep(duration);
debug!("No existing console window found");
}
if let Some(file_type) = is_output_setup()? {
debug!(r#type = file_type, "Existing output detected");
} else {
debug!("No existing output detected");
// Try to attach to parent console for direct cargo run
attach_to_parent_console()?;
info!("Successfully attached to parent console");
}
}
fn get_time(&self) -> f64 {
std::time::Instant::now().elapsed().as_secs_f64()
}
Ok(())
}
fn init_console(&self) -> Result<(), PlatformError> {
#[cfg(windows)]
{
use tracing::{debug, info};
use windows::Win32::System::Console::GetConsoleWindow;
pub fn requires_console() -> bool {
cfg!(windows)
}
// Check if we already have a console window
if unsafe { !GetConsoleWindow().0.is_null() } {
debug!("Already have a console window");
return Ok(());
} else {
debug!("No existing console window found");
}
if let Some(file_type) = Self::is_output_setup()? {
debug!(r#type = file_type, "Existing output detected");
} else {
debug!("No existing output detected");
// Try to attach to parent console for direct cargo run
Self::attach_to_parent_console()?;
info!("Successfully attached to parent console");
}
}
Ok(())
}
fn requires_console(&self) -> bool {
cfg!(windows)
}
fn get_canvas_size(&self) -> Option<(u32, u32)> {
None // Desktop doesn't need this
}
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
match asset {
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.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 get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
match asset {
Asset::Wav1 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/1.ogg"))),
Asset::Wav2 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/2.ogg"))),
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.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()
}
/* Internal functions */
/// Check if the output stream has been setup by a parent process
/// Windows-only
#[cfg(windows)]
impl Platform {
/// Check if the output stream has been setup by a parent process
fn is_output_setup() -> Result<Option<&'static str>, PlatformError> {
use tracing::{debug, warn};
fn is_output_setup() -> Result<Option<&'static str>, PlatformError> {
use tracing::{debug, warn};
use windows::Win32::Storage::FileSystem::{
GetFileType, FILE_TYPE_CHAR, FILE_TYPE_DISK, FILE_TYPE_PIPE, FILE_TYPE_REMOTE, FILE_TYPE_UNKNOWN,
};
use windows::Win32::Storage::FileSystem::{
GetFileType, FILE_TYPE_CHAR, FILE_TYPE_DISK, FILE_TYPE_PIPE, FILE_TYPE_REMOTE, FILE_TYPE_UNKNOWN,
};
use windows_sys::Win32::{
Foundation::INVALID_HANDLE_VALUE,
System::Console::{GetStdHandle, STD_OUTPUT_HANDLE},
};
use windows_sys::Win32::{
Foundation::INVALID_HANDLE_VALUE,
System::Console::{GetStdHandle, STD_OUTPUT_HANDLE},
};
// Get the process's standard output handle, check if it's invalid
let handle = match unsafe { GetStdHandle(STD_OUTPUT_HANDLE) } {
INVALID_HANDLE_VALUE => {
return Err(PlatformError::ConsoleInit("Invalid handle".to_string()));
}
handle => handle,
};
// Identify the file type of the handle and whether it's 'well known' (i.e. we trust it to be a reasonable output destination)
let (well_known, file_type) = match unsafe {
use windows::Win32::Foundation::HANDLE;
GetFileType(HANDLE(handle))
} {
FILE_TYPE_PIPE => (true, "pipe"),
FILE_TYPE_CHAR => (true, "char"),
FILE_TYPE_DISK => (true, "disk"),
FILE_TYPE_UNKNOWN => (false, "unknown"),
FILE_TYPE_REMOTE => (false, "remote"),
unexpected => {
warn!("Unexpected file type: {unexpected:?}");
(false, "unknown")
}
};
debug!("File type: {file_type:?}, well known: {well_known}");
// If it's anything recognizable and valid, assume that a parent process has setup an output stream
Ok(well_known.then_some(file_type))
}
/// Try to attach to parent console
fn attach_to_parent_console() -> Result<(), PlatformError> {
use windows::{
core::PCSTR,
Win32::{
Foundation::{GENERIC_READ, GENERIC_WRITE},
Storage::FileSystem::{CreateFileA, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING},
System::Console::{
AttachConsole, FreeConsole, SetStdHandle, ATTACH_PARENT_PROCESS, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE,
},
},
};
// Attach the process to the parent's console
unsafe { AttachConsole(ATTACH_PARENT_PROCESS) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to attach to parent console: {:?}", e)))?;
let handle = unsafe {
let pcstr = PCSTR::from_raw(c"CONOUT$".as_ptr() as *const u8);
CreateFileA::<PCSTR>(
pcstr,
(GENERIC_READ | GENERIC_WRITE).0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)
// Get the process's standard output handle, check if it's invalid
let handle = match unsafe { GetStdHandle(STD_OUTPUT_HANDLE) } {
INVALID_HANDLE_VALUE => {
return Err(PlatformError::ConsoleInit("Invalid handle".to_string()));
}
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to create console handle: {:?}", e)))?;
handle => handle,
};
// Set the console's output and then error handles
if let Some(handle_error) = unsafe { SetStdHandle(STD_OUTPUT_HANDLE, handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set console output handle: {:?}", e)))
.and_then(|_| {
unsafe { SetStdHandle(STD_ERROR_HANDLE, handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set console error handle: {:?}", e)))
})
.err()
{
// If either set handle call fails, free the console
unsafe { FreeConsole() }
// Free the console if the SetStdHandle calls fail
.map_err(|free_error| {
PlatformError::ConsoleInit(format!(
"Failed to free console after SetStdHandle failed: {free_error:?} ({handle_error:?})"
))
})
// And then return the original error if the FreeConsole call succeeds
.and(Err(handle_error))?;
// Identify the file type of the handle and whether it's 'well known' (i.e. we trust it to be a reasonable output destination)
let (well_known, file_type) = match unsafe {
use windows::Win32::Foundation::HANDLE;
GetFileType(HANDLE(handle))
} {
FILE_TYPE_PIPE => (true, "pipe"),
FILE_TYPE_CHAR => (true, "char"),
FILE_TYPE_DISK => (true, "disk"),
FILE_TYPE_UNKNOWN => (false, "unknown"),
FILE_TYPE_REMOTE => (false, "remote"),
unexpected => {
warn!("Unexpected file type: {unexpected:?}");
(false, "unknown")
}
};
Ok(())
}
debug!("File type: {file_type:?}, well known: {well_known}");
// If it's anything recognizable and valid, assume that a parent process has setup an output stream
Ok(well_known.then_some(file_type))
}
/// Try to attach to parent console
/// Windows-only
#[cfg(windows)]
fn attach_to_parent_console() -> Result<(), PlatformError> {
use windows::{
core::PCSTR,
Win32::{
Foundation::{GENERIC_READ, GENERIC_WRITE},
Storage::FileSystem::{CreateFileA, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING},
System::Console::{
AttachConsole, FreeConsole, SetStdHandle, ATTACH_PARENT_PROCESS, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE,
},
},
};
// Attach the process to the parent's console
unsafe { AttachConsole(ATTACH_PARENT_PROCESS) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to attach to parent console: {:?}", e)))?;
let handle = unsafe {
let pcstr = PCSTR::from_raw(c"CONOUT$".as_ptr() as *const u8);
CreateFileA::<PCSTR>(
pcstr,
(GENERIC_READ | GENERIC_WRITE).0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)
}
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to create console handle: {:?}", e)))?;
// Set the console's output and then error handles
if let Some(handle_error) = unsafe { SetStdHandle(STD_OUTPUT_HANDLE, handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set console output handle: {:?}", e)))
.and_then(|_| {
unsafe { SetStdHandle(STD_ERROR_HANDLE, handle) }
.map_err(|e| PlatformError::ConsoleInit(format!("Failed to set console error handle: {:?}", e)))
})
.err()
{
// If either set handle call fails, free the console
unsafe { FreeConsole() }
// Free the console if the SetStdHandle calls fail
.map_err(|free_error| {
PlatformError::ConsoleInit(format!(
"Failed to free console after SetStdHandle failed: {free_error:?} ({handle_error:?})"
))
})
// And then return the original error if the FreeConsole call succeeds
.and(Err(handle_error))?;
}
Ok(())
}

View File

@@ -5,62 +5,60 @@ use std::time::Duration;
use crate::asset::Asset;
use crate::error::{AssetError, PlatformError};
use crate::platform::CommonPlatform;
/// Emscripten platform implementation.
pub struct Platform;
impl CommonPlatform for Platform {
fn sleep(&self, duration: Duration, _focused: bool) {
unsafe {
emscripten_sleep(duration.as_millis() as u32);
}
}
fn get_time(&self) -> f64 {
unsafe { emscripten_get_now() }
}
fn init_console(&self) -> Result<(), PlatformError> {
Ok(()) // No-op for Emscripten
}
fn requires_console(&self) -> bool {
false
}
fn get_canvas_size(&self) -> Option<(u32, u32)> {
Some(unsafe { get_canvas_size() })
}
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
use sdl2::rwops::RWops;
use std::io::Read;
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(std::io::Error::other(e)))?;
Ok(Cow::Owned(buf))
}
}
use rand::{rngs::SmallRng, SeedableRng};
// Emscripten FFI functions
#[allow(dead_code)]
extern "C" {
fn emscripten_get_now() -> f64;
fn emscripten_sleep(ms: u32);
fn emscripten_get_element_css_size(target: *const u8, width: *mut f64, height: *mut f64) -> i32;
}
unsafe fn get_canvas_size() -> (u32, u32) {
pub fn sleep(duration: Duration, _focused: bool) {
unsafe {
emscripten_sleep(duration.as_millis() as u32);
}
}
pub fn init_console() -> Result<(), PlatformError> {
Ok(()) // No-op for Emscripten
}
pub fn requires_console() -> bool {
false
}
pub fn get_canvas_size() -> Option<(u32, u32)> {
let mut width = 0.0;
let mut height = 0.0;
emscripten_get_element_css_size(c"canvas".as_ptr().cast(), &mut width, &mut height);
(width as u32, height as u32)
unsafe {
emscripten_get_element_css_size(c"canvas".as_ptr().cast(), &mut width, &mut height);
if width == 0.0 || height == 0.0 {
return None;
}
}
Some((width as u32, height as u32))
}
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
use sdl2::rwops::RWops;
use std::io::Read;
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(std::io::Error::other(e)))?;
Ok(Cow::Owned(buf))
}
pub fn rng() -> SmallRng {
SmallRng::from_os_rng()
}

View File

@@ -1,52 +1,13 @@
//! Platform abstraction layer for cross-platform functionality.
use crate::asset::Asset;
use crate::error::{AssetError, PlatformError};
use std::borrow::Cow;
use std::time::Duration;
#[cfg(not(target_os = "emscripten"))]
mod desktop;
#[cfg(target_os = "emscripten")]
mod emscripten;
pub mod buffered_writer;
pub mod tracing_buffer;
/// Cross-platform abstraction layer providing unified APIs for platform-specific operations.
pub trait CommonPlatform {
/// Platform-specific sleep function (required due to Emscripten's non-standard sleep requirements).
///
/// Provides access to current window focus state, useful for changing sleep algorithm conditionally.
fn sleep(&self, duration: Duration, focused: bool);
#[cfg(not(target_os = "emscripten"))]
mod desktop;
#[cfg(not(target_os = "emscripten"))]
pub use desktop::*;
#[allow(dead_code)]
fn get_time(&self) -> f64;
/// Configures platform-specific console and debugging output capabilities.
fn init_console(&self) -> Result<(), PlatformError>;
/// Retrieves the actual display canvas dimensions.
#[allow(dead_code)]
fn get_canvas_size(&self) -> Option<(u32, u32)>;
/// Loads raw asset data using the appropriate platform-specific method.
fn get_asset_bytes(&self, asset: Asset) -> Result<Cow<'static, [u8]>, AssetError>;
/// Whether the platform requires a console to be initialized.
fn requires_console(&self) -> bool;
}
/// Returns the appropriate platform implementation based on compile-time target.
#[allow(dead_code)]
pub fn get_platform() -> &'static dyn CommonPlatform {
#[cfg(not(target_os = "emscripten"))]
{
&desktop::Platform
}
#[cfg(target_os = "emscripten")]
{
&emscripten::Platform
}
}
#[cfg(target_os = "emscripten")]
pub use emscripten::*;
#[cfg(target_os = "emscripten")]
mod emscripten;

View File

@@ -1,3 +1,4 @@
use crate::platform;
use crate::systems::components::{DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation};
use crate::{
map::{
@@ -14,9 +15,7 @@ use crate::{
use crate::systems::GhostAnimations;
use bevy_ecs::query::Without;
use bevy_ecs::system::{Commands, Query, Res};
use rand::rngs::SmallRng;
use rand::seq::IndexedRandom;
use rand::SeedableRng;
use smallvec::SmallVec;
/// Autonomous ghost AI system implementing randomized movement with backtracking avoidance.
@@ -49,7 +48,7 @@ pub fn ghost_movement_system(
break;
}
} else {
*non_opposite_options.choose(&mut SmallRng::from_os_rng()).unwrap()
*non_opposite_options.choose(&mut platform::rng()).unwrap()
};
velocity.direction = new_edge.direction;

View File

@@ -225,8 +225,6 @@ pub fn render_system(
})
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
canvas.copy(&backbuffer.0, None, None).unwrap();
}
pub fn present_system(