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
|
||||
|
||||
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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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