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 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-01 21:35:43 +02:00
1 parent 685182b25d
commit 83ff9511ec
5 files changed
+225 -6

No files matched your search

+12
View File
@@ -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 {
+17 -3
View File
@@ -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
+115
View File
@@ -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 "<deck>/<difficulty>"
}
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)
}
+11 -3
View File
@@ -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
+70
View File
@@ -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")
}