Add keyboard play: arrows/WASD/hjkl focus, Enter/Space flips

Focus ring appears on the first key press and hides when the mouse moves.
Enter also triggers Play Again on the win panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-01 21:34:00 +02:00
1 parent 8214fda8f6
commit 685182b25d
4 files changed
+74 -9

No files matched your search

+10 -6
View File
@@ -229,18 +229,17 @@ smoothstep :: proc(t: f32) -> f32 {
return t * t * (3 - 2 * t) 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) eased := smoothstep(card.flip_t)
squash := abs(math.cos(eased * math.PI)) // 1 → 0 → 1 across the flip squash := abs(math.cos(eased * math.PI)) // 1 → 0 → 1 across the flip
showing_face := eased >= 0.5 showing_face := eased >= 0.5
// Cards lift as they turn, and a little on hover // Cards lift as they turn, and a little on hover
lift := math.sin(eased * math.PI) * 6 lift := math.sin(eased * math.PI) * 6
hovered := clickable && is_hovered(card) mouse_over := clickable && is_hovered(card)
if hovered { if mouse_over { ui.set_hovered() }
lift += HOVER_LIFT hovered := mouse_over || (clickable && focused)
ui.set_hovered() if hovered { lift += HOVER_LIFT }
}
scale: f32 = 1 scale: f32 = 1
if card.pulse_t > 0 { 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} dest := raylib.Rectangle{center.x, center.y, width * squash, height}
raylib.DrawTexturePro(texture, source, dest, {dest.width / 2, dest.height / 2}, 0, tint) 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 { if card.matched && card.flip_t == 1 {
outline := raylib.Rectangle{center.x - width / 2 - 3, center.y - height / 2 - 3, width + 6, height + 6} 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) raylib.DrawRectangleRoundedLinesEx(outline, ROUNDNESS, SEGMENTS, 3, theme.accent)
+42 -2
View File
@@ -56,6 +56,8 @@ elapsed: f32
resolve_timer: f32 resolve_timer: f32
win_t: f32 // fades in the win panel win_t: f32 // fades in the win panel
restart_requested: bool 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. // Deals a new game and renders the card faces. Needs the window to exist.
start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) { 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 resolve_timer = 0
win_t = 0 win_t = 0
restart_requested = false restart_requested = false
focus = 0
} }
active_cards :: proc() -> []card.Card { active_cards :: proc() -> []card.Card {
@@ -146,6 +149,42 @@ select_card :: proc(i: int) -> bool {
return true 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) @(private)
resolve :: proc() { resolve :: proc() {
a, b := &cards[first], &cards[second] a, b := &cards[first], &cards[second]
@@ -180,6 +219,7 @@ update :: proc(dt: f32) {
} }
for &c in active_cards() { card.update(&c, dt) } for &c in active_cards() { card.update(&c, dt) }
if state != .OVER { handle_keyboard() }
switch state { switch state {
case .WAITING, .FIRST: case .WAITING, .FIRST:
@@ -221,7 +261,7 @@ draw :: proc() -> themes.Screen {
} }
for &c, i in active_cards() { 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 { if state == .OVER {
@@ -256,7 +296,7 @@ draw_win_panel :: proc() -> Maybe(themes.Screen) {
if win_t < 1 { return nil } if win_t < 1 { return nil }
button_w: f32 = 200 button_w: f32 = 200
button_y := panel.y + panel_h - 100 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 restart_requested = true
} }
if ui.button({cx + 10, button_y, button_w, 60}, "Menu", theme.ink) { if ui.button({cx + 10, button_y, button_w, 60}, "Menu", theme.ink) {
+2 -1
View File
@@ -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 return picked
} }
+20
View File
@@ -270,3 +270,23 @@ card_pulse_counts_down :: proc(t: ^testing.T) {
card.update(&c, 1) card.update(&c, 1)
testing.expect_value(t, c.pulse_t, 0) 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
}