Initial commit

This commit is contained in:
DenisToxic
2026-10-27 19:02:27 +01:00
commit f1cafa14d8
24 changed files with 7234 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target/
/release/
*.log

5589
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

22
Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "first_person_maze"
version = "1.0.0"
edition = "2024"
rust-version = "1.95"
[dependencies]
bevy = { version = "=0.19.1", default-features = false, features = ["std", "async_executor", "multi_threaded", "bevy_asset", "bevy_log", "bevy_state", "bevy_winit", "bevy_render", "bevy_core_pipeline", "bevy_pbr", "bevy_ui_render", "bevy_text", "default_font", "bevy_gilrs", "bevy_audio", "wav", "x11", "wayland", "keyboard", "mouse"] }
serde = { version = "1", features = ["derive"] }
ron = "0.12"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "debuginfo"
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 2

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# First Person Maze
A native Rust / Bevy game. Collect every pellet in the maze while avoiding four
ghosts. Power pellets let you catch ghosts for bonus points. You have three lives.
## Play
Run `first_person_maze.exe` on Windows or `./first_person_maze` on Linux from a
release package. Keep `assets/` next to the executable. A graphical desktop and
working GPU drivers are required.
| Input | Action |
| --- | --- |
| W / A / S / D | Move / strafe |
| Mouse or left / right arrows | Look / turn |
| Enter | Start / activate menu selection |
| Escape | Pause / resume / leave settings |
| Arrow keys | Navigate menus / adjust settings |
| F3 | Toggle performance HUD |
| Gamepad sticks | Move / look |
| Gamepad D-pad / south button | Navigate / activate |
| Gamepad Start | Pause / resume |
Losing focus pauses the game. Settings include resolution, fullscreen, volume,
mouse sensitivity, keyboard-only camera, head bob, 30/60 FPS and low graphics.
ARM64 defaults to low graphics and 30 FPS; other architectures default to 60 FPS.
Settings save to `%APPDATA%/first_person_maze/settings.ron` on Windows, or
`$XDG_CONFIG_HOME/first_person_maze/settings.ron` on Linux (default:
`~/.config/first_person_maze/settings.ron`). Delete this file to restore defaults.
## Build
Requires Rust 1.95 or newer. Keep `Cargo.lock` and build with `--locked`.
Windows requires the MSVC Rust toolchain, Visual Studio C++ Build Tools and a
Windows SDK. Linux requires native development libraries; on Debian / Ubuntu:
```sh
sudo apt install build-essential pkg-config libasound2-dev libudev-dev \
libx11-dev libxi-dev libxrandr-dev libxcursor-dev libxinerama-dev \
libxkbcommon-dev libwayland-dev libvulkan-dev
```
```sh
cargo build --release --locked
cargo run --release --locked
```
When building from a network share, use a local build directory. In PowerShell:
```powershell
$env:CARGO_TARGET_DIR = "$env:LOCALAPPDATA/first_person_maze/target"
```
Linux also needs a Vulkan loader and GPU driver at runtime. For Raspberry Pi,
use a 64-bit desktop OS and a working V3DV driver; build natively on the device.
Linux, ARM64 and physical gamepads have not been validated in this cleanup.
## Package
From PowerShell on Windows:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package.ps1
```
From a POSIX shell on Linux:
```sh
sh tools/package.sh
```
An optional target triple can be supplied as the first argument. Cross builds
require the corresponding Rust target, linker and native target libraries.
`CARGO_TARGET_DIR` is supported by both scripts.
Packages are written to `release/<target>/first_person_maze/` and contain only
the executable, this README and runtime audio. Each packaging run replaces the
previous package for that target. Distribute the folder as an archive. Windows
players may need the Microsoft Visual C++ runtime; Linux players need the
system libraries matching the build environment.
## Audio and maintenance
Seven synthesized WAV effects are included. To regenerate them, run
`python tools/generate_audio.py` (Python is only needed for regeneration).
Add your own licensed `assets/audio/music.wav` to enable looping background music.
Geometry and the default font are generated or embedded. Missing audio is skipped.
Before packaging:
```sh
cargo fmt --check
cargo clippy --locked --all-targets -- -D warnings
cargo build --release --locked
```
Manually verify start, movement, pellet collection, ghost contact, pause/resume,
settings, restart and result screens on each supported platform before release.

BIN
assets/audio/death.wav Normal file

Binary file not shown.

BIN
assets/audio/defeated.wav Normal file

Binary file not shown.

BIN
assets/audio/frightened.wav Normal file

Binary file not shown.

BIN
assets/audio/game_over.wav Normal file

Binary file not shown.

BIN
assets/audio/pellet.wav Normal file

Binary file not shown.

BIN
assets/audio/power.wav Normal file

Binary file not shown.

BIN
assets/audio/victory.wav Normal file

Binary file not shown.

63
src/audio.rs Normal file
View File

@@ -0,0 +1,63 @@
use crate::settings::Settings;
use bevy::{audio::Volume, prelude::*};
#[derive(Clone, Copy)]
pub enum Sound {
Pellet,
Power,
Frightened,
Defeated,
Death,
Victory,
GameOver,
}
impl Sound {
fn file(self) -> &'static str {
match self {
Self::Pellet => "pellet",
Self::Power => "power",
Self::Frightened => "frightened",
Self::Defeated => "defeated",
Self::Death => "death",
Self::Victory => "victory",
Self::GameOver => "game_over",
}
}
}
#[derive(Resource, Default)]
pub struct SoundQueue(pub Vec<Sound>);
#[derive(Component)]
pub struct Music;
pub fn setup(mut commands: Commands, assets: Res<AssetServer>, settings: Res<Settings>) {
if std::path::Path::new("assets/audio/music.wav").is_file() {
commands.spawn((
Music,
AudioPlayer::new(assets.load("audio/music.wav")),
PlaybackSettings::LOOP.with_volume(Volume::Linear(settings.master * settings.music)),
));
}
}
pub fn play(
mut commands: Commands,
assets: Res<AssetServer>,
settings: Res<Settings>,
mut queue: ResMut<SoundQueue>,
mut music: Query<&mut AudioSink, With<Music>>,
) {
if settings.is_changed() {
for mut sink in &mut music {
sink.set_volume(Volume::Linear(settings.master * settings.music));
}
}
for sound in queue.0.drain(..) {
let path = format!("audio/{}.wav", sound.file());
if settings.master * settings.sound > 0.
&& std::path::Path::new("assets").join(&path).is_file()
{
commands.spawn((
AudioPlayer::new(assets.load(path)),
PlaybackSettings::DESPAWN
.with_volume(Volume::Linear(settings.master * settings.sound)),
));
}
}
}

147
src/game.rs Normal file
View File

@@ -0,0 +1,147 @@
use crate::{audio, ghost, maze, pellets, player, settings, ui};
use bevy::{ecs::system::RunSystemOnce, prelude::*};
#[derive(States, Default, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum GameState {
#[default]
MainMenu,
Playing,
Paused,
PlayerDeath,
Victory,
GameOver,
}
#[derive(Component)]
pub struct WorldEntity;
#[derive(Resource)]
pub struct Session {
pub score: u32,
pub lives: u32,
pub remaining: u32,
pub power: f32,
pub chain: u32,
pub elapsed: f32,
pub death: f32,
}
impl Default for Session {
fn default() -> Self {
Self {
score: 0,
lives: 3,
remaining: 0,
power: 0.,
chain: 0,
elapsed: 0.,
death: 0.,
}
}
}
pub struct GamePlugin;
impl Plugin for GamePlugin {
fn build(&self, app: &mut App) {
app.init_state::<GameState>()
.init_resource::<maze::Maze>()
.init_resource::<Session>()
.init_resource::<crate::pathfinding::Pathfinder>()
.init_resource::<ghost::BrainTimer>()
.init_resource::<audio::SoundQueue>()
.init_resource::<ui::Menu>()
.add_systems(
Startup,
(
maze::build,
player::spawn,
pellets::spawn,
ghost::spawn,
ui::setup,
audio::setup,
)
.chain(),
)
.add_systems(
Update,
(ui::input, settings::apply, ui::scale, player::cursor).chain(),
)
.add_systems(
Update,
(
tick,
player::move_player,
pellets::collect,
ghost::think,
ghost::movement,
ghost::contact,
ghost::appearance,
pellets::pulse,
)
.chain()
.run_if(in_state(GameState::Playing)),
)
.add_systems(Update, death.run_if(in_state(GameState::PlayerDeath)))
.add_systems(PostUpdate, (ui::draw, audio::play).chain())
.add_systems(OnEnter(GameState::Victory), victory)
.add_systems(OnEnter(GameState::GameOver), game_over)
.add_systems(Last, settings::frame_cap);
}
}
fn tick(time: Res<Time>, mut s: ResMut<Session>) {
let dt = time.delta_secs().min(0.1);
s.elapsed += dt;
s.power = (s.power - dt).max(0.);
if s.power == 0. {
s.chain = 0;
}
}
fn death(
time: Res<Time>,
mut s: ResMut<Session>,
maze: Res<maze::Maze>,
mut next: ResMut<NextState<GameState>>,
mut players: Query<(&mut Transform, &mut player::Player)>,
mut ghosts: Query<(&mut Transform, &mut ghost::Ghost), Without<player::Player>>,
) {
s.death -= time.delta_secs();
if s.death > 0. {
return;
}
if s.lives == 0 {
next.set(GameState::GameOver);
return;
}
for (mut t, mut p) in &mut players {
t.translation = maze::position(maze.spawn()) + Vec3::Y * 1.15;
t.rotation = Quat::from_rotation_y(player::SPAWN_YAW);
p.yaw = player::SPAWN_YAW;
p.pitch = 0.;
}
for (mut t, mut g) in &mut ghosts {
t.translation = maze::position(g.spawn) + Vec3::Y * 0.83;
g.next = g.spawn;
g.target = g.spawn;
g.state = ghost::GhostState::Scatter;
g.recovery = 1. + g.id as f32 * 0.6;
}
next.set(GameState::Playing);
}
pub fn restart(world: &mut World) {
let entities: Vec<Entity> = world
.query_filtered::<Entity, With<WorldEntity>>()
.iter(world)
.collect();
for e in entities {
world.despawn(e);
}
world.insert_resource(Session::default());
world.run_system_once(maze::build).unwrap();
world.run_system_once(player::spawn).unwrap();
world.run_system_once(pellets::spawn).unwrap();
world.run_system_once(ghost::spawn).unwrap();
world
.resource_mut::<NextState<GameState>>()
.set(GameState::Playing);
}
fn victory(mut sounds: ResMut<audio::SoundQueue>) {
sounds.0.push(audio::Sound::Victory);
}
fn game_over(mut sounds: ResMut<audio::SoundQueue>) {
sounds.0.push(audio::Sound::GameOver);
}

275
src/ghost.rs Normal file
View File

@@ -0,0 +1,275 @@
use crate::{
audio::{Sound, SoundQueue},
game::{GameState, Session, WorldEntity},
maze::{self, Maze, Tile},
pathfinding::Pathfinder,
player::Player,
};
use bevy::prelude::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GhostState {
Scatter,
Chase,
Frightened,
Defeated,
}
#[derive(Component)]
pub struct Ghost {
pub id: usize,
pub state: GhostState,
pub spawn: IVec2,
pub target: IVec2,
pub next: IVec2,
pub normal: Handle<StandardMaterial>,
pub recovery: f32,
}
#[derive(Resource)]
pub struct GhostMaterials {
frightened: Handle<StandardMaterial>,
defeated: Handle<StandardMaterial>,
}
#[derive(Resource)]
pub struct BrainTimer(pub Timer);
impl Default for BrainTimer {
fn default() -> Self {
Self(Timer::from_seconds(0.125, TimerMode::Repeating))
}
}
pub fn spawn(
mut commands: Commands,
maze: Res<Maze>,
mut meshes: ResMut<Assets<Mesh>>,
mut mats: ResMut<Assets<StandardMaterial>>,
) {
let body = meshes.add(Sphere::new(0.49).mesh().uv(12, 8));
let skirt = meshes.add(Cylinder::new(0.49, 0.5).mesh().resolution(12));
let eye = meshes.add(Sphere::new(0.115).mesh().uv(8, 6));
let pupil = meshes.add(Sphere::new(0.055).mesh().uv(8, 6));
let white = maze::material(&mut mats, Color::WHITE);
let black = maze::material(&mut mats, Color::srgb(0.01, 0.02, 0.08));
commands.insert_resource(GhostMaterials {
frightened: maze::material(&mut mats, Color::srgb(0.12, 0.2, 1.)),
defeated: maze::material(&mut mats, Color::srgb(0.22, 0.25, 0.3)),
});
let colors = [
Color::srgb(1., 0.12, 0.22),
Color::srgb(1., 0.35, 0.7),
Color::srgb(0.1, 0.95, 0.9),
Color::srgb(1., 0.55, 0.08),
];
for (id, c) in maze
.cells()
.filter(|&c| maze.tile(c) == Tile::GhostSpawn)
.enumerate()
{
let normal = maze::material(&mut mats, colors[id]);
commands
.spawn((
WorldEntity,
Ghost {
id,
state: GhostState::Scatter,
spawn: c,
target: c,
next: c,
normal: normal.clone(),
recovery: 1. + id as f32 * 0.6,
},
Mesh3d(body.clone()),
MeshMaterial3d(normal.clone()),
Transform::from_translation(maze::position(c) + Vec3::Y * 0.83),
))
.with_children(|parent| {
parent.spawn((
Mesh3d(skirt.clone()),
MeshMaterial3d(normal),
Transform::from_xyz(0., -0.25, 0.),
));
for x in [-0.19, 0.19] {
parent.spawn((
Mesh3d(eye.clone()),
MeshMaterial3d(white.clone()),
Transform::from_xyz(x, 0.05, -0.42),
));
parent.spawn((
Mesh3d(pupil.clone()),
MeshMaterial3d(black.clone()),
Transform::from_xyz(x, 0.05, -0.51),
));
}
});
}
}
pub fn think(
time: Res<Time>,
mut timer: ResMut<BrainTimer>,
maze: Res<Maze>,
session: Res<Session>,
player: Query<(&Transform, &Player), Without<Ghost>>,
mut ghosts: Query<(&Transform, &mut Ghost), Without<Player>>,
) {
if !timer.0.tick(time.delta()).just_finished() {
return;
}
let Ok((p, look)) = player.single() else {
return;
};
let pc = maze::cell(p.translation);
let a = ghosts
.iter()
.find(|(_, g)| g.id == 0)
.map(|(t, _)| maze::cell(t.translation))
.unwrap_or(pc);
let direction = Quat::from_rotation_y(look.yaw) * Vec3::NEG_Z;
let ahead = pc
+ if direction.x.abs() > direction.z.abs() {
IVec2::new(direction.x.signum() as i32, 0)
} else {
IVec2::new(0, direction.z.signum() as i32)
} * 3;
let corners = [
IVec2::new(17, 1),
IVec2::new(1, 1),
IVec2::new(17, 13),
IVec2::new(1, 13),
];
for (t, mut g) in &mut ghosts {
if g.state == GhostState::Defeated {
g.target = g.spawn;
continue;
}
let c = maze::cell(t.translation);
g.state = if session.power > 0. {
GhostState::Frightened
} else if session.elapsed % 27. < 7. {
GhostState::Scatter
} else {
GhostState::Chase
};
g.target = match g.state {
GhostState::Defeated => g.spawn,
GhostState::Scatter => corners[g.id],
GhostState::Frightened => *corners
.iter()
.max_by_key(|&&c| (c - pc).length_squared())
.unwrap(),
GhostState::Chase => match g.id {
0 => pc,
1 => ahead,
2 => ahead * 2 - a,
_ => {
if (c - pc).length_squared() > 36 {
pc
} else {
corners[g.id]
}
}
},
};
g.target = maze.nearest(g.target);
}
}
pub fn movement(
time: Res<Time>,
maze: Res<Maze>,
mut bfs: ResMut<Pathfinder>,
session: Res<Session>,
mut ghosts: Query<(&mut Transform, &mut Ghost)>,
) {
let dt = time.delta_secs().min(0.1);
for (mut t, mut g) in &mut ghosts {
if g.recovery > 0. {
g.recovery -= dt;
continue;
}
// Only route at cell centers (at most ~3 Hz at these speeds).
let dest = maze::position(g.next) + Vec3::Y * 0.83;
let delta = dest - t.translation;
if delta.length() < 0.005 {
t.translation = dest;
if g.state == GhostState::Defeated && g.next == g.spawn {
g.state = if session.power > 0. {
GhostState::Frightened
} else {
GhostState::Scatter
};
g.recovery = 2.;
continue;
}
if g.next == g.target {
continue;
}
g.next = bfs.step(&maze, g.next, g.target);
}
let dest = maze::position(g.next) + Vec3::Y * 0.83;
let delta = dest - t.translation;
let speed = match g.state {
GhostState::Frightened => 1.6,
GhostState::Defeated => 6.,
_ => 2.55 + g.id as f32 * 0.08,
};
t.translation += delta.clamp_length_max(speed * dt);
if delta.length_squared() > 0.001 {
t.rotation = Quat::from_rotation_y((-delta.x).atan2(-delta.z));
}
}
}
pub fn appearance(
materials: Res<GhostMaterials>,
mut ghosts: Query<(&Ghost, &mut MeshMaterial3d<StandardMaterial>, &Children)>,
mut child_materials: Query<&mut MeshMaterial3d<StandardMaterial>, Without<Ghost>>,
) {
for (g, mut mat, children) in &mut ghosts {
let desired = match g.state {
GhostState::Frightened => &materials.frightened,
GhostState::Defeated => &materials.defeated,
_ => &g.normal,
};
if mat.0 != *desired {
mat.0 = desired.clone();
if let Some(&skirt) = children.first()
&& let Ok(mut m) = child_materials.get_mut(skirt)
{
m.0 = desired.clone();
}
}
}
}
pub fn contact(
player: Query<&Transform, With<Player>>,
mut ghosts: Query<(&Transform, &mut Ghost), Without<Player>>,
mut session: ResMut<Session>,
mut next: ResMut<NextState<GameState>>,
mut sounds: ResMut<SoundQueue>,
) {
if session.remaining == 0 {
return;
}
let Ok(p) = player.single() else {
return;
};
for (t, mut g) in &mut ghosts {
if g.state == GhostState::Defeated
|| g.recovery > 0.
|| (t.translation - p.translation).xz().length() > 0.7
{
continue;
}
if session.power > 0. {
g.state = GhostState::Defeated;
g.target = g.spawn;
session.score += 200 * (1 << session.chain.min(3));
session.chain = (session.chain + 1).min(4);
sounds.0.push(Sound::Defeated);
} else {
session.lives -= 1;
session.power = 0.;
session.chain = 0;
session.death = 1.6;
sounds.0.push(Sound::Death);
next.set(GameState::PlayerDeath);
break;
}
}
}

44
src/main.rs Normal file
View File

@@ -0,0 +1,44 @@
mod audio;
mod game;
mod ghost;
mod maze;
mod pathfinding;
mod pellets;
mod player;
mod settings;
mod ui;
use bevy::prelude::*;
use bevy::window::PresentMode;
fn main() {
// Packaged assets are next to the executable; Cargo runs use the project root.
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent().filter(|p| p.join("assets").is_dir())
{
let _ = std::env::set_current_dir(dir);
}
let settings = settings::Settings::load();
App::new()
.insert_resource(ClearColor(Color::srgb(0.008, 0.012, 0.035)))
.add_plugins(
DefaultPlugins
.set(bevy::pbr::PbrPlugin {
prepass_enabled: false,
add_default_deferred_lighting_plugin: false,
..default()
})
.set(WindowPlugin {
primary_window: Some(Window {
title: "FIRST PERSON MAZE".into(),
resolution: (settings.width, settings.height).into(),
present_mode: PresentMode::AutoNoVsync,
..default()
}),
..default()
}),
)
.insert_resource(settings)
.add_plugins(game::GamePlugin)
.run();
}

153
src/maze.rs Normal file
View File

@@ -0,0 +1,153 @@
use crate::game::WorldEntity;
use bevy::prelude::*;
pub const CELL: f32 = 2.4;
pub const MAP: [&str; 15] = [
"###################",
"#o.......#.......o#",
"#.##.###.#.###.##.#",
"#.................#",
"#.##.#.#####.#.##.#",
"#....#...#...#....#",
"####.###.#.###.####",
"#....#..GGG..#....#",
"#.##.#.##G##.#.##.#",
"#.................#",
"#.##.###.#.###.##.#",
"#..#.....P.....#..#",
"##.#.#.#####.#.#.##",
"#o...#.......#...o#",
"###################",
];
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tile {
Wall,
Floor,
PlayerSpawn,
GhostSpawn,
PowerPellet,
}
#[derive(Resource)]
pub struct Maze {
pub tiles: Vec<Tile>,
pub width: i32,
pub height: i32,
}
impl Default for Maze {
fn default() -> Self {
Self {
width: MAP[0].len() as i32,
height: MAP.len() as i32,
tiles: MAP
.iter()
.flat_map(|r| {
r.chars().map(|c| match c {
'#' => Tile::Wall,
'P' => Tile::PlayerSpawn,
'G' => Tile::GhostSpawn,
'o' => Tile::PowerPellet,
_ => Tile::Floor,
})
})
.collect(),
}
}
}
impl Maze {
pub fn tile(&self, p: IVec2) -> Tile {
if p.x < 0 || p.y < 0 || p.x >= self.width || p.y >= self.height {
Tile::Wall
} else {
self.tiles[(p.y * self.width + p.x) as usize]
}
}
pub fn open(&self, p: IVec2) -> bool {
self.tile(p) != Tile::Wall
}
pub fn cells(&self) -> impl Iterator<Item = IVec2> + '_ {
(0..self.height).flat_map(move |y| (0..self.width).map(move |x| IVec2::new(x, y)))
}
pub fn spawn(&self) -> IVec2 {
self.cells()
.find(|&p| self.tile(p) == Tile::PlayerSpawn)
.unwrap()
}
pub fn nearest(&self, p: IVec2) -> IVec2 {
self.cells()
.filter(|&c| self.open(c))
.min_by_key(|&c| (c - p).length_squared())
.unwrap()
}
pub fn neighbors(&self, p: IVec2) -> impl Iterator<Item = IVec2> + '_ {
[IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y]
.into_iter()
.map(move |d| p + d)
.filter(|&c| self.open(c))
}
pub fn fits(&self, p: Vec3, radius: f32) -> bool {
let lo = cell(p - Vec3::new(radius, 0., radius));
let hi = cell(p + Vec3::new(radius, 0., radius));
(lo.y..=hi.y).all(|y| (lo.x..=hi.x).all(|x| self.open(IVec2::new(x, y))))
}
}
pub fn position(c: IVec2) -> Vec3 {
Vec3::new(c.x as f32 * CELL, 0., c.y as f32 * CELL)
}
pub fn cell(p: Vec3) -> IVec2 {
IVec2::new((p.x / CELL).round() as i32, (p.z / CELL).round() as i32)
}
pub fn material(
materials: &mut Assets<StandardMaterial>,
color: Color,
) -> Handle<StandardMaterial> {
materials.add(StandardMaterial {
base_color: color,
unlit: true,
..default()
})
}
pub fn build(
mut commands: Commands,
maze: Res<Maze>,
mut meshes: ResMut<Assets<Mesh>>,
mut mats: ResMut<Assets<StandardMaterial>>,
) {
let wall = meshes.add(Cuboid::new(CELL, 2.8, CELL));
let edge = meshes.add(Cuboid::new(CELL + 0.015, 0.045, CELL + 0.015));
let dark = material(&mut mats, Color::srgb(0.025, 0.045, 0.12));
let blue = material(&mut mats, Color::srgb(0.08, 0.25, 0.85));
for c in maze.cells().filter(|&p| !maze.open(p)) {
commands.spawn((
WorldEntity,
Mesh3d(wall.clone()),
MeshMaterial3d(dark.clone()),
Transform::from_translation(position(c) + Vec3::Y * 1.4),
));
for y in [0.12, 2.65] {
commands.spawn((
WorldEntity,
Mesh3d(edge.clone()),
MeshMaterial3d(blue.clone()),
Transform::from_translation(position(c) + Vec3::Y * y),
));
}
}
let floor = meshes.add(Cuboid::new(
maze.width as f32 * CELL,
0.1,
maze.height as f32 * CELL,
));
let floor_mat = material(&mut mats, Color::srgb(0.014, 0.022, 0.042));
commands.spawn((
WorldEntity,
Mesh3d(floor),
MeshMaterial3d(floor_mat),
Transform::from_xyz(
(maze.width - 1) as f32 * CELL / 2.,
-0.06,
(maze.height - 1) as f32 * CELL / 2.,
),
));
}

43
src/pathfinding.rs Normal file
View File

@@ -0,0 +1,43 @@
use crate::maze::Maze;
use bevy::prelude::*;
use std::collections::VecDeque;
#[derive(Default, Resource)]
pub struct Pathfinder {
queue: VecDeque<IVec2>,
parents: Vec<Option<IVec2>>,
}
impl Pathfinder {
pub fn step(&mut self, maze: &Maze, start: IVec2, goal: IVec2) -> IVec2 {
let goal = maze.nearest(goal);
let index = |p: IVec2| (p.y * maze.width + p.x) as usize;
self.parents.clear();
self.parents.resize(maze.tiles.len(), None);
self.queue.clear();
self.parents[index(start)] = Some(start);
self.queue.push_back(start);
while let Some(c) = self.queue.pop_front() {
if c == goal {
break;
}
for n in maze.neighbors(c) {
if self.parents[index(n)].is_none() {
self.parents[index(n)] = Some(c);
self.queue.push_back(n);
}
}
}
if self.parents[index(goal)].is_none() {
return start;
}
let mut c = goal;
while c != start {
let parent = self.parents[index(c)].unwrap();
if parent == start {
return c;
}
c = parent;
}
start
}
}

84
src/pellets.rs Normal file
View File

@@ -0,0 +1,84 @@
use crate::{
audio::{Sound, SoundQueue},
game::{GameState, Session, WorldEntity},
maze::{self, Maze, Tile},
player::Player,
settings::Settings,
};
use bevy::prelude::*;
#[derive(Component)]
pub struct Pellet {
pub power: bool,
}
pub fn spawn(
mut commands: Commands,
maze: Res<Maze>,
mut meshes: ResMut<Assets<Mesh>>,
mut mats: ResMut<Assets<StandardMaterial>>,
mut session: ResMut<Session>,
) {
let small = meshes.add(Sphere::new(0.105).mesh().uv(8, 6));
let big = meshes.add(Sphere::new(0.26).mesh().uv(12, 8));
let gold = maze::material(&mut mats, Color::srgb(1., 0.8, 0.25));
let white = maze::material(&mut mats, Color::srgb(1., 0.95, 0.65));
session.remaining = 0;
for c in maze
.cells()
.filter(|&c| matches!(maze.tile(c), Tile::Floor | Tile::PowerPellet))
{
let power = maze.tile(c) == Tile::PowerPellet;
session.remaining += 1;
commands.spawn((
WorldEntity,
Pellet { power },
Mesh3d(if power { big.clone() } else { small.clone() }),
MeshMaterial3d(if power { white.clone() } else { gold.clone() }),
Transform::from_translation(maze::position(c) + Vec3::Y * 0.48),
));
}
}
pub fn collect(
mut commands: Commands,
players: Query<&Transform, With<Player>>,
pellets: Query<(Entity, &Transform, &Pellet), Without<Player>>,
mut session: ResMut<Session>,
mut next: ResMut<NextState<GameState>>,
mut sounds: ResMut<SoundQueue>,
) {
let Ok(p) = players.single() else {
return;
};
for (e, t, pellet) in &pellets {
if (p.translation - t.translation).xz().length() < 0.65 {
commands.entity(e).despawn();
session.remaining -= 1;
session.score += if pellet.power { 50 } else { 10 };
if pellet.power {
session.power = 8.;
session.chain = 0;
sounds.0.push(Sound::Power);
sounds.0.push(Sound::Frightened);
} else {
sounds.0.push(Sound::Pellet);
}
}
}
if session.remaining == 0 {
next.set(GameState::Victory);
}
}
pub fn pulse(
time: Res<Time>,
settings: Res<Settings>,
mut pellets: Query<(&Pellet, &mut Transform)>,
) {
for (p, mut t) in &mut pellets {
if p.power {
t.scale = Vec3::splat(if settings.low {
1.
} else {
1. + 0.16 * (time.elapsed_secs() * 5.).sin()
});
}
}
}

128
src/player.rs Normal file
View File

@@ -0,0 +1,128 @@
use crate::{
game::{GameState, WorldEntity},
maze::{self, Maze},
settings::Settings,
};
use bevy::{
input::mouse::AccumulatedMouseMotion,
prelude::*,
window::{CursorGrabMode, CursorOptions},
};
#[derive(Component)]
pub struct Player {
pub yaw: f32,
pub pitch: f32,
pub distance: f32,
}
pub const SPAWN_YAW: f32 = std::f32::consts::FRAC_PI_2;
pub fn spawn(mut commands: Commands, maze: Res<Maze>, settings: Res<Settings>) {
commands.spawn((
WorldEntity,
Player {
yaw: SPAWN_YAW,
pitch: 0.,
distance: 0.,
},
Camera3d::default(),
bevy::render::view::NoIndirectDrawing,
bevy::core_pipeline::tonemapping::Tonemapping::None,
if settings.low {
Msaa::Off
} else {
Msaa::Sample4
},
Projection::Perspective(PerspectiveProjection {
fov: 80_f32.to_radians(),
near: 0.05,
far: 90.,
..default()
}),
Transform::from_translation(maze::position(maze.spawn()) + Vec3::Y * 1.15)
.with_rotation(Quat::from_rotation_y(SPAWN_YAW)),
));
}
pub fn cursor(state: Res<State<GameState>>, mut windows: Query<(&Window, &mut CursorOptions)>) {
for (window, mut c) in &mut windows {
let grab = *state.get() == GameState::Playing && window.focused;
c.visible = !grab;
c.grab_mode = if grab {
CursorGrabMode::Locked
} else {
CursorGrabMode::None
};
}
}
pub fn move_player(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
mouse: Res<AccumulatedMouseMotion>,
pads: Query<&Gamepad>,
settings: Res<Settings>,
maze: Res<Maze>,
mut players: Query<(&mut Player, &mut Transform)>,
) {
let dt = time.delta_secs().min(0.1);
for (mut p, mut t) in &mut players {
let mut walk = Vec2::ZERO;
if keys.pressed(KeyCode::KeyW) {
walk.y += 1.;
}
if keys.pressed(KeyCode::KeyS) {
walk.y -= 1.;
}
if keys.pressed(KeyCode::KeyD) {
walk.x += 1.;
}
if keys.pressed(KeyCode::KeyA) {
walk.x -= 1.;
}
let mut turn = Vec2::ZERO;
if keys.pressed(KeyCode::ArrowLeft) {
turn.x -= 1.;
}
if keys.pressed(KeyCode::ArrowRight) {
turn.x += 1.;
}
for pad in &pads {
let left = pad.left_stick();
if left.length() > 0.18 {
walk += left;
}
let right = pad.right_stick();
if right.length() > 0.18 {
turn += Vec2::new(right.x, -right.y);
}
}
p.yaw -= turn.x * dt * 2.;
p.pitch = (p.pitch - turn.y * dt * 1.5).clamp(-1.1, 1.1);
if !settings.keyboard_only {
p.yaw -= mouse.delta.x * settings.sensitivity;
p.pitch = (p.pitch - mouse.delta.y * settings.sensitivity).clamp(-1.1, 1.1);
}
let rotation = Quat::from_rotation_y(p.yaw);
let velocity =
rotation * Vec3::new(walk.x, 0., -walk.y).clamp_length_max(1.) * settings.speed * dt;
// Small swept substeps prevent tunneling even after a long frame.
let steps = (velocity.length() / 0.08).ceil().max(1.) as u32;
let delta = velocity / steps as f32;
for _ in 0..steps {
let x = t.translation + Vec3::X * delta.x;
if maze.fits(x, 0.25) {
t.translation = x;
}
let z = t.translation + Vec3::Z * delta.z;
if maze.fits(z, 0.25) {
t.translation = z;
}
}
p.distance += velocity.length();
t.translation.y = 1.15
+ if settings.bob && !settings.low {
(p.distance * 7.).sin() * 0.025
} else {
0.
};
t.rotation = rotation * Quat::from_rotation_x(p.pitch);
}
}

128
src/settings.rs Normal file
View File

@@ -0,0 +1,128 @@
use bevy::{
prelude::*,
window::{MonitorSelection, WindowMode},
};
use serde::{Deserialize, Serialize};
use std::{
path::PathBuf,
time::{Duration, Instant},
};
#[derive(Resource, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct Settings {
pub sensitivity: f32,
pub speed: f32,
pub master: f32,
pub music: f32,
pub sound: f32,
pub fullscreen: bool,
pub width: u32,
pub height: u32,
pub fps: u32,
pub keyboard_only: bool,
pub low: bool,
pub bob: bool,
pub debug: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
sensitivity: 0.0025,
speed: 4.,
master: 0.7,
music: 0.3,
sound: 0.7,
fullscreen: false,
width: 1280,
height: 720,
fps: if cfg!(target_arch = "aarch64") {
30
} else {
60
},
keyboard_only: false,
low: cfg!(target_arch = "aarch64"),
bob: false,
debug: false,
}
}
}
impl Settings {
pub fn path() -> PathBuf {
let root = std::env::var_os("APPDATA")
.or_else(|| std::env::var_os("XDG_CONFIG_HOME"))
.map(PathBuf::from)
.unwrap_or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
});
root.join("first_person_maze").join("settings.ron")
}
pub fn load() -> Self {
let mut s: Self = std::fs::read_to_string(Self::path())
.ok()
.and_then(|s| ron::from_str(&s).ok())
.unwrap_or_default();
s.sensitivity = if s.sensitivity.is_finite() {
s.sensitivity.clamp(0.0005, 0.01)
} else {
0.0025
};
s.speed = if s.speed.is_finite() {
s.speed.clamp(2., 7.)
} else {
4.
};
for v in [&mut s.master, &mut s.music, &mut s.sound] {
*v = if v.is_finite() { v.clamp(0., 1.) } else { 0.5 };
}
s.width = s.width.clamp(640, 3840);
s.height = s.height.clamp(360, 2160);
s.fps = if s.fps <= 30 { 30 } else { 60 };
s
}
pub fn save(&self) {
let path = Self::path();
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, ron::ser::to_string_pretty(self, Default::default())?)?;
Ok(())
})();
if let Err(e) = result {
warn!("Could not save settings: {e}");
}
}
}
pub fn apply(
s: Res<Settings>,
mut windows: Query<&mut Window>,
mut cameras: Query<&mut Msaa, With<Camera3d>>,
) {
if !s.is_changed() {
return;
}
for mut w in &mut windows {
w.mode = if s.fullscreen {
WindowMode::BorderlessFullscreen(MonitorSelection::Current)
} else {
WindowMode::Windowed
};
w.resolution.set(s.width as f32, s.height as f32);
}
for mut msaa in &mut cameras {
*msaa = if s.low { Msaa::Off } else { Msaa::Sample4 };
}
}
pub fn frame_cap(s: Res<Settings>, mut last: Local<Option<Instant>>) {
let target = Duration::from_secs_f64(1. / s.fps as f64);
if let Some(t) = *last {
let elapsed = t.elapsed();
if elapsed < target {
std::thread::sleep(target - elapsed);
}
}
*last = Some(Instant::now());
}

351
src/ui.rs Normal file
View File

@@ -0,0 +1,351 @@
use crate::{
game::{self, GameState, Session},
ghost::Ghost,
maze,
player::Player,
settings::Settings,
};
use bevy::prelude::*;
#[derive(Resource, Default)]
pub struct Menu {
pub settings: bool,
pub selected: usize,
}
#[derive(Component)]
pub struct Hud;
#[derive(Component)]
pub struct Overlay;
#[derive(Component)]
pub struct Heading;
#[derive(Component)]
pub struct Row(pub usize);
#[derive(Component)]
pub struct RowText(pub usize);
#[derive(Component)]
pub struct DebugText;
pub fn scale(windows: Query<&Window>, mut scale: ResMut<UiScale>) {
if let Ok(window) = windows.single() {
let value = (window.width() / 1280.)
.min(window.height() / 720.)
.clamp(0.4, 2.);
if scale.0 != value {
scale.0 = value;
}
}
}
fn options(state: GameState, menu: &Menu, s: &Settings) -> Vec<String> {
if menu.settings {
return vec![
format!("Mouse sensitivity {:.4}", s.sensitivity),
format!("Movement speed {:.1}", s.speed),
format!("Master volume {:.0}%", s.master * 100.),
format!("Music volume {:.0}%", s.music * 100.),
format!("Sound volume {:.0}%", s.sound * 100.),
format!(
"Display {}",
if s.fullscreen {
"FULLSCREEN"
} else {
"WINDOWED"
}
),
format!("Resolution {} x {}", s.width, s.height),
format!("FPS limit {}", s.fps),
format!("Keyboard-only camera {}", s.keyboard_only),
format!("Graphics {}", if s.low { "LOW" } else { "NORMAL" }),
format!("Head bob {}", s.bob),
format!("Debug HUD {}", s.debug),
"BACK / SAVE".into(),
];
}
match state {
GameState::MainMenu => vec!["START GAME".into(), "SETTINGS".into(), "QUIT".into()],
GameState::Paused => vec![
"RESUME".into(),
"SETTINGS".into(),
"RESTART".into(),
"QUIT TO MENU".into(),
],
GameState::Victory | GameState::GameOver => {
vec!["PLAY AGAIN".into(), "QUIT TO MENU".into()]
}
_ => vec![],
}
}
pub fn setup(mut commands: Commands) {
commands.spawn((
Hud,
Text::new(""),
TextFont::from_font_size(24.),
TextColor(Color::srgb(1., 0.85, 0.35)),
Node {
position_type: PositionType::Absolute,
left: px(24),
top: px(18),
..default()
},
));
commands.spawn((
DebugText,
Text::new(""),
TextFont::from_font_size(15.),
TextColor(Color::WHITE),
Node {
position_type: PositionType::Absolute,
left: px(24),
bottom: px(20),
..default()
},
));
commands.spawn((Overlay,Node{position_type:PositionType::Absolute,width:percent(100),height:percent(100),flex_direction:FlexDirection::Column,align_items:AlignItems::Center,justify_content:JustifyContent::Center,row_gap:px(4),..default()},BackgroundColor(Color::srgba(0.005,0.012,0.035,0.95))))
.with_children(|parent| {
parent.spawn((Heading,Text::new("FIRST PERSON MAZE"),TextFont::from_font_size(34.),TextColor(Color::srgb(1.,0.82,0.2)),Node{margin:UiRect::bottom(px(12)),..default()}));
for index in 0..13 {
parent.spawn((Row(index),Button,Node{width:px(460),height:px(29),justify_content:JustifyContent::Center,align_items:AlignItems::Center,..default()},BackgroundColor(Color::NONE)))
.with_children(|row| {row.spawn((RowText(index),Text::new(""),TextFont::from_font_size(19.),TextColor(Color::srgb(0.65,0.82,1.))));});
}
parent.spawn((Text::new("WASD move / mouse look / arrows turn\nMenu: up/down + enter; left/right adjusts\nGamepad: D-pad + A; Start pauses / F3 debug"),TextFont::from_font_size(15.),TextColor(Color::srgb(0.4,0.55,0.7)),Node{margin:UiRect::top(px(16)),..default()}));
});
}
#[allow(clippy::too_many_arguments)] // Bevy injects these system parameters.
pub fn input(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
pads: Query<&Gamepad>,
state: Res<State<GameState>>,
mut next: ResMut<NextState<GameState>>,
mut menu: ResMut<Menu>,
mut settings: ResMut<Settings>,
mut exit: MessageWriter<AppExit>,
buttons: Query<(&Interaction, &Row), Changed<Interaction>>,
windows: Query<&Window>,
) {
if keys.just_pressed(KeyCode::F3) {
settings.debug = !settings.debug;
settings.save();
}
let pad = |button| pads.iter().any(|g| g.just_pressed(button));
let pause = keys.just_pressed(KeyCode::Escape) || pad(GamepadButton::Start);
if *state.get() == GameState::Playing {
if pause || windows.iter().any(|w| !w.focused) {
menu.selected = 0;
next.set(GameState::Paused);
}
return;
}
if pause {
if menu.settings {
menu.settings = false;
menu.selected = 0;
settings.save();
} else if *state.get() == GameState::Paused {
next.set(GameState::Playing);
}
return;
}
let count = options(*state.get(), &menu, &settings).len();
if count == 0 {
return;
}
menu.selected = menu.selected.min(count - 1);
if keys.just_pressed(KeyCode::ArrowDown) || pad(GamepadButton::DPadDown) {
menu.selected = (menu.selected + 1) % count;
}
if keys.just_pressed(KeyCode::ArrowUp) || pad(GamepadButton::DPadUp) {
menu.selected = (menu.selected + count - 1) % count;
}
let left = keys.just_pressed(KeyCode::ArrowLeft) || pad(GamepadButton::DPadLeft);
let right = keys.just_pressed(KeyCode::ArrowRight) || pad(GamepadButton::DPadRight);
let mut activate = keys.just_pressed(KeyCode::Enter) || pad(GamepadButton::South);
for (interaction, row) in &buttons {
if row.0 < count && *interaction == Interaction::Pressed {
menu.selected = row.0;
activate = true;
}
}
if menu.settings {
if !(left || right || activate) {
return;
}
let d = if left { -1. } else { 1. };
match menu.selected {
0 => settings.sensitivity = (settings.sensitivity + d * 0.0005).clamp(0.0005, 0.01),
1 => settings.speed = (settings.speed + d * 0.5).clamp(2., 7.),
2 => settings.master = (settings.master + d * 0.1).clamp(0., 1.),
3 => settings.music = (settings.music + d * 0.1).clamp(0., 1.),
4 => settings.sound = (settings.sound + d * 0.1).clamp(0., 1.),
5 => settings.fullscreen = !settings.fullscreen,
6 => {
let resolutions = [(960, 540), (1280, 720), (1920, 1080)];
let i = resolutions
.iter()
.position(|r| *r == (settings.width, settings.height))
.unwrap_or(1);
let n = (i as i32 + if left { 2 } else { 1 }) as usize % 3;
(settings.width, settings.height) = resolutions[n];
}
7 => settings.fps = if settings.fps == 30 { 60 } else { 30 },
8 => settings.keyboard_only = !settings.keyboard_only,
9 => settings.low = !settings.low,
10 => settings.bob = !settings.bob,
11 => settings.debug = !settings.debug,
_ => {
menu.settings = false;
menu.selected = 0;
}
}
settings.save();
return;
}
if !activate {
return;
}
match (*state.get(), menu.selected) {
(GameState::MainMenu, 0)
| (GameState::Paused, 2)
| (GameState::Victory | GameState::GameOver, 0) => {
commands.queue(game::restart);
}
(GameState::MainMenu, 1) | (GameState::Paused, 1) => {
menu.settings = true;
}
(GameState::MainMenu, 2) => {
exit.write(AppExit::Success);
}
(GameState::Paused, 0) => next.set(GameState::Playing),
(GameState::Paused, 3) | (GameState::Victory | GameState::GameOver, 1) => {
next.set(GameState::MainMenu)
}
_ => {}
}
menu.selected = 0;
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // Explicit disjoint UI queries.
pub fn draw(
state: Res<State<GameState>>,
session: Res<Session>,
menu: Res<Menu>,
settings: Res<Settings>,
time: Res<Time>,
mut hud: Query<&mut Text, With<Hud>>,
mut heading: Query<&mut Text, (With<Heading>, Without<Hud>)>,
mut overlay: Query<&mut Node, With<Overlay>>,
mut rows: Query<(&Row, &mut Node, &mut BackgroundColor), Without<Overlay>>,
mut labels: Query<(&RowText, &mut Text), (Without<Hud>, Without<Heading>)>,
mut debug: Query<
&mut Text,
(
With<DebugText>,
Without<Hud>,
Without<Heading>,
Without<RowText>,
),
>,
player: Query<&Transform, With<Player>>,
ghosts: Query<&Ghost>,
mut fps: Local<f32>,
) {
*fps = *fps * 0.95 + 0.05 / time.delta_secs().max(0.001);
for mut text in &mut hud {
let value = format!(
"SCORE {:06} LIVES {} PELLETS {}{}",
session.score,
session.lives,
session.remaining,
if session.power > 0. {
format!(" POWER: {:.1}", session.power)
} else {
String::new()
}
);
if text.0 != value {
text.0 = value;
}
}
for mut node in &mut overlay {
let display = if *state.get() == GameState::Playing {
Display::None
} else {
Display::Flex
};
if node.display != display {
node.display = display;
}
}
for mut text in &mut heading {
let value = if menu.settings {
"SETTINGS".into()
} else {
match state.get() {
GameState::MainMenu => "FIRST PERSON MAZE\nNEON CORRIDORS".into(),
GameState::Paused => "PAUSED".into(),
GameState::PlayerDeath => format!("CAUGHT!\n{} lives remaining", session.lives),
GameState::Victory | GameState::GameOver => format!(
"{}\nSCORE {} LIVES {} TIME {:.1}s",
if *state.get() == GameState::Victory {
"MAZE CLEARED"
} else {
"GAME OVER"
},
session.score,
session.lives,
session.elapsed
),
_ => String::new(),
}
};
if text.0 != value {
text.0 = value;
}
}
let opts = options(*state.get(), &menu, &settings);
for (row, mut node, mut bg) in &mut rows {
let display = if row.0 < opts.len() {
Display::Flex
} else {
Display::None
};
if node.display != display {
node.display = display;
}
let color = if row.0 == menu.selected {
Color::srgb(0.06, 0.16, 0.32)
} else {
Color::NONE
};
if bg.0 != color {
bg.0 = color;
}
}
for (row, mut text) in &mut labels {
let value = opts.get(row.0).cloned().unwrap_or_default();
if text.0 != value {
text.0 = value;
}
}
for mut text in &mut debug {
let value = if settings.debug {
let p = player
.single()
.map(|t| maze::cell(t.translation))
.unwrap_or_default();
let mut s = format!("FPS {:.0} GRID {}, {}", *fps, p.x, p.y);
for g in &ghosts {
s.push_str(&format!(
"\nGhost {} {:?} -> {}, {}",
g.id + 1,
g.state,
g.target.x,
g.target.y
));
}
s
} else {
String::new()
};
if text.0 != value {
text.0 = value;
}
}
}

38
tools/generate_audio.py Normal file
View File

@@ -0,0 +1,38 @@
"""Generate original mono PCM effects using only the Python standard library.
Python is a development convenience; it is never needed to play the game.
"""
from pathlib import Path
import math
import struct
import wave
ROOT = Path(__file__).resolve().parents[1] / "assets" / "audio"
CUES = {
"pellet": ([880, 1320], 0.035),
"power": ([330, 440, 660, 880], 0.09),
"frightened": ([165, 220, 165], 0.10),
"defeated": ([660, 880, 1320], 0.07),
"death": ([660, 550, 440, 330, 165], 0.12),
"victory": ([440, 554.37, 659.25, 880, 659.25, 880], 0.16),
"game_over": ([330, 293.66, 261.63, 164.81], 0.22),
}
def main():
ROOT.mkdir(parents=True, exist_ok=True)
rate = 22050
for name, (notes, duration) in CUES.items():
samples = bytearray()
for frequency in notes:
count = int(rate * duration)
for i in range(count):
envelope = min(i / (rate * 0.005), 1) * (1 - i / count) ** 1.5
tone = math.sin(2 * math.pi * frequency * i / rate)
samples.extend(struct.pack("<h", int(tone * envelope * 6500)))
with wave.open(str(ROOT / f"{name}.wav"), "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(rate)
output.writeframes(samples)
if __name__ == "__main__":
main()

38
tools/package.ps1 Normal file
View File

@@ -0,0 +1,38 @@
param(
[ValidatePattern('^[A-Za-z0-9_]+(-[A-Za-z0-9_]+)+$')]
[string]$Target = "x86_64-pc-windows-msvc"
)
$ErrorActionPreference = "Stop"
$project = Split-Path $PSScriptRoot -Parent
Push-Location $project
try {
$rustInfo = rustc -vV
if ($LASTEXITCODE -ne 0) { throw "Could not determine Rust host target" }
$hostTarget = (($rustInfo | Select-String '^host: ').ToString() -replace '^host: ', '')
$targetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { "target" }
$executable = if ($Target -match '-windows-') { "first_person_maze.exe" } else { "first_person_maze" }
if ($Target -eq $hostTarget) {
cargo build --release --locked
$binary = Join-Path $targetDir "release/$executable"
} else {
cargo build --release --locked --target $Target
$binary = Join-Path $targetDir "$Target/release/$executable"
}
if ($LASTEXITCODE -ne 0) { throw "Cargo build failed" }
if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { throw "Missing binary: $binary" }
$releaseRoot = [System.IO.Path]::GetFullPath((Join-Path $project "release"))
$destination = [System.IO.Path]::GetFullPath((Join-Path $releaseRoot "$Target/first_person_maze"))
if (-not $destination.StartsWith($releaseRoot + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Package destination is outside the release directory"
}
if (Test-Path -LiteralPath $destination) {
Remove-Item -LiteralPath $destination -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $destination | Out-Null
Copy-Item -LiteralPath $binary -Destination $destination
$audioDestination = Join-Path $destination "assets/audio"
New-Item -ItemType Directory -Force -Path $audioDestination | Out-Null
Get-ChildItem -LiteralPath "assets/audio" -Filter "*.wav" -File | Copy-Item -Destination $audioDestination
Copy-Item -LiteralPath "README.md" -Destination $destination
Write-Host "Package: $destination"
} finally { Pop-Location }

28
tools/package.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env sh
set -eu
cd "$(dirname "$0")/.."
host_target="$(rustc -vV | sed -n 's/^host: //p')"
target="${1:-$host_target}"
case "$target" in
''|*[!a-zA-Z0-9_-]*) printf 'Invalid target: %s\n' "$target" >&2; exit 1 ;;
esac
target_dir="${CARGO_TARGET_DIR:-target}"
case "$target" in
*-windows-*) executable=first_person_maze.exe ;;
*) executable=first_person_maze ;;
esac
if [ "$target" = "$host_target" ]; then
cargo build --release --locked
binary="$target_dir/release/$executable"
else
cargo build --release --locked --target "$target"
binary="$target_dir/$target/release/$executable"
fi
destination="release/$target/first_person_maze"
test -f "$binary"
rm -rf "$destination"
mkdir -p "$destination/assets/audio"
cp "$binary" "$destination/"
cp assets/audio/*.wav "$destination/assets/audio/"
cp README.md "$destination/"
printf 'Package: %s\n' "$destination"