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:
parent
690fdedb91
commit
672ee9641a
BIN
assets/sprites/gold.png
Normal file
BIN
assets/sprites/gold.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 619 B |
BIN
assets/sprites/padlock.png
Normal file
BIN
assets/sprites/padlock.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 652 B |
BIN
assets/sprites/silver.png
Normal file
BIN
assets/sprites/silver.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 620 B |
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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{
|
||||||
|
|
|
@ -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
232
pkg/savegame/savegame.go
Normal 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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user