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

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;
}
}
}