From 83ff9511ecc7e185a22fd941279a30adf9817d5c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 1 Oct 2026 21:35:43 +0200 Subject: [PATCH] Save best scores and last difficulty to a JSON profile - profile package: best moves, best time and games played per deck/difficulty, stored in the user data folder - Win panel shows the best (or New best!), language menu shows bests - Profile memory always comes from the heap allocator (it outlives callers) Co-Authored-By: Claude Opus 5.5 --- main.odin | 12 ++++ src/memory/memory.odin | 20 ++++++- src/profile/profile.odin | 115 +++++++++++++++++++++++++++++++++++++++ src/screens/screens.odin | 14 ++++- tests/profile_test.odin | 70 ++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 src/profile/profile.odin create mode 100644 tests/profile_test.odin diff --git a/main.odin b/main.odin index df6b56c..ecce60f 100644 --- a/main.odin +++ b/main.odin @@ -1,11 +1,14 @@ package main +import "core:fmt" +import "core:reflect" import raylib "vendor:raylib" import assets "src/assets" import audio "src/audio" import card "src/card" import decks "src/decks" import memory "src/memory" +import profile "src/profile" import screens "src/screens" import themes "src/themes" import ui "src/ui" @@ -40,9 +43,16 @@ main :: proc() { defer card.unload_art() defer memory.unload() + if !profile.init() { + raylib.TraceLog(.WARNING, "PROFILE: Could not read saved scores, starting fresh") + } + screen := themes.Screen.MAIN_MENU language := decks.Language.JAPANESE difficulty := memory.Difficulty.NORMAL + if saved, ok := reflect.enum_from_name(memory.Difficulty, profile.data.difficulty); ok { + difficulty = saved + } running := true for running && !raylib.WindowShouldClose() { dt := raylib.GetFrameTime() @@ -82,6 +92,8 @@ main :: proc() { } ui.end_frame() raylib.EndDrawing() + free_all(context.temp_allocator) + profile.set_difficulty(fmt.tprintf("%v", difficulty)) // Building card faces renders into textures, so do it outside of drawing if deck, ok := start_deck.?; ok { diff --git a/src/memory/memory.odin b/src/memory/memory.odin index 64fd99e..d4593a7 100644 --- a/src/memory/memory.odin +++ b/src/memory/memory.odin @@ -1,10 +1,13 @@ package memory +import "core:fmt" import "core:math/rand" +import "core:strings" import raylib "vendor:raylib" import audio "../audio" import card "../card" import decks "../decks" +import profile "../profile" import themes "../themes" import ui "../ui" @@ -56,9 +59,15 @@ elapsed: f32 resolve_timer: f32 win_t: f32 // fades in the win panel restart_requested: bool +last_result: profile.Result // filled in when a game is won focus: int // card selected with the keyboard keyboard_active: bool // show the focus ring (hidden again when the mouse moves) +// Key for this deck and difficulty in the saved profile +score_key :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) -> string { + return fmt.tprintf("%v/%v", deck_id, game_difficulty) +} + // Deals a new game and renders the card faces. Needs the window to exist. start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) { deal(deck_id, game_difficulty) @@ -196,6 +205,7 @@ resolve :: proc() { matches += 1 if matches == pair_count { state = .OVER + last_result = profile.record_game(score_key(deck, difficulty), moves, elapsed) audio.play(.WIN) } else { audio.play(.MATCH) @@ -288,10 +298,14 @@ draw_win_panel :: proc() -> Maybe(themes.Screen) { cx := panel.x + panel_w / 2 ui.draw_text_centered(language.congrats, cx, panel.y + 34, 56, raylib.Fade(theme.primary, win_t), language.congrats_style) ui.draw_text_centered("All pairs found!", cx, panel.y + 112, 26, raylib.Fade(theme.ink, win_t)) - minutes := int(elapsed) / 60 - seconds := int(elapsed) % 60 - stats := raylib.TextFormat("%d moves · %d:%02d", moves, minutes, seconds) + stats := strings.clone_to_cstring(fmt.tprintf("%d moves · %s", moves, profile.format_time(elapsed)), context.temp_allocator) ui.draw_text_centered(stats, cx, panel.y + 156, 26, raylib.Fade(theme.ink, win_t), .BOLD) + best := last_result.record + best_text := strings.clone_to_cstring(fmt.tprintf("Best %d moves · %s", best.best_moves, profile.format_time(best.best_seconds)), context.temp_allocator) + if last_result.new_best_moves || last_result.new_best_time { + best_text = strings.clone_to_cstring(fmt.tprintf("New best! · %d games played", best.games), context.temp_allocator) + } + ui.draw_text_centered(best_text, cx, panel.y + 198, 22, raylib.Fade(theme.primary, win_t), .BOLD) if win_t < 1 { return nil } button_w: f32 = 200 diff --git a/src/profile/profile.odin b/src/profile/profile.odin new file mode 100644 index 0000000..0559245 --- /dev/null +++ b/src/profile/profile.odin @@ -0,0 +1,115 @@ +package profile + +import "base:runtime" +import "core:encoding/json" +import "core:fmt" +import "core:os" +import "core:strings" + +// Best results and settings, saved as JSON in the user's data folder +// (e.g. ~/Library/Application Support/HanjaKanaMun on macOS, +// ~/.local/share/HanjaKanaMun on Linux, %APPDATA%/HanjaKanaMun on Windows). + +FILE_NAME :: "profile.json" + +Record :: struct { + best_moves: int, // fewest moves to clear the board + best_seconds: f32, // fastest time to clear the board + games: int, +} + +Data :: struct { + difficulty: string, // last chosen difficulty, by name + records: map[string]Record, // keyed by "/" +} + +Result :: struct { + record: Record, + new_best_moves: bool, + new_best_time: bool, +} + +data: Data +path: string // empty until init: nothing is read or written + +// The profile lives for the whole program, so it always allocates from the heap +// rather than whatever allocator the caller's context has. +@(private) +persistent :: proc() -> runtime.Allocator { + return runtime.heap_allocator() +} + +// Loads the profile from `file_path`, or the default location when empty. +// Returns false if it existed but could not be read (the game then starts fresh). +init :: proc(file_path := "") -> bool { + context.allocator = persistent() + path = file_path + if path == "" { + dir, err := os.user_data_dir(context.allocator) + if err != nil { return false } + dir = strings.concatenate({dir, "/HanjaKanaMun"}) + os.make_directory_all(dir) + path = strings.concatenate({dir, "/" + FILE_NAME}) + } + if !os.exists(path) { return true } + + bytes, read_err := os.read_entire_file(path, context.allocator) + if read_err != nil { return false } + defer delete(bytes) + loaded: Data + if json.unmarshal(bytes, &loaded) != nil { return false } + data = loaded + return true +} + +save :: proc() -> bool { + if path == "" { return true } + bytes, err := json.marshal(data, {pretty = true, use_spaces = true, spaces = 2}) + if err != nil { return false } + defer delete(bytes) + return os.write_entire_file(path, bytes) == nil +} + +get :: proc(key: string) -> (Record, bool) { + record, ok := data.records[key] + return record, ok +} + +// Records a finished game and saves. Reports which bests were beaten. +record_game :: proc(key: string, moves: int, seconds: f32) -> Result { + context.allocator = persistent() + result: Result + old, existed := data.records[key] + new := old + new.games += 1 + if !existed || moves < old.best_moves { + new.best_moves = moves + result.new_best_moves = existed + } + if !existed || seconds < old.best_seconds { + new.best_seconds = seconds + result.new_best_time = existed + } + if existed { + data.records[key] = new + } else { + data.records[strings.clone(key)] = new + } + result.record = new + save() + return result +} + +set_difficulty :: proc(difficulty: string) { + if data.difficulty == difficulty { return } + context.allocator = persistent() + delete(data.difficulty) + data.difficulty = strings.clone(difficulty) + save() +} + +// Formats seconds as m:ss +format_time :: proc(seconds: f32) -> string { + total := int(seconds) + return fmt.tprintf("%d:%02d", total / 60, total % 60) +} diff --git a/src/screens/screens.odin b/src/screens/screens.odin index 51b57f7..4fbcd30 100644 --- a/src/screens/screens.odin +++ b/src/screens/screens.odin @@ -1,10 +1,13 @@ package screens +import "core:fmt" import "core:math" +import "core:strings" import raylib "vendor:raylib" import card "../card" import decks "../decks" import memory "../memory" +import profile "../profile" import themes "../themes" import ui "../ui" @@ -100,21 +103,26 @@ language_menu :: proc(language: decks.Language, difficulty: ^memory.Difficulty) if ui.button(rect, decks.DECKS[deck].name, theme.primary, hotkey = keys[i]) { result = deck } - ui.draw_text_centered(decks.DECKS[deck].native, x + button_w / 2, 450, 22, theme.ink, info.symbol_style) + ui.draw_text_centered(decks.DECKS[deck].native, x + button_w / 2, 448, 22, theme.ink, info.symbol_style) + best_text := cstring("No games yet") + if record, ok := profile.get(memory.score_key(deck, difficulty^)); ok { + best_text = strings.clone_to_cstring(fmt.tprintf("Best %d moves · %s", record.best_moves, profile.format_time(record.best_seconds)), context.temp_allocator) + } + ui.draw_text_centered(best_text, x + button_w / 2, 476, 17, raylib.Fade(theme.ink, 0.65)) x += button_w + gap } // Difficulty selector names := memory.DIFFICULTY_NAMES layouts := memory.LAYOUTS - ui.draw_text_centered("Difficulty (Left / Right)", themes.SCREEN_WIDTH / 2, 500, 20, raylib.Fade(theme.ink, 0.7)) + ui.draw_text_centered("Difficulty (Left / Right)", themes.SCREEN_WIDTH / 2, 512, 20, raylib.Fade(theme.ink, 0.7)) seg_w: f32 = 150 seg_x := (themes.SCREEN_WIDTH - 3 * seg_w - 2 * 12) / 2 for level in memory.Difficulty { selected := difficulty^ == level color := selected ? theme.primary : raylib.ColorLerp(theme.primary, themes.WHITE, 0.55) label := raylib.TextFormat("%s %d", names[level], layouts[level].rows * layouts[level].cols / 2) - if ui.button({seg_x, 530, seg_w, 48}, label, color, font_size = 22, selected = selected) { + if ui.button({seg_x, 542, seg_w, 48}, label, color, font_size = 22, selected = selected) { difficulty^ = level } seg_x += seg_w + 12 diff --git a/tests/profile_test.odin b/tests/profile_test.odin new file mode 100644 index 0000000..84667f8 --- /dev/null +++ b/tests/profile_test.odin @@ -0,0 +1,70 @@ +package tests + +import "core:os" +import "core:testing" +import memory "../src/memory" +import profile "../src/profile" + +@(test) +record_game_tracks_bests :: proc(t: ^testing.T) { + key := "TEST/RECORD" + first := profile.record_game(key, 20, 90) + testing.expect_value(t, first.record.games, 1) + testing.expect_value(t, first.record.best_moves, 20) + testing.expect(t, !first.new_best_moves && !first.new_best_time) // nothing to beat yet + + worse := profile.record_game(key, 25, 120) + testing.expect(t, !worse.new_best_moves && !worse.new_best_time) + testing.expect_value(t, worse.record.best_moves, 20) + + // Fewer moves but slower: only the moves record changes + fewer := profile.record_game(key, 15, 100) + testing.expect(t, fewer.new_best_moves && !fewer.new_best_time) + testing.expect_value(t, fewer.record.best_moves, 15) + testing.expect_value(t, fewer.record.best_seconds, 90) + testing.expect_value(t, fewer.record.games, 3) +} + +@(test) +profile_round_trips_through_json :: proc(t: ^testing.T) { + path := "build/test-profile.json" + os.remove(path) + defer os.remove(path) + + testing.expect(t, profile.init(path)) + defer profile.path = "" // stop other tests from writing the file + profile.set_difficulty("HARD") + profile.record_game("TEST/SAVE", 12, 61.5) + + // Reload from disk into a clean state + profile.data = {} + testing.expect(t, profile.init(path)) + testing.expect_value(t, profile.data.difficulty, "HARD") + record, ok := profile.get("TEST/SAVE") + testing.expect(t, ok) + testing.expect_value(t, record.best_moves, 12) + testing.expect_value(t, record.best_seconds, 61.5) +} + +@(test) +winning_records_a_score :: proc(t: ^testing.T) { + memory.deal(.KATAKANA, .EASY) + for i in 0 ..< memory.card_count { + if memory.cards[i].matched { continue } + memory.select_card(i) + memory.select_card(partner_of(i)) + advance(2) + } + testing.expect_value(t, memory.state, memory.State.OVER) + record, ok := profile.get(memory.score_key(.KATAKANA, .EASY)) + testing.expect(t, ok) + testing.expect_value(t, record.best_moves, memory.pair_count) + testing.expect_value(t, memory.last_result.record.games, record.games) +} + +@(test) +time_format :: proc(t: ^testing.T) { + testing.expect_value(t, profile.format_time(0), "0:00") + testing.expect_value(t, profile.format_time(65.9), "1:05") + testing.expect_value(t, profile.format_time(600), "10:00") +}