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:
1 parent
5557de62a3
commit
5806f0498c
11 files changed
+1046
-425
No files matched your search
+251
-67
@@ -1,81 +1,265 @@
|
||||
package card
|
||||
|
||||
import "core:math"
|
||||
import raylib "vendor:raylib"
|
||||
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 {
|
||||
x: i32,
|
||||
y: i32,
|
||||
character: cstring,
|
||||
is_face_up: bool,
|
||||
is_matched: bool,
|
||||
pair_id: i32,
|
||||
flip_progress: f32, // 0 = face down, 1 = face up
|
||||
is_flipping: bool,
|
||||
flip_direction: bool, // true = to face up, false = to face down
|
||||
rect: raylib.Rectangle, // resting position on screen
|
||||
text: cstring,
|
||||
is_kana: bool,
|
||||
pair_id: int,
|
||||
face_up: bool, // logical state; the animation follows it
|
||||
matched: bool,
|
||||
flip_t: f32, // 0 = back showing, 1 = face showing
|
||||
pulse_t: f32, // counts down after a match
|
||||
face_art: raylib.RenderTexture2D,
|
||||
}
|
||||
|
||||
draw_card :: proc(card: ^Card, mouse_x, mouse_y: i32, theme_color: raylib.Color) {
|
||||
draw_color := theme_color
|
||||
if card.is_matched {
|
||||
draw_color = themes.LIGHT_GRAY
|
||||
}
|
||||
|
||||
is_hovered := mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
|
||||
mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT
|
||||
if is_hovered && !card.is_face_up && !card.is_matched && !card.is_flipping {
|
||||
draw_color = raylib.Color{
|
||||
byte(f32(theme_color.r) * 0.8),
|
||||
byte(f32(theme_color.g) * 0.8),
|
||||
byte(f32(theme_color.b) * 0.8),
|
||||
theme_color.a,
|
||||
}
|
||||
}
|
||||
|
||||
// Draw card background with rounded corners
|
||||
raylib.DrawRectangleRounded(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, draw_color)
|
||||
raylib.DrawRectangleRoundedLines(raylib.Rectangle{f32(card.x), f32(card.y), f32(themes.CARD_WIDTH), f32(themes.CARD_HEIGHT)}, 0.2, 10, themes.BLACK)
|
||||
|
||||
// Handle flip animation
|
||||
if card.is_flipping {
|
||||
// Draw the flip animation
|
||||
flip_angle := card.flip_progress * 180.0
|
||||
if !card.flip_direction {
|
||||
flip_angle = 180.0 - flip_angle
|
||||
}
|
||||
|
||||
// Draw the card as a rectangle that's rotating
|
||||
center_x := f32(card.x + themes.CARD_WIDTH / 2)
|
||||
center_y := f32(card.y + themes.CARD_HEIGHT / 2)
|
||||
|
||||
// For simplicity, just show the character when flip_progress > 0.5
|
||||
show_face := card.flip_progress > 0.5
|
||||
if card.flip_direction {
|
||||
show_face = card.flip_progress >= 0.5
|
||||
@(private)
|
||||
back_art: [themes.Flag]raylib.RenderTexture2D
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Art generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(private)
|
||||
art_rect :: proc(inset: f32 = 0) -> raylib.Rectangle {
|
||||
return {inset, inset, ART_WIDTH - 2 * inset, ART_HEIGHT - 2 * inset}
|
||||
}
|
||||
|
||||
@(private)
|
||||
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)
|
||||
// Transparent, but with the edge's RGB so filtering doesn't create dark fringes
|
||||
raylib.ClearBackground({edge_color.r, edge_color.g, edge_color.b, 0})
|
||||
raylib.EndTextureMode()
|
||||
return rt
|
||||
}
|
||||
|
||||
@(private)
|
||||
draw_frame :: proc(fill, border: raylib.Color) {
|
||||
raylib.DrawRectangleRounded(art_rect(), ROUNDNESS, SEGMENTS, fill)
|
||||
raylib.DrawRectangleRoundedLinesEx(art_rect(3), ROUNDNESS, SEGMENTS, 6, border)
|
||||
}
|
||||
|
||||
// Japan: Hinomaru, a crimson sun disc on white, with a double crimson border
|
||||
@(private)
|
||||
draw_japan_back :: proc() {
|
||||
draw_frame(themes.WHITE, themes.HINOMARU_RED)
|
||||
raylib.DrawRectangleRoundedLinesEx(art_rect(16), ROUNDNESS, SEGMENTS, 2, themes.HINOMARU_RED)
|
||||
center := raylib.Vector2{ART_WIDTH / 2, ART_HEIGHT / 2}
|
||||
raylib.DrawCircleV(center, ART_WIDTH * 0.3, themes.HINOMARU_RED)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
return mouse_x >= card.x && mouse_x <= card.x + themes.CARD_WIDTH &&
|
||||
mouse_y >= card.y && mouse_y <= card.y + themes.CARD_HEIGHT
|
||||
// Korea: Taegukgi, a red/blue taeguk circle with the four black trigrams in the corners
|
||||
@(private)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user