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:
1 parent
685182b25d
commit
83ff9511ec
5 files changed
+225
-6
No files matched your search
@@ -1,11 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import "core:fmt"
|
||||||
|
import "core:reflect"
|
||||||
import raylib "vendor:raylib"
|
import raylib "vendor:raylib"
|
||||||
import assets "src/assets"
|
import assets "src/assets"
|
||||||
import audio "src/audio"
|
import audio "src/audio"
|
||||||
import card "src/card"
|
import card "src/card"
|
||||||
import decks "src/decks"
|
import decks "src/decks"
|
||||||
import memory "src/memory"
|
import memory "src/memory"
|
||||||
|
import profile "src/profile"
|
||||||
import screens "src/screens"
|
import screens "src/screens"
|
||||||
import themes "src/themes"
|
import themes "src/themes"
|
||||||
import ui "src/ui"
|
import ui "src/ui"
|
||||||
@@ -40,9 +43,16 @@ main :: proc() {
|
|||||||
defer card.unload_art()
|
defer card.unload_art()
|
||||||
defer memory.unload()
|
defer memory.unload()
|
||||||
|
|
||||||
|
if !profile.init() {
|
||||||
|
raylib.TraceLog(.WARNING, "PROFILE: Could not read saved scores, starting fresh")
|
||||||
|
}
|
||||||
|
|
||||||
screen := themes.Screen.MAIN_MENU
|
screen := themes.Screen.MAIN_MENU
|
||||||
language := decks.Language.JAPANESE
|
language := decks.Language.JAPANESE
|
||||||
difficulty := memory.Difficulty.NORMAL
|
difficulty := memory.Difficulty.NORMAL
|
||||||
|
if saved, ok := reflect.enum_from_name(memory.Difficulty, profile.data.difficulty); ok {
|
||||||
|
difficulty = saved
|
||||||
|
}
|
||||||
running := true
|
running := true
|
||||||
for running && !raylib.WindowShouldClose() {
|
for running && !raylib.WindowShouldClose() {
|
||||||
dt := raylib.GetFrameTime()
|
dt := raylib.GetFrameTime()
|
||||||
@@ -82,6 +92,8 @@ main :: proc() {
|
|||||||
}
|
}
|
||||||
ui.end_frame()
|
ui.end_frame()
|
||||||
raylib.EndDrawing()
|
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
|
// Building card faces renders into textures, so do it outside of drawing
|
||||||
if deck, ok := start_deck.?; ok {
|
if deck, ok := start_deck.?; ok {
|
||||||
|
|||||||
+17
-3
@@ -1,10 +1,13 @@
|
|||||||
package memory
|
package memory
|
||||||
|
|
||||||
|
import "core:fmt"
|
||||||
import "core:math/rand"
|
import "core:math/rand"
|
||||||
|
import "core:strings"
|
||||||
import raylib "vendor:raylib"
|
import raylib "vendor:raylib"
|
||||||
import audio "../audio"
|
import audio "../audio"
|
||||||
import card "../card"
|
import card "../card"
|
||||||
import decks "../decks"
|
import decks "../decks"
|
||||||
|
import profile "../profile"
|
||||||
import themes "../themes"
|
import themes "../themes"
|
||||||
import ui "../ui"
|
import ui "../ui"
|
||||||
|
|
||||||
@@ -56,9 +59,15 @@ elapsed: f32
|
|||||||
resolve_timer: f32
|
resolve_timer: f32
|
||||||
win_t: f32 // fades in the win panel
|
win_t: f32 // fades in the win panel
|
||||||
restart_requested: bool
|
restart_requested: bool
|
||||||
|
last_result: profile.Result // filled in when a game is won
|
||||||
focus: int // card selected with the keyboard
|
focus: int // card selected with the keyboard
|
||||||
keyboard_active: bool // show the focus ring (hidden again when the mouse moves)
|
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.
|
// Deals a new game and renders the card faces. Needs the window to exist.
|
||||||
start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
|
start :: proc(deck_id: decks.Deck_Id, game_difficulty: Difficulty) {
|
||||||
deal(deck_id, game_difficulty)
|
deal(deck_id, game_difficulty)
|
||||||
@@ -196,6 +205,7 @@ resolve :: proc() {
|
|||||||
matches += 1
|
matches += 1
|
||||||
if matches == pair_count {
|
if matches == pair_count {
|
||||||
state = .OVER
|
state = .OVER
|
||||||
|
last_result = profile.record_game(score_key(deck, difficulty), moves, elapsed)
|
||||||
audio.play(.WIN)
|
audio.play(.WIN)
|
||||||
} else {
|
} else {
|
||||||
audio.play(.MATCH)
|
audio.play(.MATCH)
|
||||||
@@ -288,10 +298,14 @@ draw_win_panel :: proc() -> Maybe(themes.Screen) {
|
|||||||
cx := panel.x + panel_w / 2
|
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(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))
|
ui.draw_text_centered("All pairs found!", cx, panel.y + 112, 26, raylib.Fade(theme.ink, win_t))
|
||||||
minutes := int(elapsed) / 60
|
stats := strings.clone_to_cstring(fmt.tprintf("%d moves · %s", moves, profile.format_time(elapsed)), context.temp_allocator)
|
||||||
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)
|
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 }
|
if win_t < 1 { return nil }
|
||||||
button_w: f32 = 200
|
button_w: f32 = 200
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package screens
|
package screens
|
||||||
|
|
||||||
|
import "core:fmt"
|
||||||
import "core:math"
|
import "core:math"
|
||||||
|
import "core:strings"
|
||||||
import raylib "vendor:raylib"
|
import raylib "vendor:raylib"
|
||||||
import card "../card"
|
import card "../card"
|
||||||
import decks "../decks"
|
import decks "../decks"
|
||||||
import memory "../memory"
|
import memory "../memory"
|
||||||
|
import profile "../profile"
|
||||||
import themes "../themes"
|
import themes "../themes"
|
||||||
import ui "../ui"
|
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]) {
|
if ui.button(rect, decks.DECKS[deck].name, theme.primary, hotkey = keys[i]) {
|
||||||
result = deck
|
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
|
x += button_w + gap
|
||||||
}
|
}
|
||||||
|
|
||||||
// Difficulty selector
|
// Difficulty selector
|
||||||
names := memory.DIFFICULTY_NAMES
|
names := memory.DIFFICULTY_NAMES
|
||||||
layouts := memory.LAYOUTS
|
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_w: f32 = 150
|
||||||
seg_x := (themes.SCREEN_WIDTH - 3 * seg_w - 2 * 12) / 2
|
seg_x := (themes.SCREEN_WIDTH - 3 * seg_w - 2 * 12) / 2
|
||||||
for level in memory.Difficulty {
|
for level in memory.Difficulty {
|
||||||
selected := difficulty^ == level
|
selected := difficulty^ == level
|
||||||
color := selected ? theme.primary : raylib.ColorLerp(theme.primary, themes.WHITE, 0.55)
|
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)
|
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
|
difficulty^ = level
|
||||||
}
|
}
|
||||||
seg_x += seg_w + 12
|
seg_x += seg_w + 12
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in new issue
Block a user