//! This module contains the main game logic and state. include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); use crate::constants::{animation, MapTile, CANVAS_SIZE}; use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; use crate::map::direction::Direction; use crate::systems::blinking::Blinking; use crate::systems::movement::{BufferedDirection, Position, Velocity}; use crate::systems::profiling::SystemId; use crate::systems::render::RenderDirty; use crate::systems::{self, ghost_collision_system, present_system, Hidden, MovementModifiers}; use crate::systems::{ audio_system, blinking_system, collision_system, debug_render_system, directional_render_system, dirty_render_system, eaten_ghost_system, ghost_movement_system, ghost_state_animation_system, hud_render_system, item_system, profile, render_system, AudioEvent, AudioResource, AudioState, BackbufferResource, Collider, DebugFontResource, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimated, EntityType, Frozen, Ghost, GhostAnimationSet, GhostAnimations, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider, MapTextureResource, PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence, SystemTimings, }; use crate::texture::animated::AnimatedTexture; use crate::texture::sprite::AtlasTile; 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::ResMut; use bevy_ecs::world::World; use sdl2::image::LoadTexture; use sdl2::render::{BlendMode, Canvas, ScaleMode, TextureCreator}; use sdl2::rwops::RWops; use sdl2::video::{Window, WindowContext}; use sdl2::EventPump; use smallvec::smallvec; use crate::{ asset::{get_asset_bytes, Asset}, constants, events::GameCommand, map::render::MapRenderer, systems::input::{Bindings, CursorPosition}, texture::sprite::{AtlasMapper, SpriteAtlas}, }; /// System set for all rendering systems to ensure they run after gameplay logic #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] pub struct RenderSet; /// Core game state manager built on the Bevy ECS architecture. /// /// Orchestrates all game systems through a centralized `World` containing entities, /// components, and resources, while a `Schedule` defines system execution order. /// Handles initialization of graphics resources, entity spawning, and per-frame /// game logic coordination. SDL2 resources are stored as `NonSend` to respect /// thread safety requirements while integrating with the ECS. pub struct Game { pub world: World, pub schedule: Schedule, } impl Game { /// Initializes the complete game state including ECS world, graphics, and entity spawning. /// /// Performs extensive setup: creates render targets and debug textures, loads and parses /// the sprite atlas, renders the static map to a cached texture, builds the navigation /// graph from the board layout, spawns Pac-Man with directional animations, creates /// all four ghosts with their AI behavior, and places collectible items throughout /// the maze. Registers event types and configures the system execution schedule. /// /// # Arguments /// /// * `canvas` - SDL2 rendering context with static lifetime for ECS storage /// * `texture_creator` - SDL2 texture factory for creating render targets /// * `event_pump` - SDL2 event polling interface for input handling /// /// # Errors /// /// Returns `GameError` for SDL2 failures, asset loading problems, atlas parsing /// errors, or entity initialization issues. pub fn new( canvas: &'static mut Canvas, texture_creator: &'static mut TextureCreator, event_pump: &'static mut EventPump, ) -> GameResult { let ttf_context = Box::leak(Box::new(sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?)); let mut backbuffer = texture_creator .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) .map_err(|e| GameError::Sdl(e.to_string()))?; backbuffer.set_scale_mode(ScaleMode::Nearest); let mut map_texture = texture_creator .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) .map_err(|e| GameError::Sdl(e.to_string()))?; map_texture.set_scale_mode(ScaleMode::Nearest); // Create debug texture at output resolution for crisp debug rendering let output_size = canvas.output_size().unwrap(); let mut debug_texture = texture_creator .create_texture_target(None, output_size.0, output_size.1) .map_err(|e| GameError::Sdl(e.to_string()))?; // Debug texture is copied over the backbuffer, it requires transparency abilities debug_texture.set_blend_mode(BlendMode::Blend); debug_texture.set_scale_mode(ScaleMode::Nearest); let font_data = get_asset_bytes(Asset::Font)?; let static_font_data: &'static [u8] = Box::leak(font_data.to_vec().into_boxed_slice()); let font_asset = RWops::from_bytes(static_font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?; let debug_font = ttf_context .load_font_from_rwops(font_asset, 12) .map_err(|e| GameError::Sdl(e.to_string()))?; // Initialize audio system let audio = crate::audio::Audio::new(); // Load atlas and create map texture let atlas_bytes = get_asset_bytes(Asset::AtlasImage)?; let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { if e.to_string().contains("format") || e.to_string().contains("unsupported") { GameError::Texture(crate::error::TextureError::InvalidFormat(format!( "Unsupported texture format: {e}" ))) } else { GameError::Texture(crate::error::TextureError::LoadFailed(e.to_string())) } })?; let atlas_mapper = AtlasMapper { frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(), }; let mut atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); // Create map tiles let mut map_tiles = Vec::with_capacity(35); for i in 0..35 { let tile_name = format!("maze/tiles/{}.png", i); let tile = atlas.get_tile(&tile_name).unwrap(); map_tiles.push(tile); } // Render map to texture canvas .with_texture_canvas(&mut map_texture, |map_canvas| { MapRenderer::render_map(map_canvas, &mut atlas, &map_tiles); }) .map_err(|e| GameError::Sdl(e.to_string()))?; let map = Map::new(constants::RAW_BOARD)?; // Create directional animated textures for Pac-Man let mut textures = [None, None, None, None]; let mut stopped_textures = [None, None, None, None]; for direction in Direction::DIRECTIONS { let moving_prefix = match direction { Direction::Up => "pacman/up", Direction::Down => "pacman/down", Direction::Left => "pacman/left", Direction::Right => "pacman/right", }; let moving_tiles = smallvec![ SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_a.png")) .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?, SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_b.png")) .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?, SpriteAtlas::get_tile(&atlas, "pacman/full.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, ]; let stopped_tiles = smallvec![SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_b.png")) .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?]; textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?); stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); } let player = PlayerBundle { player: PlayerControlled, position: Position::Stopped { node: map.start_positions.pacman, }, velocity: Velocity { speed: 1.15, direction: Direction::Left, }, movement_modifiers: MovementModifiers::default(), buffered_direction: BufferedDirection::None, sprite: Renderable { sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, layer: 0, }, directional_animated: DirectionalAnimated { textures, stopped_textures, }, entity_type: EntityType::Player, collider: Collider { size: constants::CELL_SIZE as f32 * 1.375, }, pacman_collider: PacmanCollider, }; let mut world = World::default(); let mut schedule = Schedule::default(); EventRegistry::register_event::(&mut world); EventRegistry::register_event::(&mut world); EventRegistry::register_event::(&mut world); world.insert_resource(Self::create_ghost_animations(&atlas)?); world.insert_resource(map); world.insert_resource(GlobalState { exit: false }); world.insert_resource(ScoreResource(0)); world.insert_resource(SystemTimings::default()); world.insert_resource(Bindings::default()); world.insert_resource(DeltaTime(0f32)); world.insert_resource(RenderDirty::default()); world.insert_resource(DebugState::default()); world.insert_resource(AudioState::default()); world.insert_resource(CursorPosition::default()); world.insert_resource(StartupSequence::new(60 * 3, 60)); world.insert_non_send_resource(atlas); world.insert_non_send_resource(event_pump); world.insert_non_send_resource(canvas); world.insert_non_send_resource(BackbufferResource(backbuffer)); world.insert_non_send_resource(MapTextureResource(map_texture)); world.insert_non_send_resource(DebugTextureResource(debug_texture)); world.insert_non_send_resource(DebugFontResource(debug_font)); world.insert_non_send_resource(AudioResource(audio)); world.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| { if matches!(*event, GameEvent::Command(GameCommand::Exit)) { state.exit = true; } }, ); 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 vulnerable_tick_system = profile(SystemId::Ghost, systems::vulnerable_tick_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); let directional_render_system = profile(SystemId::DirectionalRender, directional_render_system); let dirty_render_system = profile(SystemId::DirtyRender, dirty_render_system); let render_system = profile(SystemId::Render, render_system); let hud_render_system = profile(SystemId::HudRender, hud_render_system); let debug_render_system = profile(SystemId::DebugRender, debug_render_system); let present_system = profile(SystemId::Present, present_system); let ghost_state_animation_system = profile(SystemId::GhostStateAnimation, ghost_state_animation_system); let forced_dirty_system = |mut dirty: ResMut| { dirty.0 = true; }; schedule.add_systems(( forced_dirty_system.run_if(resource_changed::.or(resource_changed::)), ( input_system, player_control_system, player_movement_system, startup_stage_system, ) .chain(), player_tunnel_slowdown_system, ghost_movement_system, profile(SystemId::EatenGhost, eaten_ghost_system), vulnerable_tick_system, ghost_state_animation_system, (collision_system, ghost_collision_system, item_system).chain(), audio_system, blinking_system, ( directional_render_system, dirty_render_system, render_system, hud_render_system, debug_render_system, present_system, ) .chain(), )); // Spawn player and attach initial state bundle world.spawn(player).insert((Frozen, Hidden)); // Spawn ghosts Self::spawn_ghosts(&mut world)?; let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::(), "maze/pellet.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?; let energizer_sprite = SpriteAtlas::get_tile(world.non_send_resource::(), "maze/energizer.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/energizer.png".to_string())))?; // Build a list of item entities to spawn from the map let nodes: Vec<(usize, EntityType, AtlasTile, f32)> = world .resource::() .iter_nodes() .filter_map(|(id, tile)| match tile { MapTile::Pellet => Some((*id, EntityType::Pellet, pellet_sprite, constants::CELL_SIZE as f32 * 0.4)), MapTile::PowerPellet => Some(( *id, EntityType::PowerPellet, energizer_sprite, constants::CELL_SIZE as f32 * 0.95, )), _ => None, }) .collect(); // Construct and spawn the item entities for (id, item_type, sprite, size) in nodes { let mut item = world.spawn(ItemBundle { position: Position::Stopped { node: id }, sprite: Renderable { sprite, layer: 1 }, entity_type: item_type, collider: Collider { size }, item_collider: ItemCollider, }); // Make power pellets blink if item_type == EntityType::PowerPellet { item.insert((Frozen, Blinking::new(0.2))); } } Ok(Game { world, schedule }) } /// Creates and spawns all four ghosts with unique AI personalities and directional animations. /// /// # Errors /// /// Returns `GameError::Texture` if any ghost sprite cannot be found in the atlas, /// typically indicating missing or misnamed sprite files. fn spawn_ghosts(world: &mut World) -> GameResult<()> { // Extract the data we need first to avoid borrow conflicts let ghost_start_positions = { let map = world.resource::(); [ (Ghost::Blinky, map.start_positions.blinky), (Ghost::Pinky, map.start_positions.pinky), (Ghost::Inky, map.start_positions.inky), (Ghost::Clyde, map.start_positions.clyde), ] }; 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::().0.get(&ghost_type).unwrap().clone(); let atlas = world.non_send_resource::(); GhostBundle { ghost: ghost_type, position: Position::Stopped { node: start_node }, velocity: Velocity { speed: ghost_type.base_speed(), direction: Direction::Left, }, sprite: Renderable { sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else( || { GameError::Texture(TextureError::AtlasTileNotFound(format!( "ghost/{}/left_a.png", ghost_type.as_str() ))) }, )?, layer: 0, }, directional_animated: animations.normal().unwrap().clone(), entity_type: EntityType::Ghost, collider: Collider { size: crate::constants::CELL_SIZE as f32 * 1.375, }, ghost_collider: GhostCollider, } }; world.spawn(ghost).insert((Frozen, Hidden)); } Ok(()) } fn create_ghost_animations(atlas: &SpriteAtlas) -> GameResult { let mut animations = std::collections::HashMap::new(); for ghost_type in [Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] { // Normal animations let mut normal_textures = [None, None, None, None]; for direction in Direction::DIRECTIONS { let dir_str = direction.as_ref(); let tile_a = atlas .get_tile(&format!("ghost/{}/{}_a.png", ghost_type.as_str(), dir_str)) .ok_or_else(|| { GameError::Texture(TextureError::AtlasTileNotFound(format!( "ghost/{}/{}_a.png", ghost_type.as_str(), dir_str ))) })?; let tile_b = atlas .get_tile(&format!("ghost/{}/{}_b.png", ghost_type.as_str(), dir_str)) .ok_or_else(|| { GameError::Texture(TextureError::AtlasTileNotFound(format!( "ghost/{}/{}_b.png", ghost_type.as_str(), dir_str ))) })?; let tiles = smallvec![tile_a, tile_b]; normal_textures[direction.as_usize()] = Some(AnimatedTexture::new(tiles, animation::GHOST_NORMAL_SPEED)?); } let normal = DirectionalAnimated { textures: normal_textures.clone(), stopped_textures: normal_textures, }; // Eaten (eyes) animations let mut eaten_textures = [None, None, None, None]; for direction in Direction::DIRECTIONS { let dir_str = direction.as_ref(); let tile = atlas .get_tile(&format!("ghost/eyes/{}.png", dir_str)) .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("ghost/eyes/{}.png", dir_str))))?; eaten_textures[direction.as_usize()] = Some(AnimatedTexture::new(smallvec![tile], animation::GHOST_EATEN_SPEED)?); } let eaten = DirectionalAnimated { textures: eaten_textures.clone(), stopped_textures: eaten_textures, }; animations.insert( ghost_type, GhostAnimationSet::new( normal, DirectionalAnimated::default(), // Placeholder for frightened DirectionalAnimated::default(), // Placeholder for frightened_flashing eaten, ), ); } // Frightened animations (same for all ghosts) let frightened_blue_a = atlas .get_tile("ghost/frightened/blue_a.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/blue_a.png".to_string())))?; let frightened_blue_b = atlas .get_tile("ghost/frightened/blue_b.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/blue_b.png".to_string())))?; let frightened_white_a = atlas .get_tile("ghost/frightened/white_a.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/white_a.png".to_string())))?; let frightened_white_b = atlas .get_tile("ghost/frightened/white_b.png") .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("ghost/frightened/white_b.png".to_string())))?; let frightened_anim = AnimatedTexture::new( smallvec![frightened_blue_a, frightened_blue_b], animation::GHOST_FRIGHTENED_SPEED, )?; let flashing_anim = AnimatedTexture::new( smallvec![frightened_blue_a, frightened_white_a, frightened_blue_b, frightened_white_b], animation::GHOST_FLASHING_SPEED, )?; let frightened_da = DirectionalAnimated::from_animation(frightened_anim); let frightened_flashing_da = DirectionalAnimated::from_animation(flashing_anim); for ghost_type in [Ghost::Blinky, Ghost::Pinky, Ghost::Inky, Ghost::Clyde] { let entry = animations.get_mut(&ghost_type).unwrap(); entry.animations.insert( crate::systems::GhostAnimation::Frightened { flash: false }, frightened_da.clone(), ); entry.animations.insert( crate::systems::GhostAnimation::Frightened { flash: true }, frightened_flashing_da.clone(), ); } Ok(GhostAnimations(animations)) } /// Executes one frame of game logic by running all scheduled ECS systems. /// /// Updates the world's delta time resource and runs the complete system pipeline: /// input processing, entity movement, collision detection, item collection, /// audio playback, animation updates, and rendering. Each system operates on /// relevant entities and modifies world state, with the schedule ensuring /// proper execution order and data dependencies. /// /// # Arguments /// /// * `dt` - Frame delta time in seconds for time-based animations and movement /// /// # Returns /// /// `true` if the game should terminate (exit command received), `false` to continue pub fn tick(&mut self, dt: f32) -> bool { self.world.insert_resource(DeltaTime(dt)); // Run all systems self.schedule.run(&mut self.world); let state = self .world .get_resource::() .expect("GlobalState could not be acquired"); 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(&self, canvas: &mut Canvas) -> 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(()) // } }