feat: toggleable inventory overlay with mouse navigation

- Press Tab to open/close inventory overlay
- 4x2 grid of item slots (8 slots) centered on screen
- Each slot shows 2-char glyph + item name label
- Mouse hover highlights slot with golden border
- Hover shows action hint (Use/Equip/Equipped)
- Left-click slot: use/equip item at that index
- Right-click slot: drop item at that index
- Equipped items shown at bottom of panel
- Character panel hidden when inventory is open (saves space)
- Shooting disabled while inventory is open (prevents accidental fire)
- Mouse position tracked in UI coordinates for slot detection

All 171 tests + 14 scenarios pass.
This commit is contained in:
Emil
2026-06-21 18:51:45 +03:00
parent 07c7b88521
commit a2db99f900
4 changed files with 327 additions and 3 deletions
+19 -2
View File
@@ -40,6 +40,9 @@ pub struct Game {
pub score: u32,
pub depth: u32,
pub fps: f32,
pub inventory_open: bool,
pub inventory_mouse_x: i32,
pub inventory_mouse_y: i32,
}
impl Game {
@@ -73,6 +76,9 @@ impl Game {
score: 0,
depth: 1,
fps: 0.0,
inventory_open: false,
inventory_mouse_x: 0,
inventory_mouse_y: 0,
}
}
@@ -425,8 +431,10 @@ impl Game {
let player_alive = player.as_ref().map(|e| e.alive).unwrap_or(false);
let ui_w = (vw as i32) * crate::ui::UI_SCALE;
let ui_h = (vh as i32) * crate::ui::UI_SCALE;
self.ui
.draw_character_panel(1, 1, player.as_ref(), &self.player);
if !self.inventory_open {
self.ui
.draw_character_panel(1, 1, player.as_ref(), &self.player);
}
self.ui.draw_hud(
ui_w as usize,
ui_h as usize,
@@ -461,6 +469,15 @@ impl Game {
self.cam_x,
self.cam_y,
);
if self.inventory_open {
self.ui.draw_inventory_overlay(
ui_w as usize,
ui_h as usize,
&self.player,
self.inventory_mouse_x,
self.inventory_mouse_y,
);
}
if !player_alive {
self.ui
.draw_death_screen(ui_w as usize, ui_h as usize, self.kills, self.score);
+84 -1
View File
@@ -304,7 +304,7 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
game.player.stop_horizontal(&mut game.entities);
}
if input.shoot_mouse
if (input.shoot_mouse && !game.inventory_open)
|| input.shoot_left
|| input.shoot_right
|| input.shoot_up
@@ -348,6 +348,89 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
game.drop_item(0);
}
if input.inventory_toggle {
game.inventory_open = !game.inventory_open;
}
let ui_scale = verbatim::ui::UI_SCALE as f64;
let cell_px = 8.0_f64;
let ui_cell_px = cell_px / ui_scale;
game.inventory_mouse_x = (input.mouse_x / ui_cell_px) as i32;
game.inventory_mouse_y = (input.mouse_y / ui_cell_px) as i32;
if game.inventory_open {
if let Some((mx, my)) = &input.inventory_click {
let mouse_ui_x = (*mx / ui_cell_px) as i32;
let mouse_ui_y = (*my / ui_cell_px) as i32;
let vw = renderer.grid_w();
let vh = renderer.grid_h();
let ui_w = (vw as i32) * verbatim::ui::UI_SCALE;
let ui_h = (vh as i32) * verbatim::ui::UI_SCALE;
let cols = 4i32;
let rows = 2i32;
let slot_w = 8i32;
let slot_h = 6i32;
let gap = 2i32;
let panel_w = cols * slot_w + (cols + 1) * gap + 4;
let panel_h = rows * slot_h + (rows + 1) * gap + 20;
let px_start = (ui_w - panel_w) / 2;
let py_start = (ui_h - panel_h) / 2;
for row in 0..rows {
for col in 0..cols {
let idx = (row * cols + col) as usize;
let sx = px_start + gap + col * (slot_w + gap) + 2;
let sy = py_start + 10 + row * (slot_h + gap) + gap;
if mouse_ui_x >= sx
&& mouse_ui_x < sx + slot_w
&& mouse_ui_y >= sy
&& mouse_ui_y < sy + slot_h
{
if idx < game.player.inventory.len() {
game.use_item(idx);
}
}
}
}
}
if let Some((mx, my)) = &input.inventory_right_click {
let mouse_ui_x = (*mx / ui_cell_px) as i32;
let mouse_ui_y = (*my / ui_cell_px) as i32;
let vw = renderer.grid_w();
let vh = renderer.grid_h();
let ui_w = (vw as i32) * verbatim::ui::UI_SCALE;
let ui_h = (vh as i32) * verbatim::ui::UI_SCALE;
let cols = 4i32;
let rows = 2i32;
let slot_w = 8i32;
let slot_h = 6i32;
let gap = 2i32;
let panel_w = cols * slot_w + (cols + 1) * gap + 4;
let panel_h = rows * slot_h + (rows + 1) * gap + 20;
let px_start = (ui_w - panel_w) / 2;
let py_start = (ui_h - panel_h) / 2;
for row in 0..rows {
for col in 0..cols {
let idx = (row * cols + col) as usize;
let sx = px_start + gap + col * (slot_w + gap) + 2;
let sy = py_start + 10 + row * (slot_h + gap) + gap;
if mouse_ui_x >= sx
&& mouse_ui_x < sx + slot_w
&& mouse_ui_y >= sy
&& mouse_ui_y < sy + slot_h
{
if idx < game.player.inventory.len() {
game.drop_item(idx);
}
}
}
}
}
}
{
let (px, _py) = game.player.center(&game.entities);
let player_screen_x = ((px as i32 - game.cam_x) as f64) * 8.0 + 4.0;
+33
View File
@@ -23,8 +23,14 @@ pub struct WindowInput {
pub mouse_y: f64,
pub mouse_left: bool,
pub mouse_left_was_down: bool,
pub mouse_right: bool,
pub mouse_right_was_down: bool,
pub shoot_mouse: bool,
pub inventory_toggle: bool,
pub inventory_click: Option<(f64, f64)>,
pub inventory_right_click: Option<(f64, f64)>,
jump_was_down: bool,
inventory_tab_was_down: bool,
shoot_left_was_down: bool,
shoot_right_was_down: bool,
shoot_up_was_down: bool,
@@ -60,8 +66,14 @@ impl WindowInput {
mouse_y: 0.0,
mouse_left: false,
mouse_left_was_down: false,
mouse_right: false,
mouse_right_was_down: false,
shoot_mouse: false,
inventory_toggle: false,
inventory_click: None,
inventory_right_click: None,
jump_was_down: false,
inventory_tab_was_down: false,
shoot_left_was_down: false,
shoot_right_was_down: false,
shoot_up_was_down: false,
@@ -93,6 +105,7 @@ impl WindowInput {
pub fn clear_keys(&mut self) {
self.down_keys.clear();
self.mouse_left = false;
self.mouse_right = false;
}
pub fn on_mouse_move(&mut self, x: f64, y: f64) {
@@ -112,6 +125,12 @@ impl WindowInput {
(winit::event::MouseButton::Left, winit::event::ElementState::Released) => {
self.mouse_left = false;
}
(winit::event::MouseButton::Right, winit::event::ElementState::Pressed) => {
self.mouse_right = true;
}
(winit::event::MouseButton::Right, winit::event::ElementState::Released) => {
self.mouse_right = false;
}
_ => {}
}
}
@@ -164,6 +183,20 @@ impl WindowInput {
self.shoot_mouse = self.mouse_left && !self.mouse_left_was_down;
self.mouse_left_was_down = self.mouse_left;
let now_tab = keys.contains(&KeyCode::Tab);
self.inventory_toggle = now_tab && !self.inventory_tab_was_down;
self.inventory_tab_was_down = now_tab;
self.inventory_click = None;
self.inventory_right_click = None;
if self.mouse_left && !self.mouse_left_was_down {
self.inventory_click = Some((self.mouse_x, self.mouse_y));
}
if self.mouse_right && !self.mouse_right_was_down {
self.inventory_right_click = Some((self.mouse_x, self.mouse_y));
}
self.mouse_right_was_down = self.mouse_right;
self.cam_left = keys.contains(&KeyCode::KeyY);
self.cam_right = keys.contains(&KeyCode::KeyU);
self.cam_up = keys.contains(&KeyCode::KeyI);
+191
View File
@@ -464,6 +464,197 @@ impl UiLayer {
}
}
pub fn draw_inventory_overlay(
&mut self,
screen_w: usize,
screen_h: usize,
player_state: &crate::entity::player::Player,
mouse_ui_x: i32,
mouse_ui_y: i32,
) -> Vec<(usize, i32, i32)> {
let bg = [15u8, 18, 28];
let border = [160u8, 180, 255];
let title_col = [220u8, 230, 255];
let dim = [120u8, 130, 160];
let slot_bg = [30u8, 36, 50];
let slot_border = [80u8, 100, 140];
let hover_border = [255u8, 220, 100];
let cols = 4i32;
let rows = 2i32;
let slot_w = 8i32;
let slot_h = 6i32;
let gap = 2i32;
let panel_w = cols * slot_w + (cols + 1) * gap + 4;
let panel_h = rows * slot_h + (rows + 1) * gap + 20;
let px_start = (screen_w as i32 - panel_w) / 2;
let py_start = (screen_h as i32 - panel_h) / 2;
for y in 0..panel_h {
for x in 0..panel_w {
self.set_alpha(px_start + x, py_start + y, ' ', [0, 0, 0], bg, 200);
}
}
for x in 0..panel_w {
if x % 2 == 0 {
self.set_alpha(px_start + x, py_start, '.', border, bg, 240);
self.set_alpha(px_start + x, py_start + panel_h - 1, '.', border, bg, 240);
}
}
for y in 0..panel_h {
if y % 2 == 0 {
self.set_alpha(px_start, py_start + y, '.', border, bg, 240);
self.set_alpha(px_start + panel_w - 1, py_start + y, '.', border, bg, 240);
}
}
self.set_alpha(px_start, py_start, '*', border, bg, 240);
self.set_alpha(px_start + panel_w - 1, py_start, '*', border, bg, 240);
self.set_alpha(px_start, py_start + panel_h - 1, '*', border, bg, 240);
self.set_alpha(
px_start + panel_w - 1,
py_start + panel_h - 1,
'*',
border,
bg,
240,
);
let title = "INVENTORY";
self.draw_text(px_start + 4, py_start + 3, title, title_col, 255);
let count_text = format!("({}/{} items)", player_state.inventory.len(), cols * rows);
self.draw_text(
px_start + 4 + Self::text_width(title) + 2,
py_start + 3,
&count_text,
dim,
200,
);
let mut hover_slot: Option<usize> = None;
let mut slots: Vec<(usize, i32, i32)> = Vec::new();
for row in 0..rows {
for col in 0..cols {
let idx = (row * cols + col) as usize;
let sx = px_start + gap + col * (slot_w + gap) + 2;
let sy = py_start + 10 + row * (slot_h + gap) + gap;
for y in 0..slot_h {
for x in 0..slot_w {
self.set_alpha(sx + x, sy + y, ' ', [0, 0, 0], slot_bg, 200);
}
}
let sb = if mouse_ui_x >= sx
&& mouse_ui_x < sx + slot_w
&& mouse_ui_y >= sy
&& mouse_ui_y < sy + slot_h
{
hover_slot = Some(idx);
hover_border
} else {
slot_border
};
for x in 0..slot_w {
self.set_alpha(sx + x, sy, '.', sb, slot_bg, 240);
self.set_alpha(sx + x, sy + slot_h - 1, '.', sb, slot_bg, 240);
}
for y in 0..slot_h {
self.set_alpha(sx, sy + y, '.', sb, slot_bg, 240);
self.set_alpha(sx + slot_w - 1, sy + y, '.', sb, slot_bg, 240);
}
if idx < player_state.inventory.len() {
let item = &player_state.inventory[idx];
let [ch1, ch2] = item.display_glyph();
let col = item.color();
let cx = sx + slot_w / 2 - 1;
let cy = sy + slot_h / 2 - 1;
self.set(cx, cy, ch1, col, slot_bg);
self.set(
cx + 1,
cy,
ch2,
[col[0] / 2, col[1] / 2, col[2] / 2],
slot_bg,
);
let label = item.name();
let label_w = Self::text_width(label) as i32;
let lx = sx + (slot_w - label_w) / 2;
let ly = sy + slot_h - 1;
if lx >= sx && lx + label_w <= sx + slot_w {
self.draw_text(lx, ly, label, dim, 200);
}
if hover_slot == Some(idx) {
let action = if item.is_consumable() {
"L-click: Use"
} else if item.is_weapon() {
if player_state
.weapon
.as_ref()
.map(|w| w.typ == item.typ)
.unwrap_or(false)
{
"Equipped"
} else {
"L-click: Equip"
}
} else if item.is_armor() {
if player_state
.armor
.as_ref()
.map(|a| a.typ == item.typ)
.unwrap_or(false)
{
"Equipped"
} else {
"L-click: Equip"
}
} else {
"L-click: Use"
};
self.draw_text(sx, sy + slot_h, action, hover_border, 240);
}
} else if hover_slot == Some(idx) {
self.draw_text(sx + 1, sy + slot_h / 2, "empty", dim, 200);
}
slots.push((idx, sx, sy));
}
}
if player_state.weapon.is_some() || player_state.armor.is_some() {
let eq_y = py_start + panel_h - 6;
self.draw_text(px_start + 4, eq_y, "EQ:", dim, 200);
let mut eq_x = px_start + 8;
if let Some(ref w) = player_state.weapon {
let [c1, c2] = w.display_glyph();
let col = w.color();
self.set(eq_x, eq_y, c1, col, bg);
self.set(eq_x + 1, eq_y, c2, [col[0] / 2, col[1] / 2, col[2] / 2], bg);
eq_x += 4;
}
if let Some(ref a) = player_state.armor {
let [c1, c2] = a.display_glyph();
let col = a.color();
self.set(eq_x, eq_y, c1, col, bg);
self.set(eq_x + 1, eq_y, c2, [col[0] / 2, col[1] / 2, col[2] / 2], bg);
}
}
self.draw_text(
px_start + 4,
py_start + panel_h - 3,
"Tab: close L-click: use R-click: drop",
dim,
200,
);
slots
}
pub fn draw_hud(
&mut self,
screen_w: usize,