diff --git a/assets/sprites/gold.png b/assets/sprites/gold.png new file mode 100644 index 0000000..efbe517 Binary files /dev/null and b/assets/sprites/gold.png differ diff --git a/assets/sprites/padlock.png b/assets/sprites/padlock.png new file mode 100644 index 0000000..1204b7b Binary files /dev/null and b/assets/sprites/padlock.png differ diff --git a/assets/sprites/silver.png b/assets/sprites/silver.png new file mode 100644 index 0000000..3a6fa53 Binary files /dev/null and b/assets/sprites/silver.png differ diff --git a/pkg/balance/theme.go b/pkg/balance/theme.go index 4468bf0..7e692b4 100644 --- a/pkg/balance/theme.go +++ b/pkg/balance/theme.go @@ -8,7 +8,11 @@ import ( // Theme and appearance variables. var ( + // Sprite filenames. WindowIcon = "assets/icons/96.png" + GoldCoin = "assets/sprites/gold.png" + SilverCoin = "assets/sprites/silver.png" + LockIcon = "assets/sprites/padlock.png" // Title Screen Font TitleScreenFont = render.Text{ @@ -131,6 +135,16 @@ var ( 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{ Size: 18, FontFilename: "DejaVuSans-Bold.ttf", @@ -177,6 +191,14 @@ var ( 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. DoodadButtonBackground = render.RGBA(255, 255, 200, 255) DoodadButtonSize = 64 diff --git a/pkg/cheats.go b/pkg/cheats.go index 1d58d8c..7019ff9 100644 --- a/pkg/cheats.go +++ b/pkg/cheats.go @@ -26,6 +26,7 @@ func (c Command) cheatCommand(d *Doodle) bool { case "don't edit and drive": if isPlay { playScene.drawing.Editable = true + playScene.SetCheated() d.Flash("Level canvas is now editable. Don't edit and drive!") } else { 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": if isPlay { playScene.drawing.Scrollable = true + playScene.SetCheated() d.Flash("Level canvas is now scrollable with the arrow keys.") } else { 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": if isPlay { + playScene.SetCheated() + playScene.antigravity = !playScene.antigravity playScene.Player.SetGravity(!playScene.antigravity) @@ -55,6 +59,8 @@ func (c Command) cheatCommand(d *Doodle) bool { case "ghost mode": if isPlay { + playScene.SetCheated() + playScene.noclip = !playScene.noclip playScene.Player.SetNoclip(playScene.noclip) @@ -72,6 +78,7 @@ func (c Command) cheatCommand(d *Doodle) bool { case "show all actors": if isPlay { + playScene.SetCheated() for _, actor := range playScene.drawing.Actors() { actor.Show() } @@ -82,6 +89,7 @@ func (c Command) cheatCommand(d *Doodle) bool { case "give all keys": if isPlay { + playScene.SetCheated() playScene.Player.AddItem("key-red.doodad", 0) playScene.Player.AddItem("key-blue.doodad", 0) playScene.Player.AddItem("key-green.doodad", 0) @@ -94,6 +102,7 @@ func (c Command) cheatCommand(d *Doodle) bool { case "drop all items": if isPlay { + playScene.SetCheated() playScene.Player.ClearInventory() d.Flash("Cleared inventory of player character.") } else { diff --git a/pkg/levelpack/levelpack.go b/pkg/levelpack/levelpack.go index 3e2a408..30174a1 100644 --- a/pkg/levelpack/levelpack.go +++ b/pkg/levelpack/levelpack.go @@ -37,6 +37,9 @@ type LevelPack struct { // The loaded zip file for reading an existing levelpack. 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. @@ -84,7 +87,8 @@ func LoadFile(filename string) (LevelPack, error) { } lp := LevelPack{ - Zipfile: reader, + Filename: filename, + Zipfile: reader, } // Read the index.json. diff --git a/pkg/modal/end_level.go b/pkg/modal/end_level.go index 6fe9aca..c43462f 100644 --- a/pkg/modal/end_level.go +++ b/pkg/modal/end_level.go @@ -2,13 +2,19 @@ package modal import ( "fmt" + "time" "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" ) // ConfigEndLevel sets options for the EndLevel modal. type ConfigEndLevel struct { + Engine render.Engine Success bool // false = failure condition // Handler functions - what you don't define will not @@ -18,6 +24,11 @@ type ConfigEndLevel struct { OnEditLevel func() OnNextLevel func() // Next Level 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. @@ -62,6 +73,60 @@ func makeEndLevel(m *Modal, cfg ConfigEndLevel) *ui.Window { 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. btnBar := ui.NewFrame("Button Bar") msgFrame.Pack(btnBar, ui.Pack{ diff --git a/pkg/play_scene.go b/pkg/play_scene.go index b5a2026..b6e1f21 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -14,7 +14,9 @@ import ( "git.kirsle.net/apps/doodle/pkg/modal" "git.kirsle.net/apps/doodle/pkg/modal/loadscreen" "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/sprites" "git.kirsle.net/apps/doodle/pkg/uix" "git.kirsle.net/go/render" "git.kirsle.net/go/render/event" @@ -42,6 +44,11 @@ type PlayScene struct { running bool 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. supervisor *ui.Supervisor screen *ui.Frame // A window sized invisible frame to position UI elements. @@ -67,6 +74,12 @@ type PlayScene struct { invenItems []string // item list invenDoodads map[string]*uix.Canvas + // Elapsed Time frame. + timerFrame *ui.Frame + timerPerfectImage *ui.Image + timerImperfectImage *ui.Image + timerLabel *ui.Label + // Touchscreen controls state. isTouching bool 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. 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. s.drawing = uix.NewCanvas(balance.ChunkSize, false) 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. loadscreen.PreloadAllChunkBitmaps(s.Level.Chunker) + s.startTime = time.Now() + s.perfectRun = true s.running = true return nil @@ -241,8 +295,8 @@ func (s *PlayScene) setupPlayer() { if linkedActor, ok := s.Level.Actors[linkID]; ok { playerCharacterFilename = linkedActor.Filename log.Info("Playing as: %s", playerCharacterFilename) + break } - break } // TODO: start-flag.doodad is 86x86 pixels but we can't tell that @@ -346,6 +400,7 @@ func (s *PlayScene) BeatLevel() { // FailLevel handles a level failure triggered by a doodad. func (s *PlayScene) FailLevel(message string) { + s.SetImperfect() s.d.FlashError(message) s.ShowEndLevelModal( false, @@ -359,11 +414,42 @@ func (s *PlayScene) DieByFire(name string) { 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. // This is the common handler function between easy methods such as // BeatLevel, FailLevel, and DieByFire. func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) { config := modal.ConfigEndLevel{ + Engine: s.d.Engine, Success: success, OnRestartLevel: s.RestartLevel, OnRetryCheckpoint: s.RetryCheckpoint, @@ -380,9 +466,35 @@ func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) { if success { config.OnRetryCheckpoint = nil - // Are we in a levelpack? Show the "Next Level" button if there is - // a sequel to this level. + // Are we in a levelpack? 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 { i := i level := level @@ -419,6 +531,9 @@ func (s *PlayScene) Loop(d *Doodle, ev *event.State) error { *s.debViewport = s.drawing.Viewport().String() *s.debScroll = s.drawing.Scroll.String() + // Update the timer. + s.timerLabel.Text = savegame.FormatDuration(time.Since(s.startTime)) + s.supervisor.Loop(ev) // Has the window been resized? @@ -428,6 +543,7 @@ func (s *PlayScene) Loop(d *Doodle, ev *event.State) error { d.width = w d.height = h s.drawing.Resize(render.NewRect(d.width, d.height)) + s.screen.Resize(render.NewRect(d.width, d.height)) return nil } } diff --git a/pkg/savegame/savegame.go b/pkg/savegame/savegame.go new file mode 100644 index 0000000..71591d9 --- /dev/null +++ b/pkg/savegame/savegame.go @@ -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 +} 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)