From 672ee9641a85604880cefec9e3370a22df71e450 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 2 Jan 2022 16:28:43 -0800 Subject: [PATCH] Savegame and High Scores * Adds pkg/savegame to store user progress thru Level Packs. * The savegame.json is mildly tamper resistant by including a checksum along with the JSON body. * The checksum combines the JSON string + an app secret (in savegame.go) + user specific entropy (stored in their settings.json). If the user modifies their save file and the checksum becomes invalid the game will not load the save file, acting like it didn't exist, resetting all their high scores. Updates to the Story Mode window: * On the LevelPacks list: shows e.g. "[completed 0 of 3 levels]" showing a user's progress thru the level pack. * Below the levels on the Detail screen: * Shows an indicator whether the level is completed or not. * Shows high scores (fastest times beating the level) * Shows a padlock icon if levels are locked and the player hasn't reached them yet. Pops up an Alert modal if a locked level is clicked on. Scoring is based around your fastest time elapsed to finish the level. * Perfect Time (gold coin): player has not died during the level. * Best Time (silver coin): player has continued from a checkpoint. In-game an elapsed timer is shown in the top left corner along with the gold or silver coin indicating if your run has been Perfect. If the user enters any Cheat Codes during gameplay they are not eligible to win a high score, but the level will still be marked as completed. The icon next to the in-game timer disappears when a cheat code has been entered. --- assets/sprites/gold.png | Bin 0 -> 619 bytes assets/sprites/padlock.png | Bin 0 -> 652 bytes assets/sprites/silver.png | Bin 0 -> 620 bytes pkg/balance/theme.go | 22 ++++ pkg/cheats.go | 9 ++ pkg/levelpack/levelpack.go | 6 +- pkg/modal/end_level.go | 65 ++++++++++ pkg/play_scene.go | 122 +++++++++++++++++- pkg/savegame/savegame.go | 232 ++++++++++++++++++++++++++++++++++ pkg/sprites/sprites.go | 18 +++ pkg/usercfg/usercfg.go | 26 +++- pkg/userdir/userdir.go | 2 + pkg/windows/levelpack_open.go | 133 ++++++++++++++++++- 13 files changed, 626 insertions(+), 9 deletions(-) create mode 100644 assets/sprites/gold.png create mode 100644 assets/sprites/padlock.png create mode 100644 assets/sprites/silver.png create mode 100644 pkg/savegame/savegame.go diff --git a/assets/sprites/gold.png b/assets/sprites/gold.png new file mode 100644 index 0000000000000000000000000000000000000000..efbe517afd6e152277ec8908e7e85ac82fbe75de GIT binary patch literal 619 zcmV-x0+juUP)EX>4Tx04R}tkv&MmP!xqvQ$>-AgGEFHGgLvaAS&XhRVYG*P%E_RVDi#GXws0R zxHt-~1qXi?s}3&Cx;nTDg5VE`qmz@OiTyrGnJet4ik&{7FJrA6-_CX>@2HM@dakSAh-}0000&NklKuG}uBLf2ign%h@p<9Vznkvw#2q002ovPDHLk FV1huK^{fB@ literal 0 HcmV?d00001 diff --git a/assets/sprites/padlock.png b/assets/sprites/padlock.png new file mode 100644 index 0000000000000000000000000000000000000000..1204b7b5394cec9e8f005716daa483fe4f080a09 GIT binary patch literal 652 zcmV;70(1R|P)EX>4Tx04R}tkv&MmP!xqvQ$>-AgGEFHGgLvaAS&XhRVYG*P%E_RVDi#GXws0R zxHt-~1qXi?s}3&Cx;nTDg5VE`qmz@OiTyrGnJet4ik&{7FJrA6-y{D4^000SaNLh0L z01FcU01FcV0GgZ_00007bV*G`2j&3-00_CX>@2HM@dakSAh-}0001ENkl z2EU)`;2jqLP=$zsh@xjs!!gDJMC7*37^9f|2~U%UN4Z*p++gbrTSCeVDf4FX8d~~! m!!pzoz|#K{KHqGeuZ9T#aV&=7?55fP0000EX>4Tx04R}tkv&MmP!xqvQ$>-AgGEFHGgLvaAS&XhRVYG*P%E_RVDi#GXws0R zxHt-~1qXi?s}3&Cx;nTDg5VE`qmz@OiTyrGnJet4ik&{7FJrA6-y{D4^000SaNLh0L z04^f{04^f|c%?sf00007bV*G`2j&3+79uYAZzm}L000?uMObu0Z*6U5Zgc=ca%Ew3 zWn>_CX>@2HM@dakSAh-}0000(Nkl135W4j0_A642;MMR8&;p z3hvyw16Rn1O#!+>CR_@T#hDn00K_YT*^SFh#QBbxZ~_2a$05lQtJT^70000 elapsed { + score.PerfectTime = &elapsed + newHigh = true + } + } else { + if score.BestTime == nil || *score.BestTime > elapsed { + score.BestTime = &elapsed + newHigh = true + } + } + + if newHigh { + if sg.LevelPacks[levelpack] == nil { + sg.LevelPacks[levelpack] = NewLevelPack() + } + sg.LevelPacks[levelpack].Levels[filename] = score + } + + return newHigh +} + +// GetLevelScore finds or creates a default Level score. +func (sg *SaveGame) GetLevelScore(levelpack, filename string) *Level { + levelpack = filepath.Base(levelpack) + filename = filepath.Base(filename) + + if _, ok := sg.LevelPacks[levelpack]; !ok { + sg.LevelPacks[levelpack] = NewLevelPack() + } + + if row, ok := sg.LevelPacks[levelpack].Levels[filename]; ok { + return row + } else { + row = &Level{} + sg.LevelPacks[levelpack].Levels[filename] = row + return row + } +} + +// CountCompleted returns the number of completed levels in a levelpack. +func (sg *SaveGame) CountCompleted(levelpack string) int { + var count int + levelpack = filepath.Base(levelpack) + + if lp, ok := sg.LevelPacks[levelpack]; ok { + for _, lvl := range lp.Levels { + if lvl.Completed { + count++ + } + } + } + + return count +} + +// FormatDuration pretty prints a time.Duration in MM:SS format. +func FormatDuration(d time.Duration) string { + d = d.Round(time.Millisecond) + var ( + hour = d / time.Hour + minute = d / time.Minute + second = d / time.Second + ms = fmt.Sprintf("%d", d/time.Millisecond%1000) + ) + + // Limit milliseconds to 2 digits. + if len(ms) > 2 { + ms = ms[:2] + } + + return strings.TrimPrefix( + fmt.Sprintf("%02d:%02d:%02d.%s", hour, minute, second, ms), + "00:", + ) +} + +// Hashing key that goes into the level's save data. +var secretKey = []byte(`Sc\x96R\x8e\xba\x96\x8e\x1fg\x01Q\xf5\xcbIX`) + +func makeChecksum(jsontext []byte) string { + h := sha1.New() + h.Write(jsontext) + h.Write(secretKey) + h.Write(usercfg.Current.Entropy) + return hex.EncodeToString(h.Sum(nil)) +} + +func verifyChecksum(jsontext []byte, checksum string) bool { + expect := makeChecksum(jsontext) + return expect == checksum +} diff --git a/pkg/sprites/sprites.go b/pkg/sprites/sprites.go index 8b5b6ee..28853c7 100644 --- a/pkg/sprites/sprites.go +++ b/pkg/sprites/sprites.go @@ -1,3 +1,9 @@ +/* +Package sprites manages miscellaneous in-game sprites. + +The sprites are relatively few for UI purposes. Their textures are +loaded ONE time and cached in this package for performance. +*/ package sprites import ( @@ -15,11 +21,23 @@ import ( "git.kirsle.net/go/ui" ) +// Cache of loaded sprites. +var cache = map[string]*ui.Image{} + +// FlushCache clears the sprites cache. +func FlushCache() { + panic("TODO: free textures") +} + // LoadImage loads a sprite as a ui.Image object. It checks Doodle's embedded // bindata, then the filesystem before erroring out. // // NOTE: only .png images supported as of now. TODO func LoadImage(e render.Engine, filename string) (*ui.Image, error) { + if cached, ok := cache[filename]; ok { + return cached, nil + } + // Try the bindata first. if data, err := assets.Asset(filename); err == nil { log.Debug("sprites.LoadImage: %s from bindata", filename) diff --git a/pkg/usercfg/usercfg.go b/pkg/usercfg/usercfg.go index 583432a..df16647 100644 --- a/pkg/usercfg/usercfg.go +++ b/pkg/usercfg/usercfg.go @@ -11,6 +11,7 @@ package usercfg import ( "bytes" + "crypto/rand" "encoding/json" "io/ioutil" "os" @@ -27,13 +28,14 @@ type Settings struct { // disk, so the game may decide some default settings for first-time // user experience, e.g. set horizontal toolbars for mobile. Initialized bool + Entropy []byte `json:"entropy"` // Configurable settings (pkg/windows/settings.go) HorizontalToolbars bool `json:",omitempty"` EnableFeatures bool `json:",omitempty"` CrosshairSize int `json:",omitempty"` CrosshairColor render.Color - HideTouchHints bool `json:"omitempty"` + HideTouchHints bool `json:",omitempty"` // Secret boolprops from balance/boolprops.go ShowHiddenDoodads bool `json:",omitempty"` @@ -67,6 +69,11 @@ func Save() error { enc.SetIndent("", "\t") Current.Initialized = true Current.UpdatedAt = time.Now() + if Current.Entropy == nil || len(Current.Entropy) == 0 { + if key, err := MakeEntropy(); err == nil { + Current.Entropy = key + } + } if err := enc.Encode(Current); err != nil { return err } @@ -98,5 +105,22 @@ func Load() error { } Current = settings + + // If we don't have an entropy key saved, make one and save it. + if Current.Entropy == nil || len(Current.Entropy) == 0 { + Save() + } + return nil } + +// MakeEntropy creates a random string one time that saves into the settings.json, +// used for checksum calculations for the user's savegame. +func MakeEntropy() ([]byte, error) { + key := make([]byte, 16) + _, err := rand.Read(key) + if err != nil { + return nil, err + } + return key, nil +} diff --git a/pkg/userdir/userdir.go b/pkg/userdir/userdir.go index 3f4bc92..99e7104 100644 --- a/pkg/userdir/userdir.go +++ b/pkg/userdir/userdir.go @@ -21,6 +21,7 @@ var ( DoodadDirectory string CampaignDirectory string ScreenshotDirectory string + SaveFile string CacheDirectory string FontDirectory string @@ -41,6 +42,7 @@ func init() { DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads") CampaignDirectory = configdir.LocalConfig(ConfigDirectoryName, "campaigns") ScreenshotDirectory = configdir.LocalConfig(ConfigDirectoryName, "screenshots") + SaveFile = configdir.LocalConfig(ConfigDirectoryName, "savegame.json") // Cache directory to extract font files to. CacheDirectory = configdir.LocalCache(ConfigDirectoryName) diff --git a/pkg/windows/levelpack_open.go b/pkg/windows/levelpack_open.go index 298d00b..c0c4457 100644 --- a/pkg/windows/levelpack_open.go +++ b/pkg/windows/levelpack_open.go @@ -7,6 +7,9 @@ import ( "git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/levelpack" "git.kirsle.net/apps/doodle/pkg/log" + "git.kirsle.net/apps/doodle/pkg/modal" + "git.kirsle.net/apps/doodle/pkg/savegame" + "git.kirsle.net/apps/doodle/pkg/sprites" "git.kirsle.net/go/render" "git.kirsle.net/go/ui" ) @@ -21,8 +24,11 @@ type LevelPack struct { OnCloseWindow func() // Internal variables - window *ui.Window - tabFrame *ui.TabFrame + window *ui.Window + tabFrame *ui.TabFrame + savegame *savegame.SaveGame + goldSprite *ui.Image + silverSprite *ui.Image } // NewLevelPackWindow initializes the window. @@ -33,7 +39,7 @@ func NewLevelPackWindow(config LevelPack) *ui.Window { // size of the popup window width = 320 - height = 300 + height = 340 ) // Get the available .levelpack files. @@ -42,6 +48,21 @@ func NewLevelPackWindow(config LevelPack) *ui.Window { log.Error("Couldn't list levelpack files: %s", err) } + // Load the user's savegame.json + sg, err := savegame.GetOrCreate() + config.savegame = sg + if err != nil { + log.Warn("NewLevelPackWindow: didn't load savegame json (fresh struct created): %s", err) + } + + // Cache the gold/silver sprite icons. + if goldSprite, err := sprites.LoadImage(config.Engine, "sprites/gold.png"); err == nil { + config.goldSprite = goldSprite + } + if silverSprite, err := sprites.LoadImage(config.Engine, "sprites/silver.png"); err == nil { + config.silverSprite = silverSprite + } + window := ui.NewWindow(title) window.SetButtons(ui.CloseButton) window.Configure(ui.Config{ @@ -183,7 +204,7 @@ func (config LevelPack) makeIndexScreen(frame *ui.Frame, width, height int, }) numLevels := ui.NewLabel(ui.Label{ - Text: fmt.Sprintf("[%d levels]", len(lp.Levels)), + Text: fmt.Sprintf("[completed %d of %d levels]", config.savegame.CountCompleted(lp.Filename), len(lp.Levels)), Font: balance.MenuFont, }) btnFrame.Pack(numLevels, ui.Pack{ @@ -263,6 +284,16 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp maxPageButtons = 10 ) + // Load the padlock icon for locked levels. + // If not loadable, won't be used in UI. + padlock, _ := sprites.LoadImage(config.Engine, balance.LockIcon) + + // How many levels completed? + var ( + numCompleted = config.savegame.CountCompleted(lp.Filename) + numUnlocked = lp.FreeLevels + numCompleted + ) + /** Back Button */ backButton := ui.NewButton("Back", ui.NewLabel(ui.Label{ Text: "< Back", @@ -332,6 +363,7 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp var buttons []*ui.Button for i, level := range lp.Levels { level := level + score := config.savegame.GetLevelScore(lp.Filename, level.Filename) // Make a frame to hold a complex button layout. btnFrame := ui.NewFrame("Frame") @@ -340,6 +372,16 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp H: buttonHeight, }) + // Padlock icon in the corner. + var locked = lp.FreeLevels > 0 && i+1 > numUnlocked + if locked && padlock != nil { + btnFrame.Pack(padlock, ui.Pack{ + Side: ui.NE, + Padding: 4, + }) + } + + // Title Line title := ui.NewLabel(ui.Label{ Text: level.Title, Font: balance.LabelFont, @@ -348,8 +390,91 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp Side: ui.NW, }) + // Score Frame + detail := ui.NewFrame("Score") + btnFrame.Pack(detail, ui.Pack{ + Side: ui.NW, + }) + if score.Completed { + check := ui.NewLabel(ui.Label{ + Text: "✓ Completed", + Font: balance.MenuFont, + }) + detail.Pack(check, ui.Pack{ + Side: ui.W, + }) + + // Perfect Time + if score.PerfectTime != nil { + perfFrame := ui.NewFrame("Perfect Score") + detail.Pack(perfFrame, ui.Pack{ + Side: ui.W, + PadX: 8, + }) + + if config.goldSprite != nil { + perfFrame.Pack(config.goldSprite, ui.Pack{ + Side: ui.W, + PadX: 1, + }) + } + + timeLabel := ui.NewLabel(ui.Label{ + Text: savegame.FormatDuration(*score.PerfectTime), + Font: balance.MenuFont.Update(render.Text{ + Color: render.DarkYellow, + }), + }) + perfFrame.Pack(timeLabel, ui.Pack{ + Side: ui.W, + }) + } + + // Best Time (non-perfect) + if score.BestTime != nil { + bestFrame := ui.NewFrame("Best Score") + detail.Pack(bestFrame, ui.Pack{ + Side: ui.W, + PadX: 4, + }) + + if config.silverSprite != nil { + bestFrame.Pack(config.silverSprite, ui.Pack{ + Side: ui.W, + PadX: 1, + }) + } + + timeLabel := ui.NewLabel(ui.Label{ + Text: savegame.FormatDuration(*score.BestTime), + Font: balance.MenuFont.Update(render.Text{ + Color: render.DarkGreen, + }), + }) + bestFrame.Pack(timeLabel, ui.Pack{ + Side: ui.W, + }) + } + } else { + detail.Pack(ui.NewLabel(ui.Label{ + Text: "Not completed", + Font: balance.MenuFont, + }), ui.Pack{ + Side: ui.W, + }) + } + btn := ui.NewButton(level.Filename, btnFrame) btn.Handle(ui.Click, func(ed ui.EventData) error { + // Is this level locked? + if locked { + modal.Alert( + "This level hasn't been unlocked! Complete the earlier\n" + + "levels in this pack to unlock later levels.", + ).WithTitle("Locked Level") + return nil + } + // Play Level if config.OnPlayLevel != nil { config.OnPlayLevel(lp, level)