Restructure into themed screens with flag card backs, real font, flip animation and sound

- Replace the bogus DroidSansJapanese.ttf (an HTML page) with M PLUS Rounded 1c,
  loaded with LoadFontEx and explicit kana codepoints
- Theme struct per language with flag-based card backs (Hinomaru, Taegukgi, Trairong)
- Card art rendered to textures so the flip squashes the whole card
- Synthesized flip/match/mismatch/win/click sounds (new audio package)
- Shared ui package for text and buttons; Screen enum replaces i32 ids
- Kana <-> romaji pairs drawn from all 46 basic hiragana

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-01 20:47:04 +02:00
1 parent 5557de62a3
commit 5806f0498c
11 files changed
+1046 -425

No files matched your search

Binary file not shown.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
Copyright 2016 The Rounded M+ 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.
Copyright 2016 The Rounded M+ Project Authors.
+62 -41
View File
@@ -1,56 +1,77 @@
package main package main
import raylib "vendor:raylib" import raylib "vendor:raylib"
import themes "src/themes"
import screens "src/screens"
import memory "src/memory"
import assets "src/assets" import assets "src/assets"
import audio "src/audio"
import card "src/card"
import memory "src/memory"
import screens "src/screens"
import themes "src/themes"
import ui "src/ui"
current_screen: i32 = themes.MAIN_MENU // Where Escape takes you from each screen (nil = quit)
mouse_x: i32 parent_screen :: proc(screen: themes.Screen) -> Maybe(themes.Screen) {
mouse_y: i32 switch screen {
case .MAIN_MENU: return nil
case .JAPANESE_MEMORY: return .JAPANESE_MENU
case .JAPANESE_MENU, .KOREAN_MENU, .THAI_MENU: return .MAIN_MENU
}
return nil
}
main :: proc() { main :: proc() {
raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, cstring("HanjaKanaMun")) raylib.SetConfigFlags({.MSAA_4X_HINT, .VSYNC_HINT})
raylib.InitWindow(themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, "HanjaKanaMun")
defer raylib.CloseWindow() defer raylib.CloseWindow()
raylib.SetExitKey(raylib.KeyboardKey.KEY_NULL) raylib.SetExitKey(.KEY_NULL)
raylib.SetTargetFPS(60)
raylib.InitAudioDevice()
defer raylib.CloseAudioDevice()
audio.load()
defer audio.unload()
assets.load_fonts() assets.load_fonts()
defer assets.unload_fonts() defer assets.unload_fonts()
card.load_art()
raylib.SetTargetFPS(60) defer card.unload_art()
memory.init_memory_game() defer memory.unload()
for !raylib.WindowShouldClose() { screen := themes.Screen.MAIN_MENU
mouse_x = raylib.GetMouseX() running := true
mouse_y = raylib.GetMouseY() for running && !raylib.WindowShouldClose() {
dt := raylib.GetFrameTime()
if raylib.IsKeyPressed(raylib.KeyboardKey.ESCAPE) {
current_screen = themes.MAIN_MENU if raylib.IsKeyPressed(.ESCAPE) {
} if parent, ok := parent_screen(screen).?; ok {
screen = parent
if current_screen == themes.JAPANESE_MEMORY { } else {
delta_time := raylib.GetFrameTime() running = false
memory.update_memory_game(delta_time, mouse_x, mouse_y)
}
raylib.BeginDrawing()
if current_screen == themes.MAIN_MENU {
current_screen = screens.draw_main_menu(mouse_x, mouse_y)
} else if current_screen == themes.JAPANESE_SCREEN {
current_screen = screens.draw_japanese_screen(mouse_x, mouse_y)
if current_screen == themes.JAPANESE_MEMORY {
memory.init_memory_game()
} }
} else if current_screen == themes.JAPANESE_MEMORY {
current_screen = memory.draw_memory_game(mouse_x, mouse_y)
} else if current_screen == themes.KOREAN_SCREEN {
current_screen = screens.draw_korean_screen(mouse_x, mouse_y)
} else if current_screen == themes.THAI_SCREEN {
current_screen = screens.draw_thai_screen(mouse_x, mouse_y)
} }
if screen == .JAPANESE_MEMORY {
memory.update(dt)
}
raylib.BeginDrawing()
ui.begin_frame()
next := screen
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()
}
ui.end_frame()
raylib.EndDrawing() 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)
}
screen = next
} }
} }
+41 -6
View File
@@ -2,17 +2,52 @@ package assets
import raylib "vendor:raylib" import raylib "vendor:raylib"
japanese_font: raylib.Font 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
// 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) }
return codepoints
}
@(private)
load_font :: proc(path: cstring, codepoints: []rune) -> raylib.Font {
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)
return raylib.GetFontDefault()
}
// Mipmaps keep small UI text smooth when the large atlas is scaled down
raylib.GenTextureMipmaps(&font.texture)
raylib.SetTextureFilter(font.texture, .TRILINEAR)
return font
}
load_fonts :: proc() { load_fonts :: proc() {
// Use raylib's default font for now codepoints := build_codepoints()
japanese_font = raylib.GetFontDefault() defer delete(codepoints)
regular_font = load_font(REGULAR_FONT_PATH, codepoints[:])
bold_font = load_font(BOLD_FONT_PATH, codepoints[:])
} }
unload_fonts :: proc() { unload_fonts :: proc() {
// Don't unload the default font 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) }
} }
get_font :: proc() -> raylib.Font { get_font :: proc(bold := false) -> raylib.Font {
return japanese_font return bold ? bold_font : regular_font
} }
+135
View File
@@ -0,0 +1,135 @@
package audio
import "core:math"
import "core:math/rand"
import raylib "vendor:raylib"
// All sounds are synthesized at startup, so no audio files are needed
SAMPLE_RATE :: 44100
Sound_Effect :: enum {
FLIP,
MATCH,
MISMATCH,
WIN,
CLICK,
}
sounds: [Sound_Effect]raylib.Sound
ready: bool
@(private)
Tone :: struct {
start: f32, // seconds
freq: f32, // Hz
duration: f32, // seconds
amp: f32,
}
// Sine with a little 2nd/3rd harmonic, soft attack and exponential decay: a small bell
@(private)
add_tone :: proc(samples: []f32, tone: Tone) {
first := int(tone.start * SAMPLE_RATE)
count := int(tone.duration * SAMPLE_RATE)
attack: f32 = 0.006
for i in 0 ..< count {
idx := first + i
if idx >= len(samples) { break }
t := f32(i) / SAMPLE_RATE
env := min(t / attack, 1) * math.exp(-5 * t / tone.duration)
phase := 2 * math.PI * tone.freq * t
s := math.sin(phase) + 0.25 * math.sin(2 * phase) + 0.08 * math.sin(3 * phase)
samples[idx] += tone.amp * env * s
}
}
// Card "snap": low-passed noise with a fast decay over a short low thump
@(private)
add_flip :: proc(samples: []f32) {
lp: f32 = 0
for i in 0 ..< len(samples) {
t := f32(i) / SAMPLE_RATE
noise := rand.float32_range(-1, 1)
lp += 0.35 * (noise - lp)
swish := lp * math.exp(-t * 55) * min(t / 0.004, 1)
thump := math.sin(2 * math.PI * 150 * t) * math.exp(-t * 70)
samples[i] += 0.55 * swish + 0.35 * thump
}
}
@(private)
make_sound :: proc(samples: []f32) -> raylib.Sound {
pcm := make([]i16, len(samples))
defer delete(pcm)
for s, i in samples {
pcm[i] = i16(math.clamp(s, -1, 1) * 32000)
}
wave := raylib.Wave{
frameCount = u32(len(pcm)),
sampleRate = SAMPLE_RATE,
sampleSize = 16,
channels = 1,
data = raw_data(pcm),
}
// LoadSoundFromWave copies the samples into its own buffer
return raylib.LoadSoundFromWave(wave)
}
@(private)
build :: proc(duration: f32, tones: []Tone, flip := false) -> raylib.Sound {
samples := make([]f32, int(duration * SAMPLE_RATE))
defer delete(samples)
if flip { add_flip(samples) }
for tone in tones { add_tone(samples, tone) }
return make_sound(samples)
}
// Call after raylib.InitAudioDevice
load :: proc() {
if !raylib.IsAudioDeviceReady() { return }
E5 :: 659.25
A5 :: 880.00
B5 :: 987.77
C5 :: 523.25
G5 :: 783.99
C6 :: 1046.50
E6 :: 1318.51
sounds[.FLIP] = build(0.12, {}, flip = true)
sounds[.MATCH] = build(0.6, {
{start = 0.00, freq = E5, duration = 0.35, amp = 0.30},
{start = 0.09, freq = B5, duration = 0.50, amp = 0.28},
})
sounds[.MISMATCH] = build(0.45, {
{start = 0.00, freq = 330, duration = 0.20, amp = 0.20},
{start = 0.11, freq = 262, duration = 0.30, amp = 0.20},
})
sounds[.WIN] = build(1.3, {
{start = 0.00, freq = C5, duration = 0.40, amp = 0.18},
{start = 0.11, freq = E5, duration = 0.40, amp = 0.18},
{start = 0.22, freq = G5, duration = 0.40, amp = 0.18},
{start = 0.33, freq = C6, duration = 0.90, amp = 0.2},
{start = 0.33, freq = E6, duration = 0.90, amp = 0.07},
})
sounds[.CLICK] = build(0.08, {{start = 0, freq = A5, duration = 0.06, amp = 0.15}})
raylib.SetSoundVolume(sounds[.FLIP], 0.7)
ready = true
}
unload :: proc() {
if !ready { return }
for s in sounds { raylib.UnloadSound(s) }
ready = false
}
play :: proc(effect: Sound_Effect) {
if !ready { return }
if effect == .FLIP {
// Slight pitch variation so repeated flips don't sound identical
raylib.SetSoundPitch(sounds[.FLIP], rand.float32_range(0.9, 1.12))
}
raylib.PlaySound(sounds[effect])
}
+251 -67
View File
@@ -1,81 +1,265 @@
package card package card
import "core:math"
import raylib "vendor:raylib" import raylib "vendor:raylib"
import themes "../themes" import themes "../themes"
import assets "../assets" import ui "../ui"
// Card art is rendered once into textures at 2x size and scaled down when drawn.
// This lets the flip animation squash the whole card (text included) horizontally.
ART_SCALE :: 2
ART_WIDTH :: themes.CARD_WIDTH * ART_SCALE
ART_HEIGHT :: themes.CARD_HEIGHT * ART_SCALE
ROUNDNESS :: 0.16
SEGMENTS :: 16
FLIP_SPEED :: 2.6 // flips per second
PULSE_DURATION :: 0.35 // seconds
HOVER_LIFT :: 4
Card :: struct { Card :: struct {
x: i32, rect: raylib.Rectangle, // resting position on screen
y: i32, text: cstring,
character: cstring, is_kana: bool,
is_face_up: bool, pair_id: int,
is_matched: bool, face_up: bool, // logical state; the animation follows it
pair_id: i32, matched: bool,
flip_progress: f32, // 0 = face down, 1 = face up flip_t: f32, // 0 = back showing, 1 = face showing
is_flipping: bool, pulse_t: f32, // counts down after a match
flip_direction: bool, // true = to face up, false = to face down face_art: raylib.RenderTexture2D,
} }
draw_card :: proc(card: ^Card, mouse_x, mouse_y: i32, theme_color: raylib.Color) { @(private)
draw_color := theme_color back_art: [themes.Flag]raylib.RenderTexture2D
if card.is_matched {
draw_color = themes.LIGHT_GRAY // ---------------------------------------------------------------------------
} // Art generation
// ---------------------------------------------------------------------------
is_hovered := mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT @(private)
if is_hovered && !card.is_face_up && !card.is_matched && !card.is_flipping { art_rect :: proc(inset: f32 = 0) -> raylib.Rectangle {
draw_color = raylib.Color{ return {inset, inset, ART_WIDTH - 2 * inset, ART_HEIGHT - 2 * inset}
byte(f32(theme_color.r) * 0.8), }
byte(f32(theme_color.g) * 0.8),
byte(f32(theme_color.b) * 0.8), @(private)
theme_color.a, new_art_texture :: proc(edge_color: raylib.Color) -> raylib.RenderTexture2D {
} rt := raylib.LoadRenderTexture(ART_WIDTH, ART_HEIGHT)
} raylib.SetTextureFilter(rt.texture, .BILINEAR)
raylib.BeginTextureMode(rt)
// Draw card background with rounded corners // Transparent, but with the edge's RGB so filtering doesn't create dark fringes
raylib.DrawRectangleRounded(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, draw_color) raylib.ClearBackground({edge_color.r, edge_color.g, edge_color.b, 0})
raylib.DrawRectangleRoundedLines(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, themes.BLACK) raylib.EndTextureMode()
return rt
// Handle flip animation }
if card.is_flipping {
// Draw the flip animation @(private)
flip_angle := card.flip_progress * 180.0 draw_frame :: proc(fill, border: raylib.Color) {
if !card.flip_direction { raylib.DrawRectangleRounded(art_rect(), ROUNDNESS, SEGMENTS, fill)
flip_angle = 180.0 - flip_angle raylib.DrawRectangleRoundedLinesEx(art_rect(3), ROUNDNESS, SEGMENTS, 6, border)
} }
// Draw the card as a rectangle that's rotating // Japan: Hinomaru, a crimson sun disc on white, with a double crimson border
center_x := f32(card.x + themes.CARD_WIDTH / 2) @(private)
center_y := f32(card.y + themes.CARD_HEIGHT / 2) draw_japan_back :: proc() {
draw_frame(themes.WHITE, themes.HINOMARU_RED)
// For simplicity, just show the character when flip_progress > 0.5 raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, themes.HINOMARU_RED)
show_face := card.flip_progress > 0.5 center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2}
if card.flip_direction { raylib.DrawCircleV(center, ART_WIDTH * 0.3, themes.HINOMARU_RED)
show_face = card.flip_progress >= 0.5 }
// One Taegukgi trigram: three bars, each either solid or broken in the middle
@(private)
draw_trigram :: proc(center: raylib.Vector2, angle_deg: f32, solid: [3]bool, scale: f32) {
length := 46 * scale
thick := 8 * scale
gap := 6 * scale
rad := angle_deg * math.RAD_PER_DEG
along := raylib.Vector2{math.cos(rad), math.sin(rad)} // bar direction
across := raylib.Vector2{-along.y, along.x} // stacking direction
for i in 0 ..< 3 {
bar_center := center + across * (f32(i - 1) * (thick + gap))
if solid[i] {
raylib.DrawRectanglePro({bar_center.x, bar_center.y, length, thick}, {length / 2, thick / 2}, angle_deg, themes.BLACK)
} else { } else {
show_face = card.flip_progress < 0.5 half := (length - gap) / 2
for side in ([2]f32{-1, 1}) {
c := bar_center + along * (side * (half + gap) / 2)
raylib.DrawRectanglePro({c.x, c.y, half, thick}, {half / 2, thick / 2}, angle_deg, themes.BLACK)
}
} }
if show_face || card.is_face_up || card.is_matched {
font := assets.get_font()
text_w := raylib.MeasureText(card.character, 40)
text_x := card.x + (themes.CARD_WIDTH - text_w) / 2
text_y := card.y + (themes.CARD_HEIGHT - 40) / 2 - 8
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x + 1), f32(text_y + 1)}, 40.0, 0, themes.BLACK)
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x), f32(text_y)}, 40.0, 0, themes.WHITE)
}
} else if card.is_face_up || card.is_matched {
font := assets.get_font()
text_w := raylib.MeasureText(card.character, 40)
text_x := card.x + (themes.CARD_WIDTH - text_w) / 2
text_y := card.y + (themes.CARD_HEIGHT - 40) / 2 - 8
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x + 1), f32(text_y + 1)}, 40.0, 0, themes.BLACK)
raylib.DrawTextEx(font, card.character, raylib.Vector2{f32(text_x), f32(text_y)}, 40.0, 0, themes.WHITE)
} }
} }
is_card_clicked :: proc(card: ^Card, mouse_x, mouse_y: i32) -> bool { // Korea: Taegukgi, a red/blue taeguk circle with the four black trigrams in the corners
return mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH && @(private)
mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT draw_korea_back :: proc() {
draw_frame(themes.WHITE, themes.TAEGUK_BLUE)
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2}
radius: f32 = ART_WIDTH * 0.26
// The flag's taeguk is tilted along the diagonal of a 3:2 rectangle
tilt := math.atan2(f32(2), 3) * math.DEG_PER_RAD
raylib.DrawCircleSector(center, radius, 180 + tilt, 360 + tilt, 48, themes.TAEGUK_RED)
raylib.DrawCircleSector(center, radius, tilt, 180 + tilt, 48, themes.TAEGUK_BLUE)
offset := raylib.Vector2{math.cos(tilt * math.RAD_PER_DEG), math.sin(tilt * math.RAD_PER_DEG)} * (radius / 2)
// Red head toward the upper-left (geon ☰), blue head toward the lower-right (gon ☷)
raylib.DrawCircleV(center - offset, radius / 2, themes.TAEGUK_RED)
raylib.DrawCircleV(center + offset, radius / 2, themes.TAEGUK_BLUE)
scale: f32 = 0.8
dx: f32 = ART_WIDTH * 0.24
dy: f32 = ART_HEIGHT * 0.26
draw_trigram(center + {-dx, -dy}, -45, {true, true, true}, scale) // geon ☰
draw_trigram(center + {dx, -dy}, 45, {false, true, false}, scale) // gam ☵
draw_trigram(center + {-dx, dy}, 45, {true, false, true}, scale) // ri ☲
draw_trigram(center + {dx, dy}, -45, {false, false, false}, scale) // gon ☷
}
// Thailand: Thong Trairong, red-white-blue-white-red stripes (blue twice as wide)
@(private)
draw_thailand_back :: proc() {
draw_frame(themes.WHITE, themes.THAI_BLUE)
panel := art_rect(18)
unit := panel.height / 6
stripes := [5]struct{ color: raylib.Color, units: f32 }{
{themes.THAI_RED, 1}, {themes.WHITE, 1}, {themes.THAI_BLUE, 2}, {themes.WHITE, 1}, {themes.THAI_RED, 1},
}
y := panel.y
for stripe in stripes {
raylib.DrawRectangleRec({panel.x, y, panel.width, unit * stripe.units}, stripe.color)
y += unit * stripe.units
}
raylib.DrawRectangleLinesEx(panel, 2, themes.THAI_BLUE)
}
// Builds the card back textures. Call once after the window is created.
load_art :: proc() {
edges := [themes.Flag]raylib.Color{.JAPAN = themes.HINOMARU_RED, .KOREA = themes.TAEGUK_BLUE, .THAILAND = themes.THAI_BLUE}
for flag in themes.Flag {
back_art[flag] = new_art_texture(edges[flag])
raylib.BeginTextureMode(back_art[flag])
switch flag {
case .JAPAN: draw_japan_back()
case .KOREA: draw_korea_back()
case .THAILAND: draw_thailand_back()
}
raylib.EndTextureMode()
}
}
unload_art :: proc() {
for rt in back_art { raylib.UnloadRenderTexture(rt) }
}
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) {
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)
raylib.EndTextureMode()
}
unload_face :: proc(card: ^Card) {
if card.face_art.id != 0 {
raylib.UnloadRenderTexture(card.face_art)
card.face_art = {}
}
}
// ---------------------------------------------------------------------------
// Behaviour
// ---------------------------------------------------------------------------
is_animating :: proc(card: ^Card) -> bool {
target: f32 = card.face_up ? 1 : 0
return card.flip_t != target
}
update :: proc(card: ^Card, dt: f32) {
target: f32 = card.face_up ? 1 : 0
step := dt * FLIP_SPEED
if card.flip_t < target {
card.flip_t = min(card.flip_t + step, target)
} else if card.flip_t > target {
card.flip_t = max(card.flip_t - step, target)
}
if card.pulse_t > 0 {
card.pulse_t = max(card.pulse_t - dt, 0)
}
}
start_pulse :: proc(card: ^Card) {
card.pulse_t = PULSE_DURATION
}
is_hovered :: proc(card: ^Card) -> bool {
return raylib.CheckCollisionPointRec(raylib.GetMousePosition(), card.rect)
}
@(private)
smoothstep :: proc(t: f32) -> f32 {
return t * t * (3 - 2 * t)
}
draw :: proc(card: ^Card, theme: themes.Theme, clickable: bool) {
eased := smoothstep(card.flip_t)
squash := abs(math.cos(eased * math.PI)) // 1 → 0 → 1 across the flip
showing_face := eased >= 0.5
// Cards lift as they turn, and a little on hover
lift := math.sin(eased * math.PI) * 6
hovered := clickable && is_hovered(card)
if hovered {
lift += HOVER_LIFT
ui.set_hovered()
}
scale: f32 = 1
if card.pulse_t > 0 {
progress := 1 - card.pulse_t / PULSE_DURATION
scale += 0.09 * math.sin(progress * math.PI)
}
width := card.rect.width * scale
height := card.rect.height * scale
center := raylib.Vector2{card.rect.x + card.rect.width / 2, card.rect.y + card.rect.height / 2 - lift}
// Shadow stays on the table and spreads as the card lifts
shadow := raylib.Rectangle{
center.x - width * squash / 2 + 2,
card.rect.y + card.rect.height * (1 - scale) / 2 + 4 + lift * 0.15,
max(width * squash, 2),
height,
}
raylib.DrawRectangleRounded(shadow, ROUNDNESS, SEGMENTS, raylib.Fade(themes.BLACK, 0.12 - lift * 0.004))
// Darken the card while it is edge-on, to fake lighting
shade := u8(255 - (1 - squash) * 70)
tint := raylib.Color{shade, shade, shade, 255}
if hovered && !showing_face {
tint = raylib.ColorBrightness(tint, 0.08)
}
texture := showing_face ? card.face_art.texture : back_art[theme.flag].texture
source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)} // render textures are upside down
dest := raylib.Rectangle{center.x, center.y, width * squash, height}
raylib.DrawTexturePro(texture, source, dest, {dest.width / 2, dest.height / 2}, 0, tint)
if card.matched && card.flip_t == 1 {
outline := raylib.Rectangle{center.x - width / 2 - 3, center.y - height / 2 - 3, width + 6, height + 6}
raylib.DrawRectangleRoundedLinesEx(outline, ROUNDNESS, SEGMENTS, 3, theme.accent)
}
} }
+207 -188
View File
@@ -2,213 +2,232 @@ package memory
import "core:math/rand" import "core:math/rand"
import raylib "vendor:raylib" import raylib "vendor:raylib"
import themes "../themes" import audio "../audio"
import card "../card" import card "../card"
import assets "../assets" import themes "../themes"
import ui "../ui"
MemoryState :: enum { PAIR_COUNT :: 12
WAITING, CARD_COUNT :: PAIR_COUNT * 2
FIRST,
SECOND, 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
State :: enum {
WAITING, // no card selected
FIRST, // one card selected
RESOLVING, // two cards up, waiting before match/mismatch
OVER, OVER,
} }
memory_cards: [24]card.Card Kana :: struct {
memory_state: MemoryState kana: cstring,
memory_first: i32 romaji: cstring,
memory_second: i32
memory_matches: i32
memory_moves: i32
memory_time: f32
memory_flip: f32
init_memory_game :: proc() {
hiragana_chars: [12]cstring = {"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ", "さ", "し"}
romanji_chars: [12]cstring = {"A", "I", "U", "E", "O", "KA", "KI", "KU", "KE", "KO", "SA", "SHI"}
for i := 0; i < 12; i += 1 {
memory_cards[i*2] = card.Card{
x = 0, y = 0,
character = romanji_chars[i],
is_face_up = false,
is_matched = false,
pair_id = i32(i),
flip_progress = 0.0,
is_flipping = false,
flip_direction = false,
}
memory_cards[i*2+1] = card.Card{
x = 0, y = 0,
character = romanji_chars[i],
is_face_up = false,
is_matched = false,
pair_id = i32(i),
flip_progress = 0.0,
is_flipping = false,
flip_direction = false,
}
}
rand.shuffle(memory_cards[:])
total_w := themes.GRID_COLS * (themes.CARD_WIDTH + themes.CARD_SPACING) - themes.CARD_SPACING
start_x := (themes.SCREEN_WIDTH - total_w) / 2
start_y := themes.GRID_TOP
for i := 0; i < 24; i += 1 {
row := i32(i) / themes.GRID_COLS
col := i32(i) % themes.GRID_COLS
memory_cards[i].x = start_x + col * (themes.CARD_WIDTH + themes.CARD_SPACING)
memory_cards[i].y = start_y + row * (themes.CARD_HEIGHT + themes.CARD_SPACING)
}
memory_state = MemoryState.WAITING
memory_first = -1
memory_second = -1
memory_matches = 0
memory_moves = 0
memory_time = 0.0
memory_flip = 0.0
} }
update_memory_game :: proc(delta_time: f32, mouse_x, mouse_y: i32) { // The 46 basic hiragana (gojūon)
memory_time += delta_time HIRAGANA :: [?]Kana{
{"あ", "a"}, {"い", "i"}, {"う", "u"}, {"え", "e"}, {"お", "o"},
if memory_state == MemoryState.SECOND { {"か", "ka"}, {"き", "ki"}, {"く", "ku"}, {"け", "ke"}, {"こ", "ko"},
memory_flip += delta_time {"さ", "sa"}, {"し", "shi"}, {"す", "su"}, {"せ", "se"}, {"そ", "so"},
if memory_flip >= themes.FLIP_DELAY { {"た", "ta"}, {"ち", "chi"}, {"つ", "tsu"}, {"て", "te"}, {"と", "to"},
if memory_first >= 0 && memory_second >= 0 { {"な", "na"}, {"に", "ni"}, {"ぬ", "nu"}, {"ね", "ne"}, {"の", "no"},
if memory_cards[memory_first].pair_id == memory_cards[memory_second].pair_id { {"は", "ha"}, {"ひ", "hi"}, {"ふ", "fu"}, {"へ", "he"}, {"ほ", "ho"},
memory_cards[memory_first].is_matched = true {"ま", "ma"}, {"み", "mi"}, {"む", "mu"}, {"め", "me"}, {"も", "mo"},
memory_cards[memory_second].is_matched = true {"や", "ya"}, {"ゆ", "yu"}, {"よ", "yo"},
memory_matches += 1 {"ら", "ra"}, {"り", "ri"}, {"る", "ru"}, {"れ", "re"}, {"ろ", "ro"},
} else { {"わ", "wa"}, {"を", "wo"}, {"ん", "n"},
memory_cards[memory_first].is_flipping = true }
memory_cards[memory_first].flip_direction = false
memory_cards[memory_first].flip_progress = 0.0 cards: [CARD_COUNT]card.Card
memory_cards[memory_second].is_flipping = true state: State
memory_cards[memory_second].flip_direction = false theme: themes.Theme
memory_cards[memory_second].flip_progress = 0.0 first: int = -1
} second: int = -1
} matches: int
memory_state = MemoryState.WAITING moves: int
memory_flip = 0.0 elapsed: f32
memory_first = -1 resolve_timer: f32
memory_second = -1 win_t: f32 // fades in the win panel
} restart_requested: bool
start :: proc(game_theme: themes.Theme) {
theme = game_theme
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}
} }
rand.shuffle(cards[:])
// Update flip animations
for i := 0; i < 24; i += 1 { total_w: f32 = themes.GRID_COLS * (themes.CARD_WIDTH + themes.CARD_SPACING) - themes.CARD_SPACING
if memory_cards[i].is_flipping { start_x := (themes.SCREEN_WIDTH - total_w) / 2
memory_cards[i].flip_progress += delta_time * 3.0 // Flip speed for &c, i in cards {
if memory_cards[i].flip_progress >= 1.0 { row := f32(i / themes.GRID_COLS)
memory_cards[i].flip_progress = 1.0 col := f32(i % themes.GRID_COLS)
memory_cards[i].is_flipping = false c.rect = {
memory_cards[i].is_face_up = memory_cards[i].flip_direction start_x + col * (themes.CARD_WIDTH + themes.CARD_SPACING),
} themes.GRID_TOP + row * (themes.CARD_HEIGHT + themes.CARD_SPACING),
themes.CARD_WIDTH,
themes.CARD_HEIGHT,
} }
card.build_face(&c, theme)
} }
if memory_state == MemoryState.WAITING || memory_state == MemoryState.FIRST { state = .WAITING
for i := 0; i < 24; i += 1 { first = -1
if !memory_cards[i].is_matched && !memory_cards[i].is_flipping && second = -1
!memory_cards[i].is_face_up && i32(i) != memory_first { matches = 0
if card.is_card_clicked(&memory_cards[i], mouse_x, mouse_y) && moves = 0
raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) { elapsed = 0
memory_cards[i].is_flipping = true resolve_timer = 0
memory_cards[i].flip_direction = true win_t = 0
memory_cards[i].flip_progress = 0.0 restart_requested = false
if memory_first == -1 { }
memory_first = i32(i)
memory_state = MemoryState.FIRST unload :: proc() {
} else { for &c in cards { card.unload_face(&c) }
memory_second = i32(i) }
memory_moves += 1
memory_state = MemoryState.SECOND @(private)
} can_pick :: proc(i: int) -> bool {
c := &cards[i]
return (state == .WAITING || state == .FIRST) && !c.face_up && !c.matched
}
@(private)
pick :: proc(i: int) {
cards[i].face_up = true
audio.play(.FLIP)
if state == .WAITING {
first = i
state = .FIRST
} else {
second = i
moves += 1
is_match := cards[first].pair_id == cards[second].pair_id
resolve_timer = is_match ? MATCH_DELAY : MISMATCH_DELAY
state = .RESOLVING
}
}
@(private)
resolve :: proc() {
a, b := &cards[first], &cards[second]
if a.pair_id == b.pair_id {
a.matched = true
b.matched = true
card.start_pulse(a)
card.start_pulse(b)
matches += 1
if matches == PAIR_COUNT {
state = .OVER
audio.play(.WIN)
} else {
audio.play(.MATCH)
state = .WAITING
}
} else {
a.face_up = false
b.face_up = false
audio.play(.MISMATCH)
audio.play(.FLIP)
state = .WAITING
}
first = -1
second = -1
}
update :: proc(dt: f32) {
if restart_requested {
start(theme)
return
}
for &c in cards { card.update(&c, dt) }
switch state {
case .WAITING, .FIRST:
elapsed += dt
if raylib.IsMouseButtonPressed(.LEFT) {
for &c, i in cards {
if can_pick(i) && card.is_hovered(&c) {
pick(i)
break break
} }
} }
} }
} case .RESOLVING:
elapsed += dt
if memory_matches >= 12 { // Wait for the second card to finish turning before starting the delay
memory_state = MemoryState.OVER if !card.is_animating(&cards[second]) {
resolve_timer -= dt
if resolve_timer <= 0 { resolve() }
}
case .OVER:
win_t = min(win_t + dt * 2.5, 1)
} }
} }
draw_memory_game :: proc(mouse_x, mouse_y: i32) -> i32 { // Draws the game and returns the screen to show next frame
raylib.ClearBackground(themes.JAPAN_BACKGROUND) draw :: proc() -> themes.Screen {
next := themes.Screen.JAPANESE_MEMORY
font := assets.get_font() raylib.ClearBackground(theme.background)
title: cstring = "Memory Game - Japanese"
title_size: i32 = 48 ui.draw_title("Hiragana Memory", 24, theme.primary, 48)
title_w := raylib.MeasureText(title, title_size) minutes := int(elapsed) / 60
title_x := (themes.SCREEN_WIDTH - title_w) / 2 seconds := int(elapsed) % 60
raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x + 3), 33}, f32(title_size), 0, themes.BLACK) info := raylib.TextFormat("Moves %d · Pairs %d/%d · %d:%02d", moves, matches, PAIR_COUNT, minutes, seconds)
raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x), 30}, f32(title_size), 0, themes.JAPAN_PRIMARY) ui.draw_text_centered(info, themes.SCREEN_WIDTH / 2, 88, 24, theme.ink)
info_text := raylib.TextFormat("Moves: %d | Time: %.1fs", memory_moves, memory_time) if state != .OVER && ui.button({30, 28, 120, 48}, "Back", theme.primary, font_size = 22) {
info_w := raylib.MeasureText(info_text, themes.SUBTITLE_FONT_SIZE) next = .JAPANESE_MENU
info_x := (themes.SCREEN_WIDTH - info_w) / 2
raylib.DrawTextEx(font, info_text, raylib.Vector2{f32(info_x), 92}, f32(themes.SUBTITLE_FONT_SIZE), 0, themes.DARK_GRAY)
for i := 0; i < 24; i += 1 {
card.draw_card(&memory_cards[i], mouse_x, mouse_y, themes.JAPAN_PRIMARY)
} }
back_w: i32 = 120 for &c, i in cards {
back_h: i32 = 50 card.draw(&c, theme, can_pick(i))
back_x: i32 = 30
back_y: i32 = 30
back_hovered := mouse_x >= back_x && mouse_x <= back_x + back_w &&
mouse_y >= back_y && mouse_y <= back_y + back_h
back_color := themes.JAPAN_PRIMARY
if back_hovered {
back_color = themes.JAPAN_ACCENT
} }
raylib.DrawRectangle(back_x, back_y, back_w, back_h, back_color)
raylib.DrawRectangleLines(back_x, back_y, back_w, back_h, themes.BLACK) if state == .OVER {
back_text: cstring = "Back" if action := draw_win_panel(); action != nil {
back_text_w := raylib.MeasureText(back_text, themes.BUTTON_FONT_SIZE) next = action.?
back_text_x := back_x + (back_w - back_text_w) / 2
back_text_y := back_y + (back_h - themes.BUTTON_FONT_SIZE) / 2
raylib.DrawTextEx(font, back_text, raylib.Vector2{f32(back_text_x), f32(back_text_y)}, f32(themes.BUTTON_FONT_SIZE), 0, themes.WHITE)
if back_hovered && raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
return themes.JAPANESE_SCREEN
}
if memory_state == MemoryState.OVER {
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, raylib.Color{0, 0, 0, 128})
win_text: cstring = "Congratulations! You Win!"
win_w := raylib.MeasureText(win_text, 48)
win_x := (themes.SCREEN_WIDTH - win_w) / 2
raylib.DrawTextEx(font, win_text, raylib.Vector2{f32(win_x), f32(themes.SCREEN_HEIGHT / 2 - 60)}, 48, 0, themes.WHITE)
stats_text := raylib.TextFormat("Moves: %d | Time: %.1fs", memory_moves, memory_time)
stats_w := raylib.MeasureText(stats_text, 32)
stats_x := (themes.SCREEN_WIDTH - stats_w) / 2
raylib.DrawTextEx(font, stats_text, raylib.Vector2{f32(stats_x), f32(themes.SCREEN_HEIGHT / 2)}, 32, 0, themes.WHITE)
again_text: cstring = "Play Again"
again_w := raylib.MeasureText(again_text, themes.BUTTON_FONT_SIZE)
again_x := (themes.SCREEN_WIDTH - again_w) / 2
again_y := themes.SCREEN_HEIGHT / 2 + 60
again_hovered := mouse_x >= again_x && mouse_x <= again_x + again_w &&
mouse_y >= again_y && mouse_y <= again_y + themes.BUTTON_FONT_SIZE
again_color := themes.JAPAN_PRIMARY
if again_hovered {
again_color = themes.JAPAN_ACCENT
}
raylib.DrawTextEx(font, again_text, raylib.Vector2{f32(again_x), f32(again_y)}, f32(themes.BUTTON_FONT_SIZE), 0, again_color)
if again_hovered && raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) {
init_memory_game()
} }
} }
return themes.JAPANESE_MEMORY return next
}
@(private)
draw_win_panel :: proc() -> Maybe(themes.Screen) {
raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, themes.SCREEN_HEIGHT, raylib.Fade(themes.BLACK, 0.45 * win_t))
panel_w: f32 = 560
panel_h: f32 = 340
slide := (1 - win_t) * 40
panel := raylib.Rectangle{(themes.SCREEN_WIDTH - panel_w) / 2, (themes.SCREEN_HEIGHT - panel_h) / 2 + slide, panel_w, panel_h}
raylib.DrawRectangleRounded({panel.x, panel.y + 8, panel.width, panel.height}, 0.12, 16, raylib.Fade(themes.BLACK, 0.25 * win_t))
raylib.DrawRectangleRounded(panel, 0.12, 16, raylib.Fade(theme.card_face, win_t))
raylib.DrawRectangleRoundedLinesEx(panel, 0.12, 16, 4, raylib.Fade(theme.primary, win_t))
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))
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)
if win_t < 1 { return nil }
button_w: f32 = 200
button_y := panel.y + panel_h - 100
if ui.button({cx - button_w - 10, button_y, button_w, 60}, "Play Again", theme.primary) {
restart_requested = true
}
if ui.button({cx + 10, button_y, button_w, 60}, "Menu", theme.ink) {
return .JAPANESE_MENU
}
return nil
} }
+88 -90
View File
@@ -1,113 +1,111 @@
package screens package screens
import "core:math"
import raylib "vendor:raylib" import raylib "vendor:raylib"
import card "../card"
import themes "../themes" import themes "../themes"
import assets "../assets" import ui "../ui"
draw_button :: proc(x, y, w, h: i32, text: cstring, base_color, hover_color: raylib.Color, mouse_x, mouse_y: i32) -> bool { LANGUAGES :: [3]struct{ theme: themes.Theme, screen: themes.Screen }{
is_hovered := mouse_x >= x && mouse_x <= x + w && mouse_y >= y && mouse_y <= y + h {themes.JAPAN, .JAPANESE_MENU},
color := base_color {themes.KOREA, .KOREAN_MENU},
if is_hovered { {themes.THAI, .THAI_MENU},
color = hover_color
}
raylib.DrawRectangle(x, y, w, h, color)
raylib.DrawRectangleLines(x, y, w, h, themes.BLACK)
font := assets.get_font()
text_w := raylib.MeasureText(text, themes.BUTTON_FONT_SIZE)
text_x := x + (w - text_w) / 2
text_y := y + (h - themes.BUTTON_FONT_SIZE) / 2
raylib.DrawTextEx(font, text, raylib.Vector2{f32(text_x), f32(text_y)}, f32(themes.BUTTON_FONT_SIZE), 0, themes.WHITE)
return is_hovered && raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT)
} }
draw_title :: proc(text: cstring, y: i32, color: raylib.Color) { // Draws a card back like a playing card lying on the table
font := assets.get_font() @(private)
text_w := raylib.MeasureText(text, themes.TITLE_FONT_SIZE) draw_card_art :: proc(flag: themes.Flag, rect: raylib.Rectangle, rotation: f32 = 0) {
x := (themes.SCREEN_WIDTH - text_w) / 2 texture := card.get_back_art(flag)
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x + 4), f32(y + 4)}, f32(themes.TITLE_FONT_SIZE), 0, themes.BLACK) source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)}
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(themes.TITLE_FONT_SIZE), 0, color) dest := raylib.Rectangle{rect.x + rect.width / 2, rect.y + rect.height / 2, rect.width, rect.height}
origin := raylib.Vector2{rect.width / 2, rect.height / 2}
shadow := dest
shadow.x += 3
shadow.y += 6
raylib.DrawRectanglePro(shadow, origin, rotation, raylib.Fade(themes.BLACK, 0.15))
raylib.DrawTexturePro(texture, source, dest, origin, rotation, themes.WHITE)
} }
draw_centered_text :: proc(text: cstring, y: i32, font_size: i32, color: raylib.Color) { main_menu :: proc() -> themes.Screen {
font := assets.get_font() next := themes.Screen.MAIN_MENU
text_w := raylib.MeasureText(text, font_size) raylib.ClearBackground(themes.MENU_BACKGROUND)
x := (themes.SCREEN_WIDTH - text_w) / 2
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x + 4), f32(y + 4)}, f32(font_size), 0, themes.BLACK) // Thin stripes along the top in each language's primary color
raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(font_size), 0, color) stripe_w: i32 = themes.SCREEN_WIDTH / 3
for lang, i in LANGUAGES {
raylib.DrawRectangle(i32(i) * stripe_w, 0, stripe_w, 8, lang.theme.primary)
}
ui.draw_title("HanjaKanaMun", 70, themes.SUMI_INK, 84)
ui.draw_text_centered("Learn Japanese, Korean and Thai scripts", themes.SCREEN_WIDTH / 2, 170, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
tile_w: f32 = 150
tile_h: f32 = 200
gap: f32 = 110
total := 3 * tile_w + 2 * gap
start_x := (themes.SCREEN_WIDTH - total) / 2
// Gentle bobbing so the menu feels alive
time := f32(raylib.GetTime())
for lang, i in LANGUAGES {
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
}
}
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
} }
draw_main_menu :: proc(mouse_x, mouse_y: i32) -> i32 { // Shared layout for a language's menu screen. Returns the clicked option index, -1 for back, or nil.
raylib.ClearBackground(themes.WHITE) @(private)
language_menu :: proc(theme: themes.Theme, subtitle: cstring, options: []cstring) -> Maybe(int) {
draw_title("HanjaKanaMun", 100, themes.JAPAN_PRIMARY) raylib.ClearBackground(theme.background)
draw_centered_text("Learn Japanese, Korean, and Thai", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY) raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, 8, theme.primary)
button_w := themes.BUTTON_WIDTH ui.draw_title(theme.name, 70, theme.primary)
button_h := themes.BUTTON_HEIGHT ui.draw_text_centered(subtitle, themes.SCREEN_WIDTH / 2, 170, themes.SUBTITLE_FONT_SIZE, theme.ink)
button_space: i32 = 40
total_w := button_w * 3 + button_space * 2 // A small fan of card backs
start_x := (themes.SCREEN_WIDTH - total_w) / 2 for i in 0 ..< 3 {
start_y := themes.SCREEN_HEIGHT / 2 + 100 offset := f32(i - 1)
draw_card_art(theme.flag, {themes.SCREEN_WIDTH / 2 - 55 + offset * 70, 235 + abs(offset) * 10, 110, 146}, offset * 9)
if draw_button(start_x, start_y, button_w, button_h, "Japanese", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
return themes.JAPANESE_SCREEN
} }
if draw_button(start_x + button_w + button_space, start_y, button_w, button_h, "Korean", themes.KOREA_PRIMARY, themes.KOREA_ACCENT, mouse_x, mouse_y) { result: Maybe(int)
return themes.KOREAN_SCREEN 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
}
y += themes.BUTTON_HEIGHT + 20
} }
if ui.button({(themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, y, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT}, "Back", theme.ink) {
if draw_button(start_x + i32(button_w) + i32(button_space) + i32(button_w) + i32(button_space), start_y, button_w, button_h, "Thai", themes.THAI_PRIMARY, themes.THAI_ACCENT, mouse_x, mouse_y) { result = -1
return themes.THAI_SCREEN
} }
return result
return themes.MAIN_MENU
} }
draw_japanese_screen :: proc(mouse_x, mouse_y: i32) -> i32 { japanese_menu :: proc() -> themes.Screen {
raylib.ClearBackground(themes.WHITE) choice, clicked := language_menu(themes.JAPAN, "ひらがな · Hiragana", {"Memory Game"}).?
if !clicked { return .JAPANESE_MENU }
draw_title("Japanese", 100, themes.JAPAN_PRIMARY) return choice == 0 ? .JAPANESE_MEMORY : .MAIN_MENU
draw_centered_text("Select a game to play", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
button_w := themes.BUTTON_WIDTH
button_h := themes.BUTTON_HEIGHT
button_space := 40
start_y := themes.SCREEN_HEIGHT / 2
start_x := (themes.SCREEN_WIDTH - button_w) / 2
if draw_button(start_x, start_y, button_w, button_h, "Memory Game", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
return themes.JAPANESE_MEMORY
}
back_y := i32(start_y) + i32(button_h) + i32(button_space)
if draw_button(start_x, back_y, button_w, button_h, "Back", themes.JAPAN_PRIMARY, themes.JAPAN_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
}
return themes.JAPANESE_SCREEN
} }
draw_korean_screen :: proc(mouse_x, mouse_y: i32) -> i32 { korean_menu :: proc() -> themes.Screen {
raylib.ClearBackground(themes.WHITE) if _, clicked := language_menu(themes.KOREA, "Hangul · coming soon", {}).?; clicked {
draw_title("Korean", 100, themes.KOREA_PRIMARY) return .MAIN_MENU
draw_centered_text("Coming Soon", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
if draw_button((themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, themes.SCREEN_HEIGHT / 2, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT, "Back", themes.KOREA_PRIMARY, themes.KOREA_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
} }
return .KOREAN_MENU
return themes.KOREAN_SCREEN
} }
draw_thai_screen :: proc(mouse_x, mouse_y: i32) -> i32 { thai_menu :: proc() -> themes.Screen {
raylib.ClearBackground(themes.WHITE) if _, clicked := language_menu(themes.THAI, "Thai script · coming soon", {}).?; clicked {
draw_title("Thai", 100, themes.THAI_PRIMARY) return .MAIN_MENU
draw_centered_text("Coming Soon", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY)
if draw_button((themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, themes.SCREEN_HEIGHT / 2, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT, "Back", themes.THAI_PRIMARY, themes.THAI_ACCENT, mouse_x, mouse_y) {
return themes.MAIN_MENU
} }
return .THAI_MENU
return themes.THAI_SCREEN
} }
+84 -33
View File
@@ -2,40 +2,91 @@ package themes
import raylib "vendor:raylib" import raylib "vendor:raylib"
SCREEN_WIDTH: i32 = 1280 SCREEN_WIDTH :: 1280
SCREEN_HEIGHT: i32 = 720 SCREEN_HEIGHT :: 720
JAPAN_PRIMARY: raylib.Color = raylib.Color{230, 57, 70, 255} Screen :: enum {
JAPAN_ACCENT: raylib.Color = raylib.Color{253, 184, 190, 255} MAIN_MENU,
JAPAN_BACKGROUND: raylib.Color = raylib.Color{250, 245, 245, 255} JAPANESE_MENU,
KOREA_PRIMARY: raylib.Color = raylib.Color{0, 71, 160, 255} JAPANESE_MEMORY,
KOREA_ACCENT: raylib.Color = raylib.Color{180, 180, 255, 255} KOREAN_MENU,
KOREA_BACKGROUND: raylib.Color = raylib.Color{245, 248, 255, 255} THAI_MENU,
THAI_PRIMARY: raylib.Color = raylib.Color{165, 25, 49, 255} }
THAI_ACCENT: raylib.Color = raylib.Color{255, 180, 200, 255}
THAI_BACKGROUND: raylib.Color = raylib.Color{255, 245, 248, 255}
WHITE: raylib.Color = raylib.Color{255, 255, 255, 255}
BLACK: raylib.Color = raylib.Color{0, 0, 0, 255}
DARK_GRAY: raylib.Color = raylib.Color{64, 64, 64, 255}
LIGHT_GRAY: raylib.Color = raylib.Color{200, 200, 200, 255}
CARD_BACK_COLOR: raylib.Color = raylib.Color{40, 40, 50, 255}
BUTTON_WIDTH: i32 = 240 // Which national flag the card backs are modelled on
BUTTON_HEIGHT: i32 = 60 Flag :: enum {
TITLE_FONT_SIZE: i32 = 72 JAPAN, // Nisshōki / Hinomaru
SUBTITLE_FONT_SIZE: i32 = 24 KOREA, // Taegukgi
BUTTON_FONT_SIZE: i32 = 24 THAILAND, // Thong Trairong
}
CARD_WIDTH: i32 = 90 Theme :: struct {
CARD_HEIGHT: i32 = 120 name: cstring,
CARD_SPACING: i32 = 12 flag: Flag,
GRID_TOP: i32 = 140 primary: raylib.Color, // main brand color (buttons, titles)
GRID_ROWS: i32 = 4 secondary: raylib.Color, // second flag color
GRID_COLS: i32 = 6 accent: raylib.Color, // highlights, matched outlines
FLIP_DELAY: f32 = 1.0 background: raylib.Color,
card_face: raylib.Color,
ink: raylib.Color, // body text
}
MAIN_MENU: i32 = 0 // Flag colors
JAPANESE_SCREEN: i32 = 1 HINOMARU_RED :: raylib.Color{188, 0, 45, 255}
JAPANESE_MEMORY: i32 = 2 TAEGUK_RED :: raylib.Color{205, 46, 58, 255}
KOREAN_SCREEN: i32 = 3 TAEGUK_BLUE :: raylib.Color{0, 71, 160, 255}
THAI_SCREEN: i32 = 4 THAI_RED :: raylib.Color{165, 25, 49, 255}
THAI_BLUE :: raylib.Color{45, 42, 74, 255}
WHITE :: raylib.Color{255, 255, 255, 255}
BLACK :: raylib.Color{0, 0, 0, 255}
SUMI_INK :: raylib.Color{35, 31, 32, 255}
DARK_GRAY :: raylib.Color{80, 76, 72, 255}
LIGHT_GRAY :: raylib.Color{200, 200, 200, 255}
MENU_BACKGROUND :: raylib.Color{248, 246, 242, 255}
JAPAN :: Theme{
name = "Japanese",
flag = .JAPAN,
primary = HINOMARU_RED,
secondary = WHITE,
accent = raylib.Color{212, 160, 23, 255}, // kin (gold)
background = raylib.Color{250, 246, 238, 255}, // washi paper
card_face = raylib.Color{255, 252, 245, 255},
ink = SUMI_INK,
}
KOREA :: Theme{
name = "Korean",
flag = .KOREA,
primary = TAEGUK_BLUE,
secondary = TAEGUK_RED,
accent = raylib.Color{30, 30, 30, 255}, // trigram black
background = raylib.Color{244, 246, 251, 255},
card_face = raylib.Color{255, 255, 255, 255},
ink = raylib.Color{22, 24, 35, 255},
}
THAI :: Theme{
name = "Thai",
flag = .THAILAND,
primary = THAI_RED,
secondary = THAI_BLUE,
accent = raylib.Color{218, 165, 32, 255}, // temple gold
background = raylib.Color{251, 246, 240, 255},
card_face = raylib.Color{255, 253, 248, 255},
ink = raylib.Color{30, 28, 45, 255},
}
BUTTON_WIDTH :: 240
BUTTON_HEIGHT :: 60
TITLE_FONT_SIZE :: 72
SUBTITLE_FONT_SIZE :: 26
BUTTON_FONT_SIZE :: 26
CARD_WIDTH :: 96
CARD_HEIGHT :: 128
CARD_SPACING :: 14
GRID_TOP :: 136
GRID_ROWS :: 4
GRID_COLS :: 6
+84
View File
@@ -0,0 +1,84 @@
package ui
import raylib "vendor:raylib"
import assets "../assets"
import audio "../audio"
import themes "../themes"
TEXT_SPACING :: 1
@(private)
any_hovered: bool
// Call at the start of each frame's drawing
begin_frame :: proc() {
any_hovered = false
}
// Call at the end of each frame's drawing
end_frame :: proc() {
raylib.SetMouseCursor(any_hovered ? .POINTING_HAND : .DEFAULT)
}
// Mark that something clickable is under the mouse (shows the hand cursor)
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)
}
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)
}
// 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
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, size, color, bold)
}
// 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)
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_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)
}
// A rounded button with hover, press and shadow states. Returns true when clicked.
button :: proc(rect: raylib.Rectangle, text: cstring, color: raylib.Color,
text_color := themes.WHITE, font_size: f32 = themes.BUTTON_FONT_SIZE) -> bool {
hovered := raylib.CheckCollisionPointRec(raylib.GetMousePosition(), rect)
pressed := hovered && raylib.IsMouseButtonDown(.LEFT)
if hovered { set_hovered() }
lift: f32 = 0
fill := color
if hovered {
lift = 2
fill = raylib.ColorBrightness(color, 0.12)
}
if pressed {
lift = -1
fill = raylib.ColorBrightness(color, -0.08)
}
shadow := raylib.Rectangle{rect.x, rect.y + 4, rect.width, rect.height}
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)
clicked := hovered && raylib.IsMouseButtonPressed(.LEFT)
if clicked { audio.play(.CLICK) }
return clicked
}