Cards slide in one after another when a game starts and can't be picked until they have landed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
10 KiB
Odin
327 lines
10 KiB
Odin
package memory
|
|
|
|
import "core:fmt"
|
|
import "core:math/rand"
|
|
import "core:strings"
|
|
import raylib "vendor:raylib"
|
|
import audio "../audio"
|
|
import card "../card"
|
|
import decks "../decks"
|
|
import profile "../profile"
|
|
import themes "../themes"
|
|
import ui "../ui"
|
|
|
|
MAX_CARDS :: 32
|
|
|
|
MATCH_DELAY :: 0.35 // seconds both cards stay up before a match is confirmed
|
|
MISMATCH_DELAY :: 0.9 // seconds before a wrong pair flips back
|
|
|
|
Difficulty :: enum {
|
|
EASY,
|
|
NORMAL,
|
|
HARD,
|
|
}
|
|
|
|
Layout :: struct {
|
|
rows, cols: int,
|
|
}
|
|
|
|
@(rodata)
|
|
LAYOUTS := [Difficulty]Layout{
|
|
.EASY = {3, 4},
|
|
.NORMAL = {4, 6},
|
|
.HARD = {4, 8},
|
|
}
|
|
|
|
@(rodata)
|
|
DIFFICULTY_NAMES := [Difficulty]cstring{.EASY = "Easy", .NORMAL = "Normal", .HARD = "Hard"}
|
|
|
|
State :: enum {
|
|
WAITING, // no card selected
|
|
FIRST, // one card selected
|
|
RESOLVING, // two cards up, waiting before match/mismatch
|
|
OVER,
|
|
}
|
|
|
|
cards: [MAX_CARDS]card.Card
|
|
card_count: int
|
|
pair_count: int
|
|
deck: decks.Deck_Id
|
|
difficulty: Difficulty
|
|
theme: themes.Theme
|
|
layout: Layout
|
|
state: State
|
|
first: int = -1
|
|
second: int = -1
|
|
matches: int
|
|
moves: int
|
|
elapsed: f32
|
|
resolve_timer: f32
|
|
win_t: f32 // fades in the win panel
|
|
restart_requested: bool
|
|
last_result: profile.Result // filled in when a game is won
|
|
focus: int // card selected with the keyboard
|
|
keyboard_active: bool // show the focus ring (hidden again when the mouse moves)
|
|
|
|
// Key for this deck and difficulty in the saved profile
|
|
score_key :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) -> string {
|
|
return fmt.tprintf("%v/%v", deck_id, game_difficulty)
|
|
}
|
|
|
|
// Deals a new game and renders the card faces. Needs the window to exist.
|
|
start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
|
|
deal(deck_id, game_difficulty)
|
|
style := decks.language_of(deck).symbol_style
|
|
for &c in active_cards() { card.build_face(&c, theme, style) }
|
|
}
|
|
|
|
// Shuffles, lays out and resets the game state without touching the GPU
|
|
deal :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
|
|
unload()
|
|
deck = deck_id
|
|
difficulty = game_difficulty
|
|
theme = decks.language_of(deck).theme
|
|
layout = LAYOUTS[difficulty]
|
|
card_count = layout.rows * layout.cols
|
|
pair_count = card_count / 2
|
|
|
|
// Pick random entries from the deck
|
|
entries := decks.entries(deck)
|
|
order := make([]int, len(entries), context.temp_allocator)
|
|
for &o, i in order { o = i }
|
|
rand.shuffle(order)
|
|
|
|
for pair in 0 ..< pair_count {
|
|
entry := entries[order[pair]]
|
|
cards[pair * 2] = card.Card{text = entry.symbol, is_symbol = true, pair_id = pair}
|
|
cards[pair * 2 + 1] = card.Card{text = entry.reading, hint = entry.hint, pair_id = pair}
|
|
}
|
|
rand.shuffle(cards[:card_count])
|
|
|
|
// Center the grid horizontally and in the space below the header
|
|
step_x: f32 = themes.CARD_WIDTH + themes.CARD_SPACING
|
|
step_y: f32 = themes.CARD_HEIGHT + themes.CARD_SPACING
|
|
total_w := f32(layout.cols) * step_x - themes.CARD_SPACING
|
|
total_h := f32(layout.rows) * step_y - themes.CARD_SPACING
|
|
start_x := (themes.SCREEN_WIDTH - total_w) / 2
|
|
start_y := themes.GRID_TOP + (themes.SCREEN_HEIGHT - 16 - themes.GRID_TOP - total_h) / 2
|
|
for &c, i in active_cards() {
|
|
card.start_deal(&c, i)
|
|
c.rect = {
|
|
start_x + f32(i % layout.cols) * step_x,
|
|
start_y + f32(i / layout.cols) * step_y,
|
|
themes.CARD_WIDTH,
|
|
themes.CARD_HEIGHT,
|
|
}
|
|
}
|
|
|
|
state = .WAITING
|
|
first = -1
|
|
second = -1
|
|
matches = 0
|
|
moves = 0
|
|
elapsed = 0
|
|
resolve_timer = 0
|
|
win_t = 0
|
|
restart_requested = false
|
|
focus = 0
|
|
}
|
|
|
|
active_cards :: proc() -> []card.Card {
|
|
return cards[:card_count]
|
|
}
|
|
|
|
unload :: proc() {
|
|
for &c in cards { card.unload_face(&c) }
|
|
}
|
|
|
|
// Whether the player can click or press on card i right now (it must have finished being dealt)
|
|
can_interact :: proc(i: int) -> bool {
|
|
return can_pick(i) && cards[i].deal_t == 0
|
|
}
|
|
|
|
can_pick :: proc(i: int) -> bool {
|
|
if i < 0 || i >= card_count { return false }
|
|
c := &cards[i]
|
|
return (state == .WAITING || state == .FIRST) && !c.face_up && !c.matched
|
|
}
|
|
|
|
// Turns card i face up if it can be picked. Returns false when the pick is not allowed.
|
|
select_card :: proc(i: int) -> bool {
|
|
if !can_pick(i) { return false }
|
|
cards[i].face_up = true
|
|
audio.play(.FLIP)
|
|
if state == .WAITING {
|
|
first = i
|
|
state = .FIRST
|
|
} else {
|
|
second = i
|
|
moves += 1
|
|
is_match := cards[first].pair_id == cards[second].pair_id
|
|
resolve_timer = is_match ? MATCH_DELAY : MISMATCH_DELAY
|
|
state = .RESOLVING
|
|
}
|
|
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
|
|
if can_interact(focus) { select_card(focus) }
|
|
}
|
|
}
|
|
|
|
@(private)
|
|
resolve :: proc() {
|
|
a, b := &cards[first], &cards[second]
|
|
if a.pair_id == b.pair_id {
|
|
a.matched = true
|
|
b.matched = true
|
|
card.start_pulse(a)
|
|
card.start_pulse(b)
|
|
matches += 1
|
|
if matches == pair_count {
|
|
state = .OVER
|
|
last_result = profile.record_game(score_key(deck, difficulty), moves, elapsed)
|
|
audio.play(.WIN)
|
|
} else {
|
|
audio.play(.MATCH)
|
|
state = .WAITING
|
|
}
|
|
} else {
|
|
a.face_up = false
|
|
b.face_up = false
|
|
audio.play(.MISMATCH)
|
|
audio.play(.FLIP)
|
|
state = .WAITING
|
|
}
|
|
first = -1
|
|
second = -1
|
|
}
|
|
|
|
update :: proc(dt: f32) {
|
|
if restart_requested {
|
|
start(deck, difficulty)
|
|
return
|
|
}
|
|
|
|
for &c in active_cards() { card.update(&c, dt) }
|
|
if state != .OVER { handle_keyboard() }
|
|
|
|
switch state {
|
|
case .WAITING, .FIRST:
|
|
elapsed += dt
|
|
if raylib.IsMouseButtonPressed(.LEFT) {
|
|
for &c, i in active_cards() {
|
|
if card.is_hovered(&c) && can_interact(i) && select_card(i) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
case .RESOLVING:
|
|
elapsed += dt
|
|
// Wait for the second card to finish turning before starting the delay
|
|
if !card.is_animating(&cards[second]) {
|
|
resolve_timer -= dt
|
|
if resolve_timer <= 0 { resolve() }
|
|
}
|
|
case .OVER:
|
|
win_t = min(win_t + dt * 2.5, 1)
|
|
}
|
|
}
|
|
|
|
// Draws the game and returns the screen to show next frame
|
|
draw :: proc() -> themes.Screen {
|
|
next := themes.Screen.MEMORY
|
|
raylib.ClearBackground(theme.background)
|
|
|
|
info := decks.DECKS[deck]
|
|
ui.draw_title(raylib.TextFormat("%s · %s", theme.name, info.name), 24, theme.primary, 48)
|
|
minutes := int(elapsed) / 60
|
|
seconds := int(elapsed) % 60
|
|
stats := raylib.TextFormat("%s · Moves %d · Pairs %d/%d · %d:%02d",
|
|
DIFFICULTY_NAMES[difficulty], moves, matches, pair_count, minutes, seconds)
|
|
ui.draw_text_centered(stats, themes.SCREEN_WIDTH / 2, 88, 24, theme.ink)
|
|
|
|
if state != .OVER && ui.button({30, 28, 120, 48}, "Back", theme.primary, font_size = 22) {
|
|
next = .LANGUAGE_MENU
|
|
}
|
|
|
|
for &c, i in active_cards() {
|
|
card.draw(&c, theme, can_interact(i), focused = keyboard_active && i == focus && state != .OVER && c.deal_t == 0)
|
|
}
|
|
|
|
if state == .OVER {
|
|
if action := draw_win_panel(); action != nil {
|
|
next = action.?
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
|
|
@(private)
|
|
draw_win_panel :: proc() -> Maybe(themes.Screen) {
|
|
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, raylib.Fade(themes.BLACK, 0.45 * win_t))
|
|
|
|
panel_w: f32 = 560
|
|
panel_h: f32 = 340
|
|
slide := (1 - win_t) * 40
|
|
panel := raylib.Rectangle{(themes.SCREEN_WIDTH - panel_w) / 2, (themes.SCREEN_HEIGHT - panel_h) / 2 + slide, panel_w, panel_h}
|
|
raylib.DrawRectangleRounded({panel.x, panel.y + 8, panel.width, panel.height}, 0.12, 16, raylib.Fade(themes.BLACK, 0.25 * win_t))
|
|
raylib.DrawRectangleRounded(panel, 0.12, 16, raylib.Fade(theme.card_face, win_t))
|
|
raylib.DrawRectangleRoundedLinesEx(panel, 0.12, 16, 4, raylib.Fade(theme.primary, win_t))
|
|
|
|
language := decks.language_of(deck)
|
|
cx := panel.x + panel_w / 2
|
|
ui.draw_text_centered(language.congrats, cx, panel.y + 34, 56, raylib.Fade(theme.primary, win_t), language.congrats_style)
|
|
ui.draw_text_centered("All pairs found!", cx, panel.y + 112, 26, raylib.Fade(theme.ink, win_t))
|
|
stats := strings.clone_to_cstring(fmt.tprintf("%d moves · %s", moves, profile.format_time(elapsed)), context.temp_allocator)
|
|
ui.draw_text_centered(stats, cx, panel.y + 156, 26, raylib.Fade(theme.ink, win_t), .BOLD)
|
|
best := last_result.record
|
|
best_text := strings.clone_to_cstring(fmt.tprintf("Best %d moves · %s", best.best_moves, profile.format_time(best.best_seconds)), context.temp_allocator)
|
|
if last_result.new_best_moves || last_result.new_best_time {
|
|
best_text = strings.clone_to_cstring(fmt.tprintf("New best! · %d games played", best.games), context.temp_allocator)
|
|
}
|
|
ui.draw_text_centered(best_text, cx, panel.y + 198, 22, raylib.Fade(theme.primary, win_t), .BOLD)
|
|
|
|
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, hotkey = .ENTER) {
|
|
restart_requested = true
|
|
}
|
|
if ui.button({cx + 10, button_y, button_w, 60}, "Menu", theme.ink) {
|
|
return .LANGUAGE_MENU
|
|
}
|
|
return nil
|
|
}
|