diff --git a/assets/MPLUSRounded1c-ExtraBold.ttf b/assets/MPLUSRounded1c-ExtraBold.ttf new file mode 100644 index 0000000..8713926 Binary files /dev/null and b/assets/MPLUSRounded1c-ExtraBold.ttf differ diff --git a/assets/MPLUSRounded1c-Medium.ttf b/assets/MPLUSRounded1c-Medium.ttf new file mode 100644 index 0000000..1b7ff5d Binary files /dev/null and b/assets/MPLUSRounded1c-Medium.ttf differ diff --git a/assets/MPLUSRounded1c-OFL.txt b/assets/MPLUSRounded1c-OFL.txt new file mode 100644 index 0000000..90025a9 --- /dev/null +++ b/assets/MPLUSRounded1c-OFL.txt @@ -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. diff --git a/main.odin b/main.odin index b3857b8..bb097a5 100644 --- a/main.odin +++ b/main.odin @@ -1,56 +1,77 @@ package main import raylib "vendor:raylib" -import themes "src/themes" -import screens "src/screens" -import memory "src/memory" 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 -mouse_x: i32 -mouse_y: i32 +// Where Escape takes you from each screen (nil = quit) +parent_screen :: proc(screen: themes.Screen) -> Maybe(themes.Screen) { + switch screen { + case .MAIN_MENU: return nil + case .JAPANESE_MEMORY: return .JAPANESE_MENU + case .JAPANESE_MENU, .KOREAN_MENU, .THAI_MENU: return .MAIN_MENU + } + return nil +} 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() - 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() defer assets.unload_fonts() - - raylib.SetTargetFPS(60) - memory.init_memory_game() - - for !raylib.WindowShouldClose() { - mouse_x = raylib.GetMouseX() - mouse_y = raylib.GetMouseY() - - if raylib.IsKeyPressed(raylib.KeyboardKey.ESCAPE) { - current_screen = themes.MAIN_MENU - } - - if current_screen == themes.JAPANESE_MEMORY { - delta_time := raylib.GetFrameTime() - 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() + card.load_art() + defer card.unload_art() + defer memory.unload() + + screen := themes.Screen.MAIN_MENU + running := true + for running && !raylib.WindowShouldClose() { + dt := raylib.GetFrameTime() + + if raylib.IsKeyPressed(.ESCAPE) { + if parent, ok := parent_screen(screen).?; ok { + screen = parent + } else { + running = false } - } 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() + + // 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 } } diff --git a/src/assets/assets.odin b/src/assets/assets.odin index b6bbb47..b491002 100644 --- a/src/assets/assets.odin +++ b/src/assets/assets.odin @@ -2,17 +2,52 @@ package assets 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() { - // Use raylib's default font for now - japanese_font = raylib.GetFontDefault() + codepoints := build_codepoints() + defer delete(codepoints) + regular_font = load_font(REGULAR_FONT_PATH, codepoints[:]) + bold_font = load_font(BOLD_FONT_PATH, codepoints[:]) } 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 { - return japanese_font +get_font :: proc(bold := false) -> raylib.Font { + return bold ? bold_font : regular_font } diff --git a/src/audio/audio.odin b/src/audio/audio.odin new file mode 100644 index 0000000..cbdc5b6 --- /dev/null +++ b/src/audio/audio.odin @@ -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]) +} diff --git a/src/card/card.odin b/src/card/card.odin index 9d15b44..2e46f27 100644 --- a/src/card/card.odin +++ b/src/card/card.odin @@ -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) + } } diff --git a/src/memory/memory.odin b/src/memory/memory.odin index 8809d50..c173301 100644 --- a/src/memory/memory.odin +++ b/src/memory/memory.odin @@ -2,213 +2,232 @@ package memory import "core:math/rand" import raylib "vendor:raylib" -import themes "../themes" +import audio "../audio" import card "../card" -import assets "../assets" +import themes "../themes" +import ui "../ui" -MemoryState :: enum { - WAITING, - FIRST, - SECOND, +PAIR_COUNT :: 12 +CARD_COUNT :: PAIR_COUNT * 2 + +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, } -memory_cards: [24]card.Card -memory_state: MemoryState -memory_first: i32 -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 +Kana :: struct { + kana: cstring, + romaji: cstring, } -update_memory_game :: proc(delta_time: f32, mouse_x, mouse_y: i32) { - memory_time += delta_time - - if memory_state == MemoryState.SECOND { - memory_flip += delta_time - if memory_flip >= themes.FLIP_DELAY { - if memory_first >= 0 && memory_second >= 0 { - if memory_cards[memory_first].pair_id == memory_cards[memory_second].pair_id { - memory_cards[memory_first].is_matched = true - memory_cards[memory_second].is_matched = true - memory_matches += 1 - } else { - memory_cards[memory_first].is_flipping = true - memory_cards[memory_first].flip_direction = false - memory_cards[memory_first].flip_progress = 0.0 - memory_cards[memory_second].is_flipping = true - memory_cards[memory_second].flip_direction = false - memory_cards[memory_second].flip_progress = 0.0 - } - } - memory_state = MemoryState.WAITING - memory_flip = 0.0 - memory_first = -1 - memory_second = -1 - } +// 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 +first: int = -1 +second: int = -1 +matches: int +moves: int +elapsed: f32 +resolve_timer: f32 +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} } - - // Update flip animations - for i := 0; i < 24; i += 1 { - if memory_cards[i].is_flipping { - memory_cards[i].flip_progress += delta_time * 3.0 // Flip speed - if memory_cards[i].flip_progress >= 1.0 { - memory_cards[i].flip_progress = 1.0 - memory_cards[i].is_flipping = false - memory_cards[i].is_face_up = memory_cards[i].flip_direction - } + rand.shuffle(cards[:]) + + total_w: f32 = themes.GRID_COLS * (themes.CARD_WIDTH + themes.CARD_SPACING) - themes.CARD_SPACING + start_x := (themes.SCREEN_WIDTH - total_w) / 2 + for &c, i in cards { + row := f32(i / themes.GRID_COLS) + col := f32(i % themes.GRID_COLS) + c.rect = { + 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 { - for i := 0; i < 24; i += 1 { - if !memory_cards[i].is_matched && !memory_cards[i].is_flipping && - !memory_cards[i].is_face_up && i32(i) != memory_first { - if card.is_card_clicked(&memory_cards[i], mouse_x, mouse_y) && - raylib.IsMouseButtonPressed(raylib.MouseButton.LEFT) { - memory_cards[i].is_flipping = true - memory_cards[i].flip_direction = true - memory_cards[i].flip_progress = 0.0 - if memory_first == -1 { - memory_first = i32(i) - memory_state = MemoryState.FIRST - } else { - memory_second = i32(i) - memory_moves += 1 - memory_state = MemoryState.SECOND - } + + state = .WAITING + first = -1 + second = -1 + matches = 0 + moves = 0 + elapsed = 0 + resolve_timer = 0 + win_t = 0 + restart_requested = false +} + +unload :: proc() { + for &c in cards { card.unload_face(&c) } +} + +@(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 } } } - } - - if memory_matches >= 12 { - memory_state = MemoryState.OVER + case .RESOLVING: + elapsed += dt + // Wait for the second card to finish turning before starting the delay + 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 { - raylib.ClearBackground(themes.JAPAN_BACKGROUND) - - font := assets.get_font() - title: cstring = "Memory Game - Japanese" - title_size: i32 = 48 - title_w := raylib.MeasureText(title, title_size) - title_x := (themes.SCREEN_WIDTH - title_w) / 2 - raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x + 3), 33}, f32(title_size), 0, themes.BLACK) - raylib.DrawTextEx(font, title, raylib.Vector2{f32(title_x), 30}, f32(title_size), 0, themes.JAPAN_PRIMARY) - - info_text := raylib.TextFormat("Moves: %d | Time: %.1fs", memory_moves, memory_time) - info_w := raylib.MeasureText(info_text, themes.SUBTITLE_FONT_SIZE) - 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) +// Draws the game and returns the screen to show next frame +draw :: proc() -> themes.Screen { + next := themes.Screen.JAPANESE_MEMORY + raylib.ClearBackground(theme.background) + + ui.draw_title("Hiragana Memory", 24, theme.primary, 48) + minutes := int(elapsed) / 60 + seconds := int(elapsed) % 60 + info := raylib.TextFormat("Moves %d · Pairs %d/%d · %d:%02d", moves, matches, PAIR_COUNT, minutes, seconds) + ui.draw_text_centered(info, themes.SCREEN_WIDTH / 2, 88, 24, theme.ink) + + if state != .OVER && ui.button({30, 28, 120, 48}, "Back", theme.primary, font_size = 22) { + next = .JAPANESE_MENU } - - back_w: i32 = 120 - back_h: i32 = 50 - 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 + + for &c, i in cards { + card.draw(&c, theme, can_pick(i)) } - raylib.DrawRectangle(back_x, back_y, back_w, back_h, back_color) - raylib.DrawRectangleLines(back_x, back_y, back_w, back_h, themes.BLACK) - back_text: cstring = "Back" - back_text_w := raylib.MeasureText(back_text, themes.BUTTON_FONT_SIZE) - 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() + + if state == .OVER { + if action := draw_win_panel(); action != nil { + next = action.? } } - 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 } diff --git a/src/screens/screens.odin b/src/screens/screens.odin index c975d7c..41f573a 100644 --- a/src/screens/screens.odin +++ b/src/screens/screens.odin @@ -1,113 +1,111 @@ package screens +import "core:math" import raylib "vendor:raylib" +import card "../card" 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 { - is_hovered := mouse_x >= x && mouse_x <= x + w && mouse_y >= y && mouse_y <= y + h - color := base_color - if is_hovered { - 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) +LANGUAGES :: [3]struct{ theme: themes.Theme, screen: themes.Screen }{ + {themes.JAPAN, .JAPANESE_MENU}, + {themes.KOREA, .KOREAN_MENU}, + {themes.THAI, .THAI_MENU}, } -draw_title :: proc(text: cstring, y: i32, color: raylib.Color) { - font := assets.get_font() - text_w := raylib.MeasureText(text, themes.TITLE_FONT_SIZE) - x := (themes.SCREEN_WIDTH - text_w) / 2 - raylib.DrawTextEx(font, text, raylib.Vector2{f32(x + 4), f32(y + 4)}, f32(themes.TITLE_FONT_SIZE), 0, themes.BLACK) - raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(themes.TITLE_FONT_SIZE), 0, color) +// Draws a card back like a playing card lying on the table +@(private) +draw_card_art :: proc(flag: themes.Flag, rect: raylib.Rectangle, rotation: f32 = 0) { + texture := card.get_back_art(flag) + source := raylib.Rectangle{0, 0, f32(texture.width), -f32(texture.height)} + 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) { - font := assets.get_font() - text_w := raylib.MeasureText(text, font_size) - 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) - raylib.DrawTextEx(font, text, raylib.Vector2{f32(x), f32(y)}, f32(font_size), 0, color) +main_menu :: proc() -> themes.Screen { + next := themes.Screen.MAIN_MENU + raylib.ClearBackground(themes.MENU_BACKGROUND) + + // Thin stripes along the top in each language's primary 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 { - raylib.ClearBackground(themes.WHITE) - - draw_title("HanjaKanaMun", 100, themes.JAPAN_PRIMARY) - draw_centered_text("Learn Japanese, Korean, and Thai", 180, themes.SUBTITLE_FONT_SIZE, themes.DARK_GRAY) - - button_w := themes.BUTTON_WIDTH - button_h := themes.BUTTON_HEIGHT - button_space: i32 = 40 - total_w := button_w * 3 + button_space * 2 - start_x := (themes.SCREEN_WIDTH - total_w) / 2 - start_y := themes.SCREEN_HEIGHT / 2 + 100 - - 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 +// Shared layout for a language's menu screen. Returns the clicked option index, -1 for back, or nil. +@(private) +language_menu :: proc(theme: themes.Theme, subtitle: cstring, options: []cstring) -> Maybe(int) { + raylib.ClearBackground(theme.background) + raylib.DrawRectangle(0, 0, themes.SCREEN_WIDTH, 8, theme.primary) + + ui.draw_title(theme.name, 70, theme.primary) + ui.draw_text_centered(subtitle, themes.SCREEN_WIDTH / 2, 170, themes.SUBTITLE_FONT_SIZE, theme.ink) + + // A small fan of card backs + for i in 0 ..< 3 { + offset := f32(i - 1) + draw_card_art(theme.flag, {themes.SCREEN_WIDTH / 2 - 55 + offset * 70, 235 + abs(offset) * 10, 110, 146}, offset * 9) } - - 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) { - return themes.KOREAN_SCREEN + + result: Maybe(int) + y: f32 = 440 + for option, i in options { + if ui.button({(themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, y, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT}, option, theme.primary) { + result = i + } + y += themes.BUTTON_HEIGHT + 20 } - - 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) { - return themes.THAI_SCREEN + if ui.button({(themes.SCREEN_WIDTH - themes.BUTTON_WIDTH) / 2, y, themes.BUTTON_WIDTH, themes.BUTTON_HEIGHT}, "Back", theme.ink) { + result = -1 } - - return themes.MAIN_MENU + return result } -draw_japanese_screen :: proc(mouse_x, mouse_y: i32) -> i32 { - raylib.ClearBackground(themes.WHITE) - - draw_title("Japanese", 100, themes.JAPAN_PRIMARY) - 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 +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 } -draw_korean_screen :: proc(mouse_x, mouse_y: i32) -> i32 { - raylib.ClearBackground(themes.WHITE) - draw_title("Korean", 100, themes.KOREA_PRIMARY) - 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 +korean_menu :: proc() -> themes.Screen { + if _, clicked := language_menu(themes.KOREA, "Hangul · coming soon", {}).?; clicked { + return .MAIN_MENU } - - return themes.KOREAN_SCREEN + return .KOREAN_MENU } -draw_thai_screen :: proc(mouse_x, mouse_y: i32) -> i32 { - raylib.ClearBackground(themes.WHITE) - draw_title("Thai", 100, themes.THAI_PRIMARY) - 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 +thai_menu :: proc() -> themes.Screen { + if _, clicked := language_menu(themes.THAI, "Thai script · coming soon", {}).?; clicked { + return .MAIN_MENU } - - return themes.THAI_SCREEN + return .THAI_MENU } diff --git a/src/themes/themes.odin b/src/themes/themes.odin index cc4a28b..95673a7 100644 --- a/src/themes/themes.odin +++ b/src/themes/themes.odin @@ -2,40 +2,91 @@ package themes import raylib "vendor:raylib" -SCREEN_WIDTH: i32 = 1280 -SCREEN_HEIGHT: i32 = 720 +SCREEN_WIDTH :: 1280 +SCREEN_HEIGHT :: 720 -JAPAN_PRIMARY: raylib.Color = raylib.Color{230, 57, 70, 255} -JAPAN_ACCENT: raylib.Color = raylib.Color{253, 184, 190, 255} -JAPAN_BACKGROUND: raylib.Color = raylib.Color{250, 245, 245, 255} -KOREA_PRIMARY: raylib.Color = raylib.Color{0, 71, 160, 255} -KOREA_ACCENT: raylib.Color = raylib.Color{180, 180, 255, 255} -KOREA_BACKGROUND: raylib.Color = raylib.Color{245, 248, 255, 255} -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} +Screen :: enum { + MAIN_MENU, + JAPANESE_MENU, + JAPANESE_MEMORY, + KOREAN_MENU, + THAI_MENU, +} -BUTTON_WIDTH: i32 = 240 -BUTTON_HEIGHT: i32 = 60 -TITLE_FONT_SIZE: i32 = 72 -SUBTITLE_FONT_SIZE: i32 = 24 -BUTTON_FONT_SIZE: i32 = 24 +// Which national flag the card backs are modelled on +Flag :: enum { + JAPAN, // Nisshōki / Hinomaru + KOREA, // Taegukgi + THAILAND, // Thong Trairong +} -CARD_WIDTH: i32 = 90 -CARD_HEIGHT: i32 = 120 -CARD_SPACING: i32 = 12 -GRID_TOP: i32 = 140 -GRID_ROWS: i32 = 4 -GRID_COLS: i32 = 6 -FLIP_DELAY: f32 = 1.0 +Theme :: struct { + name: cstring, + flag: Flag, + primary: raylib.Color, // main brand color (buttons, titles) + secondary: raylib.Color, // second flag color + accent: raylib.Color, // highlights, matched outlines + background: raylib.Color, + card_face: raylib.Color, + ink: raylib.Color, // body text +} -MAIN_MENU: i32 = 0 -JAPANESE_SCREEN: i32 = 1 -JAPANESE_MEMORY: i32 = 2 -KOREAN_SCREEN: i32 = 3 -THAI_SCREEN: i32 = 4 +// Flag colors +HINOMARU_RED :: raylib.Color{188, 0, 45, 255} +TAEGUK_RED :: raylib.Color{205, 46, 58, 255} +TAEGUK_BLUE :: raylib.Color{0, 71, 160, 255} +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 diff --git a/src/ui/ui.odin b/src/ui/ui.odin new file mode 100644 index 0000000..0d0f1cd --- /dev/null +++ b/src/ui/ui.odin @@ -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 +}