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

+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleIdentifier</key>
<string>com.apple.xcode.dsym.HanjaKanaMun</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>dSYM</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
File diff suppressed because it is too large. Load diff
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Jua Project Authors
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Mali Project Authors (https://github.com/cadsondemak/Mali)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+26 -13
View File
@@ -4,6 +4,7 @@ import raylib "vendor:raylib"
import assets "src/assets"
import audio "src/audio"
import card "src/card"
import decks "src/decks"
import memory "src/memory"
import screens "src/screens"
import themes "src/themes"
@@ -12,9 +13,9 @@ import ui "src/ui"
// Where Escape takes you from each screen (nil = quit)
parent_screen :: proc(screen: themes.Screen) -> Maybe(themes.Screen) {
switch screen {
case .MAIN_MENU: return nil
case .JAPANESE_MEMORY: return .JAPANESE_MENU
case .JAPANESE_MENU, .KOREAN_MENU, .THAI_MENU: return .MAIN_MENU
case .MAIN_MENU: return nil
case .LANGUAGE_MENU: return .MAIN_MENU
case .MEMORY: return .LANGUAGE_MENU
}
return nil
}
@@ -40,6 +41,8 @@ main :: proc() {
defer memory.unload()
screen := themes.Screen.MAIN_MENU
language := decks.Language.JAPANESE
difficulty := memory.Difficulty.NORMAL
running := true
for running && !raylib.WindowShouldClose() {
dt := raylib.GetFrameTime()
@@ -52,27 +55,37 @@ main :: proc() {
}
}
if screen == .JAPANESE_MEMORY {
if screen == .MEMORY {
memory.update(dt)
}
raylib.BeginDrawing()
ui.begin_frame()
next := screen
start_deck: Maybe(decks.Deck_Id)
switch screen {
case .MAIN_MENU: next = screens.main_menu()
case .JAPANESE_MENU: next = screens.japanese_menu()
case .JAPANESE_MEMORY: next = memory.draw()
case .KOREAN_MENU: next = screens.korean_menu()
case .THAI_MENU: next = screens.thai_menu()
case .MAIN_MENU:
if picked, ok := screens.main_menu().?; ok {
language = picked
next = .LANGUAGE_MENU
}
case .LANGUAGE_MENU:
switch result in screens.language_menu(language, &difficulty) {
case decks.Deck_Id:
start_deck = result
next = .MEMORY
case themes.Screen:
next = result
}
case .MEMORY:
next = memory.draw()
}
ui.end_frame()
raylib.EndDrawing()
// Start a fresh game when entering the memory screen (outside of drawing,
// because building card faces renders into textures)
if next == .JAPANESE_MEMORY && screen != .JAPANESE_MEMORY {
memory.start(themes.JAPAN)
// Building card faces renders into textures, so do it outside of drawing
if deck, ok := start_deck.?; ok {
memory.start(deck, difficulty)
}
screen = next
}
+51 -22
View File
@@ -2,28 +2,57 @@ package assets
import raylib "vendor:raylib"
REGULAR_FONT_PATH :: "assets/MPLUSRounded1c-Medium.ttf"
BOLD_FONT_PATH :: "assets/MPLUSRounded1c-ExtraBold.ttf"
// Glyphs are rasterized at this size and scaled down when drawn
FONT_BASE_SIZE :: 128
regular_font: raylib.Font
bold_font: raylib.Font
Font_Style :: enum {
REGULAR, // UI text (M PLUS Rounded 1c Medium)
BOLD, // UI headings and Japanese symbols (M PLUS Rounded 1c ExtraBold)
KOREAN, // Hangul (Jua)
THAI, // Thai script (Mali SemiBold)
}
// raylib only bakes ASCII by default, so every non-ASCII glyph must be listed
@(private)
build_codepoints :: proc() -> [dynamic]rune {
codepoints: [dynamic]rune
for r in rune(0x20) ..= 0x7E { append(&codepoints, r) } // ASCII
for r in rune(0x3041) ..= 0x3096 { append(&codepoints, r) } // Hiragana
for r in rune(0x30A1) ..= 0x30FC { append(&codepoints, r) } // Katakana
for r in "日本語・·āō" { append(&codepoints, r) }
@(rodata)
FONT_PATHS := [Font_Style]cstring{
.REGULAR = "assets/MPLUSRounded1c-Medium.ttf",
.BOLD = "assets/MPLUSRounded1c-ExtraBold.ttf",
.KOREAN = "assets/Jua-Regular.ttf",
.THAI = "assets/Mali-SemiBold.ttf",
}
fonts: [Font_Style]raylib.Font
// Extra non-range characters used in UI text
EXTRA_JAPANESE :: "日本語・·āō"
EXTRA_KOREAN :: "한글국어축하해요"
EXTRA_THAI :: "ไทย"
// raylib only bakes the codepoints it is given, so every glyph a font must draw is listed here.
// Tests check that every deck's characters are covered.
codepoints_for :: proc(style: Font_Style, allocator := context.allocator) -> [dynamic]rune {
codepoints := make([dynamic]rune, allocator)
for r in rune(0x20) ..= 0x7E { append(&codepoints, r) } // ASCII
switch style {
case .REGULAR, .BOLD:
for r in rune(0x3041) ..= 0x3096 { append(&codepoints, r) } // Hiragana
for r in rune(0x30A1) ..= 0x30FC { append(&codepoints, r) } // Katakana
for r in EXTRA_JAPANESE { append(&codepoints, r) }
case .KOREAN:
for r in rune(0x3131) ..= 0x3163 { append(&codepoints, r) } // Modern Hangul compatibility jamo
for r in EXTRA_KOREAN { append(&codepoints, r) }
case .THAI:
for r in rune(0x0E01) ..= 0x0E3A { append(&codepoints, r) } // Thai (U+0E3B-0E3E are unassigned)
for r in rune(0x0E3F) ..= 0x0E5B { append(&codepoints, r) }
for r in EXTRA_THAI { append(&codepoints, r) }
}
return codepoints
}
@(private)
load_font :: proc(path: cstring, codepoints: []rune) -> raylib.Font {
load_font :: proc(style: Font_Style) -> raylib.Font {
codepoints := codepoints_for(style)
defer delete(codepoints)
path := FONT_PATHS[style]
font := raylib.LoadFontEx(path, FONT_BASE_SIZE, raw_data(codepoints), i32(len(codepoints)))
if !raylib.IsFontValid(font) || font.texture.id == 0 {
raylib.TraceLog(.WARNING, "ASSETS: Falling back to default font, could not load %s", path)
@@ -36,18 +65,18 @@ load_font :: proc(path: cstring, codepoints: []rune) -> raylib.Font {
}
load_fonts :: proc() {
codepoints := build_codepoints()
defer delete(codepoints)
regular_font = load_font(REGULAR_FONT_PATH, codepoints[:])
bold_font = load_font(BOLD_FONT_PATH, codepoints[:])
for style in Font_Style {
fonts[style] = load_font(style)
}
}
unload_fonts :: proc() {
default_id := raylib.GetFontDefault().texture.id
if regular_font.texture.id != default_id { raylib.UnloadFont(regular_font) }
if bold_font.texture.id != default_id { raylib.UnloadFont(bold_font) }
for font in fonts {
if font.texture.id != default_id { raylib.UnloadFont(font) }
}
}
get_font :: proc(bold := false) -> raylib.Font {
return bold ? bold_font : regular_font
get_font :: proc(style: Font_Style = .REGULAR) -> raylib.Font {
return fonts[style]
}
+26 -11
View File
@@ -2,6 +2,7 @@ package card
import "core:math"
import raylib "vendor:raylib"
import assets "../assets"
import themes "../themes"
import ui "../ui"
@@ -19,8 +20,9 @@ HOVER_LIFT :: 4
Card :: struct {
rect: raylib.Rectangle, // resting position on screen
text: cstring,
is_kana: bool,
text: cstring,
hint: cstring, // small second line on reading cards
is_symbol: bool, // script symbol (vs. its reading)
pair_id: int,
face_up: bool, // logical state; the animation follows it
matched: bool,
@@ -154,21 +156,34 @@ get_back_art :: proc(flag: themes.Flag) -> raylib.Texture2D {
return back_art[flag].texture
}
// Renders the card's face into its own texture
build_face :: proc(card: ^Card, theme: themes.Theme) {
// Font size that fits text inside the card width
@(private)
fit_size :: proc(text: cstring, size: f32, style: assets.Font_Style) -> f32 {
max_width: f32 = ART_WIDTH * 0.78
width := ui.measure_text(text, size, style).x
return width > max_width ? size * max_width / width : size
}
// Renders the card's face into its own texture. Symbols use their script's font.
build_face :: proc(card: ^Card, theme: themes.Theme, symbol_style: assets.Font_Style) {
unload_face(card)
card.face_art = new_art_texture(theme.primary)
raylib.BeginTextureMode(card.face_art)
draw_frame(theme.card_face, theme.primary)
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, raylib.Fade(theme.primary, 0.35))
size: f32 = card.is_kana ? 132 : 96
color := card.is_kana ? theme.ink : theme.primary
// Shrink long romaji (e.g. "TSU") to fit inside the card
max_width: f32 = ART_WIDTH * 0.78
width := ui.measure_text(card.text, size, bold = true).x
if width > max_width { size *= max_width / width }
ui.draw_text_in_rect(card.text, art_rect(), size, color, bold = true)
if card.is_symbol {
ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 132, symbol_style), theme.ink, symbol_style)
} else if card.hint == nil {
ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 96, .BOLD), theme.primary, .BOLD)
} else {
// Reading on top, hint underneath
main_rect := art_rect()
main_rect.height -= 56
ui.draw_text_in_rect(card.text, main_rect, fit_size(card.text, 88, .BOLD), theme.primary, .BOLD)
hint_rect := raylib.Rectangle{0, ART_HEIGHT * 0.62, ART_WIDTH, 60}
ui.draw_text_in_rect(card.hint, hint_rect, fit_size(card.hint, 34, .REGULAR), theme.ink, .REGULAR)
}
raylib.EndTextureMode()
}
+141
View File
@@ -0,0 +1,141 @@
package decks
import assets "../assets"
import themes "../themes"
Language :: enum {
JAPANESE,
KOREAN,
THAI,
}
Deck_Id :: enum {
HIRAGANA,
KATAKANA,
HANGUL,
THAI_CONSONANTS,
}
// One card pair: the script symbol and how it reads
Entry :: struct {
symbol: cstring,
reading: cstring, // romanization, shown large
hint: cstring, // optional small line under the reading
}
Deck_Info :: struct {
name: cstring, // English name, used on buttons
native: cstring, // name in its own script
language: Language,
}
Language_Info :: struct {
theme: themes.Theme,
native: cstring, // language name in its own script
symbol_style: assets.Font_Style, // font for the script's symbols
congrats: cstring, // win message
congrats_style: assets.Font_Style,
}
@(rodata)
DECKS := [Deck_Id]Deck_Info{
.HIRAGANA = {name = "Hiragana", native = "ひらがな", language = .JAPANESE},
.KATAKANA = {name = "Katakana", native = "カタカナ", language = .JAPANESE},
.HANGUL = {name = "Hangul", native = "한글", language = .KOREAN},
.THAI_CONSONANTS = {name = "Consonants", native = "ก ข ค", language = .THAI},
}
@(rodata)
LANGUAGES := [Language]Language_Info{
.JAPANESE = {theme = themes.JAPAN, native = "日本語", symbol_style = .BOLD, congrats = "おめでとう!", congrats_style = .BOLD},
.KOREAN = {theme = themes.KOREA, native = "한국어", symbol_style = .KOREAN, congrats = "축하해요!", congrats_style = .KOREAN},
// Thai words with vowel/tone marks need text shaping, which raylib doesn't do
.THAI = {theme = themes.THAI, native = "ไทย", symbol_style = .THAI, congrats = "Well done!", congrats_style = .BOLD},
}
@(private, rodata)
japanese_decks := [?]Deck_Id{.HIRAGANA, .KATAKANA}
@(private, rodata)
korean_decks := [?]Deck_Id{.HANGUL}
@(private, rodata)
thai_decks := [?]Deck_Id{.THAI_CONSONANTS}
decks_for :: proc(language: Language) -> []Deck_Id {
switch language {
case .JAPANESE: return japanese_decks[:]
case .KOREAN: return korean_decks[:]
case .THAI: return thai_decks[:]
}
return nil
}
language_of :: proc(deck: Deck_Id) -> Language_Info {
return LANGUAGES[DECKS[deck].language]
}
entries :: proc(deck: Deck_Id) -> []Entry {
switch deck {
case .HIRAGANA: return hiragana[:]
case .KATAKANA: return katakana[:]
case .HANGUL: return hangul[:]
case .THAI_CONSONANTS: return thai_consonants[:]
}
return nil
}
// The 46 basic hiragana (gojūon)
@(private, rodata)
hiragana := [?]Entry{
{"あ", "a", nil}, {"い", "i", nil}, {"う", "u", nil}, {"え", "e", nil}, {"お", "o", nil},
{"か", "ka", nil}, {"き", "ki", nil}, {"く", "ku", nil}, {"け", "ke", nil}, {"こ", "ko", nil},
{"さ", "sa", nil}, {"し", "shi", nil}, {"す", "su", nil}, {"せ", "se", nil}, {"そ", "so", nil},
{"た", "ta", nil}, {"ち", "chi", nil}, {"つ", "tsu", nil}, {"て", "te", nil}, {"と", "to", nil},
{"な", "na", nil}, {"に", "ni", nil}, {"ぬ", "nu", nil}, {"ね", "ne", nil}, {"の", "no", nil},
{"は", "ha", nil}, {"ひ", "hi", nil}, {"ふ", "fu", nil}, {"へ", "he", nil}, {"ほ", "ho", nil},
{"ま", "ma", nil}, {"み", "mi", nil}, {"む", "mu", nil}, {"め", "me", nil}, {"も", "mo", nil},
{"や", "ya", nil}, {"ゆ", "yu", nil}, {"よ", "yo", nil},
{"ら", "ra", nil}, {"り", "ri", nil}, {"る", "ru", nil}, {"れ", "re", nil}, {"ろ", "ro", nil},
{"わ", "wa", nil}, {"を", "wo", nil}, {"ん", "n", nil},
}
// The 46 basic katakana
@(private, rodata)
katakana := [?]Entry{
{"ア", "a", nil}, {"イ", "i", nil}, {"ウ", "u", nil}, {"エ", "e", nil}, {"オ", "o", nil},
{"カ", "ka", nil}, {"キ", "ki", nil}, {"ク", "ku", nil}, {"ケ", "ke", nil}, {"コ", "ko", nil},
{"サ", "sa", nil}, {"シ", "shi", nil}, {"ス", "su", nil}, {"セ", "se", nil}, {"ソ", "so", nil},
{"タ", "ta", nil}, {"チ", "chi", nil}, {"ツ", "tsu", nil}, {"テ", "te", nil}, {"ト", "to", nil},
{"ナ", "na", nil}, {"ニ", "ni", nil}, {"ヌ", "nu", nil}, {"ネ", "ne", nil}, {"ノ", "no", nil},
{"ハ", "ha", nil}, {"ヒ", "hi", nil}, {"フ", "fu", nil}, {"ヘ", "he", nil}, {"ホ", "ho", nil},
{"マ", "ma", nil}, {"ミ", "mi", nil}, {"ム", "mu", nil}, {"メ", "me", nil}, {"モ", "mo", nil},
{"ヤ", "ya", nil}, {"ユ", "yu", nil}, {"ヨ", "yo", nil},
{"ラ", "ra", nil}, {"リ", "ri", nil}, {"ル", "ru", nil}, {"レ", "re", nil}, {"ロ", "ro", nil},
{"ワ", "wa", nil}, {"ヲ", "wo", nil}, {"ン", "n", nil},
}
// The 14 basic consonants and 10 basic vowels, Revised Romanization (initial position)
@(private, rodata)
hangul := [?]Entry{
{"ㄱ", "g", nil}, {"ㄴ", "n", nil}, {"ㄷ", "d", nil}, {"ㄹ", "r", nil}, {"ㅁ", "m", nil},
{"ㅂ", "b", nil}, {"ㅅ", "s", nil}, {"ㅇ", "ng", "silent first"}, {"ㅈ", "j", nil}, {"ㅊ", "ch", nil},
{"ㅋ", "k", nil}, {"ㅌ", "t", nil}, {"ㅍ", "p", nil}, {"ㅎ", "h", nil},
{"ㅏ", "a", nil}, {"ㅑ", "ya", nil}, {"ㅓ", "eo", nil}, {"ㅕ", "yeo", nil}, {"ㅗ", "o", nil},
{"ㅛ", "yo", nil}, {"ㅜ", "u", nil}, {"ㅠ", "yu", nil}, {"ㅡ", "eu", nil}, {"ㅣ", "i", nil},
}
// The 42 Thai consonants in use (ฃ and ฅ are obsolete). Many share a sound, so the
// hint is each letter's acrophonic name word (RTGS), which is unique.
@(private, rodata)
thai_consonants := [?]Entry{
{"ก", "k", "kai"}, {"ข", "kh", "khai"}, {"ค", "kh", "khwai"}, {"ฆ", "kh", "rakhang"},
{"ง", "ng", "ngu"}, {"จ", "ch", "chan"}, {"ฉ", "ch", "ching"}, {"ช", "ch", "chang"},
{"ซ", "s", "so"}, {"ฌ", "ch", "choe"}, {"ญ", "y", "ying"}, {"ฎ", "d", "chada"},
{"ฏ", "t", "patak"}, {"ฐ", "th", "than"}, {"ฑ", "th", "montho"}, {"ฒ", "th", "phuthao"},
{"ณ", "n", "nen"}, {"ด", "d", "dek"}, {"ต", "t", "tao"}, {"ถ", "th", "thung"},
{"ท", "th", "thahan"}, {"ธ", "th", "thong"}, {"น", "n", "nu"}, {"บ", "b", "baimai"},
{"ป", "p", "pla"}, {"ผ", "ph", "phueng"}, {"ฝ", "f", "fa"}, {"พ", "ph", "phan"},
{"ฟ", "f", "fan"}, {"ภ", "ph", "samphao"}, {"ม", "m", "ma"}, {"ย", "y", "yak"},
{"ร", "r", "ruea"}, {"ล", "l", "ling"}, {"ว", "w", "waen"}, {"ศ", "s", "sala"},
{"ษ", "s", "ruesi"}, {"ส", "s", "suea"}, {"ห", "h", "hip"}, {"ฬ", "l", "chula"},
{"อ", "o", "ang"}, {"ฮ", "h", "nokhuk"},
}
+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
}
+73 -50
View File
@@ -3,15 +3,11 @@ package screens
import "core:math"
import raylib "vendor:raylib"
import card "../card"
import decks "../decks"
import memory "../memory"
import themes "../themes"
import ui "../ui"
LANGUAGES :: [3]struct{ theme: themes.Theme, screen: themes.Screen }{
{themes.JAPAN, .JAPANESE_MENU},
{themes.KOREA, .KOREAN_MENU},
{themes.THAI, .THAI_MENU},
}
// Draws a card back like a playing card lying on the table
@(private)
draw_card_art :: proc(flag: themes.Flag, rect: raylib.Rectangle, rotation: f32 = 0) {
@@ -26,13 +22,18 @@ draw_card_art :: proc(flag: themes.Flag, rect: raylib.Rectangle, rotation: f32 =
raylib.DrawTexturePro(texture, source, dest, origin, rotation, themes.WHITE)
}
main_menu :: proc() -> themes.Screen {
next := themes.Screen.MAIN_MENU
@(private)
NUMBER_KEYS :: [?]raylib.KeyboardKey{.ONE, .TWO, .THREE, .FOUR, .FIVE}
// Returns the language picked this frame, if any
main_menu :: proc() -> Maybe(decks.Language) {
raylib.ClearBackground(themes.MENU_BACKGROUND)
languages := decks.LANGUAGES
keys := NUMBER_KEYS
// Thin stripes along the top in each language's primary color
stripe_w: i32 = themes.SCREEN_WIDTH / 3
for lang, i in LANGUAGES {
stripe_w: i32 = themes.SCREEN_WIDTH / len(decks.Language)
for lang, i in languages {
raylib.DrawRectangle(i32(i) * stripe_w, 0, stripe_w, 8, lang.theme.primary)
}
@@ -46,66 +47,88 @@ main_menu :: proc() -> themes.Screen {
start_x := (themes.SCREEN_WIDTH - total) / 2
// Gentle bobbing so the menu feels alive
time := f32(raylib.GetTime())
for lang, i in LANGUAGES {
picked: Maybe(decks.Language)
for lang, language in languages {
i := int(language)
x := start_x + f32(i) * (tile_w + gap)
bob := math.sin(time * 1.6 + f32(i) * 1.1) * 4
rotation := f32(i - 1) * 4
draw_card_art(lang.theme.flag, {x, 250 + bob, tile_w, tile_h}, rotation)
button_rect := raylib.Rectangle{x + tile_w / 2 - themes.BUTTON_WIDTH / 2 + 30, 500, themes.BUTTON_WIDTH - 60, themes.BUTTON_HEIGHT}
if ui.button(button_rect, lang.theme.name, lang.theme.primary) {
next = lang.screen
button_rect := raylib.Rectangle{x + tile_w / 2 - (themes.BUTTON_WIDTH - 60) / 2, 500, themes.BUTTON_WIDTH - 60, themes.BUTTON_HEIGHT}
if ui.button(button_rect, lang.theme.name, lang.theme.primary, hotkey = keys[i]) {
picked = language
}
}
ui.draw_text_centered("Esc to go back · Esc on this screen quits", themes.SCREEN_WIDTH / 2, 660, 18, raylib.Fade(themes.DARK_GRAY, 0.7))
return next
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))
return picked
}
// Shared layout for a language's menu screen. Returns the clicked option index, -1 for back, or nil.
@(private)
language_menu :: proc(theme: themes.Theme, subtitle: cstring, options: []cstring) -> Maybe(int) {
Language_Menu_Result :: union {
decks.Deck_Id, // start a game with this deck
themes.Screen, // go to another screen
}
// A language's menu: pick a deck and a difficulty
language_menu :: proc(language: decks.Language, difficulty: ^memory.Difficulty) -> Language_Menu_Result {
info := decks.LANGUAGES[language]
theme := info.theme
keys := NUMBER_KEYS
raylib.ClearBackground(theme.background)
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, 8, theme.primary)
ui.draw_title(theme.name, 70, theme.primary)
ui.draw_text_centered(subtitle, themes.SCREEN_WIDTH / 2, 170, themes.SUBTITLE_FONT_SIZE, theme.ink)
ui.draw_title(theme.name, 50, theme.primary)
ui.draw_text_centered(info.native, themes.SCREEN_WIDTH / 2, 140, 30, theme.ink, info.symbol_style)
// A small fan of card backs
for i in 0 ..< 3 {
offset := f32(i - 1)
draw_card_art(theme.flag, {themes.SCREEN_WIDTH / 2 - 55 + offset * 70, 235 + abs(offset) * 10, 110, 146}, offset * 9)
draw_card_art(theme.flag, {themes.SCREEN_WIDTH / 2 - 50 + offset * 64, 195 + abs(offset) * 10, 100, 133}, offset * 9)
}
result: Maybe(int)
y: f32 = 440
for option, i in options {
if ui.button({(themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, y, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT}, option, theme.primary) {
result = i
result: Language_Menu_Result
// One button per deck
deck_list := decks.decks_for(language)
button_w: f32 = 220
gap: f32 = 24
total := f32(len(deck_list)) * button_w + f32(len(deck_list) - 1) * gap
x := (themes.SCREEN_WIDTH - total) / 2
for deck, i in deck_list {
rect := raylib.Rectangle{x, 380, button_w, themes.BUTTON_HEIGHT}
if ui.button(rect, decks.DECKS[deck].name, theme.primary, hotkey = keys[i]) {
result = deck
}
y += themes.BUTTON_HEIGHT + 20
ui.draw_text_centered(decks.DECKS[deck].native, x + button_w / 2, 450, 22, theme.ink, info.symbol_style)
x += button_w + gap
}
if ui.button({(themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, y, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT}, "Back", theme.ink) {
result = -1
// Difficulty selector
names := memory.DIFFICULTY_NAMES
layouts := memory.LAYOUTS
ui.draw_text_centered("Difficulty (Left / Right)", themes.SCREEN_WIDTH / 2, 500, 20, raylib.Fade(theme.ink, 0.7))
seg_w: f32 = 150
seg_x := (themes.SCREEN_WIDTH - 3 * seg_w - 2 * 12) / 2
for level in memory.Difficulty {
selected := difficulty^ == level
color := selected ? theme.primary : raylib.ColorLerp(theme.primary, themes.WHITE, 0.55)
label := raylib.TextFormat("%s %d", names[level], layouts[level].rows * layouts[level].cols / 2)
if ui.button({seg_x, 530, seg_w, 48}, label, color, font_size = 22, selected = selected) {
difficulty^ = level
}
seg_x += seg_w + 12
}
if raylib.IsKeyPressed(.LEFT) && difficulty^ > .EASY {
difficulty^ = memory.Difficulty(int(difficulty^) - 1)
}
if raylib.IsKeyPressed(.RIGHT) && difficulty^ < .HARD {
difficulty^ = memory.Difficulty(int(difficulty^) + 1)
}
if ui.button({30, 28, 120, 48}, "Back", theme.ink, font_size = 22) {
result = themes.Screen.MAIN_MENU
}
hint := len(deck_list) > 1 ? cstring("1-2 pick a deck · Left / Right difficulty · Esc back") : cstring("1 start · Left / Right difficulty · Esc back")
ui.draw_text_centered(hint, themes.SCREEN_WIDTH / 2, 660, 18, raylib.Fade(theme.ink, 0.6))
return result
}
japanese_menu :: proc() -> themes.Screen {
choice, clicked := language_menu(themes.JAPAN, "ひらがな · Hiragana", {"Memory Game"}).?
if !clicked { return .JAPANESE_MENU }
return choice == 0 ? .JAPANESE_MEMORY : .MAIN_MENU
}
korean_menu :: proc() -> themes.Screen {
if _, clicked := language_menu(themes.KOREA, "Hangul · coming soon", {}).?; clicked {
return .MAIN_MENU
}
return .KOREAN_MENU
}
thai_menu :: proc() -> themes.Screen {
if _, clicked := language_menu(themes.THAI, "Thai script · coming soon", {}).?; clicked {
return .MAIN_MENU
}
return .THAI_MENU
}
+2 -6
View File
@@ -7,10 +7,8 @@ SCREEN_HEIGHT :: 720
Screen :: enum {
MAIN_MENU,
JAPANESE_MENU,
JAPANESE_MEMORY,
KOREAN_MENU,
THAI_MENU,
LANGUAGE_MENU,
MEMORY,
}
// Which national flag the card backs are modelled on
@@ -88,5 +86,3 @@ CARD_WIDTH :: 96
CARD_HEIGHT :: 128
CARD_SPACING :: 14
GRID_TOP :: 136
GRID_ROWS :: 4
GRID_COLS :: 6
+26 -15
View File
@@ -5,6 +5,8 @@ import assets "../assets"
import audio "../audio"
import themes "../themes"
Font_Style :: assets.Font_Style
TEXT_SPACING :: 1
@(private)
@@ -25,38 +27,42 @@ set_hovered :: proc() {
any_hovered = true
}
measure_text :: proc(text: cstring, size: f32, bold := false) -> raylib.Vector2 {
return raylib.MeasureTextEx(assets.get_font(bold), text, size, TEXT_SPACING)
measure_text :: proc(text: cstring, size: f32, style: Font_Style = .REGULAR) -> raylib.Vector2 {
return raylib.MeasureTextEx(assets.get_font(style), text, size, TEXT_SPACING)
}
draw_text :: proc(text: cstring, pos: raylib.Vector2, size: f32, color: raylib.Color, bold := false) {
raylib.DrawTextEx(assets.get_font(bold), text, pos, size, TEXT_SPACING, color)
draw_text :: proc(text: cstring, pos: raylib.Vector2, size: f32, color: raylib.Color, style: Font_Style = .REGULAR) {
raylib.DrawTextEx(assets.get_font(style), text, pos, size, TEXT_SPACING, color)
}
// Draws text horizontally centered on center_x with its top at y
draw_text_centered :: proc(text: cstring, center_x, y, size: f32, color: raylib.Color, bold := false, shadow := false) {
w := measure_text(text, size, bold).x
draw_text_centered :: proc(text: cstring, center_x, y, size: f32, color: raylib.Color,
style: Font_Style = .REGULAR, shadow := false) {
w := measure_text(text, size, style).x
pos := raylib.Vector2{center_x - w / 2, y}
if shadow {
draw_text(text, pos + {0, size * 0.05}, size, raylib.Fade(themes.BLACK, 0.18), bold)
draw_text(text, pos + {0, size * 0.05}, size, raylib.Fade(themes.BLACK, 0.18), style)
}
draw_text(text, pos, size, color, bold)
draw_text(text, pos, size, color, style)
}
// Draws text centered in a rectangle
draw_text_in_rect :: proc(text: cstring, rect: raylib.Rectangle, size: f32, color: raylib.Color, bold := false) {
dims := measure_text(text, size, bold)
draw_text_in_rect :: proc(text: cstring, rect: raylib.Rectangle, size: f32, color: raylib.Color,
style: Font_Style = .REGULAR) {
dims := measure_text(text, size, style)
pos := raylib.Vector2{rect.x + (rect.width - dims.x) / 2, rect.y + (rect.height - dims.y) / 2}
draw_text(text, pos, size, color, bold)
draw_text(text, pos, size, color, style)
}
draw_title :: proc(text: cstring, y: f32, color: raylib.Color, size: f32 = themes.TITLE_FONT_SIZE) {
draw_text_centered(text, themes.SCREEN_WIDTH / 2, y, size, color, bold = true, shadow = true)
draw_text_centered(text, themes.SCREEN_WIDTH / 2, y, size, color, style = .BOLD, shadow = true)
}
// A rounded button with hover, press and shadow states. Returns true when clicked.
// A rounded button with hover, press and shadow states. Returns true when clicked
// or when its hotkey is pressed.
button :: proc(rect: raylib.Rectangle, text: cstring, color: raylib.Color,
text_color := themes.WHITE, font_size: f32 = themes.BUTTON_FONT_SIZE) -> bool {
text_color := themes.WHITE, font_size: f32 = themes.BUTTON_FONT_SIZE,
hotkey := raylib.KeyboardKey.KEY_NULL, selected := false) -> bool {
hovered := raylib.CheckCollisionPointRec(raylib.GetMousePosition(), rect)
pressed := hovered && raylib.IsMouseButtonDown(.LEFT)
if hovered { set_hovered() }
@@ -76,9 +82,14 @@ button :: proc(rect: raylib.Rectangle, text: cstring, color: raylib.Color,
raylib.DrawRectangleRounded(shadow, 0.4, 12, raylib.Fade(themes.BLACK, 0.18))
body := raylib.Rectangle{rect.x, rect.y - lift, rect.width, rect.height}
raylib.DrawRectangleRounded(body, 0.4, 12, fill)
draw_text_in_rect(text, body, font_size, text_color, bold = true)
if selected {
ring := raylib.Rectangle{body.x - 4, body.y - 4, body.width + 8, body.height + 8}
raylib.DrawRectangleRoundedLinesEx(ring, 0.4, 12, 3, color)
}
draw_text_in_rect(text, body, font_size, text_color, style = .BOLD)
clicked := hovered && raylib.IsMouseButtonPressed(.LEFT)
if hotkey != .KEY_NULL && raylib.IsKeyPressed(hotkey) { clicked = true }
if clicked { audio.play(.CLICK) }
return clicked
}
+128 -54
View File
@@ -1,6 +1,8 @@
package tests
import "core:testing"
import assets "../src/assets"
import decks "../src/decks"
import card "../src/card"
import memory "../src/memory"
import themes "../src/themes"
@@ -17,7 +19,7 @@ advance :: proc(seconds: f32) {
// Finds the card index holding the other half of card i's pair
@(private)
partner_of :: proc(i: int) -> int {
for c, j in memory.cards {
for c, j in memory.active_cards() {
if j != i && c.pair_id == memory.cards[i].pair_id { return j }
}
return -1
@@ -26,76 +28,148 @@ partner_of :: proc(i: int) -> int {
// Finds a card index that does not pair with card i
@(private)
non_partner_of :: proc(i: int) -> int {
for c, j in memory.cards {
for c, j in memory.active_cards() {
if j != i && c.pair_id != memory.cards[i].pair_id { return j }
}
return -1
}
@(test)
hiragana_table_is_complete_and_unique :: proc(t: ^testing.T) {
table := memory.HIRAGANA
testing.expect_value(t, len(table), 46)
for a, i in table {
for b, j in table {
if i == j { continue }
testing.expectf(t, a.kana != b.kana, "duplicate kana %s", a.kana)
testing.expectf(t, a.romaji != b.romaji, "duplicate romaji %s", a.romaji)
deck_sizes :: proc(t: ^testing.T) {
testing.expect_value(t, len(decks.entries(.HIRAGANA)), 46)
testing.expect_value(t, len(decks.entries(.KATAKANA)), 46)
testing.expect_value(t, len(decks.entries(.HANGUL)), 24)
testing.expect_value(t, len(decks.entries(.THAI_CONSONANTS)), 42)
}
@(test)
decks_have_unique_symbols_and_readings :: proc(t: ^testing.T) {
for deck in decks.Deck_Id {
entries := decks.entries(deck)
for a, i in entries {
for b, j in entries {
if i == j { continue }
testing.expectf(t, a.symbol != b.symbol, "%v: duplicate symbol %s", deck, a.symbol)
// Readings may repeat (e.g. Thai "kh") only when the hint tells them apart
same_reading := a.reading == b.reading && a.hint == b.hint
testing.expectf(t, !same_reading, "%v: %s and %s read the same", deck, a.symbol, b.symbol)
}
}
}
}
@(private)
covers :: proc(codepoints: []rune, text: cstring) -> (missing: rune, ok: bool) {
outer: for r in string(text) {
for c in codepoints {
if c == r { continue outer }
}
return r, false
}
return 0, true
}
// raylib renders "?" for any glyph that wasn't baked into the font
@(test)
fonts_cover_every_deck_character :: proc(t: ^testing.T) {
for deck in decks.Deck_Id {
symbol_points := assets.codepoints_for(decks.language_of(deck).symbol_style)
defer delete(symbol_points)
ui_points := assets.codepoints_for(.BOLD)
defer delete(ui_points)
for entry in decks.entries(deck) {
if r, ok := covers(symbol_points[:], entry.symbol); !ok {
testing.expectf(t, false, "%v: %s needs %r in the symbol font", deck, entry.symbol, r)
}
if r, ok := covers(ui_points[:], entry.reading); !ok {
testing.expectf(t, false, "%v: %s needs %r in the UI font", deck, entry.reading, r)
}
if entry.hint == nil { continue }
if r, ok := covers(ui_points[:], entry.hint); !ok {
testing.expectf(t, false, "%v: %s needs %r in the UI font", deck, entry.hint, r)
}
}
info := decks.DECKS[deck]
if r, ok := covers(symbol_points[:], info.native); !ok {
testing.expectf(t, false, "%v: deck name %s needs %r", deck, info.native, r)
}
}
for language in decks.Language {
info := decks.LANGUAGES[language]
points := assets.codepoints_for(info.congrats_style)
defer delete(points)
if r, ok := covers(points[:], info.congrats); !ok {
testing.expectf(t, false, "%v: %s needs %r", language, info.congrats, r)
}
native_points := assets.codepoints_for(info.symbol_style)
defer delete(native_points)
if r, ok := covers(native_points[:], info.native); !ok {
testing.expectf(t, false, "%v: %s needs %r", language, info.native, r)
}
}
}
@(test)
deal_creates_kana_romaji_pairs :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN)
testing.expect_value(t, memory.state, memory.State.WAITING)
testing.expect_value(t, memory.moves, 0)
testing.expect_value(t, memory.matches, 0)
deal_creates_symbol_reading_pairs :: proc(t: ^testing.T) {
for deck in decks.Deck_Id {
for difficulty in memory.Difficulty {
memory.deal(deck, difficulty)
layout := memory.LAYOUTS[difficulty]
testing.expect_value(t, memory.card_count, layout.rows * layout.cols)
testing.expect_value(t, memory.pair_count, memory.card_count / 2)
testing.expect_value(t, memory.state, memory.State.WAITING)
testing.expect_value(t, memory.moves, 0)
testing.expect_value(t, memory.matches, 0)
for pair in 0 ..< memory.PAIR_COUNT {
kana_count, romaji_count := 0, 0
kana, romaji: cstring
for c in memory.cards {
if c.pair_id != pair { continue }
testing.expect(t, !c.face_up && !c.matched)
if c.is_kana {
kana_count += 1
kana = c.text
} else {
romaji_count += 1
romaji = c.text
for pair in 0 ..< memory.pair_count {
symbol_count, reading_count := 0, 0
symbol, reading, hint: cstring
for c in memory.active_cards() {
if c.pair_id != pair { continue }
testing.expect(t, !c.face_up && !c.matched)
if c.is_symbol {
symbol_count += 1
symbol = c.text
} else {
reading_count += 1
reading = c.text
hint = c.hint
}
}
testing.expect_value(t, symbol_count, 1)
testing.expect_value(t, reading_count, 1)
// The two halves must belong to the same deck entry
found := false
for entry in decks.entries(deck) {
if entry.symbol == symbol && entry.reading == reading && entry.hint == hint { found = true }
}
testing.expectf(t, found, "%v pair %d is %s/%s, which is not in the deck", deck, pair, symbol, reading)
}
}
testing.expect_value(t, kana_count, 1)
testing.expect_value(t, romaji_count, 1)
// The two halves must belong to the same table entry
found := false
for entry in memory.HIRAGANA {
if entry.kana == kana && entry.romaji == romaji { found = true }
}
testing.expectf(t, found, "pair %d is %s/%s, which is not in the table", pair, kana, romaji)
}
}
@(test)
deal_lays_out_cards_on_screen_without_overlap :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN)
for a, i in memory.cards {
testing.expect(t, a.rect.x >= 0 && a.rect.x + a.rect.width <= themes.SCREEN_WIDTH)
testing.expect(t, a.rect.y >= themes.GRID_TOP && a.rect.y + a.rect.height <= themes.SCREEN_HEIGHT)
for b, j in memory.cards {
if i == j { continue }
overlap := a.rect.x < b.rect.x + b.rect.width && b.rect.x < a.rect.x + a.rect.width &&
a.rect.y < b.rect.y + b.rect.height && b.rect.y < a.rect.y + a.rect.height
testing.expectf(t, !overlap, "cards %d and %d overlap", i, j)
for difficulty in memory.Difficulty {
memory.deal(.HIRAGANA, difficulty)
for a, i in memory.active_cards() {
testing.expect(t, a.rect.x >= 0 && a.rect.x + a.rect.width <= themes.SCREEN_WIDTH)
testing.expect(t, a.rect.y >= themes.GRID_TOP && a.rect.y + a.rect.height <= themes.SCREEN_HEIGHT)
for b, j in memory.active_cards() {
if i == j { continue }
overlap := a.rect.x < b.rect.x + b.rect.width && b.rect.x < a.rect.x + a.rect.width &&
a.rect.y < b.rect.y + b.rect.height && b.rect.y < a.rect.y + a.rect.height
testing.expectf(t, !overlap, "cards %d and %d overlap", i, j)
}
}
}
}
@(test)
matching_pair_stays_up :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN)
memory.deal(.HIRAGANA, .NORMAL)
a := 0
b := partner_of(a)
@@ -114,7 +188,7 @@ matching_pair_stays_up :: proc(t: ^testing.T) {
@(test)
mismatched_pair_flips_back :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN)
memory.deal(.HIRAGANA, .NORMAL)
a := 0
b := non_partner_of(a)
@@ -130,7 +204,7 @@ mismatched_pair_flips_back :: proc(t: ^testing.T) {
@(test)
invalid_picks_are_rejected :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN)
memory.deal(.HIRAGANA, .NORMAL)
a := 0
testing.expect(t, memory.select_card(a))
testing.expect(t, !memory.select_card(a)) // same card twice
@@ -142,7 +216,7 @@ invalid_picks_are_rejected :: proc(t: ^testing.T) {
testing.expect(t, !memory.select_card(c)) // a third card while resolving
testing.expect_value(t, memory.moves, 1)
memory.deal(themes.JAPAN)
memory.deal(.HIRAGANA, .NORMAL)
p := partner_of(0)
memory.select_card(0)
memory.select_card(p)
@@ -152,17 +226,17 @@ invalid_picks_are_rejected :: proc(t: ^testing.T) {
@(test)
finding_all_pairs_wins :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN)
for i in 0 ..< memory.CARD_COUNT {
memory.deal(.HIRAGANA, .NORMAL)
for i in 0 ..< memory.card_count {
if memory.cards[i].matched { continue }
memory.select_card(i)
memory.select_card(partner_of(i))
advance(2)
}
testing.expect_value(t, memory.state, memory.State.OVER)
testing.expect_value(t, memory.matches, memory.PAIR_COUNT)
testing.expect_value(t, memory.moves, memory.PAIR_COUNT)
for &c in memory.cards { testing.expect(t, c.matched) }
testing.expect_value(t, memory.matches, memory.pair_count)
testing.expect_value(t, memory.moves, memory.pair_count)
for &c in memory.active_cards() { testing.expect(t, c.matched) }
// The clock stops once the game is over
elapsed := memory.elapsed