Compare commits

...

4 Commits

Author SHA1 Message Date
Ryan Walters
c828034d18 chore(version): bump version to v0.77.0 2025-09-08 01:15:40 -05:00
Ryan Walters
823f480916 feat: setup pacman collision, level restart, game over, death sequence, switch to Vec for TileSequence 2025-09-08 01:14:32 -05:00
Ryan Walters
53306de155 chore: add precommit bacon job 2025-09-07 16:41:43 -05:00
Ryan Walters
6ddc6d1181 chore: setup auto tag & bump scripts with pre-commit 2025-09-07 15:12:19 -05:00
17 changed files with 842 additions and 322 deletions

1
.gitattributes vendored
View File

@@ -1 +1,2 @@
* text=auto eol=lf
scripts/* linguist-detectable=false

View File

@@ -12,6 +12,13 @@ repos:
- id: forbid-submodules
- id: mixed-line-ending
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v4.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: []
- repo: local
hooks:
- id: cargo-fmt
@@ -20,15 +27,31 @@ repos:
language: system
types: [rust]
pass_filenames: false
- id: cargo-check
name: cargo check
entry: cargo check --all-targets
language: system
types_or: [rust, cargo, cargo-lock]
pass_filenames: false
- id: cargo-check-wasm
name: cargo check for wasm32-unknown-emscripten
entry: cargo check --all-targets --target=wasm32-unknown-emscripten
language: system
types_or: [rust, cargo, cargo-lock]
pass_filenames: false
- id: bump-version
name: bump version based on commit message
entry: python scripts/bump-version.py
language: system
stages: [commit-msg]
always_run: true
- id: tag-version
name: tag version based on commit message
entry: python scripts/tag-version.py
language: system
stages: [post-commit]
always_run: true

2
Cargo.lock generated
View File

@@ -663,7 +663,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pacman"
version = "0.76.1"
version = "0.77.0"
dependencies = [
"anyhow",
"bevy_ecs",

View File

@@ -1,6 +1,6 @@
[package]
name = "pacman"
version = "0.76.1"
version = "0.77.0"
authors = ["Xevion"]
edition = "2021"
rust-version = "1.86.0"

View File

@@ -28,16 +28,18 @@ need_stdout = false
[jobs.test]
command = [
"cargo", "nextest", "run",
"--hide-progress-bar", "--failure-output", "final"
"cargo",
"nextest",
"run",
"--hide-progress-bar",
"--failure-output",
"final",
]
need_stdout = true
analyzer = "nextest"
[jobs.coverage]
command = [
"just", "report-coverage"
]
command = ["just", "report-coverage"]
need_stdout = true
ignored_lines = [
"info:",
@@ -54,7 +56,7 @@ ignored_lines = [
"\\s*Finished.+in \\d+",
"\\s*Summary\\s+\\[",
"\\s*Blocking",
"Finished report saved to"
"Finished report saved to",
]
on_change_strategy = "wait_then_restart"
@@ -69,18 +71,23 @@ need_stdout = false
on_success = "back" # so that we don't open the browser at each change
[jobs.run]
command = [
"cargo", "run",
]
command = ["cargo", "run"]
need_stdout = true
allow_warnings = true
background = false
on_change_strategy = "kill_then_restart"
# kill = ["pkill", "-TERM", "-P"]'
[jobs.precommit]
command = ["pre-commit", "run", "--all-files"]
need_stdout = true
background = false
on_change_strategy = "kill_then_restart"
[keybindings]
c = "job:clippy"
alt-c = "job:check"
ctrl-alt-c = "job:check-all"
shift-c = "job:clippy-all"
f = "job:coverage"
p = "job:precommit"

143
scripts/bump-version.py Normal file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Pre-commit hook script to automatically bump Cargo.toml version based on commit message.
This script parses the commit message for version bump keywords and uses cargo set-version
to update the version in Cargo.toml accordingly.
Supported keywords:
- "major" or "breaking": Bump major version (1.0.0 -> 2.0.0)
- "minor" or "feature": Bump minor version (1.0.0 -> 1.1.0)
- "patch" or "fix" or "bugfix": Bump patch version (1.0.0 -> 1.0.1)
Usage: python scripts/bump-version.py <commit_message_file>
"""
import sys
import re
import subprocess
import os
from pathlib import Path
def get_current_version():
"""Get the current version from Cargo.toml."""
try:
result = subprocess.run(
["cargo", "metadata", "--format-version", "1", "--no-deps"],
capture_output=True,
text=True,
check=True
)
# Parse the JSON output to get version
import json
metadata = json.loads(result.stdout)
return metadata["packages"][0]["version"]
except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
print(f"Error getting current version: {e}", file=sys.stderr)
return None
def bump_version(current_version, bump_type):
"""Calculate the new version based on bump type."""
try:
major, minor, patch = map(int, current_version.split('.'))
if bump_type == "major":
return f"{major + 1}.0.0"
elif bump_type == "minor":
return f"{major}.{minor + 1}.0"
elif bump_type == "patch":
return f"{major}.{minor}.{patch + 1}"
else:
return None
except ValueError:
print(f"Invalid version format: {current_version}", file=sys.stderr)
return None
def set_version(new_version):
"""Set the new version using cargo set-version."""
try:
result = subprocess.run(
["cargo", "set-version", new_version],
capture_output=True,
text=True,
check=True
)
print(f"Successfully bumped version to {new_version}")
return True
except subprocess.CalledProcessError as e:
print(f"Error setting version: {e}", file=sys.stderr)
print(f"stdout: {e.stdout}", file=sys.stderr)
print(f"stderr: {e.stderr}", file=sys.stderr)
return False
def parse_commit_message(commit_message_file):
"""Parse the commit message file for version bump keywords."""
try:
with open(commit_message_file, 'r', encoding='utf-8') as f:
message = f.read().lower()
except FileNotFoundError:
print(f"Commit message file not found: {commit_message_file}", file=sys.stderr)
return None
except Exception as e:
print(f"Error reading commit message: {e}", file=sys.stderr)
return None
# Check for version bump keywords
if re.search(r'\b(major|breaking)\b', message):
return "major"
elif re.search(r'\b(minor|feature)\b', message):
return "minor"
elif re.search(r'\b(patch|fix|bugfix)\b', message):
return "patch"
return None
def main():
if len(sys.argv) != 2:
print("Usage: python scripts/bump-version.py <commit_message_file>", file=sys.stderr)
sys.exit(1)
commit_message_file = sys.argv[1]
# Parse commit message for version bump type
bump_type = parse_commit_message(commit_message_file)
if not bump_type:
print("No version bump keywords found in commit message")
sys.exit(0)
print(f"Found version bump type: {bump_type}")
# Get current version
current_version = get_current_version()
if not current_version:
print("Failed to get current version", file=sys.stderr)
sys.exit(1)
print(f"Current version: {current_version}")
# Calculate new version
new_version = bump_version(current_version, bump_type)
if not new_version:
print("Failed to calculate new version", file=sys.stderr)
sys.exit(1)
print(f"New version: {new_version}")
# Set the new version
if set_version(new_version):
print("Version bump completed successfully")
sys.exit(0)
else:
print("Version bump failed", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

125
scripts/tag-version.py Normal file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Post-commit hook script to automatically create git tags based on the version in Cargo.toml.
This script reads the current version from Cargo.toml and creates a git tag with that version.
It's designed to run after the version has been bumped by the bump-version.py script.
Usage: python scripts/tag-version.py
"""
import sys
import subprocess
import re
from pathlib import Path
def get_version_from_cargo_toml():
"""Get the current version from Cargo.toml."""
cargo_toml_path = Path("Cargo.toml")
if not cargo_toml_path.exists():
print("Cargo.toml not found", file=sys.stderr)
return None
try:
with open(cargo_toml_path, 'r', encoding='utf-8') as f:
content = f.read()
# Look for version = "x.y.z" pattern
version_match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
if version_match:
return version_match.group(1)
else:
print("Could not find version in Cargo.toml", file=sys.stderr)
return None
except Exception as e:
print(f"Error reading Cargo.toml: {e}", file=sys.stderr)
return None
def get_existing_tags():
"""Get list of existing git tags."""
try:
result = subprocess.run(
["git", "tag", "--list"],
capture_output=True,
text=True,
check=True
)
return result.stdout.strip().split('\n') if result.stdout.strip() else []
except subprocess.CalledProcessError as e:
print(f"Error getting git tags: {e}", file=sys.stderr)
return []
def create_git_tag(version):
"""Create a git tag with the specified version."""
tag_name = f"v{version}"
try:
# Check if tag already exists
existing_tags = get_existing_tags()
if tag_name in existing_tags:
print(f"Tag {tag_name} already exists, skipping")
return True
# Create the tag
result = subprocess.run(
["git", "tag", tag_name],
capture_output=True,
text=True,
check=True
)
print(f"Successfully created tag: {tag_name}")
return True
except subprocess.CalledProcessError as e:
print(f"Error creating git tag: {e}", file=sys.stderr)
print(f"stdout: {e.stdout}", file=sys.stderr)
print(f"stderr: {e.stderr}", file=sys.stderr)
return False
def is_git_repository():
"""Check if we're in a git repository."""
try:
subprocess.run(
["git", "rev-parse", "--git-dir"],
capture_output=True,
check=True
)
return True
except subprocess.CalledProcessError:
return False
def main():
# Check if we're in a git repository
if not is_git_repository():
print("Not in a git repository, skipping tag creation")
sys.exit(0)
# Get the current version from Cargo.toml
version = get_version_from_cargo_toml()
if not version:
print("Could not determine version, skipping tag creation")
sys.exit(0)
print(f"Current version: {version}")
# Create the git tag
if create_git_tag(version):
print("Tag creation completed successfully")
sys.exit(0)
else:
print("Tag creation failed", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -132,8 +132,6 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [
pub mod startup {
/// Number of frames for the startup sequence (3 seconds at 60 FPS)
pub const STARTUP_FRAMES: u32 = 60 * 3;
/// Number of ticks per frame during startup
pub const STARTUP_TICKS_PER_FRAME: u32 = 60;
}
/// Game mechanics constants

View File

@@ -9,32 +9,26 @@ use crate::error::{GameError, GameResult};
use crate::events::GameEvent;
use crate::map::builder::Map;
use crate::map::direction::Direction;
use crate::systems::blinking::Blinking;
use crate::systems::components::{GhostAnimation, GhostState, LastAnimationState};
use crate::systems::movement::{BufferedDirection, Position, Velocity};
use crate::systems::profiling::{SystemId, Timing};
use crate::systems::render::touch_ui_render_system;
use crate::systems::render::RenderDirty;
use crate::systems::{
self, combined_render_system, ghost_collision_system, present_system, Hidden, LinearAnimation, MovementModifiers, NodeId,
TouchState,
};
use crate::systems::{
audio_system, blinking_system, collision_system, directional_render_system, dirty_render_system, eaten_ghost_system,
ghost_movement_system, ghost_state_system, hud_render_system, item_system, linear_render_system, profile, AudioEvent,
AudioResource, AudioState, BackbufferResource, Collider, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimation,
EntityType, Frozen, Ghost, GhostAnimations, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider,
MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence,
SystemTimings,
self, audio_system, blinking_system, collision_system, combined_render_system, directional_render_system,
dirty_render_system, eaten_ghost_system, ghost_collision_system, ghost_movement_system, ghost_state_system,
hud_render_system, item_system, linear_render_system, present_system, profile, touch_ui_render_system, AudioEvent,
AudioResource, AudioState, BackbufferResource, Blinking, BufferedDirection, Collider, DebugState, DebugTextureResource,
DeltaTime, DirectionalAnimation, EntityType, Frozen, GameStage, Ghost, GhostAnimation, GhostAnimations, GhostBundle,
GhostCollider, GhostState, GlobalState, Hidden, ItemBundle, ItemCollider, LastAnimationState, LinearAnimation,
MapTextureResource, MovementModifiers, NodeId, PacmanCollider, PlayerAnimation, PlayerBundle, PlayerControlled,
PlayerDeathAnimation, PlayerLives, Position, RenderDirty, Renderable, ScoreResource, StartupSequence, SystemId,
SystemTimings, Timing, TouchState, Velocity,
};
use crate::texture::animated::{DirectionalTiles, TileSequence};
use crate::texture::sprite::AtlasTile;
use crate::texture::sprites::{FrightenedColor, GameSprite, GhostSprite, MazeSprite, PacmanSprite};
use bevy_ecs::change_detection::DetectChanges;
use bevy_ecs::event::EventRegistry;
use bevy_ecs::observer::Trigger;
use bevy_ecs::schedule::common_conditions::resource_changed;
use bevy_ecs::schedule::{Condition, IntoScheduleConfigs, Schedule, SystemSet};
use bevy_ecs::system::{Local, ResMut};
use bevy_ecs::schedule::{IntoScheduleConfigs, Schedule, SystemSet};
use bevy_ecs::system::{Local, Res, ResMut};
use bevy_ecs::world::World;
use sdl2::event::EventType;
use sdl2::image::LoadTexture;
@@ -54,7 +48,9 @@ use crate::{
/// System set for all rendering systems to ensure they run after gameplay logic
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
pub struct RenderSet;
enum RenderSet {
Animation,
}
/// Core game state manager built on the Bevy ECS architecture.
///
@@ -112,6 +108,8 @@ impl Game {
let (player_animation, player_start_sprite) = Self::create_player_animations(&atlas)?;
let player_bundle = Self::create_player_bundle(&map, player_animation, player_start_sprite);
let death_animation = Self::create_death_animation(&atlas)?;
let mut world = World::default();
let mut schedule = Schedule::default();
@@ -127,6 +125,7 @@ impl Game {
map_texture,
debug_texture,
ttf_atlas,
death_animation,
)?;
Self::configure_schedule(&mut schedule);
@@ -310,6 +309,18 @@ impl Game {
Ok((player_animation, player_start_sprite))
}
fn create_death_animation(atlas: &SpriteAtlas) -> GameResult<LinearAnimation> {
let mut death_tiles = Vec::new();
for i in 0..=10 {
// Assuming death animation has 11 frames named pacman/die_0, pacman/die_1, etc.
let tile = atlas.get_tile(&GameSprite::Pacman(PacmanSprite::Dying(i)).to_path())?;
death_tiles.push(tile);
}
let tile_sequence = TileSequence::new(&death_tiles);
Ok(LinearAnimation::new(tile_sequence, 8)) // 8 ticks per frame, non-looping
}
fn create_player_bundle(map: &Map, player_animation: DirectionalAnimation, player_start_sprite: AtlasTile) -> PlayerBundle {
PlayerBundle {
player: PlayerControlled,
@@ -361,13 +372,19 @@ impl Game {
map_texture: sdl2::render::Texture,
debug_texture: sdl2::render::Texture,
ttf_atlas: crate::texture::ttf::TtfAtlas,
death_animation: LinearAnimation,
) -> GameResult<()> {
world.insert_non_send_resource(atlas);
world.insert_resource(Self::create_ghost_animations(world.non_send_resource::<SpriteAtlas>())?);
let player_animation = Self::create_player_animations(world.non_send_resource::<SpriteAtlas>())?.0;
world.insert_resource(PlayerAnimation(player_animation));
world.insert_resource(PlayerDeathAnimation(death_animation));
world.insert_resource(BatchedLinesResource::new(&map, constants::LARGE_SCALE));
world.insert_resource(map);
world.insert_resource(GlobalState { exit: false });
world.insert_resource(GameStage::default());
world.insert_resource(PlayerLives::default());
world.insert_resource(ScoreResource(0));
world.insert_resource(SystemTimings::default());
world.insert_resource(Timing::default());
@@ -378,10 +395,9 @@ impl Game {
world.insert_resource(AudioState::default());
world.insert_resource(CursorPosition::default());
world.insert_resource(TouchState::default());
world.insert_resource(StartupSequence::new(
constants::startup::STARTUP_FRAMES,
constants::startup::STARTUP_TICKS_PER_FRAME,
));
world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: constants::startup::STARTUP_FRAMES,
}));
world.insert_non_send_resource(event_pump);
world.insert_non_send_resource::<&mut Canvas<Window>>(Box::leak(Box::new(canvas)));
@@ -394,15 +410,14 @@ impl Game {
}
fn configure_schedule(schedule: &mut Schedule) {
let stage_system = profile(SystemId::Stage, systems::stage_system);
let input_system = profile(SystemId::Input, systems::input::input_system);
let player_control_system = profile(SystemId::PlayerControls, systems::player_control_system);
let player_movement_system = profile(SystemId::PlayerMovement, systems::player_movement_system);
let startup_stage_system = profile(SystemId::Stage, systems::startup_stage_system);
let player_tunnel_slowdown_system = profile(SystemId::PlayerMovement, systems::player::player_tunnel_slowdown_system);
let ghost_movement_system = profile(SystemId::Ghost, ghost_movement_system);
let collision_system = profile(SystemId::Collision, collision_system);
let ghost_collision_system = profile(SystemId::GhostCollision, ghost_collision_system);
let item_system = profile(SystemId::Item, item_system);
let audio_system = profile(SystemId::Audio, audio_system);
let blinking_system = profile(SystemId::Blinking, blinking_system);
@@ -412,41 +427,55 @@ impl Game {
let hud_render_system = profile(SystemId::HudRender, hud_render_system);
let present_system = profile(SystemId::Present, present_system);
let unified_ghost_state_system = profile(SystemId::GhostStateAnimation, ghost_state_system);
// let death_sequence_system = profile(SystemId::DeathSequence, death_sequence_system);
// let game_over_system = profile(SystemId::GameOver, systems::game_over_system);
let eaten_ghost_system = profile(SystemId::EatenGhost, eaten_ghost_system);
let forced_dirty_system = |mut dirty: ResMut<RenderDirty>| {
dirty.0 = true;
};
schedule.add_systems((
forced_dirty_system.run_if(resource_changed::<ScoreResource>.or(resource_changed::<StartupSequence>)),
(
schedule.add_systems(
forced_dirty_system
.run_if(|score: Res<ScoreResource>, stage: Res<GameStage>| score.is_changed() || stage.is_changed()),
);
// Input system should always run to prevent SDL event pump from blocking
let input_systems = (
input_system.run_if(|mut local: Local<u8>| {
*local = local.wrapping_add(1u8);
// run every nth frame
*local % 2 == 0
}),
player_control_system,
player_movement_system,
startup_stage_system,
)
.chain(),
player_tunnel_slowdown_system,
ghost_movement_system,
profile(SystemId::EatenGhost, eaten_ghost_system),
unified_ghost_state_system,
.chain();
let gameplay_systems = (
(player_movement_system, player_tunnel_slowdown_system, ghost_movement_system).chain(),
eaten_ghost_system,
(collision_system, ghost_collision_system, item_system).chain(),
audio_system,
blinking_system,
unified_ghost_state_system,
)
.chain()
.run_if(|game_state: Res<GameStage>| matches!(*game_state, GameStage::Playing));
schedule.add_systems((blinking_system, directional_render_system, linear_render_system).in_set(RenderSet::Animation));
schedule.add_systems((
stage_system,
input_systems,
gameplay_systems,
(
directional_render_system,
linear_render_system,
dirty_render_system,
combined_render_system,
hud_render_system,
touch_ui_render_system,
present_system,
)
.chain(),
.chain()
.after(RenderSet::Animation),
audio_system,
));
}
@@ -512,7 +541,7 @@ impl Game {
for (ghost_type, start_node) in ghost_start_positions {
// Create the ghost bundle in a separate scope to manage borrows
let ghost = {
let animations = *world.resource::<GhostAnimations>().get_normal(&ghost_type).unwrap();
let animations = world.resource::<GhostAnimations>().get_normal(&ghost_type).unwrap().clone();
let atlas = world.non_send_resource::<SpriteAtlas>();
let sprite_path = GameSprite::Ghost(GhostSprite::Normal(ghost_type, Direction::Left, 0)).to_path();
@@ -557,7 +586,7 @@ impl Game {
TileSequence::new(&[left_eye]),
TileSequence::new(&[right_eye]),
);
let eyes = DirectionalAnimation::new(eyes_tiles, eyes_tiles, animation::GHOST_EATEN_SPEED);
let eyes = DirectionalAnimation::new(eyes_tiles.clone(), eyes_tiles, animation::GHOST_EATEN_SPEED);
let mut animations = HashMap::new();
@@ -586,7 +615,7 @@ impl Game {
TileSequence::new(&left_tiles),
TileSequence::new(&right_tiles),
);
let normal = DirectionalAnimation::new(normal_moving, normal_moving, animation::GHOST_NORMAL_SPEED);
let normal = DirectionalAnimation::new(normal_moving.clone(), normal_moving, animation::GHOST_NORMAL_SPEED);
animations.insert(ghost_type, normal);
}
@@ -658,68 +687,4 @@ impl Game {
state.exit
}
// /// Renders pathfinding debug lines from each ghost to Pac-Man.
// ///
// /// Each ghost's path is drawn in its respective color with a small offset
// /// to prevent overlapping lines.
// fn render_pathfinding_debug<T: sdl2::render::RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> {
// let pacman_node = self.state.pacman.current_node_id();
// for ghost in self.state.ghosts.iter() {
// if let Ok(path) = ghost.calculate_path_to_target(&self.state.map.graph, pacman_node) {
// if path.len() < 2 {
// continue; // Skip if path is too short
// }
// // Set the ghost's color
// canvas.set_draw_color(ghost.debug_color());
// // Calculate offset based on ghost index to prevent overlapping lines
// // let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0
// // Calculate a consistent offset direction for the entire path
// // let first_node = self.map.graph.get_node(path[0]).unwrap();
// // let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap();
// // Use the overall direction from start to end to determine the perpendicular offset
// let offset = match ghost.ghost_type {
// GhostType::Blinky => glam::Vec2::new(0.25, 0.5),
// GhostType::Pinky => glam::Vec2::new(-0.25, -0.25),
// GhostType::Inky => glam::Vec2::new(0.5, -0.5),
// GhostType::Clyde => glam::Vec2::new(-0.5, 0.25),
// } * 5.0;
// // Calculate offset positions for all nodes using the same perpendicular direction
// let mut offset_positions = Vec::new();
// for &node_id in &path {
// let node = self
// .state
// .map
// .graph
// .get_node(node_id)
// .ok_or(crate::error::EntityError::NodeNotFound(node_id))?;
// let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
// offset_positions.push(pos + offset);
// }
// // Draw lines between the offset positions
// for window in offset_positions.windows(2) {
// if let (Some(from), Some(to)) = (window.first(), window.get(1)) {
// // Skip if the distance is too far (used for preventing lines between tunnel portals)
// if from.distance_squared(*to) > (crate::constants::CELL_SIZE * 16).pow(2) as f32 {
// continue;
// }
// // Draw the line
// canvas
// .draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32))
// .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?;
// }
// }
// }
// }
// Ok(())
// }
}

View File

@@ -1,15 +1,20 @@
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::{EventReader, EventWriter};
use bevy_ecs::query::With;
use bevy_ecs::system::{Query, Res, ResMut};
use bevy_ecs::{
component::Component,
entity::Entity,
event::{EventReader, EventWriter},
query::With,
system::{Commands, Query, Res, ResMut},
};
use crate::error::GameError;
use crate::events::GameEvent;
use crate::map::builder::Map;
use crate::systems::movement::Position;
use crate::systems::{AudioEvent, Ghost, GhostState, PlayerControlled, ScoreResource};
use crate::systems::{
components::GhostState, movement::Position, AudioEvent, DyingSequence, Frozen, GameStage, Ghost, PlayerControlled,
ScoreResource,
};
/// A component for defining the collision area of an entity.
#[derive(Component)]
pub struct Collider {
pub size: f32,
@@ -62,6 +67,7 @@ pub fn check_collision(
///
/// Also detects collisions between Pac-Man and ghosts for gameplay mechanics like
/// power pellet effects, ghost eating, and player death.
#[allow(clippy::too_many_arguments)]
pub fn collision_system(
map: Res<Map>,
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
@@ -107,10 +113,13 @@ pub fn collision_system(
}
}
#[allow(clippy::too_many_arguments)]
pub fn ghost_collision_system(
mut commands: Commands,
mut collision_events: EventReader<GameEvent>,
mut score: ResMut<ScoreResource>,
pacman_query: Query<(), With<PlayerControlled>>,
mut game_state: ResMut<GameStage>,
pacman_query: Query<Entity, With<PlayerControlled>>,
ghost_query: Query<(Entity, &Ghost), With<GhostCollider>>,
mut ghost_state_query: Query<&mut GhostState>,
mut events: EventWriter<AudioEvent>,
@@ -118,7 +127,7 @@ pub fn ghost_collision_system(
for event in collision_events.read() {
if let GameEvent::Collision(entity1, entity2) = event {
// Check if one is Pacman and the other is a ghost
let (_pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
let (pacman_entity, ghost_entity) = if pacman_query.get(*entity1).is_ok() && ghost_query.get(*entity2).is_ok() {
(*entity1, *entity2)
} else if pacman_query.get(*entity2).is_ok() && ghost_query.get(*entity1).is_ok() {
(*entity2, *entity1)
@@ -140,8 +149,12 @@ pub fn ghost_collision_system(
// Play eat sound
events.write(AudioEvent::PlayEat);
} else {
// Pac-Man dies (this would need a death system)
} else if matches!(*ghost_state, GhostState::Normal) {
// Pac-Man dies
*game_state = GameStage::PlayerDying(DyingSequence::Frozen { remaining_ticks: 60 });
commands.entity(pacman_entity).insert(Frozen);
commands.entity(ghost_entity).insert(Frozen);
events.write(AudioEvent::StopAll);
}
}
}

View File

@@ -101,7 +101,7 @@ pub struct Renderable {
}
/// Directional animation component with shared timing across all directions
#[derive(Component, Clone, Copy)]
#[derive(Component, Clone)]
pub struct DirectionalAnimation {
pub moving_tiles: DirectionalTiles,
pub stopped_tiles: DirectionalTiles,
@@ -123,13 +123,18 @@ impl DirectionalAnimation {
}
}
/// Tag component to mark animations that should loop when they reach the end
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
pub struct Looping;
/// Linear animation component for non-directional animations (frightened ghosts)
#[derive(Component, Clone, Copy)]
#[derive(Component, Resource, Clone)]
pub struct LinearAnimation {
pub tiles: TileSequence,
pub current_frame: usize,
pub time_bank: u16,
pub frame_duration: u16,
pub finished: bool,
}
impl LinearAnimation {
@@ -140,6 +145,7 @@ impl LinearAnimation {
current_frame: 0,
time_bank: 0,
frame_duration,
finished: false,
}
}
}
@@ -218,6 +224,11 @@ pub struct Frozen;
#[derive(Component, Debug, Clone, Copy)]
pub struct Eaten;
/// Tag component for Pac-Man during his death animation.
/// This is mainly because the Frozen tag would stop both movement and animation, while the Dying tag can signal that the animation should continue despite being frozen.
#[derive(Component, Debug, Clone, Copy)]
pub struct Dying;
#[derive(Component, Debug, Clone, Copy)]
pub enum GhostState {
/// Normal ghost behavior - chasing Pac-Man

View File

@@ -1,5 +1,7 @@
use crate::platform;
use crate::systems::components::{DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation};
use crate::systems::components::{
DirectionalAnimation, Frozen, GhostAnimation, GhostState, LastAnimationState, LinearAnimation, Looping,
};
use crate::{
map::{
builder::Map,
@@ -194,22 +196,26 @@ pub fn ghost_state_system(
if last_animation_state.0 != current_animation_state {
match current_animation_state {
GhostAnimation::Frightened { flash } => {
// Remove DirectionalAnimation, add LinearAnimation
// Remove DirectionalAnimation, add LinearAnimation with Looping component
commands
.entity(entity)
.remove::<DirectionalAnimation>()
.insert(*animations.frightened(flash));
.insert(animations.frightened(flash).clone())
.insert(Looping);
}
GhostAnimation::Normal => {
// Remove LinearAnimation, add DirectionalAnimation
// Remove LinearAnimation and Looping, add DirectionalAnimation
commands
.entity(entity)
.remove::<LinearAnimation>()
.insert(*animations.get_normal(ghost_type).unwrap());
.remove::<(LinearAnimation, Looping)>()
.insert(animations.get_normal(ghost_type).unwrap().clone());
}
GhostAnimation::Eyes => {
// Remove LinearAnimation, add DirectionalAnimation (eyes animation)
commands.entity(entity).remove::<LinearAnimation>().insert(*animations.eyes());
// Remove LinearAnimation and Looping, add DirectionalAnimation (eyes animation)
commands
.entity(entity)
.remove::<(LinearAnimation, Looping)>()
.insert(animations.eyes().clone());
}
}
last_animation_state.0 = current_animation_state;

View File

@@ -1,25 +1,25 @@
//! The Entity-Component-System (ECS) module.
//!
//! This module contains all the ECS-related logic, including components, systems,
//! and resources.
//! This module contains all the systems in the game.
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod audio;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod debug;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod profiling;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod render;
pub mod blinking;
pub mod collision;
pub mod components;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod debug;
pub mod ghost;
pub mod input;
pub mod item;
pub mod movement;
pub mod player;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod profiling;
#[cfg_attr(coverage_nightly, coverage(off))]
pub mod render;
pub mod stage;
pub mod state;
// Re-export all the modules. Do not fine-tune the exports.
pub use self::audio::*;
pub use self::blinking::*;
@@ -33,4 +33,4 @@ pub use self::movement::*;
pub use self::player::*;
pub use self::profiling::*;
pub use self::render::*;
pub use self::stage::*;
pub use self::state::*;

View File

@@ -1,18 +1,20 @@
use crate::constants::CANVAS_SIZE;
use crate::error::{GameError, TextureError};
use crate::map::builder::Map;
use crate::systems::input::TouchState;
use crate::systems::{
debug_render_system, BatchedLinesResource, Collider, CursorPosition, DebugState, DebugTextureResource, DeltaTime,
DirectionalAnimation, LinearAnimation, Position, Renderable, ScoreResource, StartupSequence, SystemId, SystemTimings,
TtfAtlasResource, Velocity,
DirectionalAnimation, Dying, Frozen, GameStage, LinearAnimation, Looping, Position, Renderable, ScoreResource,
StartupSequence, SystemId, SystemTimings, TtfAtlasResource, Velocity,
};
use crate::texture::sprite::SpriteAtlas;
use crate::texture::text::TextTexture;
use crate::{
constants::CANVAS_SIZE,
error::{GameError, TextureError},
};
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::query::{Changed, Or, Without};
use bevy_ecs::query::{Changed, Has, Or, With, Without};
use bevy_ecs::removal_detection::RemovedComponents;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
@@ -53,7 +55,7 @@ pub fn dirty_render_system(
/// All directions share the same frame timing to ensure perfect synchronization.
pub fn directional_render_system(
dt: Res<DeltaTime>,
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable)>,
mut query: Query<(&Position, &Velocity, &mut DirectionalAnimation, &mut Renderable), Without<Frozen>>,
) {
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
@@ -86,26 +88,35 @@ pub fn directional_render_system(
}
}
/// Updates linear animated entities (used for non-directional animations like frightened ghosts).
///
/// This system handles entities that use LinearAnimation component for simple frame cycling.
pub fn linear_render_system(dt: Res<DeltaTime>, mut query: Query<(&mut LinearAnimation, &mut Renderable)>) {
let ticks = (dt.seconds * 60.0).round() as u16; // Convert from seconds to ticks at 60 ticks/sec
for (mut anim, mut renderable) in query.iter_mut() {
// Tick animation
anim.time_bank += ticks;
while anim.time_bank >= anim.frame_duration {
anim.time_bank -= anim.frame_duration;
anim.current_frame += 1;
/// System that updates `Renderable` sprites for entities with `LinearAnimation`.
#[allow(clippy::type_complexity)]
pub fn linear_render_system(
dt: Res<DeltaTime>,
mut query: Query<(&mut LinearAnimation, &mut Renderable, Has<Looping>), Or<(Without<Frozen>, With<Dying>)>>,
) {
for (mut anim, mut renderable, looping) in query.iter_mut() {
if anim.finished {
continue;
}
if !anim.tiles.is_empty() {
let new_tile = anim.tiles.get_tile(anim.current_frame);
if renderable.sprite != new_tile {
renderable.sprite = new_tile;
anim.time_bank += dt.ticks as u16;
let frames_to_advance = (anim.time_bank / anim.frame_duration) as usize;
if frames_to_advance == 0 {
continue;
}
let total_frames = anim.tiles.len();
if !looping && anim.current_frame + frames_to_advance >= total_frames {
anim.finished = true;
anim.current_frame = total_frames - 1;
} else {
anim.current_frame += frames_to_advance;
}
anim.time_bank %= anim.frame_duration;
renderable.sprite = anim.tiles.get_tile(anim.current_frame);
}
}
@@ -194,7 +205,7 @@ pub fn hud_render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
mut atlas: NonSendMut<SpriteAtlas>,
score: Res<ScoreResource>,
startup: Res<StartupSequence>,
stage: Res<GameStage>,
mut errors: EventWriter<GameError>,
) {
let _ = canvas.with_texture_canvas(&mut backbuffer.0, |canvas| {
@@ -226,10 +237,21 @@ pub fn hud_render_system(
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
}
// Render GAME OVER text
if matches!(*stage, GameStage::GameOver) {
let game_over_text = "GAME OVER";
let game_over_width = text_renderer.text_width(game_over_text);
let game_over_position = glam::UVec2::new((CANVAS_SIZE.x - game_over_width) / 2, 160);
if let Err(e) = text_renderer.render_with_color(canvas, &mut atlas, game_over_text, game_over_position, Color::RED) {
errors.write(TextureError::RenderFailed(format!("Failed to render GAME OVER text: {}", e)).into());
}
}
// Render text based on StartupSequence stage
if matches!(
*startup,
StartupSequence::TextOnly { .. } | StartupSequence::CharactersVisible { .. }
*stage,
GameStage::Starting(StartupSequence::TextOnly { .. })
| GameStage::Starting(StartupSequence::CharactersVisible { .. })
) {
let ready_text = "READY!";
let ready_width = text_renderer.text_width(ready_text);
@@ -238,7 +260,7 @@ pub fn hud_render_system(
errors.write(TextureError::RenderFailed(format!("Failed to render READY text: {}", e)).into());
}
if matches!(*startup, StartupSequence::TextOnly { .. }) {
if matches!(*stage, GameStage::Starting(StartupSequence::TextOnly { .. })) {
let player_one_text = "PLAYER ONE";
let player_one_width = text_renderer.text_width(player_one_text);
let player_one_position = glam::UVec2::new((CANVAS_SIZE.x - player_one_width) / 2, 113);

View File

@@ -1,101 +0,0 @@
use bevy_ecs::{
entity::Entity,
query::With,
resource::Resource,
system::{Commands, Query, ResMut},
};
use tracing::debug;
use crate::systems::{Blinking, Frozen, GhostCollider, Hidden, PlayerControlled};
#[derive(Resource, Debug, Clone, Copy)]
pub enum StartupSequence {
/// Stage 1: Text-only stage
/// - Player & ghosts are hidden
/// - READY! and PLAYER ONE text are shown
/// - Energizers do not blink
TextOnly {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
/// Stage 2: Characters visible stage
/// - PLAYER ONE text is hidden, READY! text remains
/// - Ghosts and Pac-Man are now shown
CharactersVisible {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
/// Stage 3: Game begins
/// - Final state, game is fully active
GameActive,
}
impl StartupSequence {
/// Creates a new StartupSequence with the specified duration in ticks
pub fn new(text_only_ticks: u32, _characters_visible_ticks: u32) -> Self {
Self::TextOnly {
remaining_ticks: text_only_ticks,
}
}
/// Ticks the timer by one frame, returning transition information if state changes
pub fn tick(&mut self) -> Option<(StartupSequence, StartupSequence)> {
match self {
StartupSequence::TextOnly { remaining_ticks } => {
if *remaining_ticks > 0 {
*remaining_ticks -= 1;
None
} else {
let from = *self;
*self = StartupSequence::CharactersVisible {
remaining_ticks: 60, // 1 second at 60 FPS
};
Some((from, *self))
}
}
StartupSequence::CharactersVisible { remaining_ticks } => {
if *remaining_ticks > 0 {
*remaining_ticks -= 1;
None
} else {
let from = *self;
*self = StartupSequence::GameActive;
Some((from, *self))
}
}
StartupSequence::GameActive => None,
}
}
}
/// Handles startup sequence transitions and component management
pub fn startup_stage_system(
mut startup: ResMut<StartupSequence>,
mut commands: Commands,
mut blinking_query: Query<Entity, With<Blinking>>,
mut player_query: Query<Entity, With<PlayerControlled>>,
mut ghost_query: Query<Entity, With<GhostCollider>>,
) {
if let Some((from, to)) = startup.tick() {
debug!("StartupSequence transition from {from:?} to {to:?}");
match (from, to) {
(StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
// Unhide the player & ghosts
for entity in player_query.iter_mut().chain(ghost_query.iter_mut()) {
commands.entity(entity).remove::<Hidden>();
}
}
(StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
// Unfreeze the player & ghosts & pellet blinking
for entity in player_query
.iter_mut()
.chain(ghost_query.iter_mut())
.chain(blinking_query.iter_mut())
{
commands.entity(entity).remove::<Frozen>();
}
}
_ => {}
}
}
}

315
src/systems/state.rs Normal file
View File

@@ -0,0 +1,315 @@
use std::mem::discriminant;
use crate::{
map::builder::Map,
systems::{
AudioEvent, Blinking, DirectionalAnimation, Dying, Eaten, Frozen, Ghost, GhostCollider, GhostState, Hidden,
LinearAnimation, Looping, PlayerControlled, Position,
},
};
use bevy_ecs::{
entity::Entity,
event::EventWriter,
query::{With, Without},
resource::Resource,
system::{Commands, Query, Res, ResMut},
};
#[derive(Resource, Clone)]
pub struct PlayerAnimation(pub DirectionalAnimation);
#[derive(Resource, Clone)]
pub struct PlayerDeathAnimation(pub LinearAnimation);
/// A resource to track the overall stage of the game from a high-level perspective.
#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
pub enum GameStage {
Starting(StartupSequence),
/// The main gameplay loop is active.
Playing,
/// The player has died and the death sequence is in progress.
PlayerDying(DyingSequence),
/// The level is restarting after a death.
LevelRestarting,
/// The game has ended.
GameOver,
}
/// A resource that manages the multi-stage startup sequence of the game.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StartupSequence {
/// Stage 1: Text-only stage
/// - Player & ghosts are hidden
/// - READY! and PLAYER ONE text are shown
/// - Energizers do not blink
TextOnly {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
/// Stage 2: Characters visible stage
/// - PLAYER ONE text is hidden, READY! text remains
/// - Ghosts and Pac-Man are now shown
CharactersVisible {
/// Remaining ticks in this stage
remaining_ticks: u32,
},
}
impl Default for GameStage {
fn default() -> Self {
Self::Playing
}
}
/// The state machine for the multi-stage death sequence.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum DyingSequence {
/// Initial stage: entities are frozen, waiting for a delay.
Frozen { remaining_ticks: u32 },
/// Second stage: Pac-Man's death animation is playing.
Animating { remaining_ticks: u32 },
/// Third stage: Pac-Man is now gone, waiting a moment before the level restarts.
Hidden { remaining_ticks: u32 },
}
/// A resource to store the number of player lives.
#[derive(Resource, Debug)]
pub struct PlayerLives(pub u8);
impl Default for PlayerLives {
fn default() -> Self {
Self(1)
}
}
/// Handles startup sequence transitions and component management
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn stage_system(
mut game_state: ResMut<GameStage>,
player_death_animation: Res<PlayerDeathAnimation>,
player_animation: Res<PlayerAnimation>,
mut player_lives: ResMut<PlayerLives>,
map: Res<Map>,
mut commands: Commands,
mut audio_events: EventWriter<AudioEvent>,
mut blinking_query: Query<Entity, With<Blinking>>,
mut player_query: Query<(Entity, &mut Position), With<PlayerControlled>>,
mut ghost_query: Query<(Entity, &Ghost, &mut Position), (With<GhostCollider>, Without<PlayerControlled>)>,
) {
let old_state = *game_state;
let new_state: GameStage = match &mut *game_state {
GameStage::Starting(startup) => match startup {
StartupSequence::TextOnly { remaining_ticks } => {
if *remaining_ticks > 0 {
GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: *remaining_ticks - 1,
})
} else {
GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 })
}
}
StartupSequence::CharactersVisible { remaining_ticks } => {
if *remaining_ticks > 0 {
GameStage::Starting(StartupSequence::CharactersVisible {
remaining_ticks: *remaining_ticks - 1,
})
} else {
GameStage::Playing
}
}
},
GameStage::Playing => GameStage::Playing,
GameStage::PlayerDying(dying) => match dying {
DyingSequence::Frozen { remaining_ticks } => {
if *remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Frozen {
remaining_ticks: *remaining_ticks - 1,
})
} else {
let death_animation = &player_death_animation.0;
let remaining_ticks = (death_animation.tiles.len() * death_animation.frame_duration as usize) as u32;
GameStage::PlayerDying(DyingSequence::Animating { remaining_ticks })
}
}
DyingSequence::Animating { remaining_ticks } => {
if *remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Animating {
remaining_ticks: *remaining_ticks - 1,
})
} else {
GameStage::PlayerDying(DyingSequence::Hidden { remaining_ticks: 60 })
}
}
DyingSequence::Hidden { remaining_ticks } => {
if *remaining_ticks > 0 {
GameStage::PlayerDying(DyingSequence::Hidden {
remaining_ticks: *remaining_ticks - 1,
})
} else {
player_lives.0 = player_lives.0.saturating_sub(1);
if player_lives.0 > 0 {
GameStage::LevelRestarting
} else {
GameStage::GameOver
}
}
}
},
GameStage::LevelRestarting => GameStage::Starting(StartupSequence::CharactersVisible { remaining_ticks: 60 }),
GameStage::GameOver => GameStage::GameOver,
};
if old_state == new_state {
return;
}
match (old_state, new_state) {
(GameStage::Playing, GameStage::PlayerDying(DyingSequence::Frozen { .. })) => {
// Freeze the player & ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).insert(Frozen);
}
}
(GameStage::PlayerDying(DyingSequence::Frozen { .. }), GameStage::PlayerDying(DyingSequence::Animating { .. })) => {
// Hide the ghosts
for (entity, _, _) in ghost_query.iter_mut() {
commands.entity(entity).insert(Hidden);
}
// Start Pac-Man's death animation
if let Ok((player_entity, _)) = player_query.single_mut() {
commands
.entity(player_entity)
.insert((Dying, player_death_animation.0.clone()));
}
// Play the death sound
audio_events.write(AudioEvent::PlayDeath);
}
(GameStage::PlayerDying(DyingSequence::Animating { .. }), GameStage::PlayerDying(DyingSequence::Hidden { .. })) => {
// Hide the player
if let Ok((player_entity, _)) = player_query.single_mut() {
commands.entity(player_entity).insert(Hidden);
}
}
(_, GameStage::LevelRestarting) => {
if let Ok((player_entity, mut pos)) = player_query.single_mut() {
*pos = Position::Stopped {
node: map.start_positions.pacman,
};
// Freeze the blinking, force them to be visible (if they were hidden by blinking)
for entity in blinking_query.iter_mut() {
commands.entity(entity).insert(Frozen).remove::<Hidden>();
}
// Reset the player animation
commands
.entity(player_entity)
.remove::<(Frozen, Dying, Hidden, LinearAnimation, Looping)>()
.insert(player_animation.0.clone());
}
// Reset ghost positions and state
for (ghost_entity, ghost, mut ghost_pos) in ghost_query.iter_mut() {
*ghost_pos = Position::Stopped {
node: match ghost {
Ghost::Blinky => map.start_positions.blinky,
Ghost::Pinky => map.start_positions.pinky,
Ghost::Inky => map.start_positions.inky,
Ghost::Clyde => map.start_positions.clyde,
},
};
commands
.entity(ghost_entity)
.remove::<(Frozen, Hidden, Eaten)>()
.insert(GhostState::Normal);
}
}
(
GameStage::Starting(StartupSequence::TextOnly { .. }),
GameStage::Starting(StartupSequence::CharactersVisible { .. }),
) => {
// Unhide the player & ghosts
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
{
commands.entity(entity).remove::<Hidden>();
}
}
(GameStage::Starting(StartupSequence::CharactersVisible { .. }), GameStage::Playing) => {
// Unfreeze the player & ghosts & blinking
for entity in player_query
.iter_mut()
.map(|(e, _)| e)
.chain(ghost_query.iter_mut().map(|(e, _, _)| e))
.chain(blinking_query.iter_mut())
{
commands.entity(entity).remove::<Frozen>();
}
}
(GameStage::PlayerDying(..), GameStage::GameOver) => {
// Freeze blinking
for entity in blinking_query.iter_mut() {
commands.entity(entity).insert(Frozen);
}
}
_ => {
let different = discriminant(&old_state) != discriminant(&new_state);
if different {
tracing::warn!(
new_state = ?new_state,
old_state = ?old_state,
"Unhandled game stage transition");
}
}
}
*game_state = new_state;
}
// if let GameState::LevelRestarting = &*game_state {
// // When restarting, jump straight to the CharactersVisible stage
// // and unhide the entities.
// *startup = StartupSequence::new(0, 60 * 2); // 2 seconds for READY! text
// if let StartupSequence::TextOnly { .. } = *startup {
// // This will immediately transition to CharactersVisible on the next line
// } else {
// // Should be unreachable as we just set it
// }
// // Freeze Pac-Man and ghosts
// for entity in player_query.iter().chain(ghost_query.iter()) {
// commands.entity(entity).insert(Frozen);
// }
// *game_state = GameState::Playing;
// }
// if let Some((old_state, new_state)) = startup.tick() {
// debug!("StartupSequence transition from {old_state:?} to {new_state:?}");
// match (old_state, new_state) {
// (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
// // Unhide the player & ghosts
// for entity in player_query.iter().chain(ghost_query.iter()) {
// commands.entity(entity).remove::<Hidden>();
// }
// }
// (StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
// // Unfreeze Pac-Man, ghosts and energizers
// for entity in player_query.iter().chain(ghost_query.iter()).chain(blinking_query.iter()) {
// commands.entity(entity).remove::<Frozen>();
// }
// *game_state = GameState::Playing;
// }
// _ => {}
// }
// }

View File

@@ -1,53 +1,45 @@
use crate::map::direction::Direction;
use crate::texture::sprite::AtlasTile;
use glam::U16Vec2;
/// Fixed-size tile sequence that avoids heap allocation
#[derive(Clone, Copy, Debug)]
use crate::{map::direction::Direction, texture::sprite::AtlasTile};
/// A sequence of tiles for animation, backed by a vector.
#[derive(Debug, Clone)]
pub struct TileSequence {
tiles: [AtlasTile; 4], // Fixed array, max 4 frames
count: usize, // Actual number of frames used
tiles: Vec<AtlasTile>,
}
impl TileSequence {
/// Creates a new tile sequence from a slice of tiles
/// Creates a new tile sequence from a slice of tiles.
pub fn new(tiles: &[AtlasTile]) -> Self {
let mut tile_array = [AtlasTile {
pos: glam::U16Vec2::ZERO,
size: glam::U16Vec2::ZERO,
color: None,
}; 4];
let count = tiles.len().min(4);
tile_array[..count].copy_from_slice(&tiles[..count]);
Self {
tiles: tile_array,
count,
}
Self { tiles: tiles.to_vec() }
}
/// Returns the tile at the given frame index, wrapping if necessary
pub fn get_tile(&self, frame: usize) -> AtlasTile {
if self.count == 0 {
// Return a default empty tile if no tiles
AtlasTile {
pos: glam::U16Vec2::ZERO,
size: glam::U16Vec2::ZERO,
if self.tiles.is_empty() {
// Return a default or handle the error appropriately
// For now, let's return a default tile, assuming it's a sensible default
return AtlasTile {
pos: U16Vec2::ZERO,
size: U16Vec2::ZERO,
color: None,
};
}
} else {
self.tiles[frame % self.count]
}
self.tiles[frame % self.tiles.len()]
}
/// Returns true if this sequence has no tiles
pub fn len(&self) -> usize {
self.tiles.len()
}
/// Checks if the sequence contains any tiles.
pub fn is_empty(&self) -> bool {
self.count == 0
self.tiles.is_empty()
}
}
/// Type-safe directional tile storage with named fields
#[derive(Clone, Copy, Debug)]
/// A collection of tile sequences for each cardinal direction.
#[derive(Debug, Clone)]
pub struct DirectionalTiles {
pub up: TileSequence,
pub down: TileSequence,