diff --git a/src/card/card.odin b/src/card/card.odin index d84b9f1..d4a889b 100644 --- a/src/card/card.odin +++ b/src/card/card.odin @@ -229,18 +229,17 @@ smoothstep :: proc(t: f32) -> f32 { return t * t * (3 - 2 * t) } -draw :: proc(card: ^Card, theme: themes.Theme, clickable: bool) { +draw :: proc(card: ^Card, theme: themes.Theme, clickable: bool, focused := false) { eased := smoothstep(card.flip_t) squash := abs(math.cos(eased * math.PI)) // 1 → 0 → 1 across the flip showing_face := eased >= 0.5 // Cards lift as they turn, and a little on hover lift := math.sin(eased * math.PI) * 6 - hovered := clickable && is_hovered(card) - if hovered { - lift += HOVER_LIFT - ui.set_hovered() - } + mouse_over := clickable && is_hovered(card) + if mouse_over { ui.set_hovered() } + hovered := mouse_over || (clickable && focused) + if hovered { lift += HOVER_LIFT } scale: f32 = 1 if card.pulse_t > 0 { @@ -273,6 +272,11 @@ draw :: proc(card: ^Card, theme: themes.Theme, clickable: bool) { dest := raylib.Rectangle{center.x, center.y, width * squash, height} raylib.DrawTexturePro(texture, source, dest, {dest.width / 2, dest.height / 2}, 0, tint) + if focused { + ring := raylib.Rectangle{center.x - width * squash / 2 - 6, center.y - height / 2 - 6, width * squash + 12, height + 12} + raylib.DrawRectangleRoundedLinesEx(ring, ROUNDNESS, SEGMENTS, 3, raylib.Fade(theme.ink, 0.75)) + } + if card.matched && card.flip_t == 1 { outline := raylib.Rectangle{center.x - width / 2 - 3, center.y - height / 2 - 3, width + 6, height + 6} raylib.DrawRectangleRoundedLinesEx(outline, ROUNDNESS, SEGMENTS, 3, theme.accent) diff --git a/src/memory/memory.odin b/src/memory/memory.odin index 2c5d29a..64fd99e 100644 --- a/src/memory/memory.odin +++ b/src/memory/memory.odin @@ -56,6 +56,8 @@ elapsed: f32 resolve_timer: f32 win_t: f32 // fades in the win panel restart_requested: bool +focus: int // card selected with the keyboard +keyboard_active: bool // show the focus ring (hidden again when the mouse moves) // Deals a new game and renders the card faces. Needs the window to exist. start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) { @@ -112,6 +114,7 @@ deal :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) { resolve_timer = 0 win_t = 0 restart_requested = false + focus = 0 } active_cards :: proc() -> []card.Card { @@ -146,6 +149,42 @@ select_card :: proc(i: int) -> bool { return true } +// Moves the keyboard focus within the grid, clamped at the edges +move_focus :: proc(dx, dy: int) { + col := clamp(focus % layout.cols + dx, 0, layout.cols - 1) + row := clamp(focus / layout.cols + dy, 0, layout.rows - 1) + focus = row * layout.cols + col +} + +@(private) +key_pressed :: proc(keys: ..raylib.KeyboardKey) -> bool { + for key in keys { + if raylib.IsKeyPressed(key) || raylib.IsKeyPressedRepeat(key) { return true } + } + return false +} + +// Arrows, WASD or vim keys move the focus; Enter or Space flips the focused card +@(private) +handle_keyboard :: proc() { + if raylib.GetMouseDelta() != {} { keyboard_active = false } + + dx, dy := 0, 0 + if key_pressed(.LEFT, .A, .H) { dx -= 1 } + if key_pressed(.RIGHT, .D, .L) { dx += 1 } + if key_pressed(.UP, .W, .K) { dy -= 1 } + if key_pressed(.DOWN, .S, .J) { dy += 1 } + if dx != 0 || dy != 0 { + // The first key press only reveals the focus ring + if keyboard_active { move_focus(dx, dy) } + keyboard_active = true + } + if raylib.IsKeyPressed(.ENTER) || raylib.IsKeyPressed(.SPACE) { + keyboard_active = true + select_card(focus) + } +} + @(private) resolve :: proc() { a, b := &cards[first], &cards[second] @@ -180,6 +219,7 @@ update :: proc(dt: f32) { } for &c in active_cards() { card.update(&c, dt) } + if state != .OVER { handle_keyboard() } switch state { case .WAITING, .FIRST: @@ -221,7 +261,7 @@ draw :: proc() -> themes.Screen { } for &c, i in active_cards() { - card.draw(&c, theme, can_pick(i)) + card.draw(&c, theme, can_pick(i), focused = keyboard_active && i == focus && state != .OVER) } if state == .OVER { @@ -256,7 +296,7 @@ draw_win_panel :: proc() -> Maybe(themes.Screen) { if win_t < 1 { return nil } button_w: f32 = 200 button_y := panel.y + panel_h - 100 - if ui.button({cx - button_w - 10, button_y, button_w, 60}, "Play Again", theme.primary) { + if ui.button({cx - button_w - 10, button_y, button_w, 60}, "Play Again", theme.primary, hotkey = .ENTER) { restart_requested = true } if ui.button({cx + 10, button_y, button_w, 60}, "Menu", theme.ink) { diff --git a/src/screens/screens.odin b/src/screens/screens.odin index b19d035..51b57f7 100644 --- a/src/screens/screens.odin +++ b/src/screens/screens.odin @@ -60,7 +60,8 @@ main_menu :: proc() -> Maybe(decks.Language) { } } - ui.draw_text_centered("1-3 pick a language · Esc goes back, and quits from here", themes.SCREEN_WIDTH / 2, 660, 18, raylib.Fade(themes.DARK_GRAY, 0.8)) + ui.draw_text_centered("1-3 pick a language · In game: arrows, WASD or hjkl to move, Enter/Space to flip · Esc back / quit", + themes.SCREEN_WIDTH / 2, 660, 18, raylib.Fade(themes.DARK_GRAY, 0.8)) return picked } diff --git a/tests/memory_test.odin b/tests/memory_test.odin index 6b63575..57b3238 100644 --- a/tests/memory_test.odin +++ b/tests/memory_test.odin @@ -270,3 +270,23 @@ card_pulse_counts_down :: proc(t: ^testing.T) { card.update(&c, 1) testing.expect_value(t, c.pulse_t, 0) } + +@(test) +keyboard_focus_stays_inside_grid :: proc(t: ^testing.T) { + memory.deal(.HIRAGANA, .NORMAL) // 4 rows x 6 columns + testing.expect_value(t, memory.focus, 0) + + memory.move_focus(-1, -1) // already top-left + testing.expect_value(t, memory.focus, 0) + memory.move_focus(1, 0) + testing.expect_value(t, memory.focus, 1) + memory.move_focus(0, 1) + testing.expect_value(t, memory.focus, 7) + memory.move_focus(100, 100) // clamps to bottom-right + testing.expect_value(t, memory.focus, memory.card_count - 1) + + memory.deal(.HIRAGANA, .HARD) // dealing resets focus + testing.expect_value(t, memory.focus, 0) + memory.move_focus(0, 3) + testing.expect_value(t, memory.focus, 24) // 4 rows x 8 columns +}