Add Katakana, Korean and Thai games and difficulty levels

- decks package: Hiragana, Katakana, Hangul jamo and Thai consonants
  (Thai readings carry the letter's name word, since sounds repeat)
- Per-script fonts: Jua for Hangul, Mali for Thai (both OFL)
- Memory game works with any deck; Easy 3x4, Normal 4x6, Hard 4x8
- Language menus pick a deck and difficulty, with number/arrow key shortcuts
- Tests cover every deck, difficulty, and font glyph coverage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-01 21:33:16 +02:00
1 parent 4cfe73c9eb
commit 8214fda8f6
15 files changed
+1920 -229

No files matched your search

+85 -58
View File
@@ -4,44 +4,50 @@ import "core:math/rand"
import raylib "vendor:raylib"
import audio "../audio"
import card "../card"
import decks "../decks"
import themes "../themes"
import ui "../ui"
PAIR_COUNT :: 12
CARD_COUNT :: PAIR_COUNT * 2
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
WAITING, // no card selected
FIRST, // one card selected
RESOLVING, // two cards up, waiting before match/mismatch
OVER,
}
Kana :: struct {
kana: cstring,
romaji: cstring,
}
// The 46 basic hiragana (gojūon)
HIRAGANA :: [?]Kana{
{"あ", "a"}, {"い", "i"}, {"う", "u"}, {"え", "e"}, {"お", "o"},
{"か", "ka"}, {"き", "ki"}, {"く", "ku"}, {"け", "ke"}, {"こ", "ko"},
{"さ", "sa"}, {"し", "shi"}, {"す", "su"}, {"せ", "se"}, {"そ", "so"},
{"た", "ta"}, {"ち", "chi"}, {"つ", "tsu"}, {"て", "te"}, {"と", "to"},
{"な", "na"}, {"に", "ni"}, {"ぬ", "nu"}, {"ね", "ne"}, {"の", "no"},
{"は", "ha"}, {"ひ", "hi"}, {"ふ", "fu"}, {"へ", "he"}, {"ほ", "ho"},
{"ま", "ma"}, {"み", "mi"}, {"む", "mu"}, {"め", "me"}, {"も", "mo"},
{"や", "ya"}, {"ゆ", "yu"}, {"よ", "yo"},
{"ら", "ra"}, {"り", "ri"}, {"る", "ru"}, {"れ", "re"}, {"ろ", "ro"},
{"わ", "wa"}, {"を", "wo"}, {"ん", "n"},
}
cards: [CARD_COUNT]card.Card
state: State
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
@@ -52,33 +58,46 @@ win_t: f32 // fades in the win panel
restart_requested: bool
// Deals a new game and renders the card faces. Needs the window to exist.
start :: proc(game_theme: themes.Theme) {
deal(game_theme)
for &c in cards { card.build_face(&c, theme) }
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(game_theme: themes.Theme) {
theme = game_theme
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
deck := HIRAGANA
rand.shuffle(deck[:])
for i in 0 ..< PAIR_COUNT {
card.unload_face(&cards[i * 2])
card.unload_face(&cards[i * 2 + 1])
cards[i * 2] = card.Card{text = deck[i].kana, is_kana = true, pair_id = i}
cards[i * 2 + 1] = card.Card{text = deck[i].romaji, is_kana = false, pair_id = i}
// 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[:])
rand.shuffle(cards[:card_count])
total_w: f32 = themes.GRID_COLS * (themes.CARD_WIDTH + themes.CARD_SPACING) - themes.CARD_SPACING
// 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
for &c, i in cards {
row := f32(i / themes.GRID_COLS)
col := f32(i % themes.GRID_COLS)
start_y := themes.GRID_TOP + (themes.SCREEN_HEIGHT - 16 - themes.GRID_TOP - total_h) / 2
for &c, i in active_cards() {
c.rect = {
start_x + col * (themes.CARD_WIDTH + themes.CARD_SPACING),
themes.GRID_TOP + row * (themes.CARD_HEIGHT + themes.CARD_SPACING),
start_x + f32(i % layout.cols) * step_x,
start_y + f32(i / layout.cols) * step_y,
themes.CARD_WIDTH,
themes.CARD_HEIGHT,
}
@@ -95,11 +114,16 @@ deal :: proc(game_theme: themes.Theme) {
restart_requested = false
}
active_cards :: proc() -> []card.Card {
return cards[:card_count]
}
unload :: proc() {
for &c in cards { card.unload_face(&c) }
}
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
}
@@ -131,7 +155,7 @@ resolve :: proc() {
card.start_pulse(a)
card.start_pulse(b)
matches += 1
if matches == PAIR_COUNT {
if matches == pair_count {
state = .OVER
audio.play(.WIN)
} else {
@@ -151,17 +175,17 @@ resolve :: proc() {
update :: proc(dt: f32) {
if restart_requested {
start(theme)
start(deck, difficulty)
return
}
for &c in cards { card.update(&c, dt) }
for &c in active_cards() { card.update(&c, dt) }
switch state {
case .WAITING, .FIRST:
elapsed += dt
if raylib.IsMouseButtonPressed(.LEFT) {
for &c, i in cards {
for &c, i in active_cards() {
if card.is_hovered(&c) && select_card(i) {
break
}
@@ -181,20 +205,22 @@ update :: proc(dt: f32) {
// Draws the game and returns the screen to show next frame
draw :: proc() -> themes.Screen {
next := themes.Screen.JAPANESE_MEMORY
next := themes.Screen.MEMORY
raylib.ClearBackground(theme.background)
ui.draw_title("Hiragana Memory", 24, theme.primary, 48)
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
info := raylib.TextFormat("Moves %d · Pairs %d/%d · %d:%02d", moves, matches, PAIR_COUNT, minutes, seconds)
ui.draw_text_centered(info, themes.SCREEN_WIDTH / 2, 88, 24, theme.ink)
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 = .JAPANESE_MENU
next = .LANGUAGE_MENU
}
for &c, i in cards {
for &c, i in active_cards() {
card.draw(&c, theme, can_pick(i))
}
@@ -218,13 +244,14 @@ draw_win_panel :: proc() -> Maybe(themes.Screen) {
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("おめでとう!", cx, panel.y + 34, 56, raylib.Fade(theme.primary, win_t), bold = true)
ui.draw_text_centered("Congratulations, all pairs found!", cx, panel.y + 112, 26, raylib.Fade(theme.ink, win_t))
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))
minutes := int(elapsed) / 60
seconds := int(elapsed) % 60
stats := raylib.TextFormat("%d moves · %d:%02d", moves, minutes, seconds)
ui.draw_text_centered(stats, cx, panel.y + 156, 26, raylib.Fade(theme.ink, win_t), bold = true)
ui.draw_text_centered(stats, cx, panel.y + 156, 26, raylib.Fade(theme.ink, win_t), .BOLD)
if win_t < 1 { return nil }
button_w: f32 = 200
@@ -233,7 +260,7 @@ draw_win_panel :: proc() -> Maybe(themes.Screen) {
restart_requested = true
}
if ui.button({cx + 10, button_y, button_w, 60}, "Menu", theme.ink) {
return .JAPANESE_MENU
return .LANGUAGE_MENU
}
return nil
}