mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 05:15:49 -06:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1206cf9ad1 | ||
|
|
bed913d016 | ||
|
|
98196f3e07 | ||
|
|
8f504d6c77 | ||
|
|
66499b6285 |
@@ -30,6 +30,21 @@ pub const BOARD_CELL_OFFSET: UVec2 = UVec2::new(0, 3);
|
||||
/// Automatically calculated from the cell offset to maintain consistency
|
||||
/// when the cell size changes. Used for positioning sprites and debug overlays.
|
||||
pub const BOARD_PIXEL_OFFSET: UVec2 = UVec2::new(BOARD_CELL_OFFSET.x * CELL_SIZE, BOARD_CELL_OFFSET.y * CELL_SIZE);
|
||||
|
||||
/// Animation timing constants for ghost state management
|
||||
pub mod animation {
|
||||
/// Normal ghost movement animation speed (frames per second)
|
||||
pub const GHOST_NORMAL_SPEED: f32 = 0.2;
|
||||
/// Eaten ghost (eyes) animation speed (frames per second)
|
||||
pub const GHOST_EATEN_SPEED: f32 = 0.1;
|
||||
/// Frightened ghost animation speed (frames per second)
|
||||
pub const GHOST_FRIGHTENED_SPEED: f32 = 0.2;
|
||||
/// Frightened ghost flashing animation speed (frames per second)
|
||||
pub const GHOST_FLASHING_SPEED: f32 = 0.15;
|
||||
|
||||
/// Time in ticks when frightened ghosts start flashing (2 seconds at 60 FPS)
|
||||
pub const FRIGHTENED_FLASH_START_TICKS: u32 = 120;
|
||||
}
|
||||
/// The size of the canvas, in pixels.
|
||||
pub const CANVAS_SIZE: UVec2 = UVec2::new(
|
||||
(BOARD_CELL_SIZE.x + BOARD_CELL_OFFSET.x) * CELL_SIZE,
|
||||
|
||||
197
src/game.rs
197
src/game.rs
@@ -2,23 +2,23 @@
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
|
||||
|
||||
use crate::constants::{MapTile, CANVAS_SIZE};
|
||||
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::{self, ghost_collision_system, present_system, Hidden, MovementModifiers};
|
||||
|
||||
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,
|
||||
ghost_movement_system, hud_render_system, item_system, profile, render_system, AudioEvent, AudioResource, AudioState,
|
||||
BackbufferResource, Collider, DebugFontResource, DebugState, DebugTextureResource, DeltaTime, DirectionalAnimated,
|
||||
EntityType, Frozen, Ghost, GhostBundle, GhostCollider, GlobalState, ItemBundle, ItemCollider, MapTextureResource,
|
||||
PacmanCollider, PlayerBundle, PlayerControlled, Renderable, ScoreResource, StartupSequence, SystemTimings,
|
||||
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;
|
||||
@@ -33,6 +33,7 @@ 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},
|
||||
@@ -158,7 +159,7 @@ impl Game {
|
||||
Direction::Left => "pacman/left",
|
||||
Direction::Right => "pacman/right",
|
||||
};
|
||||
let moving_tiles = vec![
|
||||
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"))
|
||||
@@ -167,7 +168,7 @@ impl Game {
|
||||
.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"))
|
||||
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)?);
|
||||
@@ -208,15 +209,7 @@ impl Game {
|
||||
EventRegistry::register_event::<GameEvent>(&mut world);
|
||||
EventRegistry::register_event::<AudioEvent>(&mut world);
|
||||
|
||||
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.insert_resource(Self::create_ghost_animations(&atlas)?);
|
||||
world.insert_resource(map);
|
||||
world.insert_resource(GlobalState { exit: false });
|
||||
world.insert_resource(ScoreResource(0));
|
||||
@@ -229,6 +222,15 @@ impl Game {
|
||||
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<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| {
|
||||
if matches!(*event, GameEvent::Command(GameCommand::Exit)) {
|
||||
@@ -255,6 +257,7 @@ impl Game {
|
||||
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<RenderDirty>| {
|
||||
dirty.0 = true;
|
||||
@@ -271,7 +274,9 @@ impl Game {
|
||||
.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,
|
||||
@@ -353,58 +358,9 @@ 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>().0.get(&ghost_type).unwrap().clone();
|
||||
let atlas = world.non_send_resource::<SpriteAtlas>();
|
||||
|
||||
// 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 },
|
||||
@@ -423,10 +379,7 @@ impl Game {
|
||||
)?,
|
||||
layer: 0,
|
||||
},
|
||||
directional_animated: DirectionalAnimated {
|
||||
textures,
|
||||
stopped_textures,
|
||||
},
|
||||
directional_animated: animations.normal().unwrap().clone(),
|
||||
entity_type: EntityType::Ghost,
|
||||
collider: Collider {
|
||||
size: crate::constants::CELL_SIZE as f32 * 1.375,
|
||||
@@ -441,6 +394,106 @@ impl Game {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_ghost_animations(atlas: &SpriteAtlas) -> GameResult<GhostAnimations> {
|
||||
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:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use glam::IVec2;
|
||||
use strum_macros::AsRefStr;
|
||||
|
||||
/// The four cardinal directions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, AsRefStr)]
|
||||
#[repr(usize)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum Direction {
|
||||
Up,
|
||||
Down,
|
||||
|
||||
@@ -24,44 +24,6 @@ impl CommonPlatform for Platform {
|
||||
}
|
||||
|
||||
fn init_console(&self) -> Result<(), PlatformError> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
unsafe {
|
||||
use winapi::{
|
||||
shared::ntdef::NULL,
|
||||
um::{
|
||||
fileapi::{CreateFileA, OPEN_EXISTING},
|
||||
handleapi::INVALID_HANDLE_VALUE,
|
||||
processenv::SetStdHandle,
|
||||
winbase::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE},
|
||||
wincon::{AttachConsole, GetConsoleWindow},
|
||||
winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE},
|
||||
},
|
||||
};
|
||||
|
||||
if !std::ptr::eq(GetConsoleWindow(), std::ptr::null_mut()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if AttachConsole(winapi::um::wincon::ATTACH_PARENT_PROCESS) != 0 {
|
||||
let handle = CreateFileA(
|
||||
c"CONOUT$".as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null_mut(),
|
||||
OPEN_EXISTING,
|
||||
0,
|
||||
NULL,
|
||||
);
|
||||
|
||||
if handle != INVALID_HANDLE_VALUE {
|
||||
SetStdHandle(STD_OUTPUT_HANDLE, handle);
|
||||
SetStdHandle(STD_ERROR_HANDLE, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::error::GameError;
|
||||
use crate::events::GameEvent;
|
||||
use crate::map::builder::Map;
|
||||
use crate::systems::movement::Position;
|
||||
use crate::systems::{AudioEvent, Ghost, PlayerControlled, ScoreResource, Vulnerable};
|
||||
use crate::systems::{AudioEvent, Eaten, Ghost, PlayerControlled, ScoreResource, Vulnerable};
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct Collider {
|
||||
@@ -136,7 +136,7 @@ pub fn ghost_collision_system(
|
||||
score.0 += 200;
|
||||
|
||||
// Remove the ghost
|
||||
commands.entity(ghost_ent).despawn();
|
||||
commands.entity(ghost_ent).remove::<Vulnerable>().insert(Eaten);
|
||||
|
||||
// Play eat sound
|
||||
events.write(AudioEvent::PlayEat);
|
||||
|
||||
@@ -9,12 +9,13 @@ use crate::{
|
||||
},
|
||||
texture::{animated::AnimatedTexture, sprite::AtlasTile},
|
||||
};
|
||||
use micromap::Map;
|
||||
|
||||
/// A tag component for entities that are controlled by the player.
|
||||
#[derive(Default, Component)]
|
||||
pub struct PlayerControlled;
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Ghost {
|
||||
Blinky,
|
||||
Pinky,
|
||||
@@ -96,12 +97,33 @@ pub struct Renderable {
|
||||
}
|
||||
|
||||
/// A component for entities that have a directional animated texture.
|
||||
#[derive(Component)]
|
||||
#[derive(Component, Clone, Default)]
|
||||
pub struct DirectionalAnimated {
|
||||
pub textures: [Option<AnimatedTexture>; 4],
|
||||
pub stopped_textures: [Option<AnimatedTexture>; 4],
|
||||
}
|
||||
|
||||
impl DirectionalAnimated {
|
||||
pub fn from_animation(animation: AnimatedTexture) -> Self {
|
||||
// Create 4 copies of the animation - necessary for independent state per direction
|
||||
// This is initialization-time only, so the cloning cost is acceptable
|
||||
Self {
|
||||
textures: [
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
],
|
||||
stopped_textures: [
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
Some(animation.clone()),
|
||||
Some(animation),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct CollisionLayer: u8 {
|
||||
@@ -144,12 +166,92 @@ impl Default for MovementModifiers {
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Frozen;
|
||||
|
||||
/// Tag component for eaten ghosts
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Eaten;
|
||||
|
||||
/// Component for ghosts that are vulnerable to Pac-Man
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Vulnerable {
|
||||
pub remaining_ticks: u32,
|
||||
}
|
||||
|
||||
/// Enumeration of different ghost animation states.
|
||||
/// Note that this is used in micromap which has a fixed size based on the number of variants,
|
||||
/// so extending this should be done with caution, and will require updating the micromap's capacity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum GhostAnimation {
|
||||
/// Normal ghost appearance with directional movement animations
|
||||
Normal,
|
||||
/// Blue ghost appearance when vulnerable (power pellet active)
|
||||
Frightened { flash: bool },
|
||||
/// Eyes-only animation when ghost has been consumed by Pac-Man (Eaten state)
|
||||
Eyes,
|
||||
}
|
||||
|
||||
/// A complete set of animations for a ghost in different behavioral states.
|
||||
///
|
||||
/// Each ghost maintains animations mapped by their current gameplay state.
|
||||
/// The animation system automatically switches between these states based on
|
||||
/// the presence of `Vulnerable` and `Eaten` components on the ghost entity.
|
||||
#[derive(Component, Clone)]
|
||||
pub struct GhostAnimationSet {
|
||||
pub animations: Map<GhostAnimation, DirectionalAnimated, 4>,
|
||||
}
|
||||
|
||||
impl GhostAnimationSet {
|
||||
/// Creates a new GhostAnimationSet with the provided animations.
|
||||
pub fn new(
|
||||
normal: DirectionalAnimated,
|
||||
frightened: DirectionalAnimated,
|
||||
frightened_flashing: DirectionalAnimated,
|
||||
eyes: DirectionalAnimated,
|
||||
) -> Self {
|
||||
let mut animations = Map::new();
|
||||
animations.insert(GhostAnimation::Normal, normal);
|
||||
animations.insert(GhostAnimation::Frightened { flash: false }, frightened);
|
||||
animations.insert(GhostAnimation::Frightened { flash: true }, frightened_flashing);
|
||||
animations.insert(GhostAnimation::Eyes, eyes);
|
||||
Self { animations }
|
||||
}
|
||||
|
||||
/// Gets the animation for the specified ghost animation state.
|
||||
pub fn get(&self, animation: GhostAnimation) -> Option<&DirectionalAnimated> {
|
||||
self.animations.get(&animation)
|
||||
}
|
||||
|
||||
/// Gets the normal animation state.
|
||||
pub fn normal(&self) -> Option<&DirectionalAnimated> {
|
||||
self.get(GhostAnimation::Normal)
|
||||
}
|
||||
|
||||
/// Gets the frightened animation state (non-flashing).
|
||||
pub fn frightened(&self) -> Option<&DirectionalAnimated> {
|
||||
self.get(GhostAnimation::Frightened { flash: false })
|
||||
}
|
||||
|
||||
/// Gets the frightened flashing animation state.
|
||||
pub fn frightened_flashing(&self) -> Option<&DirectionalAnimated> {
|
||||
self.get(GhostAnimation::Frightened { flash: true })
|
||||
}
|
||||
|
||||
/// Gets the eyes animation state (for eaten ghosts).
|
||||
pub fn eyes(&self) -> Option<&DirectionalAnimated> {
|
||||
self.get(GhostAnimation::Eyes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global resource containing pre-loaded animation sets for all ghost types.
|
||||
///
|
||||
/// This resource is initialized once during game startup and provides O(1) access
|
||||
/// to animation sets for each ghost type. The animation system uses this resource
|
||||
/// to efficiently switch between different ghost states without runtime asset loading.
|
||||
///
|
||||
/// The HashMap is keyed by `Ghost` enum variants (Blinky, Pinky, Inky, Clyde) and
|
||||
/// contains complete animation sets mapped by GhostAnimation states.
|
||||
#[derive(Resource)]
|
||||
pub struct GhostAnimations(pub std::collections::HashMap<Ghost, GhostAnimationSet>);
|
||||
|
||||
#[derive(Bundle)]
|
||||
pub struct PlayerBundle {
|
||||
pub player: PlayerControlled,
|
||||
|
||||
@@ -54,8 +54,8 @@ fn render_timing_display(
|
||||
.unwrap_or(0);
|
||||
|
||||
// Only draw background if there is text to display
|
||||
if max_width > 0 {
|
||||
let total_height = (lines.len() as u32) * line_height as u32;
|
||||
let total_height = (lines.len() as u32) * line_height as u32;
|
||||
if max_width > 0 && total_height > 0 {
|
||||
let bg_padding = 5;
|
||||
|
||||
// Draw background
|
||||
|
||||
@@ -10,8 +10,16 @@ use crate::{
|
||||
movement::{Position, Velocity},
|
||||
},
|
||||
};
|
||||
use bevy_ecs::query::Without;
|
||||
use bevy_ecs::system::{Query, Res};
|
||||
|
||||
use bevy_ecs::{
|
||||
query::Added,
|
||||
removal_detection::RemovedComponents,
|
||||
system::{Commands, Query, Res},
|
||||
};
|
||||
|
||||
use crate::systems::{Eaten, GhostAnimations, Vulnerable};
|
||||
|
||||
use bevy_ecs::query::{With, Without};
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::seq::IndexedRandom;
|
||||
use rand::SeedableRng;
|
||||
@@ -68,3 +76,160 @@ pub fn ghost_movement_system(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System that manages ghost animation state transitions based on ghost behavior.
|
||||
///
|
||||
/// This system handles the following animation state changes:
|
||||
/// - When a ghost becomes vulnerable (power pellet eaten): switches to frightened animation
|
||||
/// - When a ghost is eaten by Pac-Man: switches to eaten (eyes) animation
|
||||
/// - When vulnerability ends: switches back to normal animation
|
||||
///
|
||||
/// The system uses ECS change detection to efficiently track state transitions:
|
||||
/// - `Added<Vulnerable>` detects when ghosts become frightened
|
||||
/// - `Added<Eaten>` detects when ghosts are consumed
|
||||
/// - `RemovedComponents<Vulnerable>` detects when fright period ends
|
||||
///
|
||||
/// This ensures smooth visual feedback for gameplay state changes while maintaining
|
||||
/// separation between game logic and animation state.
|
||||
pub fn ghost_state_animation_system(
|
||||
mut commands: Commands,
|
||||
animations: Res<GhostAnimations>,
|
||||
mut vulnerable_added: Query<(bevy_ecs::entity::Entity, &Ghost), Added<Vulnerable>>,
|
||||
mut eaten_added: Query<(bevy_ecs::entity::Entity, &Ghost), Added<Eaten>>,
|
||||
mut vulnerable_removed: RemovedComponents<Vulnerable>,
|
||||
ghosts: Query<&Ghost>,
|
||||
) {
|
||||
// When a ghost becomes vulnerable, switch to the frightened animation
|
||||
for (entity, ghost_type) in vulnerable_added.iter_mut() {
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.frightened() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When a ghost is eaten, switch to the eaten animation
|
||||
for (entity, ghost_type) in eaten_added.iter_mut() {
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.eyes() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When a ghost is no longer vulnerable, switch back to the normal animation
|
||||
for entity in vulnerable_removed.read() {
|
||||
if let Ok(ghost_type) = ghosts.get(entity) {
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.normal() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System that handles eaten ghost behavior and respawn logic.
|
||||
///
|
||||
/// When a ghost is eaten by Pac-Man, it enters an "eaten" state where:
|
||||
/// 1. It displays eyes-only animation
|
||||
/// 2. It moves directly back to the ghost house at increased speed
|
||||
/// 3. Once it reaches the ghost house center, it respawns as a normal ghost
|
||||
///
|
||||
/// This system runs after the main movement system to override eaten ghost movement.
|
||||
pub fn eaten_ghost_system(
|
||||
map: Res<Map>,
|
||||
delta_time: Res<DeltaTime>,
|
||||
animations: Res<GhostAnimations>,
|
||||
mut commands: Commands,
|
||||
mut eaten_ghosts: Query<(bevy_ecs::entity::Entity, &Ghost, &mut Position, &mut Velocity), With<Eaten>>,
|
||||
) {
|
||||
for (entity, ghost_type, mut position, mut velocity) in eaten_ghosts.iter_mut() {
|
||||
// Set higher speed for eaten ghosts returning to ghost house
|
||||
let original_speed = velocity.speed;
|
||||
velocity.speed = ghost_type.base_speed() * 2.0; // Move twice as fast when eaten
|
||||
|
||||
// Calculate direction towards ghost house center (using Clyde's start position)
|
||||
let ghost_house_center = map.start_positions.clyde;
|
||||
|
||||
match *position {
|
||||
Position::Stopped { node: current_node } => {
|
||||
// Find path to ghost house center and start moving
|
||||
if let Some(direction) = find_direction_to_target(&map, current_node, ghost_house_center) {
|
||||
velocity.direction = direction;
|
||||
*position = Position::Moving {
|
||||
from: current_node,
|
||||
to: map.graph.adjacency_list[current_node].get(direction).unwrap().target,
|
||||
remaining_distance: map.graph.adjacency_list[current_node].get(direction).unwrap().distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
Position::Moving { to, .. } => {
|
||||
let distance = velocity.speed * 60.0 * delta_time.0;
|
||||
if let Some(_overflow) = position.tick(distance) {
|
||||
// Reached target node, check if we're at ghost house center
|
||||
if to == ghost_house_center {
|
||||
// Respawn the ghost - remove Eaten component and switch to normal animation
|
||||
commands.entity(entity).remove::<Eaten>();
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.normal() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
// Reset to stopped at ghost house center
|
||||
*position = Position::Stopped {
|
||||
node: ghost_house_center,
|
||||
};
|
||||
} else {
|
||||
// Continue pathfinding to ghost house
|
||||
if let Some(next_direction) = find_direction_to_target(&map, to, ghost_house_center) {
|
||||
velocity.direction = next_direction;
|
||||
*position = Position::Moving {
|
||||
from: to,
|
||||
to: map.graph.adjacency_list[to].get(next_direction).unwrap().target,
|
||||
remaining_distance: map.graph.adjacency_list[to].get(next_direction).unwrap().distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original speed
|
||||
velocity.speed = original_speed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to find the direction from a node towards a target node.
|
||||
/// Uses simple greedy pathfinding - prefers straight lines when possible.
|
||||
fn find_direction_to_target(map: &Map, from_node: usize, target_node: usize) -> Option<Direction> {
|
||||
let from_pos = map.graph.get_node(from_node).unwrap().position;
|
||||
let target_pos = map.graph.get_node(target_node).unwrap().position;
|
||||
|
||||
let dx = target_pos.x as i32 - from_pos.x as i32;
|
||||
let dy = target_pos.y as i32 - from_pos.y as i32;
|
||||
|
||||
// Prefer horizontal movement first, then vertical
|
||||
let preferred_dirs = if dx.abs() > dy.abs() {
|
||||
if dx > 0 {
|
||||
[Direction::Right, Direction::Up, Direction::Down, Direction::Left]
|
||||
} else {
|
||||
[Direction::Left, Direction::Up, Direction::Down, Direction::Right]
|
||||
}
|
||||
} else if dy > 0 {
|
||||
[Direction::Down, Direction::Left, Direction::Right, Direction::Up]
|
||||
} else {
|
||||
[Direction::Up, Direction::Left, Direction::Right, Direction::Down]
|
||||
};
|
||||
|
||||
// Return first available direction towards target
|
||||
for direction in preferred_dirs {
|
||||
if let Some(edge) = map.graph.adjacency_list[from_node].get(direction) {
|
||||
if edge.traversal_flags.contains(TraversalFlags::GHOST) {
|
||||
return Some(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ pub enum SystemId {
|
||||
PlayerMovement,
|
||||
GhostCollision,
|
||||
Stage,
|
||||
GhostStateAnimation,
|
||||
EatenGhost,
|
||||
}
|
||||
|
||||
impl Display for SystemId {
|
||||
@@ -109,28 +111,26 @@ impl SystemTimings {
|
||||
}
|
||||
|
||||
pub fn get_total_stats(&self) -> (Duration, Duration) {
|
||||
let timings = self.timings.read();
|
||||
let mut all_durations = Vec::new();
|
||||
let duration_sums = {
|
||||
let timings = self.timings.read();
|
||||
timings
|
||||
.iter()
|
||||
.map(|(_, queue)| queue.lock().iter().sum::<Duration>())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for queue in timings.values() {
|
||||
all_durations.extend(queue.lock().iter().map(|d| d.as_secs_f64() * 1000.0));
|
||||
}
|
||||
let mean = duration_sums.iter().sum::<Duration>() / duration_sums.len() as u32;
|
||||
let variance = duration_sums
|
||||
.iter()
|
||||
.map(|x| {
|
||||
let diff_secs = x.as_secs_f64() - mean.as_secs_f64();
|
||||
diff_secs * diff_secs
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ duration_sums.len() as f64;
|
||||
let std_dev_secs = variance.sqrt();
|
||||
|
||||
if all_durations.is_empty() {
|
||||
return (Duration::ZERO, Duration::ZERO);
|
||||
}
|
||||
|
||||
let count = all_durations.len() as f64;
|
||||
let sum: f64 = all_durations.iter().sum();
|
||||
let mean = sum / count;
|
||||
|
||||
let variance = all_durations.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / count;
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
(
|
||||
Duration::from_secs_f64(mean / 1000.0),
|
||||
Duration::from_secs_f64(std_dev / 1000.0),
|
||||
)
|
||||
(mean, Duration::from_secs_f64(std_dev_secs))
|
||||
}
|
||||
|
||||
pub fn format_timing_display(&self) -> SmallVec<[String; SystemId::COUNT]> {
|
||||
@@ -144,16 +144,14 @@ impl SystemTimings {
|
||||
};
|
||||
|
||||
// Collect timing data for formatting
|
||||
let mut timing_data = Vec::new();
|
||||
let mut timing_data = vec![(effective_fps, total_avg, total_std)];
|
||||
|
||||
// Add total stats
|
||||
timing_data.push((effective_fps, total_avg, total_std));
|
||||
|
||||
// Add top 5 most expensive systems
|
||||
// Sort the stats by average duration
|
||||
let mut sorted_stats: Vec<_> = stats.iter().collect();
|
||||
sorted_stats.sort_by(|a, b| b.1 .0.cmp(&a.1 .0));
|
||||
|
||||
for (name, (avg, std_dev)) in sorted_stats.iter().take(5) {
|
||||
// Add the top 5 most expensive systems
|
||||
for (name, (avg, std_dev)) in sorted_stats.iter().take(7) {
|
||||
timing_data.push((name.to_string(), *avg, *std_dev));
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ pub fn hud_render_system(
|
||||
errors.write(TextureError::RenderFailed(format!("Failed to render lives text: {}", e)).into());
|
||||
}
|
||||
|
||||
// Render score text in yellow (Pac-Man's color)
|
||||
// Render score text
|
||||
let score_text = format!("{:02}", score.0);
|
||||
let score_offset = 7 - (score_text.len() as i32);
|
||||
let score_position = glam::UVec2::new(4 + 8 * score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
||||
@@ -106,6 +106,14 @@ pub fn hud_render_system(
|
||||
errors.write(TextureError::RenderFailed(format!("Failed to render score text: {}", e)).into());
|
||||
}
|
||||
|
||||
// Render high score text
|
||||
let high_score_text = format!("{:02}", score.0);
|
||||
let high_score_offset = 17 - (high_score_text.len() as i32);
|
||||
let high_score_position = glam::UVec2::new(4 + 8 * high_score_offset as u32, 10); // x_offset + score_offset * 8, 8 + y_offset
|
||||
if let Err(e) = text_renderer.render(canvas, &mut atlas, &high_score_text, high_score_position) {
|
||||
errors.write(TextureError::RenderFailed(format!("Failed to render high score text: {}", e)).into());
|
||||
}
|
||||
|
||||
// Render text based on StartupSequence stage
|
||||
if matches!(
|
||||
*startup,
|
||||
|
||||
@@ -79,15 +79,19 @@ pub fn startup_stage_system(
|
||||
if let Some((from, to)) = startup.tick() {
|
||||
debug!("StartupSequence transition from {from:?} to {to:?}");
|
||||
match (from, to) {
|
||||
// (StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {}
|
||||
(StartupSequence::CharactersVisible { .. }, StartupSequence::GameActive) => {
|
||||
// Unfreeze and unhide the player & ghosts
|
||||
(StartupSequence::TextOnly { .. }, StartupSequence::CharactersVisible { .. }) => {
|
||||
// Unhide the player & ghosts
|
||||
for entity in player_query.iter_mut().chain(ghost_query.iter_mut()) {
|
||||
commands.entity(entity).remove::<(Frozen, Hidden)>();
|
||||
commands.entity(entity).remove::<Hidden>();
|
||||
}
|
||||
|
||||
// Unfreeze pellet blinking
|
||||
for entity in blinking_query.iter_mut() {
|
||||
}
|
||||
(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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
use bevy_ecs::query::With;
|
||||
use bevy_ecs::system::{Commands, Query};
|
||||
use bevy_ecs::{
|
||||
query::With,
|
||||
system::{Commands, Query, Res},
|
||||
};
|
||||
|
||||
use crate::systems::{GhostCollider, Vulnerable};
|
||||
use crate::constants::animation::FRIGHTENED_FLASH_START_TICKS;
|
||||
use crate::systems::{Ghost, GhostAnimations, GhostCollider, Vulnerable};
|
||||
|
||||
/// System that decrements the remaining_ticks on Vulnerable components and removes them when they reach zero
|
||||
pub fn vulnerable_tick_system(
|
||||
mut commands: Commands,
|
||||
mut vulnerable_query: Query<(bevy_ecs::entity::Entity, &mut Vulnerable), With<GhostCollider>>,
|
||||
animations: Res<GhostAnimations>,
|
||||
mut vulnerable_query: Query<(bevy_ecs::entity::Entity, &mut Vulnerable, &Ghost), With<GhostCollider>>,
|
||||
) {
|
||||
for (entity, mut vulnerable) in vulnerable_query.iter_mut() {
|
||||
for (entity, mut vulnerable, ghost_type) in vulnerable_query.iter_mut() {
|
||||
if vulnerable.remaining_ticks > 0 {
|
||||
vulnerable.remaining_ticks -= 1;
|
||||
}
|
||||
|
||||
// When 2 seconds are remaining, start flashing
|
||||
if vulnerable.remaining_ticks == FRIGHTENED_FLASH_START_TICKS {
|
||||
if let Some(animation_set) = animations.0.get(ghost_type) {
|
||||
if let Some(animation) = animation_set.frightened_flashing() {
|
||||
commands.entity(entity).insert(animation.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vulnerable.remaining_ticks == 0 {
|
||||
commands.entity(entity).remove::<Vulnerable>();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::error::{AnimatedTextureError, GameError, GameResult, TextureError};
|
||||
use crate::texture::sprite::AtlasTile;
|
||||
|
||||
@@ -8,7 +10,7 @@ use crate::texture::sprite::AtlasTile;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnimatedTexture {
|
||||
/// Sequence of sprite tiles that make up the animation frames
|
||||
tiles: Vec<AtlasTile>,
|
||||
tiles: SmallVec<[AtlasTile; 4]>,
|
||||
/// Duration each frame should be displayed (in seconds)
|
||||
frame_duration: f32,
|
||||
/// Index of the currently active frame in the tiles vector
|
||||
@@ -18,7 +20,7 @@ pub struct AnimatedTexture {
|
||||
}
|
||||
|
||||
impl AnimatedTexture {
|
||||
pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> GameResult<Self> {
|
||||
pub fn new(tiles: SmallVec<[AtlasTile; 4]>, frame_duration: f32) -> GameResult<Self> {
|
||||
if frame_duration <= 0.0 {
|
||||
return Err(GameError::Texture(TextureError::Animated(
|
||||
AnimatedTextureError::InvalidFrameDuration(frame_duration),
|
||||
|
||||
@@ -3,6 +3,7 @@ use pacman::error::{AnimatedTextureError, GameError, TextureError};
|
||||
use pacman::texture::animated::AnimatedTexture;
|
||||
use pacman::texture::sprite::AtlasTile;
|
||||
use sdl2::pixels::Color;
|
||||
use smallvec::smallvec;
|
||||
|
||||
fn mock_atlas_tile(id: u32) -> AtlasTile {
|
||||
AtlasTile {
|
||||
@@ -14,7 +15,7 @@ fn mock_atlas_tile(id: u32) -> AtlasTile {
|
||||
|
||||
#[test]
|
||||
fn test_animated_texture_creation_errors() {
|
||||
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2)];
|
||||
let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2)];
|
||||
|
||||
assert!(matches!(
|
||||
AnimatedTexture::new(tiles.clone(), 0.0).unwrap_err(),
|
||||
@@ -29,7 +30,7 @@ fn test_animated_texture_creation_errors() {
|
||||
|
||||
#[test]
|
||||
fn test_animated_texture_advancement() {
|
||||
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2), mock_atlas_tile(3)];
|
||||
let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2), mock_atlas_tile(3)];
|
||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
||||
|
||||
assert_eq!(texture.current_frame(), 0);
|
||||
@@ -41,7 +42,7 @@ fn test_animated_texture_advancement() {
|
||||
|
||||
#[test]
|
||||
fn test_animated_texture_wrap_around() {
|
||||
let tiles = vec![mock_atlas_tile(1), mock_atlas_tile(2)];
|
||||
let tiles = smallvec![mock_atlas_tile(1), mock_atlas_tile(2)];
|
||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
||||
|
||||
texture.tick(0.1);
|
||||
@@ -53,7 +54,7 @@ fn test_animated_texture_wrap_around() {
|
||||
|
||||
#[test]
|
||||
fn test_animated_texture_single_frame() {
|
||||
let tiles = vec![mock_atlas_tile(1)];
|
||||
let tiles = smallvec![mock_atlas_tile(1)];
|
||||
let mut texture = AnimatedTexture::new(tiles, 0.1).unwrap();
|
||||
|
||||
texture.tick(0.1);
|
||||
|
||||
Reference in New Issue
Block a user