Initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/target/
|
||||
/release/
|
||||
*.log
|
||||
5589
Cargo.lock
generated
Normal file
5589
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal 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
100
README.md
Normal 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
BIN
assets/audio/death.wav
Normal file
Binary file not shown.
BIN
assets/audio/defeated.wav
Normal file
BIN
assets/audio/defeated.wav
Normal file
Binary file not shown.
BIN
assets/audio/frightened.wav
Normal file
BIN
assets/audio/frightened.wav
Normal file
Binary file not shown.
BIN
assets/audio/game_over.wav
Normal file
BIN
assets/audio/game_over.wav
Normal file
Binary file not shown.
BIN
assets/audio/pellet.wav
Normal file
BIN
assets/audio/pellet.wav
Normal file
Binary file not shown.
BIN
assets/audio/power.wav
Normal file
BIN
assets/audio/power.wav
Normal file
Binary file not shown.
BIN
assets/audio/victory.wav
Normal file
BIN
assets/audio/victory.wav
Normal file
Binary file not shown.
63
src/audio.rs
Normal file
63
src/audio.rs
Normal 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
147
src/game.rs
Normal 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
275
src/ghost.rs
Normal 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
44
src/main.rs
Normal 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
153
src/maze.rs
Normal 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
43
src/pathfinding.rs
Normal 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
84
src/pellets.rs
Normal 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
128
src/player.rs
Normal 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
128
src/settings.rs
Normal 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
351
src/ui.rs
Normal 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
38
tools/generate_audio.py
Normal 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
38
tools/package.ps1
Normal 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
28
tools/package.sh
Normal 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"
|
||||
Reference in New Issue
Block a user