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.
This commit is contained in:
Noah 2022-01-02 16:28:43 -08:00
parent 690fdedb91
commit 672ee9641a
13 changed files with 626 additions and 9 deletions

BIN
assets/sprites/gold.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

BIN
assets/sprites/padlock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

BIN
assets/sprites/silver.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

View File

@ -8,7 +8,11 @@ import (
// Theme and appearance variables. // Theme and appearance variables.
var ( var (
// Sprite filenames.
WindowIcon = "assets/icons/96.png" WindowIcon = "assets/icons/96.png"
GoldCoin = "assets/sprites/gold.png"
SilverCoin = "assets/sprites/silver.png"
LockIcon = "assets/sprites/padlock.png"
// Title Screen Font // Title Screen Font
TitleScreenFont = render.Text{ TitleScreenFont = render.Text{
@ -131,6 +135,16 @@ var (
Color: render.Black, Color: render.Black,
} }
// A New Record! Label (Gold/Perfect and Silver/Normal)
NewRecordPerfectFont = LabelFont.Update(render.Text{
Color: render.Yellow,
Stroke: render.Orange,
})
NewRecordFont = LabelFont.Update(render.Text{
Color: render.White,
Stroke: render.Grey,
})
LargeLabelFont = render.Text{ LargeLabelFont = render.Text{
Size: 18, Size: 18,
FontFilename: "DejaVuSans-Bold.ttf", FontFilename: "DejaVuSans-Bold.ttf",
@ -177,6 +191,14 @@ var (
Stroke: render.RGBA(100, 100, 0, 255), Stroke: render.RGBA(100, 100, 0, 255),
} }
// In-game level timer font.
TimerFont = render.Text{
FontFilename: "DejaVuSansMono.ttf",
Size: 16,
Color: render.Cyan,
Stroke: render.DarkCyan,
}
// Doodad Dropper Window settings. // Doodad Dropper Window settings.
DoodadButtonBackground = render.RGBA(255, 255, 200, 255) DoodadButtonBackground = render.RGBA(255, 255, 200, 255)
DoodadButtonSize = 64 DoodadButtonSize = 64

View File

@ -26,6 +26,7 @@ func (c Command) cheatCommand(d *Doodle) bool {
case "don't edit and drive": case "don't edit and drive":
if isPlay { if isPlay {
playScene.drawing.Editable = true playScene.drawing.Editable = true
playScene.SetCheated()
d.Flash("Level canvas is now editable. Don't edit and drive!") d.Flash("Level canvas is now editable. Don't edit and drive!")
} else { } else {
d.FlashError("Use this cheat in Play Mode to make the level canvas editable.") d.FlashError("Use this cheat in Play Mode to make the level canvas editable.")
@ -34,6 +35,7 @@ func (c Command) cheatCommand(d *Doodle) bool {
case "scroll scroll scroll your boat": case "scroll scroll scroll your boat":
if isPlay { if isPlay {
playScene.drawing.Scrollable = true playScene.drawing.Scrollable = true
playScene.SetCheated()
d.Flash("Level canvas is now scrollable with the arrow keys.") d.Flash("Level canvas is now scrollable with the arrow keys.")
} else { } else {
d.FlashError("Use this cheat in Play Mode to make the level scrollable.") d.FlashError("Use this cheat in Play Mode to make the level scrollable.")
@ -41,6 +43,8 @@ func (c Command) cheatCommand(d *Doodle) bool {
case "import antigravity": case "import antigravity":
if isPlay { if isPlay {
playScene.SetCheated()
playScene.antigravity = !playScene.antigravity playScene.antigravity = !playScene.antigravity
playScene.Player.SetGravity(!playScene.antigravity) playScene.Player.SetGravity(!playScene.antigravity)
@ -55,6 +59,8 @@ func (c Command) cheatCommand(d *Doodle) bool {
case "ghost mode": case "ghost mode":
if isPlay { if isPlay {
playScene.SetCheated()
playScene.noclip = !playScene.noclip playScene.noclip = !playScene.noclip
playScene.Player.SetNoclip(playScene.noclip) playScene.Player.SetNoclip(playScene.noclip)
@ -72,6 +78,7 @@ func (c Command) cheatCommand(d *Doodle) bool {
case "show all actors": case "show all actors":
if isPlay { if isPlay {
playScene.SetCheated()
for _, actor := range playScene.drawing.Actors() { for _, actor := range playScene.drawing.Actors() {
actor.Show() actor.Show()
} }
@ -82,6 +89,7 @@ func (c Command) cheatCommand(d *Doodle) bool {
case "give all keys": case "give all keys":
if isPlay { if isPlay {
playScene.SetCheated()
playScene.Player.AddItem("key-red.doodad", 0) playScene.Player.AddItem("key-red.doodad", 0)
playScene.Player.AddItem("key-blue.doodad", 0) playScene.Player.AddItem("key-blue.doodad", 0)
playScene.Player.AddItem("key-green.doodad", 0) playScene.Player.AddItem("key-green.doodad", 0)
@ -94,6 +102,7 @@ func (c Command) cheatCommand(d *Doodle) bool {
case "drop all items": case "drop all items":
if isPlay { if isPlay {
playScene.SetCheated()
playScene.Player.ClearInventory() playScene.Player.ClearInventory()
d.Flash("Cleared inventory of player character.") d.Flash("Cleared inventory of player character.")
} else { } else {

View File

@ -37,6 +37,9 @@ type LevelPack struct {
// The loaded zip file for reading an existing levelpack. // The loaded zip file for reading an existing levelpack.
Zipfile *zip.Reader `json:"-"` Zipfile *zip.Reader `json:"-"`
// A reference to the original filename, not stored in json.
Filename string `json:"-"`
} }
// Level holds metadata about the levels in the levelpack. // Level holds metadata about the levels in the levelpack.
@ -84,6 +87,7 @@ func LoadFile(filename string) (LevelPack, error) {
} }
lp := LevelPack{ lp := LevelPack{
Filename: filename,
Zipfile: reader, Zipfile: reader,
} }

View File

@ -2,13 +2,19 @@ package modal
import ( import (
"fmt" "fmt"
"time"
"git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/savegame"
"git.kirsle.net/apps/doodle/pkg/sprites"
"git.kirsle.net/go/render"
"git.kirsle.net/go/ui" "git.kirsle.net/go/ui"
) )
// ConfigEndLevel sets options for the EndLevel modal. // ConfigEndLevel sets options for the EndLevel modal.
type ConfigEndLevel struct { type ConfigEndLevel struct {
Engine render.Engine
Success bool // false = failure condition Success bool // false = failure condition
// Handler functions - what you don't define will not // Handler functions - what you don't define will not
@ -18,6 +24,11 @@ type ConfigEndLevel struct {
OnEditLevel func() OnEditLevel func()
OnNextLevel func() // Next Level OnNextLevel func() // Next Level
OnExitToMenu func() // Exit to Menu OnExitToMenu func() // Exit to Menu
// Set these values to show the "New Record!" part of the modal.
NewRecord bool
IsPerfect bool
TimeElapsed time.Duration
} }
// EndLevel shows the End Level modal. // EndLevel shows the End Level modal.
@ -62,6 +73,60 @@ func makeEndLevel(m *Modal, cfg ConfigEndLevel) *ui.Window {
Side: ui.N, Side: ui.N,
}) })
// New Record frame.
if cfg.NewRecord {
// Get the gold or silver sprite.
var (
coin = balance.SilverCoin
recordFont = balance.NewRecordFont
)
if cfg.IsPerfect {
coin = balance.GoldCoin
recordFont = balance.NewRecordPerfectFont
}
recordFrame := ui.NewFrame("New Record")
msgFrame.Pack(recordFrame, ui.Pack{
Side: ui.N,
})
header := ui.NewLabel(ui.Label{
Text: "A New Record!",
Font: recordFont,
})
recordFrame.Pack(header, ui.Pack{
Side: ui.N,
FillX: true,
})
// A frame to hold the icon and duration elapsed.
timeFrame := ui.NewFrame("Time Frame")
recordFrame.Pack(timeFrame, ui.Pack{
Side: ui.N,
})
// Show the coin image.
if cfg.Engine != nil {
img, err := sprites.LoadImage(cfg.Engine, coin)
if err != nil {
log.Error("Couldn't load %s: %s", coin, err)
} else {
timeFrame.Pack(img, ui.Pack{
Side: ui.W,
})
}
}
// Show the time duration label.
dur := ui.NewLabel(ui.Label{
Text: savegame.FormatDuration(cfg.TimeElapsed),
Font: balance.MenuFont,
})
timeFrame.Pack(dur, ui.Pack{
Side: ui.W,
})
}
// Ok/Cancel button bar. // Ok/Cancel button bar.
btnBar := ui.NewFrame("Button Bar") btnBar := ui.NewFrame("Button Bar")
msgFrame.Pack(btnBar, ui.Pack{ msgFrame.Pack(btnBar, ui.Pack{

View File

@ -14,7 +14,9 @@ import (
"git.kirsle.net/apps/doodle/pkg/modal" "git.kirsle.net/apps/doodle/pkg/modal"
"git.kirsle.net/apps/doodle/pkg/modal/loadscreen" "git.kirsle.net/apps/doodle/pkg/modal/loadscreen"
"git.kirsle.net/apps/doodle/pkg/physics" "git.kirsle.net/apps/doodle/pkg/physics"
"git.kirsle.net/apps/doodle/pkg/savegame"
"git.kirsle.net/apps/doodle/pkg/scripting" "git.kirsle.net/apps/doodle/pkg/scripting"
"git.kirsle.net/apps/doodle/pkg/sprites"
"git.kirsle.net/apps/doodle/pkg/uix" "git.kirsle.net/apps/doodle/pkg/uix"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/render/event" "git.kirsle.net/go/render/event"
@ -42,6 +44,11 @@ type PlayScene struct {
running bool running bool
deathBarrier int // Y position of death barrier in case of falling OOB. deathBarrier int // Y position of death barrier in case of falling OOB.
// Score variables.
startTime time.Time // wallclock time when level begins
perfectRun bool // set false on first respawn
cheated bool // user has entered a cheat code while playing
// UI widgets. // UI widgets.
supervisor *ui.Supervisor supervisor *ui.Supervisor
screen *ui.Frame // A window sized invisible frame to position UI elements. screen *ui.Frame // A window sized invisible frame to position UI elements.
@ -67,6 +74,12 @@ type PlayScene struct {
invenItems []string // item list invenItems []string // item list
invenDoodads map[string]*uix.Canvas invenDoodads map[string]*uix.Canvas
// Elapsed Time frame.
timerFrame *ui.Frame
timerPerfectImage *ui.Image
timerImperfectImage *ui.Image
timerLabel *ui.Label
// Touchscreen controls state. // Touchscreen controls state.
isTouching bool isTouching bool
playerIsIdle bool // LoopTouchable watches for inactivity on input controls. playerIsIdle bool // LoopTouchable watches for inactivity on input controls.
@ -137,6 +150,45 @@ func (s *PlayScene) setupAsync(d *Doodle) error {
// Set up the inventory HUD. // Set up the inventory HUD.
s.setupInventoryHud() s.setupInventoryHud()
// Set up the elapsed time frame.
{
s.timerFrame = ui.NewFrame("Elapsed Timer")
// Set the gold and silver images.
gold, _ := sprites.LoadImage(s.d.Engine, balance.GoldCoin)
silver, _ := sprites.LoadImage(s.d.Engine, balance.SilverCoin)
s.timerPerfectImage = gold
s.timerImperfectImage = silver
s.timerLabel = ui.NewLabel(ui.Label{
Text: "00:00",
Font: balance.TimerFont,
})
if s.timerPerfectImage != nil {
s.timerFrame.Pack(s.timerPerfectImage, ui.Pack{
Side: ui.W,
PadX: 2,
})
}
if s.timerImperfectImage != nil {
s.timerFrame.Pack(s.timerImperfectImage, ui.Pack{
Side: ui.W,
PadX: 2,
})
s.timerImperfectImage.Hide()
}
s.timerFrame.Pack(s.timerLabel, ui.Pack{
Side: ui.W,
PadX: 2,
})
s.screen.Place(s.timerFrame, ui.Place{
Top: 40,
Left: 40,
})
}
// Initialize the drawing canvas. // Initialize the drawing canvas.
s.drawing = uix.NewCanvas(balance.ChunkSize, false) s.drawing = uix.NewCanvas(balance.ChunkSize, false)
s.drawing.Name = "play-canvas" s.drawing.Name = "play-canvas"
@ -216,6 +268,8 @@ func (s *PlayScene) setupAsync(d *Doodle) error {
// runtime, + the bitmap generation is pretty wicked fast anyway. // runtime, + the bitmap generation is pretty wicked fast anyway.
loadscreen.PreloadAllChunkBitmaps(s.Level.Chunker) loadscreen.PreloadAllChunkBitmaps(s.Level.Chunker)
s.startTime = time.Now()
s.perfectRun = true
s.running = true s.running = true
return nil return nil
@ -241,9 +295,9 @@ func (s *PlayScene) setupPlayer() {
if linkedActor, ok := s.Level.Actors[linkID]; ok { if linkedActor, ok := s.Level.Actors[linkID]; ok {
playerCharacterFilename = linkedActor.Filename playerCharacterFilename = linkedActor.Filename
log.Info("Playing as: %s", playerCharacterFilename) log.Info("Playing as: %s", playerCharacterFilename)
}
break break
} }
}
// TODO: start-flag.doodad is 86x86 pixels but we can't tell that // TODO: start-flag.doodad is 86x86 pixels but we can't tell that
// from right here. // from right here.
@ -346,6 +400,7 @@ func (s *PlayScene) BeatLevel() {
// FailLevel handles a level failure triggered by a doodad. // FailLevel handles a level failure triggered by a doodad.
func (s *PlayScene) FailLevel(message string) { func (s *PlayScene) FailLevel(message string) {
s.SetImperfect()
s.d.FlashError(message) s.d.FlashError(message)
s.ShowEndLevelModal( s.ShowEndLevelModal(
false, false,
@ -359,11 +414,42 @@ func (s *PlayScene) DieByFire(name string) {
s.FailLevel(fmt.Sprintf("Watch out for %s!", name)) s.FailLevel(fmt.Sprintf("Watch out for %s!", name))
} }
// SetImperfect sets the perfectRun flag to false and changes the icon for the timer.
func (s *PlayScene) SetImperfect() {
if s.cheated {
return
}
s.perfectRun = false
if s.timerPerfectImage != nil {
s.timerPerfectImage.Hide()
}
if s.timerImperfectImage != nil {
s.timerImperfectImage.Show()
}
}
// SetCheated marks the level as having been cheated. The developer shell will call
// this if the user enters a cheat code during gameplay.
func (s *PlayScene) SetCheated() {
s.cheated = true
s.perfectRun = false
// Hide both timer icons.
if s.timerPerfectImage != nil {
s.timerPerfectImage.Hide()
}
if s.timerImperfectImage != nil {
s.timerImperfectImage.Hide()
}
}
// ShowEndLevelModal centralizes the EndLevel modal config. // ShowEndLevelModal centralizes the EndLevel modal config.
// This is the common handler function between easy methods such as // This is the common handler function between easy methods such as
// BeatLevel, FailLevel, and DieByFire. // BeatLevel, FailLevel, and DieByFire.
func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) { func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) {
config := modal.ConfigEndLevel{ config := modal.ConfigEndLevel{
Engine: s.d.Engine,
Success: success, Success: success,
OnRestartLevel: s.RestartLevel, OnRestartLevel: s.RestartLevel,
OnRetryCheckpoint: s.RetryCheckpoint, OnRetryCheckpoint: s.RetryCheckpoint,
@ -380,9 +466,35 @@ func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) {
if success { if success {
config.OnRetryCheckpoint = nil config.OnRetryCheckpoint = nil
// Are we in a levelpack? Show the "Next Level" button if there is // Are we in a levelpack?
// a sequel to this level.
if s.LevelPack != nil { if s.LevelPack != nil {
// Update the savegame to mark the level completed.
save, err := savegame.GetOrCreate()
if err != nil {
log.Warn("Load savegame file: %s", err)
}
log.Info("Mark level '%s' from pack '%s' as completed", s.Filename, s.LevelPack.Filename)
if !s.cheated {
elapsed := time.Since(s.startTime)
highscore := save.NewHighScore(s.LevelPack.Filename, s.Filename, s.perfectRun, elapsed)
if highscore {
s.d.Flash("New record!")
config.NewRecord = true
config.IsPerfect = s.perfectRun
config.TimeElapsed = elapsed
}
} else {
// Player has cheated! Mark the level completed but grant no high score.
save.MarkCompleted(s.LevelPack.Filename, s.Filename)
}
// Save the player's scores file.
if err = save.Save(); err != nil {
log.Error("Couldn't save game: %s", err)
}
// Show the "Next Level" button if there is a sequel to this level.
for i, level := range s.LevelPack.Levels { for i, level := range s.LevelPack.Levels {
i := i i := i
level := level level := level
@ -419,6 +531,9 @@ func (s *PlayScene) Loop(d *Doodle, ev *event.State) error {
*s.debViewport = s.drawing.Viewport().String() *s.debViewport = s.drawing.Viewport().String()
*s.debScroll = s.drawing.Scroll.String() *s.debScroll = s.drawing.Scroll.String()
// Update the timer.
s.timerLabel.Text = savegame.FormatDuration(time.Since(s.startTime))
s.supervisor.Loop(ev) s.supervisor.Loop(ev)
// Has the window been resized? // Has the window been resized?
@ -428,6 +543,7 @@ func (s *PlayScene) Loop(d *Doodle, ev *event.State) error {
d.width = w d.width = w
d.height = h d.height = h
s.drawing.Resize(render.NewRect(d.width, d.height)) s.drawing.Resize(render.NewRect(d.width, d.height))
s.screen.Resize(render.NewRect(d.width, d.height))
return nil return nil
} }
} }

232
pkg/savegame/savegame.go Normal file
View File

@ -0,0 +1,232 @@
package savegame
import (
"bufio"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.kirsle.net/apps/doodle/pkg/usercfg"
"git.kirsle.net/apps/doodle/pkg/userdir"
)
// SaveGame holds the user's progress thru level packs.
type SaveGame struct {
LevelPacks map[string]*LevelPack `json:"levelPacks"`
}
// LevelPack holds savegame process for a level pack.
type LevelPack struct {
Levels map[string]*Level `json:"levels"`
}
// Level holds high score information for a level.
type Level struct {
Completed bool `json:"completed"`
BestTime *time.Duration `json:"bestTime"`
PerfectTime *time.Duration `json:"perfectTime"`
}
// New creates a new SaveGame.
func New() *SaveGame {
return &SaveGame{
LevelPacks: map[string]*LevelPack{},
}
}
// NewLevelPack initializes a LevelPack struct.
func NewLevelPack() *LevelPack {
return &LevelPack{
Levels: map[string]*Level{},
}
}
// GetOrCreate the save game JSON. If the save file isn't found OR has an
// invalid checksum, it is created. Always returns a valid SaveGame struct
// and the error may communicate if there was a problem reading an existing file.
func GetOrCreate() (*SaveGame, error) {
if sg, err := Load(); err == nil {
return sg, nil
} else {
return New(), err
}
}
// Load the save game JSON from the user's profile directory.
func Load() (*SaveGame, error) {
fh, err := os.Open(userdir.SaveFile)
if err != nil {
return nil, err
}
// Read the checksum line.
scanner := bufio.NewScanner(fh)
scanner.Scan()
var (
checksum = scanner.Text()
jsontext []byte
)
for scanner.Scan() {
jsontext = append(jsontext, scanner.Bytes()...)
}
// Validate the checksum.
if !verifyChecksum(jsontext, checksum) {
return nil, errors.New("checksum error")
}
// Parse the JSON.
var sg = New()
err = json.Unmarshal(jsontext, sg)
if err != nil {
return nil, err
}
return sg, nil
}
// Save the savegame.json to disk.
func (sg *SaveGame) Save() error {
// Encode to JSON.
text, err := json.Marshal(sg)
if err != nil {
return err
}
// Create the checksum.
checksum := makeChecksum(text)
// Write the file.
fh, err := os.Create(userdir.SaveFile)
if err != nil {
return err
}
defer fh.Close()
fh.Write([]byte(checksum))
fh.Write([]byte{'\n'})
fh.Write(text)
return nil
}
// MarkCompleted is a helper function to mark a levelpack level completed.
// Parameters are the filename of the levelpack and the level therein.
// Extra path info except the base filename is stripped from both.
func (sg *SaveGame) MarkCompleted(levelpack, filename string) {
lvl := sg.GetLevelScore(levelpack, filename)
lvl.Completed = true
}
// NewHighScore may set a new highscore for a level.
//
// The level will be marked Completed and if the given score is better
// than the stored one it will update.
//
// Returns true if a new high score was logged.
func (sg *SaveGame) NewHighScore(levelpack, filename string, isPerfect bool, elapsed time.Duration) bool {
levelpack = filepath.Base(levelpack)
filename = filepath.Base(filename)
score := sg.GetLevelScore(levelpack, filename)
score.Completed = true
var newHigh bool
if isPerfect {
if score.PerfectTime == nil || *score.PerfectTime > 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
}

View File

@ -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 package sprites
import ( import (
@ -15,11 +21,23 @@ import (
"git.kirsle.net/go/ui" "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 // LoadImage loads a sprite as a ui.Image object. It checks Doodle's embedded
// bindata, then the filesystem before erroring out. // bindata, then the filesystem before erroring out.
// //
// NOTE: only .png images supported as of now. TODO // NOTE: only .png images supported as of now. TODO
func LoadImage(e render.Engine, filename string) (*ui.Image, error) { func LoadImage(e render.Engine, filename string) (*ui.Image, error) {
if cached, ok := cache[filename]; ok {
return cached, nil
}
// Try the bindata first. // Try the bindata first.
if data, err := assets.Asset(filename); err == nil { if data, err := assets.Asset(filename); err == nil {
log.Debug("sprites.LoadImage: %s from bindata", filename) log.Debug("sprites.LoadImage: %s from bindata", filename)

View File

@ -11,6 +11,7 @@ package usercfg
import ( import (
"bytes" "bytes"
"crypto/rand"
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"os" "os"
@ -27,13 +28,14 @@ type Settings struct {
// disk, so the game may decide some default settings for first-time // disk, so the game may decide some default settings for first-time
// user experience, e.g. set horizontal toolbars for mobile. // user experience, e.g. set horizontal toolbars for mobile.
Initialized bool Initialized bool
Entropy []byte `json:"entropy"`
// Configurable settings (pkg/windows/settings.go) // Configurable settings (pkg/windows/settings.go)
HorizontalToolbars bool `json:",omitempty"` HorizontalToolbars bool `json:",omitempty"`
EnableFeatures bool `json:",omitempty"` EnableFeatures bool `json:",omitempty"`
CrosshairSize int `json:",omitempty"` CrosshairSize int `json:",omitempty"`
CrosshairColor render.Color CrosshairColor render.Color
HideTouchHints bool `json:"omitempty"` HideTouchHints bool `json:",omitempty"`
// Secret boolprops from balance/boolprops.go // Secret boolprops from balance/boolprops.go
ShowHiddenDoodads bool `json:",omitempty"` ShowHiddenDoodads bool `json:",omitempty"`
@ -67,6 +69,11 @@ func Save() error {
enc.SetIndent("", "\t") enc.SetIndent("", "\t")
Current.Initialized = true Current.Initialized = true
Current.UpdatedAt = time.Now() 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 { if err := enc.Encode(Current); err != nil {
return err return err
} }
@ -98,5 +105,22 @@ func Load() error {
} }
Current = settings 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 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
}

View File

@ -21,6 +21,7 @@ var (
DoodadDirectory string DoodadDirectory string
CampaignDirectory string CampaignDirectory string
ScreenshotDirectory string ScreenshotDirectory string
SaveFile string
CacheDirectory string CacheDirectory string
FontDirectory string FontDirectory string
@ -41,6 +42,7 @@ func init() {
DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads") DoodadDirectory = configdir.LocalConfig(ConfigDirectoryName, "doodads")
CampaignDirectory = configdir.LocalConfig(ConfigDirectoryName, "campaigns") CampaignDirectory = configdir.LocalConfig(ConfigDirectoryName, "campaigns")
ScreenshotDirectory = configdir.LocalConfig(ConfigDirectoryName, "screenshots") ScreenshotDirectory = configdir.LocalConfig(ConfigDirectoryName, "screenshots")
SaveFile = configdir.LocalConfig(ConfigDirectoryName, "savegame.json")
// Cache directory to extract font files to. // Cache directory to extract font files to.
CacheDirectory = configdir.LocalCache(ConfigDirectoryName) CacheDirectory = configdir.LocalCache(ConfigDirectoryName)

View File

@ -7,6 +7,9 @@ import (
"git.kirsle.net/apps/doodle/pkg/balance" "git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/levelpack" "git.kirsle.net/apps/doodle/pkg/levelpack"
"git.kirsle.net/apps/doodle/pkg/log" "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/render"
"git.kirsle.net/go/ui" "git.kirsle.net/go/ui"
) )
@ -23,6 +26,9 @@ type LevelPack struct {
// Internal variables // Internal variables
window *ui.Window window *ui.Window
tabFrame *ui.TabFrame tabFrame *ui.TabFrame
savegame *savegame.SaveGame
goldSprite *ui.Image
silverSprite *ui.Image
} }
// NewLevelPackWindow initializes the window. // NewLevelPackWindow initializes the window.
@ -33,7 +39,7 @@ func NewLevelPackWindow(config LevelPack) *ui.Window {
// size of the popup window // size of the popup window
width = 320 width = 320
height = 300 height = 340
) )
// Get the available .levelpack files. // Get the available .levelpack files.
@ -42,6 +48,21 @@ func NewLevelPackWindow(config LevelPack) *ui.Window {
log.Error("Couldn't list levelpack files: %s", err) 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 := ui.NewWindow(title)
window.SetButtons(ui.CloseButton) window.SetButtons(ui.CloseButton)
window.Configure(ui.Config{ window.Configure(ui.Config{
@ -183,7 +204,7 @@ func (config LevelPack) makeIndexScreen(frame *ui.Frame, width, height int,
}) })
numLevels := ui.NewLabel(ui.Label{ 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, Font: balance.MenuFont,
}) })
btnFrame.Pack(numLevels, ui.Pack{ btnFrame.Pack(numLevels, ui.Pack{
@ -263,6 +284,16 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
maxPageButtons = 10 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 */ /** Back Button */
backButton := ui.NewButton("Back", ui.NewLabel(ui.Label{ backButton := ui.NewButton("Back", ui.NewLabel(ui.Label{
Text: "< Back", Text: "< Back",
@ -332,6 +363,7 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
var buttons []*ui.Button var buttons []*ui.Button
for i, level := range lp.Levels { for i, level := range lp.Levels {
level := level level := level
score := config.savegame.GetLevelScore(lp.Filename, level.Filename)
// Make a frame to hold a complex button layout. // Make a frame to hold a complex button layout.
btnFrame := ui.NewFrame("Frame") btnFrame := ui.NewFrame("Frame")
@ -340,6 +372,16 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
H: buttonHeight, 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{ title := ui.NewLabel(ui.Label{
Text: level.Title, Text: level.Title,
Font: balance.LabelFont, Font: balance.LabelFont,
@ -348,8 +390,91 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp
Side: ui.NW, 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 := ui.NewButton(level.Filename, btnFrame)
btn.Handle(ui.Click, func(ed ui.EventData) error { 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 // Play Level
if config.OnPlayLevel != nil { if config.OnPlayLevel != nil {
config.OnPlayLevel(lp, level) config.OnPlayLevel(lp, level)