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 assets "src/assets"
import audio "src/audio" import audio "src/audio"
import card "src/card" import card "src/card"
import decks "src/decks"
import memory "src/memory" import memory "src/memory"
import screens "src/screens" import screens "src/screens"
import themes "src/themes" import themes "src/themes"
@@ -12,9 +13,9 @@ import ui "src/ui"
// Where Escape takes you from each screen (nil = quit) // Where Escape takes you from each screen (nil = quit)
parent_screen :: proc(screen: themes.Screen) -> Maybe(themes.Screen) { parent_screen :: proc(screen: themes.Screen) -> Maybe(themes.Screen) {
switch screen { switch screen {
case .MAIN_MENU: return nil case .MAIN_MENU: return nil
case .JAPANESE_MEMORY: return .JAPANESE_MENU case .LANGUAGE_MENU: return .MAIN_MENU
case .JAPANESE_MENU, .KOREAN_MENU, .THAI_MENU: return .MAIN_MENU case .MEMORY: return .LANGUAGE_MENU
} }
return nil return nil
} }
@@ -40,6 +41,8 @@ main :: proc() {
defer memory.unload() defer memory.unload()
screen := themes.Screen.MAIN_MENU screen := themes.Screen.MAIN_MENU
language := decks.Language.JAPANESE
difficulty := memory.Difficulty.NORMAL
running := true running := true
for running && !raylib.WindowShouldClose() { for running && !raylib.WindowShouldClose() {
dt := raylib.GetFrameTime() dt := raylib.GetFrameTime()
@@ -52,27 +55,37 @@ main :: proc() {
} }
} }
if screen == .JAPANESE_MEMORY { if screen == .MEMORY {
memory.update(dt) memory.update(dt)
} }
raylib.BeginDrawing() raylib.BeginDrawing()
ui.begin_frame() ui.begin_frame()
next := screen next := screen
start_deck: Maybe(decks.Deck_Id)
switch screen { switch screen {
case .MAIN_MENU: next = screens.main_menu() case .MAIN_MENU:
case .JAPANESE_MENU: next = screens.japanese_menu() if picked, ok := screens.main_menu().?; ok {
case .JAPANESE_MEMORY: next = memory.draw() language = picked
case .KOREAN_MENU: next = screens.korean_menu() next = .LANGUAGE_MENU
case .THAI_MENU: next = screens.thai_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() ui.end_frame()
raylib.EndDrawing() raylib.EndDrawing()
// Start a fresh game when entering the memory screen (outside of drawing, // Building card faces renders into textures, so do it outside of drawing
// because building card faces renders into textures) if deck, ok := start_deck.?; ok {
if next == .JAPANESE_MEMORY && screen != .JAPANESE_MEMORY { memory.start(deck, difficulty)
memory.start(themes.JAPAN)
} }
screen = next screen = next
} }
+51 -22
View File
@@ -2,28 +2,57 @@ package assets
import raylib "vendor:raylib" 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 // Glyphs are rasterized at this size and scaled down when drawn
FONT_BASE_SIZE :: 128 FONT_BASE_SIZE :: 128
regular_font: raylib.Font Font_Style :: enum {
bold_font: raylib.Font 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 @(rodata)
@(private) FONT_PATHS := [Font_Style]cstring{
build_codepoints :: proc() -> [dynamic]rune { .REGULAR = "assets/MPLUSRounded1c-Medium.ttf",
codepoints: [dynamic]rune .BOLD = "assets/MPLUSRounded1c-ExtraBold.ttf",
for r in rune(0x20) ..= 0x7E { append(&codepoints, r) } // ASCII .KOREAN = "assets/Jua-Regular.ttf",
for r in rune(0x3041) ..= 0x3096 { append(&codepoints, r) } // Hiragana .THAI = "assets/Mali-SemiBold.ttf",
for r in rune(0x30A1) ..= 0x30FC { append(&codepoints, r) } // Katakana }
for r in "日本語・·āō" { append(&codepoints, r) }
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 return codepoints
} }
@(private) @(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))) font := raylib.LoadFontEx(path, FONT_BASE_SIZE, raw_data(codepoints), i32(len(codepoints)))
if !raylib.IsFontValid(font) || font.texture.id == 0 { if !raylib.IsFontValid(font) || font.texture.id == 0 {
raylib.TraceLog(.WARNING, "ASSETS: Falling back to default font, could not load %s", path) 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() { load_fonts :: proc() {
codepoints := build_codepoints() for style in Font_Style {
defer delete(codepoints) fonts[style] = load_font(style)
regular_font = load_font(REGULAR_FONT_PATH, codepoints[:]) }
bold_font = load_font(BOLD_FONT_PATH, codepoints[:])
} }
unload_fonts :: proc() { unload_fonts :: proc() {
default_id := raylib.GetFontDefault().texture.id default_id := raylib.GetFontDefault().texture.id
if regular_font.texture.id != default_id { raylib.UnloadFont(regular_font) } for font in fonts {
if bold_font.texture.id != default_id { raylib.UnloadFont(bold_font) } if font.texture.id != default_id { raylib.UnloadFont(font) }
}
} }
get_font :: proc(bold := false) -> raylib.Font { get_font :: proc(style: Font_Style = .REGULAR) -> raylib.Font {
return bold ? bold_font : regular_font return fonts[style]
} }
+26 -11
View File
@@ -2,6 +2,7 @@ package card
import "core:math" import "core:math"
import raylib "vendor:raylib" import raylib "vendor:raylib"
import assets "../assets"
import themes "../themes" import themes "../themes"
import ui "../ui" import ui "../ui"
@@ -19,8 +20,9 @@ HOVER_LIFT :: 4
Card :: struct { Card :: struct {
rect: raylib.Rectangle, // resting position on screen rect: raylib.Rectangle, // resting position on screen
text: cstring, text: cstring,
is_kana: bool, hint: cstring, // small second line on reading cards
is_symbol: bool, // script symbol (vs. its reading)
pair_id: int, pair_id: int,
face_up: bool, // logical state; the animation follows it face_up: bool, // logical state; the animation follows it
matched: bool, matched: bool,
@@ -154,21 +156,34 @@ get_back_art :: proc(flag: themes.Flag) -> raylib.Texture2D {
return back_art[flag].texture return back_art[flag].texture
} }
// Renders the card's face into its own texture // Font size that fits text inside the card width
build_face :: proc(card: ^Card, theme: themes.Theme) { @(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) unload_face(card)
card.face_art = new_art_texture(theme.primary) card.face_art = new_art_texture(theme.primary)
raylib.BeginTextureMode(card.face_art) raylib.BeginTextureMode(card.face_art)
draw_frame(theme.card_face, theme.primary) draw_frame(theme.card_face, theme.primary)
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, raylib.Fade(theme.primary, 0.35)) raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, raylib.Fade(theme.primary, 0.35))
size: f32 = card.is_kana ? 132 : 96 if card.is_symbol {
color := card.is_kana ? theme.ink : theme.primary ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 132, symbol_style), theme.ink, symbol_style)
// Shrink long romaji (e.g. "TSU") to fit inside the card } else if card.hint == nil {
max_width: f32 = ART_WIDTH * 0.78 ui.draw_text_in_rect(card.text, art_rect(), fit_size(card.text, 96, .BOLD), theme.primary, .BOLD)
width := ui.measure_text(card.text, size, bold = true).x } else {
if width > max_width { size *= max_width / width } // Reading on top, hint underneath
ui.draw_text_in_rect(card.text, art_rect(), size, color, bold = true) 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() 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 raylib "vendor:raylib"
import audio "../audio" import audio "../audio"
import card "../card" import card "../card"
import decks "../decks"
import themes "../themes" import themes "../themes"
import ui "../ui" import ui "../ui"
PAIR_COUNT :: 12 MAX_CARDS :: 32
CARD_COUNT :: PAIR_COUNT * 2
MATCH_DELAY :: 0.35 // seconds both cards stay up before a match is confirmed 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 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 { State :: enum {
WAITING, // no card selected WAITING, // no card selected
FIRST, // one card selected FIRST, // one card selected
RESOLVING, // two cards up, waiting before match/mismatch RESOLVING, // two cards up, waiting before match/mismatch
OVER, OVER,
} }
Kana :: struct { cards: [MAX_CARDS]card.Card
kana: cstring, card_count: int
romaji: cstring, pair_count: int
} deck: decks.Deck_Id
difficulty: Difficulty
// 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
theme: themes.Theme theme: themes.Theme
layout: Layout
state: State
first: int = -1 first: int = -1
second: int = -1 second: int = -1
matches: int matches: int
@@ -52,33 +58,46 @@ win_t: f32 // fades in the win panel
restart_requested: bool restart_requested: bool
// 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(game_theme: themes.Theme) { start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
deal(game_theme) deal(deck_id, game_difficulty)
for &c in cards { card.build_face(&c, theme) } 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 // Shuffles, lays out and resets the game state without touching the GPU
deal :: proc(game_theme: themes.Theme) { deal :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
theme = game_theme 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 // Pick random entries from the deck
rand.shuffle(deck[:]) entries := decks.entries(deck)
for i in 0 ..< PAIR_COUNT { order := make([]int, len(entries), context.temp_allocator)
card.unload_face(&cards[i * 2]) for &o, i in order { o = i }
card.unload_face(&cards[i * 2 + 1]) rand.shuffle(order)
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} 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 start_x := (themes.SCREEN_WIDTH - total_w) / 2
for &c, i in cards { start_y := themes.GRID_TOP + (themes.SCREEN_HEIGHT - 16 - themes.GRID_TOP - total_h) / 2
row := f32(i / themes.GRID_COLS) for &c, i in active_cards() {
col := f32(i % themes.GRID_COLS)
c.rect = { c.rect = {
start_x + col * (themes.CARD_WIDTH + themes.CARD_SPACING), start_x + f32(i % layout.cols) * step_x,
themes.GRID_TOP + row * (themes.CARD_HEIGHT + themes.CARD_SPACING), start_y + f32(i / layout.cols) * step_y,
themes.CARD_WIDTH, themes.CARD_WIDTH,
themes.CARD_HEIGHT, themes.CARD_HEIGHT,
} }
@@ -95,11 +114,16 @@ deal :: proc(game_theme: themes.Theme) {
restart_requested = false restart_requested = false
} }
active_cards :: proc() -> []card.Card {
return cards[:card_count]
}
unload :: proc() { unload :: proc() {
for &c in cards { card.unload_face(&c) } for &c in cards { card.unload_face(&c) }
} }
can_pick :: proc(i: int) -> bool { can_pick :: proc(i: int) -> bool {
if i < 0 || i >= card_count { return false }
c := &cards[i] c := &cards[i]
return (state == .WAITING || state == .FIRST) && !c.face_up && !c.matched return (state == .WAITING || state == .FIRST) && !c.face_up && !c.matched
} }
@@ -131,7 +155,7 @@ resolve :: proc() {
card.start_pulse(a) card.start_pulse(a)
card.start_pulse(b) card.start_pulse(b)
matches += 1 matches += 1
if matches == PAIR_COUNT { if matches == pair_count {
state = .OVER state = .OVER
audio.play(.WIN) audio.play(.WIN)
} else { } else {
@@ -151,17 +175,17 @@ resolve :: proc() {
update :: proc(dt: f32) { update :: proc(dt: f32) {
if restart_requested { if restart_requested {
start(theme) start(deck, difficulty)
return return
} }
for &c in cards { card.update(&c, dt) } for &c in active_cards() { card.update(&c, dt) }
switch state { switch state {
case .WAITING, .FIRST: case .WAITING, .FIRST:
elapsed += dt elapsed += dt
if raylib.IsMouseButtonPressed(.LEFT) { if raylib.IsMouseButtonPressed(.LEFT) {
for &c, i in cards { for &c, i in active_cards() {
if card.is_hovered(&c) && select_card(i) { if card.is_hovered(&c) && select_card(i) {
break break
} }
@@ -181,20 +205,22 @@ update :: proc(dt: f32) {
// Draws the game and returns the screen to show next frame // Draws the game and returns the screen to show next frame
draw :: proc() -> themes.Screen { draw :: proc() -> themes.Screen {
next := themes.Screen.JAPANESE_MEMORY next := themes.Screen.MEMORY
raylib.ClearBackground(theme.background) 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 minutes := int(elapsed) / 60
seconds := int(elapsed) % 60 seconds := int(elapsed) % 60
info := raylib.TextFormat("Moves %d · Pairs %d/%d · %d:%02d", moves, matches, PAIR_COUNT, minutes, seconds) stats := raylib.TextFormat("%s · Moves %d · Pairs %d/%d · %d:%02d",
ui.draw_text_centered(info, themes.SCREEN_WIDTH / 2, 88, 24, theme.ink) 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) { 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)) 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.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)) raylib.DrawRectangleRoundedLinesEx(panel, 0.12, 16, 4, raylib.Fade(theme.primary, win_t))
language := decks.language_of(deck)
cx := panel.x + panel_w / 2 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(language.congrats, cx, panel.y + 34, 56, raylib.Fade(theme.primary, win_t), language.congrats_style)
ui.draw_text_centered("Congratulations, all pairs found!", cx, panel.y + 112, 26, raylib.Fade(theme.ink, win_t)) ui.draw_text_centered("All pairs found!", cx, panel.y + 112, 26, raylib.Fade(theme.ink, win_t))
minutes := int(elapsed) / 60 minutes := int(elapsed) / 60
seconds := int(elapsed) % 60 seconds := int(elapsed) % 60
stats := raylib.TextFormat("%d moves · %d:%02d", moves, minutes, seconds) 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 } if win_t < 1 { return nil }
button_w: f32 = 200 button_w: f32 = 200
@@ -233,7 +260,7 @@ draw_win_panel :: proc() -> Maybe(themes.Screen) {
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) {
return .JAPANESE_MENU return .LANGUAGE_MENU
} }
return nil return nil
} }
+73 -50
View File
@@ -3,15 +3,11 @@ package screens
import "core:math" import "core:math"
import raylib "vendor:raylib" import raylib "vendor:raylib"
import card "../card" import card "../card"
import decks "../decks"
import memory "../memory"
import themes "../themes" import themes "../themes"
import ui "../ui" 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 // Draws a card back like a playing card lying on the table
@(private) @(private)
draw_card_art :: proc(flag: themes.Flag, rect: raylib.Rectangle, rotation: f32 = 0) { 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) raylib.DrawTexturePro(texture, source, dest, origin, rotation, themes.WHITE)
} }
main_menu :: proc() -> themes.Screen { @(private)
next := themes.Screen.MAIN_MENU 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) raylib.ClearBackground(themes.MENU_BACKGROUND)
languages := decks.LANGUAGES
keys := NUMBER_KEYS
// Thin stripes along the top in each language's primary color // Thin stripes along the top in each language's primary color
stripe_w: i32 = themes.SCREEN_WIDTH / 3 stripe_w: i32 = themes.SCREEN_WIDTH / len(decks.Language)
for lang, i in LANGUAGES { for lang, i in languages {
raylib.DrawRectangle(i32(i) * stripe_w, 0, stripe_w, 8, lang.theme.primary) 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 start_x := (themes.SCREEN_WIDTH - total) / 2
// Gentle bobbing so the menu feels alive // Gentle bobbing so the menu feels alive
time := f32(raylib.GetTime()) 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) x := start_x + f32(i) * (tile_w + gap)
bob := math.sin(time * 1.6 + f32(i) * 1.1) * 4 bob := math.sin(time * 1.6 + f32(i) * 1.1) * 4
rotation := f32(i - 1) * 4 rotation := f32(i - 1) * 4
draw_card_art(lang.theme.flag, {x, 250 + bob, tile_w, tile_h}, rotation) 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} 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) { if ui.button(button_rect, lang.theme.name, lang.theme.primary, hotkey = keys[i]) {
next = lang.screen 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)) 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 next return picked
} }
// Shared layout for a language's menu screen. Returns the clicked option index, -1 for back, or nil. Language_Menu_Result :: union {
@(private) decks.Deck_Id, // start a game with this deck
language_menu :: proc(theme: themes.Theme, subtitle: cstring, options: []cstring) -> Maybe(int) { 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.ClearBackground(theme.background)
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, 8, theme.primary) raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, 8, theme.primary)
ui.draw_title(theme.name, 70, theme.primary) ui.draw_title(theme.name, 50, theme.primary)
ui.draw_text_centered(subtitle, themes.SCREEN_WIDTH / 2, 170, themes.SUBTITLE_FONT_SIZE, theme.ink) ui.draw_text_centered(info.native, themes.SCREEN_WIDTH / 2, 140, 30, theme.ink, info.symbol_style)
// A small fan of card backs // A small fan of card backs
for i in 0 ..< 3 { for i in 0 ..< 3 {
offset := f32(i - 1) 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) result: Language_Menu_Result
y: f32 = 440
for option, i in options { // One button per deck
if ui.button({(themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, y, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT}, option, theme.primary) { deck_list := decks.decks_for(language)
result = i 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 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 { Screen :: enum {
MAIN_MENU, MAIN_MENU,
JAPANESE_MENU, LANGUAGE_MENU,
JAPANESE_MEMORY, MEMORY,
KOREAN_MENU,
THAI_MENU,
} }
// Which national flag the card backs are modelled on // Which national flag the card backs are modelled on
@@ -88,5 +86,3 @@ CARD_WIDTH :: 96
CARD_HEIGHT :: 128 CARD_HEIGHT :: 128
CARD_SPACING :: 14 CARD_SPACING :: 14
GRID_TOP :: 136 GRID_TOP :: 136
GRID_ROWS :: 4
GRID_COLS :: 6
+26 -15
View File
@@ -5,6 +5,8 @@ import assets "../assets"
import audio "../audio" import audio "../audio"
import themes "../themes" import themes "../themes"
Font_Style :: assets.Font_Style
TEXT_SPACING :: 1 TEXT_SPACING :: 1
@(private) @(private)
@@ -25,38 +27,42 @@ set_hovered :: proc() {
any_hovered = true any_hovered = true
} }
measure_text :: proc(text: cstring, size: f32, bold := false) -> raylib.Vector2 { measure_text :: proc(text: cstring, size: f32, style: Font_Style = .REGULAR) -> raylib.Vector2 {
return raylib.MeasureTextEx(assets.get_font(bold), text, size, TEXT_SPACING) 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) { draw_text :: proc(text: cstring, pos: raylib.Vector2, size: f32, color: raylib.Color, style: Font_Style = .REGULAR) {
raylib.DrawTextEx(assets.get_font(bold), text, pos, size, TEXT_SPACING, color) raylib.DrawTextEx(assets.get_font(style), text, pos, size, TEXT_SPACING, color)
} }
// Draws text horizontally centered on center_x with its top at y // 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) { draw_text_centered :: proc(text: cstring, center_x, y, size: f32, color: raylib.Color,
w := measure_text(text, size, bold).x style: Font_Style = .REGULAR, shadow := false) {
w := measure_text(text, size, style).x
pos := raylib.Vector2{center_x - w / 2, y} pos := raylib.Vector2{center_x - w / 2, y}
if shadow { 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 // Draws text centered in a rectangle
draw_text_in_rect :: proc(text: cstring, rect: raylib.Rectangle, size: f32, color: raylib.Color, bold := false) { draw_text_in_rect :: proc(text: cstring, rect: raylib.Rectangle, size: f32, color: raylib.Color,
dims := measure_text(text, size, bold) 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} 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_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, 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) hovered := raylib.CheckCollisionPointRec(raylib.GetMousePosition(), rect)
pressed := hovered && raylib.IsMouseButtonDown(.LEFT) pressed := hovered && raylib.IsMouseButtonDown(.LEFT)
if hovered { set_hovered() } 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)) raylib.DrawRectangleRounded(shadow, 0.4, 12, raylib.Fade(themes.BLACK, 0.18))
body := raylib.Rectangle{rect.x, rect.y - lift, rect.width, rect.height} body := raylib.Rectangle{rect.x, rect.y - lift, rect.width, rect.height}
raylib.DrawRectangleRounded(body, 0.4, 12, fill) 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) clicked := hovered && raylib.IsMouseButtonPressed(.LEFT)
if hotkey != .KEY_NULL && raylib.IsKeyPressed(hotkey) { clicked = true }
if clicked { audio.play(.CLICK) } if clicked { audio.play(.CLICK) }
return clicked return clicked
} }
+128 -54
View File
@@ -1,6 +1,8 @@
package tests package tests
import "core:testing" import "core:testing"
import assets "../src/assets"
import decks "../src/decks"
import card "../src/card" import card "../src/card"
import memory "../src/memory" import memory "../src/memory"
import themes "../src/themes" 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 // Finds the card index holding the other half of card i's pair
@(private) @(private)
partner_of :: proc(i: int) -> int { 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 } if j != i && c.pair_id == memory.cards[i].pair_id { return j }
} }
return -1 return -1
@@ -26,76 +28,148 @@ partner_of :: proc(i: int) -> int {
// Finds a card index that does not pair with card i // Finds a card index that does not pair with card i
@(private) @(private)
non_partner_of :: proc(i: int) -> int { 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 } if j != i && c.pair_id != memory.cards[i].pair_id { return j }
} }
return -1 return -1
} }
@(test) @(test)
hiragana_table_is_complete_and_unique :: proc(t: ^testing.T) { deck_sizes :: proc(t: ^testing.T) {
table := memory.HIRAGANA testing.expect_value(t, len(decks.entries(.HIRAGANA)), 46)
testing.expect_value(t, len(table), 46) testing.expect_value(t, len(decks.entries(.KATAKANA)), 46)
for a, i in table { testing.expect_value(t, len(decks.entries(.HANGUL)), 24)
for b, j in table { testing.expect_value(t, len(decks.entries(.THAI_CONSONANTS)), 42)
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) @(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) @(test)
deal_creates_kana_romaji_pairs :: proc(t: ^testing.T) { deal_creates_symbol_reading_pairs :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN) for deck in decks.Deck_Id {
testing.expect_value(t, memory.state, memory.State.WAITING) for difficulty in memory.Difficulty {
testing.expect_value(t, memory.moves, 0) memory.deal(deck, difficulty)
testing.expect_value(t, memory.matches, 0) 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 { for pair in 0 ..< memory.pair_count {
kana_count, romaji_count := 0, 0 symbol_count, reading_count := 0, 0
kana, romaji: cstring symbol, reading, hint: cstring
for c in memory.cards { for c in memory.active_cards() {
if c.pair_id != pair { continue } if c.pair_id != pair { continue }
testing.expect(t, !c.face_up && !c.matched) testing.expect(t, !c.face_up && !c.matched)
if c.is_kana { if c.is_symbol {
kana_count += 1 symbol_count += 1
kana = c.text symbol = c.text
} else { } else {
romaji_count += 1 reading_count += 1
romaji = c.text 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) @(test)
deal_lays_out_cards_on_screen_without_overlap :: proc(t: ^testing.T) { deal_lays_out_cards_on_screen_without_overlap :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN) for difficulty in memory.Difficulty {
for a, i in memory.cards { memory.deal(.HIRAGANA, difficulty)
testing.expect(t, a.rect.x >= 0 && a.rect.x + a.rect.width <= themes.SCREEN_WIDTH) for a, i in memory.active_cards() {
testing.expect(t, a.rect.y >= themes.GRID_TOP && a.rect.y + a.rect.height <= themes.SCREEN_HEIGHT) testing.expect(t, a.rect.x >= 0 && a.rect.x + a.rect.width <= themes.SCREEN_WIDTH)
for b, j in memory.cards { testing.expect(t, a.rect.y >= themes.GRID_TOP && a.rect.y + a.rect.height <= themes.SCREEN_HEIGHT)
if i == j { continue } for b, j in memory.active_cards() {
overlap := a.rect.x < b.rect.x + b.rect.width && b.rect.x < a.rect.x + a.rect.width && if i == j { continue }
a.rect.y < b.rect.y + b.rect.height && b.rect.y < a.rect.y + a.rect.height overlap := a.rect.x < b.rect.x + b.rect.width && b.rect.x < a.rect.x + a.rect.width &&
testing.expectf(t, !overlap, "cards %d and %d overlap", i, j) 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) @(test)
matching_pair_stays_up :: proc(t: ^testing.T) { matching_pair_stays_up :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN) memory.deal(.HIRAGANA, .NORMAL)
a := 0 a := 0
b := partner_of(a) b := partner_of(a)
@@ -114,7 +188,7 @@ matching_pair_stays_up :: proc(t: ^testing.T) {
@(test) @(test)
mismatched_pair_flips_back :: proc(t: ^testing.T) { mismatched_pair_flips_back :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN) memory.deal(.HIRAGANA, .NORMAL)
a := 0 a := 0
b := non_partner_of(a) b := non_partner_of(a)
@@ -130,7 +204,7 @@ mismatched_pair_flips_back :: proc(t: ^testing.T) {
@(test) @(test)
invalid_picks_are_rejected :: proc(t: ^testing.T) { invalid_picks_are_rejected :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN) memory.deal(.HIRAGANA, .NORMAL)
a := 0 a := 0
testing.expect(t, memory.select_card(a)) testing.expect(t, memory.select_card(a))
testing.expect(t, !memory.select_card(a)) // same card twice 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(t, !memory.select_card(c)) // a third card while resolving
testing.expect_value(t, memory.moves, 1) testing.expect_value(t, memory.moves, 1)
memory.deal(themes.JAPAN) memory.deal(.HIRAGANA, .NORMAL)
p := partner_of(0) p := partner_of(0)
memory.select_card(0) memory.select_card(0)
memory.select_card(p) memory.select_card(p)
@@ -152,17 +226,17 @@ invalid_picks_are_rejected :: proc(t: ^testing.T) {
@(test) @(test)
finding_all_pairs_wins :: proc(t: ^testing.T) { finding_all_pairs_wins :: proc(t: ^testing.T) {
memory.deal(themes.JAPAN) memory.deal(.HIRAGANA, .NORMAL)
for i in 0 ..< memory.CARD_COUNT { for i in 0 ..< memory.card_count {
if memory.cards[i].matched { continue } if memory.cards[i].matched { continue }
memory.select_card(i) memory.select_card(i)
memory.select_card(partner_of(i)) memory.select_card(partner_of(i))
advance(2) advance(2)
} }
testing.expect_value(t, memory.state, memory.State.OVER) testing.expect_value(t, memory.state, memory.State.OVER)
testing.expect_value(t, memory.matches, memory.PAIR_COUNT) testing.expect_value(t, memory.matches, memory.pair_count)
testing.expect_value(t, memory.moves, memory.PAIR_COUNT) testing.expect_value(t, memory.moves, memory.pair_count)
for &c in memory.cards { testing.expect(t, c.matched) } for &c in memory.active_cards() { testing.expect(t, c.matched) }
// The clock stops once the game is over // The clock stops once the game is over
elapsed := memory.elapsed elapsed := memory.elapsed