feat: detailed characters + Noita-style mouse aiming

Character detail:
- Player: 68 body parts (was 42) — eyes, hair, cape, hat, gloves
- Goblin: 56 body parts (was 39) — red eyes, pointed ears, teeth, skin tones
- New body labels: eye, hair, cape, tooth, ear
- Updated preview_ascii label map for new part types

Mouse aiming (Noita-style):
- Track mouse position via CursorMoved events
- Track left-click via MouseInput events
- Left-click shoots projectile toward mouse cursor direction
- Player faces left/right based on mouse X relative to player screen X
- Keyboard shoot (hjkl) still works as fallback
- Player.facing_right field added
- WindowInput: mouse_x, mouse_y, mouse_left, shoot_mouse fields
- on_mouse_move(), on_mouse_button() handlers
- clear_keys() also clears mouse state on focus loss

All 171 tests + 14 scenarios pass.
This commit is contained in:
Emil
2026-06-21 18:07:46 +03:00
parent 876eb031df
commit e50df7b51e
5 changed files with 148 additions and 58 deletions
+4 -2
View File
@@ -45,13 +45,15 @@ Terminal (`--mode terminal`):
GPU (`--mode ascii` / `--mode graphics`):
- `a` / `d` or `←` / `→` — move
- `w` / `↑` / `space` — jump
- `h` / `j` / `k` / `l` — shoot left / down / up / right
- `h` / `j` / `k` / `l` — shoot left / down / up / right (keyboard fallback)
- **Left-click** — shoot toward mouse cursor (Noita-style aiming)
- **Mouse position** — player faces mouse direction (left/right)
- `f` — toggle fireball mode
- `>` / `.` — descend when standing on stairs
- `e` — use/equip first inventory item
- `r` — drop first inventory item
- `1``0` / `x` — paint material brush
- `y` / `u` / `i` / `o` — move camera
- `y` / `u` / `i` / `o` — move camera offset
- `q` / `esc` — quit
## Shaders
+65 -48
View File
@@ -35,60 +35,69 @@ pub struct BodyTemplate {
impl BodyTemplate {
pub fn humanoid_player() -> Self {
let parts = vec![
// Head: 3x2 = 6
p!(-1, -6, 210, 185, 235, "head"),
p!(0, -6, 225, 205, 245, "head"),
p!(1, -6, 210, 185, 235, "head"),
p!(-1, -5, 215, 190, 240, "head"),
p!(0, -5, 225, 205, 245, "head"),
p!(0, -5, 230, 210, 250, "head"),
p!(1, -5, 215, 190, 240, "head"),
p!(-1, -4, 215, 190, 240, "head"),
p!(0, -4, 225, 205, 245, "head"),
p!(1, -4, 215, 190, 240, "head"),
// Hat brim: 5 wide
p!(-2, -5, 195, 165, 220, "head"),
p!(2, -5, 195, 165, 220, "head"),
p!(0, -5, 40, 30, 50, "eye"),
p!(-1, -5, 60, 40, 70, "eye"),
p!(1, -5, 60, 40, 70, "eye"),
p!(-2, -6, 120, 80, 40, "hair"),
p!(-1, -7, 140, 90, 50, "hair"),
p!(0, -7, 150, 100, 55, "hair"),
p!(1, -7, 140, 90, 50, "hair"),
p!(2, -6, 120, 80, 40, "hair"),
p!(-3, -7, 110, 70, 35, "hair"),
p!(3, -7, 110, 70, 35, "hair"),
p!(-2, -4, 80, 50, 130, "hat"),
p!(2, -4, 80, 50, 130, "hat"),
// Hat top: 3
p!(-1, -6, 90, 55, 140, "hat"),
p!(0, -6, 100, 60, 150, "hat"),
p!(1, -6, 90, 55, 140, "hat"),
// Neck + shoulders: belt accent
p!(0, -3, 230, 190, 110, "belt"),
p!(-1, -5, 90, 55, 140, "hat"),
p!(0, -5, 100, 60, 150, "hat"),
p!(1, -5, 90, 55, 140, "hat"),
p!(0, -4, 230, 190, 110, "belt"),
p!(-1, -4, 150, 90, 210, "shirt"),
p!(1, -4, 150, 90, 210, "shirt"),
p!(-2, -4, 205, 180, 235, "hand"),
p!(2, -4, 205, 180, 235, "hand"),
p!(-3, -4, 195, 170, 225, "hand"),
p!(3, -4, 195, 170, 225, "hand"),
p!(-1, -3, 150, 90, 210, "shirt"),
p!(0, -3, 160, 100, 220, "shirt"),
p!(1, -3, 150, 90, 210, "shirt"),
// Arms out: hands
p!(-2, -3, 205, 180, 235, "hand"),
p!(2, -3, 205, 180, 235, "hand"),
p!(-3, -3, 195, 170, 225, "hand"),
p!(3, -3, 195, 170, 225, "hand"),
// Torso: 3 wide x 2
p!(-2, -3, 210, 185, 240, "hand"),
p!(2, -3, 210, 185, 240, "hand"),
p!(-1, -2, 150, 90, 210, "shirt"),
p!(0, -2, 160, 100, 220, "shirt"),
p!(1, -2, 150, 90, 210, "shirt"),
p!(-2, -2, 200, 170, 230, "hand"),
p!(2, -2, 200, 170, 230, "hand"),
p!(-3, -2, 80, 50, 130, "cape"),
p!(3, -2, 80, 50, 130, "cape"),
p!(-1, -1, 150, 90, 210, "shirt"),
p!(0, -1, 160, 100, 220, "shirt"),
p!(1, -1, 150, 90, 210, "shirt"),
// Arms lower
p!(-2, -2, 210, 185, 240, "hand"),
p!(2, -2, 210, 185, 240, "hand"),
p!(-2, -1, 200, 170, 230, "hand"),
p!(2, -1, 200, 170, 230, "hand"),
// Belt
p!(-3, -1, 75, 45, 120, "cape"),
p!(3, -1, 75, 45, 120, "cape"),
p!(-1, 0, 230, 190, 110, "belt"),
p!(0, 0, 240, 200, 120, "belt"),
p!(1, 0, 230, 190, 110, "belt"),
// Hips: 3 wide
p!(-3, 0, 70, 40, 110, "cape"),
p!(3, 0, 70, 40, 110, "cape"),
p!(-1, 1, 95, 55, 155, "pants"),
p!(0, 1, 95, 55, 155, "pants"),
p!(1, 1, 95, 55, 155, "pants"),
// Upper legs: 3 wide
p!(-1, 2, 85, 45, 145, "pants"),
p!(0, 2, 85, 45, 145, "pants"),
p!(1, 2, 85, 45, 145, "pants"),
p!(-2, 2, 85, 45, 145, "pants"),
p!(2, 2, 85, 45, 145, "pants"),
// Lower legs: split
p!(-2, 3, 75, 40, 125, "pants"),
p!(0, 3, 75, 40, 125, "pants"),
p!(2, 3, 75, 40, 125, "pants"),
// Boots
p!(-2, 4, 55, 30, 95, "boots"),
p!(0, 4, 55, 30, 95, "boots"),
p!(2, 4, 55, 30, 95, "boots"),
@@ -109,42 +118,46 @@ impl BodyTemplate {
pub fn humanoid_goblin() -> Self {
let parts = vec![
// Head: 3x2
p!(-1, -5, 120, 200, 95, "head"),
p!(0, -5, 130, 210, 100, "head"),
p!(1, -5, 120, 200, 95, "head"),
p!(-1, -4, 120, 200, 95, "head"),
p!(0, -4, 130, 210, 100, "head"),
p!(1, -4, 120, 200, 95, "head"),
// Ears
p!(-2, -4, 110, 190, 85, "head"),
p!(2, -4, 110, 190, 85, "head"),
// Neck
p!(0, -3, 100, 180, 75, "skin"),
// Shoulders: rags
p!(-1, -6, 120, 200, 95, "head"),
p!(0, -6, 130, 210, 100, "head"),
p!(1, -6, 120, 200, 95, "head"),
p!(-1, -5, 125, 205, 97, "head"),
p!(0, -5, 135, 215, 102, "head"),
p!(1, -5, 125, 205, 97, "head"),
p!(-2, -5, 110, 190, 85, "head"),
p!(2, -5, 110, 190, 85, "head"),
p!(-1, -5, 200, 50, 50, "eye"),
p!(1, -5, 200, 50, 50, "eye"),
p!(-2, -5, 120, 80, 50, "ear"),
p!(2, -5, 120, 80, 50, "ear"),
p!(-3, -5, 110, 70, 40, "ear"),
p!(3, -5, 110, 70, 40, "ear"),
p!(-1, -4, 90, 180, 70, "tooth"),
p!(0, -4, 90, 180, 70, "tooth"),
p!(1, -4, 90, 180, 70, "tooth"),
p!(0, -4, 100, 180, 75, "skin"),
p!(-1, -4, 100, 180, 75, "skin"),
p!(1, -4, 100, 180, 75, "skin"),
p!(-1, -3, 55, 130, 45, "shirt"),
p!(1, -3, 55, 130, 45, "shirt"),
p!(0, -3, 65, 140, 50, "shirt"),
p!(-2, -3, 110, 180, 85, "hand"),
p!(2, -3, 110, 180, 85, "hand"),
p!(-3, -3, 100, 170, 75, "hand"),
p!(3, -3, 100, 170, 75, "hand"),
// Torso: rags 3x2
p!(-1, -2, 55, 130, 45, "shirt"),
p!(0, -2, 65, 140, 50, "shirt"),
p!(1, -2, 55, 130, 45, "shirt"),
p!(-2, -2, 110, 180, 85, "hand"),
p!(2, -2, 110, 180, 85, "hand"),
p!(-1, -1, 50, 120, 40, "shirt"),
p!(0, -1, 60, 130, 45, "shirt"),
p!(1, -1, 50, 120, 40, "shirt"),
// Arms lower
p!(-2, -2, 110, 180, 85, "hand"),
p!(2, -2, 110, 180, 85, "hand"),
p!(-2, -1, 100, 170, 75, "hand"),
p!(2, -1, 100, 170, 75, "hand"),
// Loincloth
p!(-1, 0, 40, 90, 30, "pants"),
p!(0, 0, 45, 100, 32, "pants"),
p!(1, 0, 40, 90, 30, "pants"),
// Legs
p!(-1, 1, 40, 100, 30, "pants"),
p!(0, 1, 40, 100, 30, "pants"),
p!(1, 1, 40, 100, 30, "pants"),
@@ -152,7 +165,6 @@ impl BodyTemplate {
p!(1, 2, 35, 85, 25, "pants"),
p!(-2, 2, 35, 85, 25, "pants"),
p!(2, 2, 35, 85, 25, "pants"),
// Bare feet
p!(-2, 3, 110, 180, 85, "skin"),
p!(0, 3, 110, 180, 85, "skin"),
p!(2, 3, 110, 180, 85, "skin"),
@@ -340,6 +352,11 @@ impl BodyTemplate {
("tail", '.'),
("hat", '^'),
("belt", '='),
("eye", ':'),
("hair", '~'),
("cape", '>'),
("tooth", '.'),
("ear", '<'),
]);
for part in &self.parts {
+2
View File
@@ -8,6 +8,7 @@ pub struct Player {
pub inventory: Vec<Item>,
pub weapon: Option<Item>,
pub armor: Option<Item>,
pub facing_right: bool,
}
impl Player {
@@ -20,6 +21,7 @@ impl Player {
inventory: Vec::new(),
weapon: None,
armor: None,
facing_right: true,
}
}
+42 -8
View File
@@ -229,6 +229,12 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
} => {
input.on_key_event(key_event.physical_key, key_event.state);
}
WindowEvent::CursorMoved { position, .. } => {
input.on_mouse_move(position.x, position.y);
}
WindowEvent::MouseInput { state, button, .. } => {
input.on_mouse_button(button, state);
}
WindowEvent::Focused(false) => {
input.clear_keys();
}
@@ -276,14 +282,36 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
game.player.stop_horizontal(&mut game.entities);
}
if input.shoot_left {
game.player_shoot(-1.0, 0.0);
} else if input.shoot_right {
game.player_shoot(1.0, 0.0);
} else if input.shoot_up {
game.player_shoot(0.0, -1.0);
} else if input.shoot_down {
game.player_shoot(0.0, 1.0);
if input.shoot_mouse
|| input.shoot_left
|| input.shoot_right
|| input.shoot_up
|| input.shoot_down
{
let (px, py) = game.player.center(&game.entities);
let player_screen_x = ((px as i32 - game.cam_x) as f64) * 8.0 + 4.0;
let player_screen_y = ((py as i32 - game.cam_y) as f64) * 8.0 + 4.0;
let (dx, dy) = if input.shoot_mouse {
let mx = input.mouse_x - player_screen_x;
let my = input.mouse_y - player_screen_y;
let len = (mx * mx + my * my).sqrt();
if len < 1.0 {
(1.0, 0.0)
} else {
(mx / len, my / len)
}
} else if input.shoot_left {
(-1.0, 0.0)
} else if input.shoot_right {
(1.0, 0.0)
} else if input.shoot_up {
(0.0, -1.0)
} else {
(0.0, 1.0)
};
game.player_shoot(dx as f32, dy as f32);
}
if input.toggle_fireball {
game.fireball_mode = !game.fireball_mode;
@@ -298,6 +326,12 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
game.drop_item(0);
}
{
let (px, _py) = game.player.center(&game.entities);
let player_screen_x = ((px as i32 - game.cam_x) as f64) * 8.0 + 4.0;
game.player.facing_right = input.mouse_x >= player_screen_x;
}
if input.cam_left {
game.cam_offset_x -= 3;
}
+35
View File
@@ -19,6 +19,11 @@ pub struct WindowInput {
pub cam_down: bool,
pub quit: bool,
pub paint: Option<u8>,
pub mouse_x: f64,
pub mouse_y: f64,
pub mouse_left: bool,
pub mouse_left_was_down: bool,
pub shoot_mouse: bool,
jump_was_down: bool,
shoot_left_was_down: bool,
shoot_right_was_down: bool,
@@ -51,6 +56,11 @@ impl WindowInput {
cam_down: false,
quit: false,
paint: None,
mouse_x: 0.0,
mouse_y: 0.0,
mouse_left: false,
mouse_left_was_down: false,
shoot_mouse: false,
jump_was_down: false,
shoot_left_was_down: false,
shoot_right_was_down: false,
@@ -82,6 +92,28 @@ impl WindowInput {
pub fn clear_keys(&mut self) {
self.down_keys.clear();
self.mouse_left = false;
}
pub fn on_mouse_move(&mut self, x: f64, y: f64) {
self.mouse_x = x;
self.mouse_y = y;
}
pub fn on_mouse_button(
&mut self,
button: winit::event::MouseButton,
state: winit::event::ElementState,
) {
match (button, state) {
(winit::event::MouseButton::Left, winit::event::ElementState::Pressed) => {
self.mouse_left = true;
}
(winit::event::MouseButton::Left, winit::event::ElementState::Released) => {
self.mouse_left = false;
}
_ => {}
}
}
pub fn update(&mut self) {
@@ -129,6 +161,9 @@ impl WindowInput {
self.drop_item = now_drop_item && !self.drop_item_was_down;
self.drop_item_was_down = now_drop_item;
self.shoot_mouse = self.mouse_left && !self.mouse_left_was_down;
self.mouse_left_was_down = self.mouse_left;
self.cam_left = keys.contains(&KeyCode::KeyY);
self.cam_right = keys.contains(&KeyCode::KeyU);
self.cam_up = keys.contains(&KeyCode::KeyI);