Merge branch '25-pom-interactions' into 'dev'

Implement Pom Interactions

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!21
This commit is contained in:
Dominik Bernroider
2025-12-01 17:00:30 +00:00
9 changed files with 336 additions and 50 deletions

View File

@@ -40,7 +40,7 @@ cargo run
### Hidden binds (Only available in the debug build) ### Hidden binds (Only available in the debug build)
- `Shift + Enter`: Duration of the current phase is set to 3 seconds. - `Shift + Enter`: Duration of the current phase is set to 3 seconds.
- `Left Mouse Button` on Tile: Rotate tile state. - `Shift + Left Mouse Button` on Tile: Toggle tile state.
- `Shift + Arrow Up`: Add one berry to your inventory - `Shift + Arrow Up`: Add one berry to your inventory
- `Shift + Arrow Down`: Remove one berry from your inventory - `Shift + Arrow Down`: Remove one berry from your inventory

View File

@@ -1,4 +1,5 @@
use crate::features::{ use crate::features::{
input::utils::mouse_to_grid,
phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage}, phase::messages::{NextPhaseMessage, PhaseTimerPauseMessage},
pom::messages::InvalidMoveMessage, pom::messages::InvalidMoveMessage,
shop::ui::open_shop, shop::ui::open_shop,
@@ -7,6 +8,8 @@ use crate::prelude::*;
use bevy::input::mouse::MouseButton; use bevy::input::mouse::MouseButton;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
pub mod utils;
pub struct InputPlugin; pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
@@ -16,11 +19,15 @@ impl Plugin for InputPlugin {
app.add_systems(Update, move_click.run_if(in_state(AppState::GameScreen))); app.add_systems(Update, move_click.run_if(in_state(AppState::GameScreen)));
app.add_message::<InteractStartMessage>(); app.add_message::<InteractStartMessage>();
app.add_message::<TileClickMessage>();
app.add_systems( app.add_systems(
Update, Update,
interact_click.run_if(in_state(AppState::GameScreen)), interact_click.run_if(in_state(AppState::GameScreen)),
); );
#[cfg(debug_assertions)]
app.add_systems(Update, debug_click.run_if(in_state(AppState::GameScreen)));
app.add_message::<PhaseTimerPauseMessage>(); app.add_message::<PhaseTimerPauseMessage>();
app.add_systems( app.add_systems(
Update, Update,
@@ -49,20 +56,7 @@ fn move_click(
} }
if mouse_btn.just_pressed(MouseButton::Right) { if mouse_btn.just_pressed(MouseButton::Right) {
let (cam, cam_transform) = *camera; let Some((x, y)) = mouse_to_grid(window, camera, config, ui_query) else {
let Some(cursor_pos) = window.cursor_position() else {
return;
};
if ui_blocks(window, cursor_pos, ui_query) {
return;
}
let Ok(world_pos) = cam.viewport_to_world(cam_transform, cursor_pos) else {
return;
};
let Ok((x, y)) =
world_to_grid_coords(world_pos.origin, config.grid_width, config.grid_height)
else {
return; return;
}; };
@@ -72,15 +66,41 @@ fn move_click(
} }
fn interact_click( fn interact_click(
mut interact_messages: MessageWriter<InteractStartMessage>, mut tile_click_messages: MessageWriter<TileClickMessage>,
mouse_btn: Res<ButtonInput<MouseButton>>, mouse_btn: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
window: Single<&Window, With<PrimaryWindow>>, window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>, camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>, config: Res<GameConfig>,
phase: Res<CurrentPhase>, phase: Res<CurrentPhase>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>, ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
// for debug ) {
grid: ResMut<Grid>, match phase.0 {
Phase::Focus { .. } => return,
_ => {}
}
if mouse_btn.just_pressed(MouseButton::Left) {
if keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight) {
return;
}
let Some((x, y)) = mouse_to_grid(window, camera, config, ui_query) else {
return;
};
tile_click_messages.write(TileClickMessage { x, y });
}
}
fn debug_click(
mouse_btn: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>,
phase: Res<CurrentPhase>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
grid: Res<Grid>,
tile_query: Query<&mut TileState>, tile_query: Query<&mut TileState>,
) { ) {
match phase.0 { match phase.0 {
@@ -89,38 +109,24 @@ fn interact_click(
} }
if mouse_btn.just_pressed(MouseButton::Left) { if mouse_btn.just_pressed(MouseButton::Left) {
let (cam, cam_transform) = *camera; if !keys.pressed(KeyCode::ShiftLeft) && !keys.pressed(KeyCode::ShiftRight) {
let Some(cursor_pos) = window.cursor_position() else {
return;
};
if ui_blocks(window, cursor_pos, ui_query) {
return; return;
} }
let Ok(world_pos) = cam.viewport_to_world(cam_transform, cursor_pos) else { let Some((x, y)) = mouse_to_grid(window, camera, config, ui_query) else {
return;
};
let Ok((x, y)) =
world_to_grid_coords(world_pos.origin, config.grid_width, config.grid_height)
else {
return; return;
}; };
println!("Interact Click: ({}, {})", x, y); println!("Debug Toggle Click: ({}, {})", x, y);
interact_messages.write(InteractStartMessage { x, y }); grid.map_tile_state(
(x, y),
if cfg!(debug_assertions) { |state| match state {
grid.map_tile_state( TileState::Unclaimed => TileState::Empty,
(x, y), TileState::Empty => TileState::Occupied,
|state| match state { TileState::Occupied => TileState::Unclaimed,
TileState::Unclaimed => TileState::Empty, },
TileState::Empty => TileState::Occupied, tile_query,
TileState::Occupied => TileState::Unclaimed, )
}, .unwrap_or_else(|_| ());
tile_query,
)
.unwrap_or_else(|_| ());
}
} }
} }

View File

@@ -0,0 +1,28 @@
use crate::prelude::*;
use bevy::window::PrimaryWindow;
pub fn mouse_to_grid(
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
) -> Option<(u32, u32)> {
let (cam, cam_transform) = *camera;
let Some(cursor_pos) = window.cursor_position() else {
return None;
};
if ui_blocks(window, cursor_pos, ui_query) {
return None;
}
let Ok(world_pos) = cam.viewport_to_world(cam_transform, cursor_pos) else {
return None;
};
let Ok(grid_pos) =
world_to_grid_coords(world_pos.origin, config.grid_width, config.grid_height)
else {
return None;
};
Some(grid_pos)
}

View File

@@ -36,3 +36,8 @@ impl MovingState {
) )
} }
} }
#[derive(Component, Default)]
pub struct InteractionTarget {
pub target: Option<(u32, u32)>,
}

View File

@@ -16,3 +16,9 @@ pub struct InteractStartMessage {
pub x: u32, pub x: u32,
pub y: u32, pub y: u32,
} }
#[derive(Message)]
pub struct TileClickMessage {
pub x: u32,
pub y: u32,
}

View File

@@ -1,22 +1,33 @@
use crate::prelude::*; use crate::prelude::*;
use components::*; use components::*;
use messages::InvalidMoveMessage; use messages::{InteractStartMessage, InvalidMoveMessage};
use std::collections::VecDeque;
use utils::find_path; use utils::find_path;
use utils::manhattan_distance;
pub mod components; pub mod components;
pub mod messages; pub mod messages;
pub mod ui;
pub mod utils; pub mod utils;
pub struct PomPlugin; pub struct PomPlugin;
impl Plugin for PomPlugin { impl Plugin for PomPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_plugins(ui::PomUiPlugin);
app.add_systems(OnEnter(AppState::GameScreen), setup); app.add_systems(OnEnter(AppState::GameScreen), setup);
app.add_systems(OnExit(AppState::GameScreen), cleanup); app.add_systems(OnExit(AppState::GameScreen), cleanup);
app.add_systems( app.add_systems(
Update, Update,
(handle_move, move_pom, update_pom).run_if(in_state(AppState::GameScreen)), (
handle_move,
handle_interact,
move_pom,
update_pom,
perform_interaction,
)
.run_if(in_state(AppState::GameScreen)),
); );
} }
} }
@@ -27,6 +38,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>, config: Res<Gam
GridPosition { x: 0, y: 0 }, GridPosition { x: 0, y: 0 },
PathQueue::default(), PathQueue::default(),
MovingState::default(), MovingState::default(),
InteractionTarget::default(),
AseAnimation { AseAnimation {
aseprite: asset_server.load("pom/pom-sleep.aseprite"), aseprite: asset_server.load("pom/pom-sleep.aseprite"),
animation: Animation::tag("sleep-sit-start").with_repeat(AnimationRepeat::Loop), animation: Animation::tag("sleep-sit-start").with_repeat(AnimationRepeat::Loop),
@@ -53,10 +65,13 @@ fn handle_move(
mut invalid_move_messages: MessageWriter<InvalidMoveMessage>, mut invalid_move_messages: MessageWriter<InvalidMoveMessage>,
grid: Res<Grid>, grid: Res<Grid>,
tile_query: Query<&TileState>, tile_query: Query<&TileState>,
mut pom_query: Query<(&GridPosition, &mut PathQueue)>, mut pom_query: Query<(&GridPosition, &mut PathQueue, &mut InteractionTarget)>,
) { ) {
for message in move_messages.read() { for message in move_messages.read() {
for (grid_pos, mut path_queue) in pom_query.iter_mut() { for (grid_pos, mut path_queue, mut interaction_target) in pom_query.iter_mut() {
// Clear any pending interaction when moving manually
interaction_target.target = None;
let grid_start = (grid_pos.x, grid_pos.y); let grid_start = (grid_pos.x, grid_pos.y);
let start = path_queue.steps.front().unwrap_or(&grid_start); let start = path_queue.steps.front().unwrap_or(&grid_start);
let end = (message.x, message.y); let end = (message.x, message.y);
@@ -78,6 +93,86 @@ fn handle_move(
} }
} }
fn handle_interact(
mut interact_messages: MessageReader<InteractStartMessage>,
mut pom_query: Query<(&GridPosition, &mut PathQueue, &mut InteractionTarget)>,
grid: Res<Grid>,
tile_query: Query<&TileState>,
) {
for message in interact_messages.read() {
for (grid_pos, mut path_queue, mut interaction_target) in pom_query.iter_mut() {
let target_pos = (message.x, message.y);
let current_pos = (grid_pos.x, grid_pos.y);
// If we are already adjacent to the target, just set the target and clear path
if manhattan_distance(current_pos.0, current_pos.1, target_pos.0, target_pos.1) == 1 {
path_queue.steps.clear();
interaction_target.target = Some(target_pos);
continue;
}
// Find a path to an adjacent tile
let neighbors = [
(target_pos.0 as i32 + 1, target_pos.1 as i32),
(target_pos.0 as i32 - 1, target_pos.1 as i32),
(target_pos.0 as i32, target_pos.1 as i32 + 1),
(target_pos.0 as i32, target_pos.1 as i32 - 1),
];
let mut best_path: Option<VecDeque<(u32, u32)>> = None;
for (nx, ny) in neighbors {
if nx < 0 || ny < 0 {
continue;
}
let neighbor_pos = (nx as u32, ny as u32);
if let Some(path) = find_path(current_pos, neighbor_pos, &grid, &tile_query) {
// Pick the shortest path
if best_path.as_ref().map_or(true, |p| path.len() < p.len()) {
best_path = Some(path);
}
}
}
if let Some(path) = best_path {
path_queue.steps = path;
interaction_target.target = Some(target_pos);
} else {
println!("Cannot reach interaction target at {:?}", target_pos);
// Don't set target if unreachable
interaction_target.target = None;
}
}
}
}
fn perform_interaction(
mut pom_query: Query<(&GridPosition, &mut InteractionTarget, &PathQueue)>,
// grid: Res<Grid>,
// mut tile_query: Query<&mut TileState>,
) {
for (pos, mut target_component, path_queue) in pom_query.iter_mut() {
if let Some(target) = target_component.target {
// Wait until movement stops
if !path_queue.steps.is_empty() {
continue;
}
if manhattan_distance(pos.x, pos.y, target.0, target.1) == 1 {
println!(
"Performing interaction on tile ({}, {})",
target.0, target.1
);
// TODO: Implement interaction logic
}
target_component.target = None;
}
}
}
fn move_pom( fn move_pom(
time: Res<Time>, time: Res<Time>,
mut query: Query<( mut query: Query<(
@@ -153,3 +248,4 @@ fn update_pom(asset_server: Res<AssetServer>, mut query: Query<(&MovingState, &m
} }
} }
} }

View File

@@ -0,0 +1,123 @@
use crate::features::ui::utils::ui_blocks;
use crate::prelude::*;
use bevy::window::PrimaryWindow;
#[derive(Component)]
pub enum RootMarker {
ContextMenu,
}
#[derive(Component)]
pub enum ButtonType {
Interact { x: u32, y: u32 },
Cancel,
}
pub fn spawn_context_menu(
mut commands: Commands,
mut tile_click_messages: MessageReader<TileClickMessage>,
root_query: Query<Entity, With<RootMarker>>,
camera_query: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>,
) {
for message in tile_click_messages.read() {
// Despawn existing menu
for entity in root_query.iter() {
commands.entity(entity).try_despawn();
}
let world_pos = grid_to_world_coords(
message.x,
message.y,
Some(0.0),
config.grid_width,
config.grid_height,
);
let (camera, camera_transform) = *camera_query;
if let Ok(screen_pos) = camera.world_to_viewport(camera_transform, world_pos) {
commands
.spawn((
Node {
position_type: PositionType::Absolute,
left: px(screen_pos.x),
top: px(screen_pos.y),
padding: UiRect::all(px(5.0)),
..Node::vstack(px(5.0))
},
ZIndex(100),
BackgroundColor(Color::srgb(0.1, 0.1, 0.1)),
BorderRadius::all(px(5)),
RootMarker::ContextMenu,
GlobalTransform::default(),
))
.with_children(|parent| {
parent.spawn(button(
ButtonType::Interact {
x: message.x,
y: message.y,
},
ButtonVariant::Primary,
Node {
padding: UiRect::all(px(5)),
..default()
},
|c| text("<Interact>", 20.0, c),
));
parent.spawn(button(
ButtonType::Cancel,
ButtonVariant::Destructive,
Node {
padding: UiRect::all(px(5)),
..default()
},
|c| text("Abbrechen", 20.0, c),
));
});
}
}
}
pub fn click_outside_context_menu(
mut commands: Commands,
mouse_btn: Res<ButtonInput<MouseButton>>,
window: Single<&Window, With<PrimaryWindow>>,
ui_query: Query<(&ComputedNode, &GlobalTransform), With<Node>>,
root_query: Query<Entity, With<RootMarker>>,
) {
if mouse_btn.just_pressed(MouseButton::Left) {
let Some(cursor_pos) = window.cursor_position() else {
return;
};
if !ui_blocks(window, cursor_pos, ui_query) {
for entity in root_query.iter() {
commands.entity(entity).try_despawn();
}
}
}
}
pub fn buttons(
mut commands: Commands,
mut button_query: Query<(&Interaction, &ButtonType), (Changed<Interaction>, With<Button>)>,
mut interact_messages: MessageWriter<InteractStartMessage>,
root_query: Query<Entity, With<RootMarker>>,
) {
for (interaction, button_type) in button_query.iter_mut() {
if *interaction == Interaction::Pressed {
match button_type {
ButtonType::Interact { x, y } => {
interact_messages.write(InteractStartMessage { x: *x, y: *y });
}
ButtonType::Cancel => (),
}
for entity in root_query.iter() {
commands.entity(entity).despawn();
}
}
}
}

View File

@@ -0,0 +1,22 @@
use crate::prelude::*;
pub mod context_menu;
pub struct PomUiPlugin;
impl Plugin for PomUiPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
context_menu::spawn_context_menu.run_if(in_state(AppState::GameScreen)),
);
app.add_systems(
Update,
context_menu::click_outside_context_menu.run_if(in_state(AppState::GameScreen)),
);
app.add_systems(
Update,
context_menu::buttons.run_if(in_state(AppState::GameScreen)),
);
}
}

View File

@@ -11,7 +11,7 @@ pub use crate::features::{
phase::components::{CurrentPhase, Phase}, phase::components::{CurrentPhase, Phase},
pom::{ pom::{
components::{GridPosition, MovingState, Pom}, components::{GridPosition, MovingState, Pom},
messages::{InteractStartMessage, MoveMessage}, messages::{InteractStartMessage, MoveMessage, TileClickMessage},
}, },
savegame::components::SavegamePath, savegame::components::SavegamePath,
ui::{components::ButtonVariant, consts::*, ui::*, utils::*}, ui::{components::ButtonVariant, consts::*, ui::*, utils::*},