//! This module contains the main game logic and state. include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); use crate::constants::CANVAS_SIZE; use crate::entity::direction::Direction; use crate::error::{GameError, GameResult, TextureError}; use crate::events::GameEvent; use crate::map::builder::Map; use crate::systems::blinking::Blinking; use crate::systems::movement::{BufferedDirection, Position, Velocity}; use crate::systems::player::player_movement_system; use crate::systems::profiling::SystemId; use crate::systems::{ audio::{audio_system, AudioEvent, AudioResource}, blinking::blinking_system, collision::collision_system, components::{ AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, Ghost, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource, }, debug::{debug_render_system, DebugState, DebugTextureResource}, ghost::ghost_movement_system, input::input_system, item::item_system, player::player_control_system, profiling::{profile, SystemTimings}, render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource}, }; use crate::texture::animated::AnimatedTexture; use bevy_ecs::schedule::IntoScheduleConfigs; use bevy_ecs::system::NonSendMut; use bevy_ecs::{ event::EventRegistry, observer::Trigger, schedule::Schedule, system::{Res, ResMut}, world::World, }; use sdl2::image::LoadTexture; use sdl2::render::{Canvas, ScaleMode, TextureCreator}; use sdl2::video::{Window, WindowContext}; use sdl2::EventPump; use crate::{ asset::{get_asset_bytes, Asset}, constants, events::GameCommand, map::render::MapRenderer, systems::{debug::CursorPosition, input::Bindings}, texture::sprite::{AtlasMapper, SpriteAtlas}, }; pub mod state; /// The `Game` struct is the main entry point for the game. /// /// It contains the game's state and logic, and is responsible for /// handling user input, updating the game state, and rendering the game. pub struct Game { pub world: World, pub schedule: Schedule, } impl Game { pub fn new( canvas: &'static mut Canvas, texture_creator: &'static mut TextureCreator, event_pump: &'static mut EventPump, ) -> GameResult { 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); 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.set_scale_mode(ScaleMode::Nearest); // Initialize audio system let audio = crate::audio::Audio::new(); // Load atlas and create map texture let atlas_bytes = get_asset_bytes(Asset::Atlas)?; 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)?; let pacman_start_node = map.start_positions.pacman; 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 = vec![ 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 = vec![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: pacman_start_node }, velocity: Velocity { speed: 1.15, direction: Direction::Left, }, 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, visible: true, }, directional_animated: DirectionalAnimated { textures, stopped_textures, }, entity_type: EntityType::Player, collider: Collider { size: constants::CELL_SIZE as f32 * 1.375, }, pacman_collider: PacmanCollider, }; 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(AudioResource(audio)); 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.add_observer( |event: Trigger, mut state: ResMut, _score: ResMut| { if matches!(*event, GameEvent::Command(GameCommand::Exit)) { state.exit = true; } }, ); schedule.add_systems( ( profile(SystemId::Input, input_system), profile(SystemId::PlayerControls, player_control_system), profile(SystemId::PlayerMovement, player_movement_system), profile(SystemId::Ghost, ghost_movement_system), profile(SystemId::Collision, collision_system), profile(SystemId::Item, item_system), profile(SystemId::Audio, audio_system), profile(SystemId::Blinking, blinking_system), profile(SystemId::DirectionalRender, directional_render_system), profile(SystemId::DirtyRender, dirty_render_system), profile(SystemId::Render, render_system), profile(SystemId::DebugRender, debug_render_system), profile( SystemId::Present, |mut canvas: NonSendMut<&mut Canvas>, backbuffer: NonSendMut, debug_state: Res, mut dirty: ResMut| { if dirty.0 || *debug_state != DebugState::Off { // Only copy backbuffer to main canvas if debug rendering is off // (debug rendering draws directly to main canvas) if *debug_state == DebugState::Off { canvas.copy(&backbuffer.0, None, None).unwrap(); } dirty.0 = false; canvas.present(); } }, ), ) .chain(), ); // Spawn player world.spawn(player); // Spawn ghosts Self::spawn_ghosts(&mut world)?; // Spawn items 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())))?; let nodes: Vec<_> = world.resource::().iter_nodes().map(|(id, tile)| (*id, *tile)).collect(); for (node_id, tile) in nodes { let (item_type, sprite, size) = match tile { crate::constants::MapTile::Pellet => (EntityType::Pellet, pellet_sprite, constants::CELL_SIZE as f32 * 0.4), crate::constants::MapTile::PowerPellet => { (EntityType::PowerPellet, energizer_sprite, constants::CELL_SIZE as f32 * 0.95) } _ => continue, }; let mut item = world.spawn(ItemBundle { position: Position::Stopped { node: node_id }, sprite: Renderable { sprite, layer: 1, visible: true, }, entity_type: item_type, collider: Collider { size }, item_collider: ItemCollider, }); if item_type == EntityType::PowerPellet { item.insert(Blinking { timer: 0.0, interval: 0.2, }); } } Ok(Game { world, schedule }) } /// Spawns all four ghosts at their starting positions with appropriate textures. 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 atlas = world.non_send_resource::(); // Create directional animated textures for the ghost 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 => "up", Direction::Down => "down", Direction::Left => "left", Direction::Right => "right", }; let moving_tiles = vec![ SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) .ok_or_else(|| { GameError::Texture(TextureError::AtlasTileNotFound(format!( "ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a" ))) })?, SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")) .ok_or_else(|| { GameError::Texture(TextureError::AtlasTileNotFound(format!( "ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b" ))) })?, ]; let stopped_tiles = vec![SpriteAtlas::get_tile( atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"), ) .ok_or_else(|| { GameError::Texture(TextureError::AtlasTileNotFound(format!( "ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a" ))) })?]; textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2)?); stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); } 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, visible: true, }, directional_animated: DirectionalAnimated { textures, stopped_textures, }, entity_type: EntityType::Ghost, collider: Collider { size: crate::constants::CELL_SIZE as f32 * 1.375, }, ghost_collider: GhostCollider, } }; world.spawn(ghost); } Ok(()) } /// Ticks the game state. /// /// Returns true if the game should exit. 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 } // fn check_collisions(&mut self) { // // Check Pac-Man vs Items // let potential_collisions = self // .state // .collision_system // .potential_collisions(&self.state.pacman.position()); // for entity_id in potential_collisions { // if entity_id != self.state.pacman_id { // // Check if this is an item collision // if let Some(item_index) = self.find_item_by_id(entity_id) { // let item = &mut self.state.items[item_index]; // if !item.is_collected() { // item.collect(); // self.state.score += item.get_score(); // self.state.audio.eat(); // // Handle energizer effects // if matches!(item.item_type, crate::entity::item::ItemType::Energizer) { // // TODO: Make ghosts frightened // tracing::info!("Energizer collected! Ghosts should become frightened."); // } // } // } // // Check if this is a ghost collision // if let Some(_ghost_index) = self.find_ghost_by_id(entity_id) { // // TODO: Handle Pac-Man being eaten by ghost // tracing::info!("Pac-Man collided with ghost!"); // } // } // } // } // fn find_item_by_id(&self, entity_id: EntityId) -> Option { // self.state.item_ids.iter().position(|&id| id == entity_id) // } // fn find_ghost_by_id(&self, entity_id: EntityId) -> Option { // self.state.ghost_ids.iter().position(|&id| id == entity_id) // } // pub fn draw(&mut self, canvas: &mut Canvas, backbuffer: &mut Texture) -> GameResult<()> { // // Only render the map texture once and cache it // if !self.state.map_rendered { // let mut map_texture = self // .state // .texture_creator // .create_texture_target(None, constants::CANVAS_SIZE.x, constants::CANVAS_SIZE.y) // .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; // canvas // .with_texture_canvas(&mut map_texture, |map_canvas| { // let mut map_tiles = Vec::with_capacity(35); // for i in 0..35 { // let tile_name = format!("maze/tiles/{}.png", i); // let tile = SpriteAtlas::get_tile(&self.state.atlas, &tile_name).unwrap(); // map_tiles.push(tile); // } // MapRenderer::render_map(map_canvas, &mut self.state.atlas, &mut map_tiles); // }) // .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?; // self.state.map_texture = Some(map_texture); // self.state.map_rendered = true; // } // canvas.set_draw_color(Color::BLACK); // canvas.clear(); // if let Some(ref map_texture) = self.state.map_texture { // canvas.copy(map_texture, None, None).unwrap(); // } // // Render all items // for item in &self.state.items { // if let Err(e) = item.render(canvas, &mut self.state.atlas, &self.state.map.graph) { // tracing::error!("Failed to render item: {}", e); // } // } // // Render all ghosts // for ghost in &self.state.ghosts { // if let Err(e) = ghost.render(canvas, &mut self.state.atlas, &self.state.map.graph) { // tracing::error!("Failed to render ghost: {}", e); // } // } // if let Err(e) = self.state.pacman.render(canvas, &mut self.state.atlas, &self.state.map.graph) { // tracing::error!("Failed to render pacman: {}", e); // } // if self.state.debug_mode { // if let Err(e) = // self.state // .map // .debug_render_with_cursor(canvas, &mut self.state.text_texture, &mut self.state.atlas, cursor_pos) // { // tracing::error!("Failed to render debug cursor: {}", e); // } // self.render_pathfinding_debug(canvas)?; // } // self.draw_hud(canvas)?; // canvas.present(); // Ok(()) // } // /// 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(()) // } // fn draw_hud(&mut self, canvas: &mut Canvas) -> GameResult<()> { // let lives = 3; // let score_text = format!("{:02}", self.state.score); // let x_offset = 4; // let y_offset = 2; // let lives_offset = 3; // let score_offset = 7 - (score_text.len() as i32); // self.state.text_texture.set_scale(1.0); // if let Err(e) = self.state.text_texture.render( // canvas, // &mut self.state.atlas, // &format!("{lives}UP HIGH SCORE "), // glam::UVec2::new(8 * lives_offset as u32 + x_offset, y_offset), // ) { // tracing::error!("Failed to render HUD text: {}", e); // } // if let Err(e) = self.state.text_texture.render( // canvas, // &mut self.state.atlas, // &score_text, // glam::UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset), // ) { // tracing::error!("Failed to render score text: {}", e); // } // // Display FPS information in top-left corner // // let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s); // // self.render_text_on( // // canvas, // // &*texture_creator, // // &fps_text, // // IVec2::new(10, 10), // // Color::RGB(255, 255, 0), // Yellow color for FPS display // // ); // Ok(()) // } }