feat: Implement Pom and Pom movement (#16, #20)

This commit is contained in:
demenik
2025-11-21 14:33:30 +01:00
parent 6ed2adc43b
commit 395941508f
13 changed files with 488 additions and 24 deletions

View File

@@ -1,4 +1,39 @@
use std::collections::VecDeque;
use bevy::prelude::*;
#[derive(Component)]
pub struct Pom;
#[derive(Component)]
pub struct GridPosition {
pub x: u32,
pub y: u32,
}
#[derive(Component, Default)]
pub struct PathQueue {
pub steps: VecDeque<(u32, u32)>,
}
#[derive(Component, Default)]
pub enum MovingState {
#[default]
Idle,
MovingUp,
MovingDown,
MovingLeft,
MovingRight,
}
impl MovingState {
pub fn is_moving(&self) -> bool {
matches!(
self,
MovingState::MovingUp
| MovingState::MovingDown
| MovingState::MovingLeft
| MovingState::MovingRight
)
}
}

View File

@@ -1,5 +1,6 @@
use bevy::prelude::*;
use bevy_aseprite_ultra::prelude::AseSlice;
use crate::errors::GridError;
#[derive(Component)]
pub struct Tile {
@@ -15,9 +16,49 @@ pub enum TileState {
Occupied,
}
impl TileState {
pub fn is_blocking(&self) -> bool {
match self {
TileState::Occupied => true,
_ => false,
}
}
}
#[derive(Resource)]
pub struct Grid {
pub width: u32,
pub height: u32,
pub tiles: Vec<Vec<Entity>>,
}
impl Grid {
pub fn get_tile(&self, pos: (u32, u32)) -> Result<Entity, GridError> {
if pos.0 >= self.width || pos.1 >= self.height {
return Err(GridError::OutOfBounds {
x: pos.0 as i32,
y: pos.1 as i32,
});
}
Ok(self.tiles[pos.0 as usize][pos.1 as usize])
}
pub fn map_tile_state<F>(
&self,
pos: (u32, u32),
mapper: F,
mut tile_query: Query<&mut TileState>,
) -> Result<(), GridError>
where
F: FnOnce(&TileState) -> TileState,
{
let tile_entity = self.get_tile(pos)?;
let mut tile_state = tile_query
.get_mut(tile_entity)
.map_err(|_| GridError::UnknownError)?;
*tile_state = mapper(&*tile_state);
Ok(())
}
}

15
src/errors.rs Normal file
View File

@@ -0,0 +1,15 @@
use std::{error::Error, fmt};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GridError {
OutOfBounds { x: i32, y: i32 },
UnknownError,
}
impl Error for GridError {}
impl fmt::Display for GridError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "GridError: {}", &self.to_string())
}
}

View File

@@ -1,3 +1,6 @@
pub mod components;
pub mod errors;
pub mod messages;
pub mod plugins;
pub mod states;
pub mod utils;

View File

@@ -26,6 +26,8 @@ fn main() {
plugins::StartScreenPlugin,
plugins::GameScreenPlugin,
plugins::GridPlugin,
plugins::PomPlugin,
plugins::InputPlugin,
))
.run();
}

18
src/messages.rs Normal file
View File

@@ -0,0 +1,18 @@
use bevy::prelude::*;
#[derive(Message)]
pub struct MoveMessage {
pub x: u32,
pub y: u32,
}
#[derive(Message)]
pub struct InvalidMoveMessage {
pub message: String,
}
#[derive(Message)]
pub struct InteractStartMessage {
pub x: u32,
pub y: u32,
}

View File

@@ -1,7 +1,5 @@
use crate::components::*;
use crate::states::*;
use bevy::prelude::*;
use bevy_aseprite_ultra::prelude::*;
pub struct GameScreenPlugin;
@@ -12,17 +10,7 @@ impl Plugin for GameScreenPlugin {
}
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((
pom::Pom,
AseAnimation {
aseprite: asset_server.load("pom/pom-sleep.aseprite"),
animation: Animation::tag("sleep-sit-start").with_repeat(AnimationRepeat::Loop),
},
Sprite::default(),
Transform::from_xyz(0.0, 0.0, 1.0),
));
fn setup(mut commands: Commands) {
commands.insert_resource(ClearColor(Color::srgb(0.294, 0.412, 0.184)));
}

View File

@@ -5,12 +5,12 @@ use crate::{
use bevy::prelude::*;
use bevy_aseprite_ultra::prelude::AseSlice;
const TILE_SIZE: f32 = 32.0;
const GRID_WIDTH: u32 = 10;
const GRID_HEIGHT: u32 = 10;
pub const TILE_SIZE: f32 = 32.0;
pub const GRID_WIDTH: u32 = 10;
pub const GRID_HEIGHT: u32 = 10;
const GRID_START_X: f32 = -(GRID_WIDTH as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0;
const GRID_START_Y: f32 = -(GRID_HEIGHT as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0;
pub const GRID_START_X: f32 = -(GRID_WIDTH as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0;
pub const GRID_START_Y: f32 = -(GRID_HEIGHT as f32 * TILE_SIZE) / 2.0 + TILE_SIZE / 2.0;
pub struct GridPlugin;
@@ -42,11 +42,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
aseprite: asset_server.load("tiles/tile-unclaimed.aseprite"),
},
Sprite::default(),
Transform::from_xyz(
GRID_START_X + x as f32 * TILE_SIZE,
GRID_START_Y + y as f32 * TILE_SIZE,
0.0,
),
Transform::from_translation(grid_to_world_coords(x, y, None)),
))
.id();
column.push(tile_entity);
@@ -87,3 +83,28 @@ fn update_tile_colors(
};
}
}
pub fn world_to_grid_coords(world_pos: Vec3) -> (u32, u32) {
let x = ((world_pos.x - GRID_START_X + TILE_SIZE / 2.0) / TILE_SIZE).floor();
let y = ((world_pos.y - GRID_START_Y + TILE_SIZE / 2.0) / TILE_SIZE).floor();
let mut x_u32 = x as u32;
let mut y_u32 = y as u32;
if x_u32 >= GRID_WIDTH {
x_u32 = GRID_WIDTH - 1;
}
if y_u32 >= GRID_HEIGHT {
y_u32 = GRID_HEIGHT - 1;
}
(x_u32, y_u32)
}
pub fn grid_to_world_coords(grid_x: u32, grid_y: u32, z: Option<f32>) -> Vec3 {
Vec3::new(
GRID_START_X + grid_x as f32 * TILE_SIZE,
GRID_START_Y + grid_y as f32 * TILE_SIZE,
z.unwrap_or(0.0),
)
}

84
src/plugins/input.rs Normal file
View File

@@ -0,0 +1,84 @@
use bevy::input::mouse::MouseButton;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use crate::components::tile::{Grid, TileState};
use crate::messages::{InteractStartMessage, InvalidMoveMessage, MoveMessage};
use crate::plugins::grid::world_to_grid_coords;
use crate::states::AppState;
pub struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.add_message::<MoveMessage>();
app.add_message::<InvalidMoveMessage>();
app.add_systems(Update, move_click.run_if(in_state(AppState::GameScreen)));
app.add_message::<InteractStartMessage>();
app.add_systems(
Update,
interact_click.run_if(in_state(AppState::GameScreen)),
);
}
}
fn move_click(
mut move_messages: MessageWriter<MoveMessage>,
mouse_btn: Res<ButtonInput<MouseButton>>,
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
) {
if mouse_btn.just_pressed(MouseButton::Right) {
let (cam, cam_transform) = *camera;
let Some(cursor_pos) = window.cursor_position() else {
return;
};
let Ok(world_pos) = cam.viewport_to_world(cam_transform, cursor_pos) else {
return;
};
let (x, y) = world_to_grid_coords(world_pos.origin);
println!("Move Click: ({}, {})", x, y);
move_messages.write(MoveMessage { x, y });
}
}
fn interact_click(
mut interact_messages: MessageWriter<InteractStartMessage>,
mouse_btn: Res<ButtonInput<MouseButton>>,
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
// for debug
grid: ResMut<Grid>,
tile_query: Query<&mut TileState>,
) {
if mouse_btn.just_pressed(MouseButton::Left) {
let (cam, cam_transform) = *camera;
let Some(cursor_pos) = window.cursor_position() else {
return;
};
let Ok(world_pos) = cam.viewport_to_world(cam_transform, cursor_pos) else {
return;
};
let (x, y) = world_to_grid_coords(world_pos.origin);
println!("Interact Click: ({}, {})", x, y);
interact_messages.write(InteractStartMessage { x, y });
if cfg!(debug_assertions) {
grid.map_tile_state(
(x, y),
|state| match state {
TileState::Unclaimed => TileState::Empty,
TileState::Empty => TileState::Occupied,
TileState::Occupied => TileState::Unclaimed,
},
tile_query,
)
.unwrap();
}
}
}

View File

@@ -1,9 +1,13 @@
pub mod core;
pub mod game_screen;
pub mod grid;
pub mod input;
pub mod pom;
pub mod start_screen;
pub use core::CorePlugin;
pub use game_screen::GameScreenPlugin;
pub use grid::GridPlugin;
pub use input::InputPlugin;
pub use pom::PomPlugin;
pub use start_screen::StartScreenPlugin;

145
src/plugins/pom.rs Normal file
View File

@@ -0,0 +1,145 @@
use crate::components::pom::{GridPosition, MovingState, PathQueue, Pom};
use crate::components::tile::{Grid, TileState};
use crate::messages::{InvalidMoveMessage, MoveMessage};
use crate::plugins::grid::{GRID_WIDTH, grid_to_world_coords};
use crate::states::*;
use crate::utils::pathfinding::find_path;
use bevy::prelude::*;
use bevy_aseprite_ultra::prelude::*;
const MOVE_SPEED: f32 = 2.0 * GRID_WIDTH as f32;
pub struct PomPlugin;
impl Plugin for PomPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(AppState::GameScreen), setup);
app.add_systems(OnExit(AppState::GameScreen), cleanup);
app.add_systems(
Update,
(handle_move, move_pom, update_pom).run_if(in_state(AppState::GameScreen)),
);
}
}
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
commands.spawn((
Pom,
GridPosition { x: 0, y: 0 },
PathQueue::default(),
MovingState::default(),
AseAnimation {
aseprite: asset_server.load("pom/pom-sleep.aseprite"),
animation: Animation::tag("sleep-sit-start").with_repeat(AnimationRepeat::Loop),
},
Sprite::default(),
Transform::from_translation(grid_to_world_coords(0, 0, Some(1.0))),
));
}
fn cleanup(mut commands: Commands, pom_query: Query<Entity, With<Pom>>) {
for pom_entity in pom_query.iter() {
commands.entity(pom_entity).despawn();
}
}
fn handle_move(
mut move_messages: MessageReader<MoveMessage>,
mut invalid_move_messages: MessageWriter<InvalidMoveMessage>,
grid: Res<Grid>,
tile_query: Query<&TileState>,
mut pom_query: Query<(&GridPosition, &mut PathQueue)>,
) {
for message in move_messages.read() {
for (grid_pos, mut path_queue) in pom_query.iter_mut() {
let start = (grid_pos.x, grid_pos.y);
let end = (message.x, message.y);
println!("{}, {}", end.0, end.1);
match find_path(start, end, &grid, &tile_query) {
Some(new_path) => {
path_queue.steps = new_path;
println!("Path found with {} steps", path_queue.steps.len());
}
None => {
let msg = format!(
"Cannot move to ({}, {}). Path blocked or invalid.",
message.x, message.y
);
println!("{}", msg);
invalid_move_messages.write(InvalidMoveMessage { message: msg });
}
}
}
}
}
fn move_pom(
time: Res<Time>,
mut query: Query<(
&mut Transform,
&mut GridPosition,
&mut PathQueue,
&mut MovingState,
)>,
) {
let dt = time.delta_secs();
for (mut transform, mut grid_pos, mut path_queue, mut moving_state) in query.iter_mut() {
if let Some(&target) = path_queue.steps.front() {
let target_pos = grid_to_world_coords(target.0, target.1, Some(1.0));
let distance = transform.translation.distance(target_pos);
let dx = target.0 as i32 - grid_pos.x as i32;
let dy = target.1 as i32 - grid_pos.y as i32;
match (dx, dy) {
(0, 1) => *moving_state = MovingState::MovingUp,
(0, -1) => *moving_state = MovingState::MovingDown,
(1, 0) => *moving_state = MovingState::MovingRight,
(-1, 0) => *moving_state = MovingState::MovingLeft,
_ => (),
}
if distance < MOVE_SPEED * dt {
transform.translation = target_pos;
grid_pos.x = target.0;
grid_pos.y = target.1;
path_queue.steps.pop_front();
} else {
let direction = (target_pos - transform.translation).normalize();
transform.translation += direction * MOVE_SPEED * dt;
}
} else {
*moving_state = MovingState::Idle;
}
}
}
fn update_pom(asset_server: Res<AssetServer>, mut query: Query<(&MovingState, &mut AseAnimation)>) {
for (moving_state, mut animation) in query.iter_mut() {
match moving_state {
MovingState::Idle => {
*animation = AseAnimation {
aseprite: asset_server.load("pom/pom-sleep.aseprite"),
animation: Animation::tag("sleep-sit-start").with_repeat(AnimationRepeat::Loop),
};
}
s if s.is_moving() => {
*animation = AseAnimation {
aseprite: asset_server.load("pom/pom-walk.aseprite"),
animation: (match s {
MovingState::MovingUp => Animation::tag("walk_up"),
MovingState::MovingDown => Animation::tag("walk_down"),
MovingState::MovingLeft => Animation::tag("walk_left"),
MovingState::MovingRight => Animation::tag("walk_right"),
_ => panic!("This shouldn't be reachable"),
})
.with_repeat(AnimationRepeat::Loop),
};
}
_ => (),
}
}
}

1
src/utils/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod pathfinding;

107
src/utils/pathfinding.rs Normal file
View File

@@ -0,0 +1,107 @@
use crate::components::tile::{Grid, TileState};
use bevy::prelude::*;
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, VecDeque};
#[derive(Copy, Clone, Eq, PartialEq)]
pub struct Node {
x: u32,
y: u32,
cost: u32, // G score (distance from start)
priority: u32, // F score (G + H)
}
impl Ord for Node {
fn cmp(&self, other: &Self) -> Ordering {
other
.priority
.cmp(&self.priority)
.then_with(|| self.cost.cmp(&other.cost))
}
}
impl PartialOrd for Node {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
pub fn manhattan_distance(x1: u32, y1: u32, x2: u32, y2: u32) -> u32 {
(x1 as i32 - x2 as i32).abs() as u32 + (y1 as i32 - y2 as i32).abs() as u32
}
pub fn find_path(
start: (u32, u32),
end: (u32, u32),
grid: &Grid,
tile_query: &Query<&TileState>,
) -> Option<VecDeque<(u32, u32)>> {
println!("find_path");
let target_entity = grid.get_tile(end).ok()?;
let state = tile_query.get(target_entity).unwrap();
if (*state).is_blocking() {
return None;
}
let mut open_set = BinaryHeap::new();
let mut came_from: HashMap<(u32, u32), (u32, u32)> = HashMap::new();
let mut g_score: HashMap<(u32, u32), u32> = HashMap::new();
g_score.insert(start, 0);
open_set.push(Node {
x: start.0,
y: start.1,
cost: 0,
priority: manhattan_distance(start.0, start.1, end.0, end.1),
});
while let Some(current) = open_set.pop() {
let current_pos = (current.x, current.y);
if current_pos == end {
let mut path = VecDeque::new();
let mut curr = end;
while curr != start {
path.push_front(curr);
curr = *came_from.get(&curr).unwrap();
}
return Some(path);
}
let neighbors = [
(current.x as i32 + 1, current.y as i32),
(current.x as i32 - 1, current.y as i32),
(current.x as i32, current.y as i32 + 1),
(current.x as i32, current.y as i32 - 1),
];
for (nx, ny) in neighbors {
if nx < 0 || ny < 0 {
continue;
}
let next_pos = (nx as u32, ny as u32);
let tile_entity = grid.get_tile(next_pos).ok()?;
let state = tile_query.get(tile_entity).unwrap();
if (*state).is_blocking() {
continue;
}
let tentative_g_score = g_score.get(&current_pos).unwrap() + 1;
if tentative_g_score < *g_score.get(&next_pos).unwrap_or(&u32::MAX) {
came_from.insert(next_pos, current_pos);
g_score.insert(next_pos, tentative_g_score);
open_set.push(Node {
x: next_pos.0,
y: next_pos.1,
cost: tentative_g_score,
priority: tentative_g_score
+ manhattan_distance(next_pos.0, next_pos.1, end.0, end.1),
});
}
}
}
None
}