diff --git a/assets/wallpapers/atmosphere.png b/assets/wallpapers/atmosphere.png new file mode 100644 index 0000000..598df17 Binary files /dev/null and b/assets/wallpapers/atmosphere.png differ diff --git a/cmd/doodad/commands/levelpack.go b/cmd/doodad/commands/levelpack.go index 201705e..cb440bd 100644 --- a/cmd/doodad/commands/levelpack.go +++ b/cmd/doodad/commands/levelpack.go @@ -211,6 +211,7 @@ func levelpackCreate(c *cli.Context) error { // Log the level in the index.json list. lp.Levels = append(lp.Levels, levelpack.Level{ + UUID: lvl.UUID, Title: lvl.Title, Author: lvl.Author, Filename: filepath.Base(filename), diff --git a/cmd/doodad/commands/resave.go b/cmd/doodad/commands/resave.go new file mode 100644 index 0000000..75f6837 --- /dev/null +++ b/cmd/doodad/commands/resave.go @@ -0,0 +1,108 @@ +package commands + +import ( + "fmt" + "path/filepath" + "strings" + + "git.kirsle.net/SketchyMaze/doodle/pkg/doodads" + "git.kirsle.net/SketchyMaze/doodle/pkg/enum" + "git.kirsle.net/SketchyMaze/doodle/pkg/level" + "git.kirsle.net/SketchyMaze/doodle/pkg/log" + "github.com/urfave/cli/v2" +) + +// Resave a Level or Doodad to adapt to file format upgrades. +var Resave *cli.Command + +func init() { + Resave = &cli.Command{ + Name: "resave", + Usage: "load and re-save a level or doodad file to migrate to newer file format versions", + ArgsUsage: "<.level or .doodad>", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "actors", + Usage: "print verbose actor data in Level files", + }, + &cli.BoolFlag{ + Name: "chunks", + Usage: "print verbose data about all the pixel chunks in a file", + }, + &cli.BoolFlag{ + Name: "script", + Usage: "print the script from a doodad file and exit", + }, + &cli.StringFlag{ + Name: "attachment", + Aliases: []string{"a"}, + Usage: "print the contents of the attached filename to terminal", + }, + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "print verbose output (all verbose flags enabled)", + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return cli.Exit( + "Usage: doodad resave <.level .doodad ...>", + 1, + ) + } + + filenames := c.Args().Slice() + for _, filename := range filenames { + switch strings.ToLower(filepath.Ext(filename)) { + case enum.LevelExt: + if err := resaveLevel(c, filename); err != nil { + log.Error(err.Error()) + return cli.Exit("Error", 1) + } + case enum.DoodadExt: + if err := resaveDoodad(c, filename); err != nil { + log.Error(err.Error()) + return cli.Exit("Error", 1) + } + default: + log.Error("File %s: not a level or doodad", filename) + } + } + return nil + }, + } +} + +// resaveLevel shows data about a level file. +func resaveLevel(c *cli.Context, filename string) error { + lvl, err := level.LoadJSON(filename) + if err != nil { + return err + } + + log.Info("Loaded level from file: %s", filename) + log.Info("Last saved game version: %s", lvl.GameVersion) + + log.Info("Saving back to disk") + if err := lvl.WriteJSON(filename); err != nil { + return fmt.Errorf("couldn't write %s: %s", filename, err) + } + return showLevel(c, filename) +} + +func resaveDoodad(c *cli.Context, filename string) error { + dd, err := doodads.LoadJSON(filename) + if err != nil { + return err + } + + log.Info("Loaded doodad from file: %s", filename) + log.Info("Last saved game version: %s", dd.GameVersion) + + log.Info("Saving back to disk") + if err := dd.WriteJSON(filename); err != nil { + return fmt.Errorf("couldn't write %s: %s", filename, err) + } + return showDoodad(c, filename) +} diff --git a/cmd/doodad/commands/show.go b/cmd/doodad/commands/show.go index 01fddfa..e43839c 100644 --- a/cmd/doodad/commands/show.go +++ b/cmd/doodad/commands/show.go @@ -105,6 +105,7 @@ func showLevel(c *cli.Context, filename string) error { fmt.Printf(" File format: %s\n", fileType) fmt.Printf(" File version: %d\n", lvl.Version) fmt.Printf(" Game version: %s\n", lvl.GameVersion) + fmt.Printf(" Level UUID: %s\n", lvl.UUID) fmt.Printf(" Level title: %s\n", lvl.Title) fmt.Printf(" Author: %s\n", lvl.Author) fmt.Printf(" Password: %s\n", lvl.Password) diff --git a/cmd/doodad/main.go b/cmd/doodad/main.go index 18b3ff7..1b4ba54 100644 --- a/cmd/doodad/main.go +++ b/cmd/doodad/main.go @@ -53,6 +53,7 @@ func main() { app.Commands = []*cli.Command{ commands.Convert, commands.Show, + commands.Resave, commands.EditLevel, commands.EditDoodad, commands.InstallScript, diff --git a/pkg/balance/numbers.go b/pkg/balance/numbers.go index 5f89a1c..cfb4da4 100644 --- a/pkg/balance/numbers.go +++ b/pkg/balance/numbers.go @@ -52,7 +52,7 @@ var ( // Number of game ticks to insist the canvas follows the player at the start // of a level - to overcome Anvils settling into their starting positions so // they don't steal the camera focus straight away. - FollowPlayerFirstTicks uint64 = 60 + FollowPlayerFirstTicks uint64 = 20 // Default chunk size for canvases. ChunkSize uint8 = 128 @@ -90,11 +90,12 @@ var ( PlayerCharacterDoodad = "boy.doodad" // Levelpack and level names for the title screen. - DemoLevelPack = "assets/levelpacks/001000-TUTORIAL.levelpack" + DemoLevelPack = "assets/levelpacks/builtin-Tutorial.levelpack" DemoLevelName = []string{ "Tutorial 1.level", "Tutorial 2.level", "Tutorial 3.level", + "Tutorial 5.level", } // Level attachment filename for the custom wallpaper. @@ -145,6 +146,13 @@ var ( LoadingViewportMarginChunks = render.NewPoint(10, 8) // hoz, vert CanvasLoadUnloadModuloTicks uint64 = 4 CanvasChunkFreeChoppingBlockTicks uint64 = 128 // number of ticks old a chunk is to free it + + // For bounded levels, the game will try and keep actors inside the boundaries. But + // in case e.g. the player is teleported far out of the boundaries (going thru a warp + // door into an interior room "off the map"), allow them to be out of bounds. This + // variable is the tolerance offset - if they are only this far out of bounds, put them + // back in bounds but further out and they're OK. + OutOfBoundsMargin = 40 ) // Edit Mode Values diff --git a/pkg/balance/theme.go b/pkg/balance/theme.go index 9054ca2..6f7ddc8 100644 --- a/pkg/balance/theme.go +++ b/pkg/balance/theme.go @@ -289,6 +289,10 @@ var ( Label: "Pure white", Value: "white.png", }, + { + Label: "Atmosphere", + Value: "atmosphere.png", + }, { Separator: true, }, diff --git a/pkg/doodads/doodad.go b/pkg/doodads/doodad.go index 4803432..08e2c5b 100644 --- a/pkg/doodads/doodad.go +++ b/pkg/doodads/doodad.go @@ -72,6 +72,8 @@ func New(dimensions ...int) *Doodad { // that size. if size <= 255 { chunkSize = uint8(size) + } else { + chunkSize = balance.ChunkSize } return &Doodad{ diff --git a/pkg/doodads/json.go b/pkg/doodads/json.go index 56b97fa..9d98b39 100644 --- a/pkg/doodads/json.go +++ b/pkg/doodads/json.go @@ -9,6 +9,7 @@ import ( "path/filepath" "git.kirsle.net/SketchyMaze/doodle/pkg/balance" + "git.kirsle.net/SketchyMaze/doodle/pkg/branding" "git.kirsle.net/SketchyMaze/doodle/pkg/usercfg" ) @@ -32,6 +33,9 @@ func (d *Doodad) ToJSON() ([]byte, error) { // AsJSON returns it just as JSON without any fancy gzip/zip magic. func (d *Doodad) AsJSON() ([]byte, error) { + // Always write the game version that last saved this doodad. + d.GameVersion = branding.Version + out := bytes.NewBuffer([]byte{}) encoder := json.NewEncoder(out) if usercfg.Current.JSONIndent { diff --git a/pkg/level/chunker_zipfile.go b/pkg/level/chunker_zipfile.go index 380c736..53c1810 100644 --- a/pkg/level/chunker_zipfile.go +++ b/pkg/level/chunker_zipfile.go @@ -33,7 +33,7 @@ func (c *Chunker) MigrateZipfile(zf *zip.Writer) error { ) for coord, chunk := range c.Chunks { if chunk.Len() == 0 { - log.Info("Chunker.MigrateZipfile: %s has become empty, remove from zip", coord) + log.Debug("Chunker.MigrateZipfile: %s has become empty, remove from zip", coord) erasedChunks[coord] = nil } } @@ -42,7 +42,7 @@ func (c *Chunker) MigrateZipfile(zf *zip.Writer) error { // These are chunks that are NOT actively loaded (those are written next), // and erasedChunks are not written to the zipfile at all. if c.Zipfile != nil { - log.Info("MigrateZipfile: Copying chunk files from old zip to new zip") + log.Debug("MigrateZipfile: Copying chunk files from old zip to new zip") for _, file := range c.Zipfile.File { m := zipChunkfileRegexp.FindStringSubmatch(file.Name) if len(m) > 0 { @@ -124,7 +124,7 @@ func (c *Chunker) MigrateZipfile(zf *zip.Writer) error { return nil } - log.Info("MigrateZipfile: chunker has %d in memory, exporting to zipfile", len(c.Chunks)) + log.Debug("MigrateZipfile: chunker has %d in memory, exporting to zipfile", len(c.Chunks)) // Flush in-memory chunks out to zipfile. for coord, chunk := range c.Chunks { diff --git a/pkg/level/filesystem.go b/pkg/level/filesystem.go index d34e938..2701db6 100644 --- a/pkg/level/filesystem.go +++ b/pkg/level/filesystem.go @@ -187,7 +187,7 @@ func (fs *FileSystem) MigrateZipfile(zf *zip.Writer) error { // except for the ones marked for deletion OR the ones currently in the // warm cache which will be written next. if fs.Zipfile != nil { - log.Info("FileSystem.MigrateZipfile: Copying files from old zip to new zip") + log.Debug("FileSystem.MigrateZipfile: Copying files from old zip to new zip") for _, file := range fs.Zipfile.File { if !strings.HasPrefix(file.Name, "assets/") { continue @@ -218,7 +218,7 @@ func (fs *FileSystem) MigrateZipfile(zf *zip.Writer) error { // Export currently warmed up files to ZIP, these will be ones that // were updated recently OR legacy files from an old level read. if fs.filemap != nil { - log.Info("FileSystem.MigrateZipfile: has %d files in memory to write to ZIP", len(fs.filemap)) + log.Debug("FileSystem.MigrateZipfile: has %d files in memory to write to ZIP", len(fs.filemap)) for filename, file := range fs.filemap { if _, ok := filesZipped[filename]; ok { continue diff --git a/pkg/level/fmt_json.go b/pkg/level/fmt_json.go index 5070fff..3d578c1 100644 --- a/pkg/level/fmt_json.go +++ b/pkg/level/fmt_json.go @@ -10,11 +10,24 @@ import ( "net/http" "git.kirsle.net/SketchyMaze/doodle/pkg/balance" + "git.kirsle.net/SketchyMaze/doodle/pkg/branding" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/usercfg" + "github.com/google/uuid" ) -// FromJSON loads a level from JSON string (gzip supported). +/* +FromJSON loads a level from "JSON string" (gzip supported). + +This is the primary "load level from file on disk" method. It can read +levels of all historical file formats the game supported: + + - If the data begins with a `{` it is parsed in the legacy (v1) JSON format. + - If the level begins with a Gzip header (hex `1F8B“) it is taken to be + a gzip compressed (v2) level JSON file. + - If the file is identified by `net/http#DetectContentType()` to be an + application/zip file (v3) it is loaded from zipfile format. +*/ func FromJSON(filename string, data []byte) (*Level, error) { var m = New() @@ -54,12 +67,25 @@ func FromJSON(filename string, data []byte) (*Level, error) { return m, nil } -// ToJSON serializes the level as JSON (gzip supported). -// -// Notice about gzip: if the pkg/balance.CompressLevels boolean is true, this -// function will apply gzip compression before returning the byte string. -// This gzip-compressed level can be read back by any functions that say -// "gzip supported" in their descriptions. +/* +ToJSON serializes the level as JSON (gzip supported). + +This is the primary "write level to disk" function and can output in a +vairety of historical formats controlled by pkg/balance#DrawingFormat: + + - balance.FormatJSON (the default): writes the level as an original-style + single JSON document that contains all chunk data directly. These levels + take a long time to load from disk for any non-trivial level design. (v1) + - balance.FormatGzip: writes as a gzip compressed JSON file (v2) + - balance.FormatZipfile: creates a zip file where most of the level JSON + is stored as "index.json" and chunks and attached doodads are separate + members of the zipfile. + +Notice about gzip: if the pkg/balance.CompressLevels boolean is true, this +function will apply gzip compression before returning the byte string. +This gzip-compressed level can be read back by any functions that say +"gzip supported" in their descriptions. +*/ func (m *Level) ToJSON() ([]byte, error) { // Gzip compressing? if balance.DrawingFormat == balance.FormatGZip { @@ -76,6 +102,13 @@ func (m *Level) ToJSON() ([]byte, error) { // AsJSON returns it just as JSON without any fancy gzip/zip magic. func (m *Level) AsJSON() ([]byte, error) { + // Always write the game version and ensure levels have a UUID set. + m.GameVersion = branding.Version + if m.UUID == "" { + m.UUID = uuid.New().String() + log.Info("Note: assigned new level UUID %s", m.UUID) + } + out := bytes.NewBuffer([]byte{}) encoder := json.NewEncoder(out) if usercfg.Current.JSONIndent { diff --git a/pkg/level/types.go b/pkg/level/types.go index cffef0e..c1411da 100644 --- a/pkg/level/types.go +++ b/pkg/level/types.go @@ -42,6 +42,7 @@ type Base struct { type Level struct { Base Password string `json:"passwd"` + UUID string `json:"uuid"` // unique level IDs, especially for the savegame.json GameRule GameRule `json:"rules"` // Chunked pixel data. diff --git a/pkg/levelpack/levelpack.go b/pkg/levelpack/levelpack.go index 9b96b4f..e9edc63 100644 --- a/pkg/levelpack/levelpack.go +++ b/pkg/levelpack/levelpack.go @@ -19,6 +19,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/balance" "git.kirsle.net/SketchyMaze/doodle/pkg/enum" "git.kirsle.net/SketchyMaze/doodle/pkg/filesystem" + "git.kirsle.net/SketchyMaze/doodle/pkg/level" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/userdir" ) @@ -50,6 +51,7 @@ type LevelPack struct { // Level holds metadata about the levels in the levelpack. type Level struct { + UUID string `json:"uuid"` Title string `json:"title"` Author string `json:"author"` Filename string `json:"filename"` @@ -251,6 +253,21 @@ func (l LevelPack) GetFile(filename string) ([]byte, error) { return ioutil.ReadAll(file) } +// GetLevel returns a parsed Level object from a file inside the zipfile. +func (l LevelPack) GetLevel(filename string) (*level.Level, error) { + levelbin, err := l.GetFile("levels/" + filename) + if err != nil { + return nil, err + } + + lvl, err := level.FromJSON(filename, levelbin) + if err != nil { + return nil, fmt.Errorf("LevelPack.GetLevel(%s) parsing from zipfile: %s", filename, err) + } + + return lvl, nil +} + // GetJSON loads a JSON file from the zipfile and marshals it into your struct. func (l LevelPack) GetJSON(v interface{}, filename string) error { data, err := l.GetFile(filename) diff --git a/pkg/main_scene.go b/pkg/main_scene.go index c1d5013..c944c76 100644 --- a/pkg/main_scene.go +++ b/pkg/main_scene.go @@ -12,6 +12,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/modal/loadscreen" "git.kirsle.net/SketchyMaze/doodle/pkg/native" + "git.kirsle.net/SketchyMaze/doodle/pkg/savegame" "git.kirsle.net/SketchyMaze/doodle/pkg/scripting" "git.kirsle.net/SketchyMaze/doodle/pkg/shmem" "git.kirsle.net/SketchyMaze/doodle/pkg/uix" @@ -276,6 +277,13 @@ func (s *MainScene) Setup(d *Doodle) error { // Check for update in the background. go s.checkUpdate() + // Migrate the savefile format to UUIDs. + go func() { + if err := savegame.Migrate(); err != nil { + log.Error(err.Error()) + } + }() + // Eager load the level in background, no time for load screen. go func() { if err := s.setupAsync(d); err != nil { diff --git a/pkg/play_scene.go b/pkg/play_scene.go index 8a539db..b3a8a79 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -730,7 +730,7 @@ func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) { 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, s.Level.GameRule) + highscore := save.NewHighScore(s.LevelPack.Filename, s.Filename, s.Level.UUID, s.perfectRun, elapsed, s.Level.GameRule) if highscore { s.d.Flash("New record!") config.NewRecord = true @@ -739,7 +739,7 @@ func (s *PlayScene) ShowEndLevelModal(success bool, title, message string) { } } else { // Player has cheated! Mark the level completed but grant no high score. - save.MarkCompleted(s.LevelPack.Filename, s.Filename) + save.MarkCompleted(s.LevelPack.Filename, s.Filename, s.Level.UUID) } // Save the player's scores file. diff --git a/pkg/player_physics.go b/pkg/player_physics.go index b207e7d..c14bc51 100644 --- a/pkg/player_physics.go +++ b/pkg/player_physics.go @@ -129,7 +129,8 @@ func (s *PlayScene) movePlayer(ev *event.State) { } // If we insist that the canvas follow the player doodad. - if shmem.Tick < s.mustFollowPlayerUntil || keybind.Up(ev) || keybind.Left(ev) || keybind.Right(ev) || keybind.Use(ev) { + // Also any directional key will focus the player unless the player is frozen. + if shmem.Tick < s.mustFollowPlayerUntil || (!s.Player.IsFrozen() && (keybind.Up(ev) || keybind.Left(ev) || keybind.Right(ev) || keybind.Use(ev))) { s.drawing.FollowActor = s.Player.ID() } diff --git a/pkg/savegame/savegame.go b/pkg/savegame/savegame.go index 5adf3ed..fec7e44 100644 --- a/pkg/savegame/savegame.go +++ b/pkg/savegame/savegame.go @@ -12,14 +12,25 @@ import ( "strings" "time" + "git.kirsle.net/SketchyMaze/doodle/pkg/filesystem" "git.kirsle.net/SketchyMaze/doodle/pkg/level" + "git.kirsle.net/SketchyMaze/doodle/pkg/levelpack" + "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/usercfg" "git.kirsle.net/SketchyMaze/doodle/pkg/userdir" ) // SaveGame holds the user's progress thru level packs. type SaveGame struct { - LevelPacks map[string]*LevelPack `json:"levelPacks"` + // DEPRECATED: savegame state spelled out by level packs and + // filenames. + LevelPacks map[string]*LevelPack `json:"levelPacks,omitempty"` + + // New data format: store high scores by level UUID. Adds a + // nice layer of obfuscation + is more robust in case levels + // move around between levelpacks, get renamed, etc. that + // the user should be able to keep their high score. + Levels map[string]*Level } // LevelPack holds savegame process for a level pack. @@ -38,6 +49,7 @@ type Level struct { func New() *SaveGame { return &SaveGame{ LevelPacks: map[string]*LevelPack{}, + Levels: map[string]*Level{}, } } @@ -92,6 +104,93 @@ func Load() (*SaveGame, error) { return sg, nil } +/* +Migrate the savegame.json file to re-save it as its newest file format. + +v0: we stored LevelPack filenames + level filenames to store high scores. +This was brittle in case a level gets moved into another levelpack later, +or either it or the levelpack is renamed. + +v1: levels get UUID numbers and we store them by that. You can re-roll a +UUID in the level editor if you want to break high scores for your new +level version. +*/ +func Migrate() error { + sg, err := Load() + if err != nil { + return err + } + + // Do we need to make any changes? + var resave bool + + // Initialize new data structures. + if sg.Levels == nil { + sg.Levels = map[string]*Level{} + } + + // Have any legacy LevelPack levels? + if sg.LevelPacks != nil && len(sg.LevelPacks) > 0 { + log.Info("Migrating savegame.json data to newer version") + + // See if we can track down a UUID for each level. + for lpFilename, lpScore := range sg.LevelPacks { + log.Info("SaveGame.Migrate: See levelpack %s", lpFilename) + + // Resolve the filename to this levelpack (on disk or bindata, etc) + filename, err := filesystem.FindFile(lpFilename) + if err != nil { + log.Error("SaveGame.Migrate: Could not find levelpack %s: can't migrate high score", lpFilename) + continue + } + + // Find the levelpack. + lp, err := levelpack.LoadFile(filename) + if err != nil { + log.Error("SaveGame.Migrate: Could not find levelpack %s: can't migrate high score", lpFilename) + continue + } + + // Search its levels for their UUIDs. + for levelFilename, score := range lpScore.Levels { + log.Info("SaveGame.Migrate: levelpack '%s' level '%s'", lp.Title, levelFilename) + + // Try and load this level. + lvl, err := lp.GetLevel(levelFilename) + if err != nil { + log.Error("SaveGame.Migrate: could not load level '%s': %s", levelFilename, err) + continue + } + + // It has a UUID? + if lvl.UUID == "" { + log.Error("SaveGame.Migrate: level '%s' does not have a UUID, can not migrate savegame for it", levelFilename) + continue + } + + // Migrate! + sg.Levels[lvl.UUID] = score + delete(lpScore.Levels, levelFilename) + resave = true + } + + // Have we run out of levels? + if len(lpScore.Levels) == 0 { + log.Info("No more levels to migrate in levelpack '%s'!", lpFilename) + delete(sg.LevelPacks, lpFilename) + resave = true + } + } + } + + if resave { + log.Info("Resaving highscore.json in migration to newer file format") + return sg.Save() + } + + return nil +} + // Save the savegame.json to disk. func (sg *SaveGame) Save() error { // Encode to JSON. @@ -120,8 +219,8 @@ func (sg *SaveGame) Save() error { // 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) +func (sg *SaveGame) MarkCompleted(levelpack, filename, uuid string) { + lvl := sg.GetLevelScore(levelpack, filename, uuid) lvl.Completed = true } @@ -131,11 +230,11 @@ func (sg *SaveGame) MarkCompleted(levelpack, filename string) { // 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, rules level.GameRule) bool { +func (sg *SaveGame) NewHighScore(levelpack, filename, uuid string, isPerfect bool, elapsed time.Duration, rules level.GameRule) bool { levelpack = filepath.Base(levelpack) filename = filepath.Base(filename) - score := sg.GetLevelScore(levelpack, filename) + score := sg.GetLevelScore(levelpack, filename, uuid) score.Completed = true var newHigh bool @@ -172,7 +271,15 @@ func (sg *SaveGame) NewHighScore(levelpack, filename string, isPerfect bool, ela } // GetLevelScore finds or creates a default Level score. -func (sg *SaveGame) GetLevelScore(levelpack, filename string) *Level { +func (sg *SaveGame) GetLevelScore(levelpack, filename, uuid string) *Level { + // New format? Easy lookup by UUID. + if uuid != "" && sg.Levels != nil { + if row, ok := sg.Levels[uuid]; ok { + return row + } + } + + // Old format: look it up by levelpack/filename. levelpack = filepath.Base(levelpack) filename = filepath.Base(filename) @@ -190,18 +297,40 @@ func (sg *SaveGame) GetLevelScore(levelpack, filename string) *Level { } // CountCompleted returns the number of completed levels in a levelpack. -func (sg *SaveGame) CountCompleted(levelpack string) int { - var count int - levelpack = filepath.Base(levelpack) +func (sg *SaveGame) CountCompleted(levelpack *levelpack.LevelPack) int { + var ( + count int + filename = filepath.Base(levelpack.Filename) + ) - if lp, ok := sg.LevelPacks[levelpack]; ok { - for _, lvl := range lp.Levels { - if lvl.Completed { + // Collect the level UUIDs for this levelpack. + var uuids = map[string]interface{}{} + for _, lvl := range levelpack.Levels { + if lvl.UUID != "" { + uuids[lvl.UUID] = nil + } + } + + // Count the new-style levels. + if sg.Levels != nil { + for uuid, lvl := range sg.Levels { + if _, ok := uuids[uuid]; ok && lvl.Completed { count++ } } } + // Count the old-style levels. + if sg.LevelPacks != nil { + if lp, ok := sg.LevelPacks[filename]; ok { + for _, lvl := range lp.Levels { + if lvl.Completed { + count++ + } + } + } + } + return count } diff --git a/pkg/uix/canvas.go b/pkg/uix/canvas.go index e62751a..0f2afb1 100644 --- a/pkg/uix/canvas.go +++ b/pkg/uix/canvas.go @@ -67,7 +67,8 @@ type Canvas struct { // Debug tools // NoLimitScroll suppresses the scroll limit for bounded levels. - NoLimitScroll bool + NoLimitScroll bool + scrollOutOfBounds bool // player is far out of bounds = temporary NoLimitScroll. // Show custom mouse cursors over this canvas (eg. editor tools) FancyCursors bool diff --git a/pkg/uix/canvas_scrolling.go b/pkg/uix/canvas_scrolling.go index cb07a4a..bd076ca 100644 --- a/pkg/uix/canvas_scrolling.go +++ b/pkg/uix/canvas_scrolling.go @@ -116,7 +116,7 @@ func (w *Canvas) loopEditorScroll(ev *event.State) error { Loop() subroutine to constrain the scrolled view to within a bounded level. */ func (w *Canvas) loopConstrainScroll() error { - if w.NoLimitScroll { + if w.NoLimitScroll || w.scrollOutOfBounds { return errors.New("NoLimitScroll enabled") } diff --git a/pkg/uix/canvas_wallpaper.go b/pkg/uix/canvas_wallpaper.go index 1e2478c..f4ffd90 100644 --- a/pkg/uix/canvas_wallpaper.go +++ b/pkg/uix/canvas_wallpaper.go @@ -1,6 +1,7 @@ package uix import ( + "git.kirsle.net/SketchyMaze/doodle/pkg/balance" "git.kirsle.net/SketchyMaze/doodle/pkg/level" "git.kirsle.net/SketchyMaze/doodle/pkg/wallpaper" "git.kirsle.net/go/render" @@ -31,17 +32,26 @@ func (w *Canvas) loopContainActorsInsideLevel(a *Actor) { } var ( - orig = a.Position() // Actor's World Position - moveBy render.Point - size = a.Size() + orig = a.Position() // Actor's World Position + moveBy render.Point + size = a.Size() + playerOOB bool // player character is out of bounds ) // Bound it on the top left edges. if orig.X < 0 { - moveBy.X = -orig.X + if orig.X > -balance.OutOfBoundsMargin { + moveBy.X = -orig.X + } else if a.IsPlayer() { + playerOOB = true + } } if orig.Y < 0 { - moveBy.Y = -orig.Y + if orig.Y > -balance.OutOfBoundsMargin { + moveBy.Y = -orig.Y + } else if a.IsPlayer() { + playerOOB = true + } } // Bound it on the right bottom edges. XXX: downcast from int64! @@ -49,23 +59,35 @@ func (w *Canvas) loopContainActorsInsideLevel(a *Actor) { if w.wallpaper.maxWidth > 0 { if int64(orig.X+size.W) > w.wallpaper.maxWidth { var delta = w.wallpaper.maxWidth - int64(orig.X+size.W) - moveBy.X = int(delta) + if delta > int64(-balance.OutOfBoundsMargin) { + moveBy.X = int(delta) + } else if a.IsPlayer() { + playerOOB = true + } } } if w.wallpaper.maxHeight > 0 { if int64(orig.Y+size.H) > w.wallpaper.maxHeight { var delta = w.wallpaper.maxHeight - int64(orig.Y+size.H) - moveBy.Y = int(delta) + if delta > int64(-balance.OutOfBoundsMargin) { + moveBy.Y = int(delta) - // Allow them to jump from the bottom by marking them as grounded. - a.SetGrounded(true) + // Allow them to jump from the bottom by marking them as grounded. + a.SetGrounded(true) + } else if a.IsPlayer() { + playerOOB = true + } } } } - if !moveBy.IsZero() { + if !moveBy.IsZero() && !(a.IsPlayer() && playerOOB) { a.MoveBy(moveBy) } + + // If the player doodad is far out of bounds, tag it as such and + // the canvas will allow scrolling OOB to see the player. + w.scrollOutOfBounds = playerOOB } // PresentWallpaper draws the wallpaper. diff --git a/pkg/uix/scripting.go b/pkg/uix/scripting.go index e36c010..c43be0d 100644 --- a/pkg/uix/scripting.go +++ b/pkg/uix/scripting.go @@ -38,6 +38,15 @@ func (w *Canvas) MakeScriptAPI(vm *scripting.VM) { return nil }, + // Actors.CameraFollowPlayer tells the camera to follow the player character. + "CameraFollowPlayer": func() { + for _, actor := range w.actors { + if actor.IsPlayer() { + w.FollowActor = actor.ID() + } + } + }, + // Actors.New: create a new actor. "New": func(filename string) *Actor { doodad, err := doodads.LoadFile(filename) @@ -104,6 +113,10 @@ func (w *Canvas) MakeSelfAPI(actor *Actor) map[string]interface{} { actor.MoveTo(p) actor.SetGrounded(false) }, + "MoveBy": func(p render.Point) { + actor.MoveBy(p) + actor.SetGrounded(false) + }, "Grounded": actor.Grounded, "SetHitbox": actor.SetHitbox, "Hitbox": actor.Hitbox, diff --git a/pkg/windows/add_edit_level.go b/pkg/windows/add_edit_level.go index 8ddeb94..2087fca 100644 --- a/pkg/windows/add_edit_level.go +++ b/pkg/windows/add_edit_level.go @@ -16,6 +16,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/wallpaper" "git.kirsle.net/go/render" "git.kirsle.net/go/ui" + "github.com/google/uuid" ) // AddEditLevel is the "Create New Level & Edit Level Properties" window @@ -73,6 +74,7 @@ func NewAddEditLevel(config AddEditLevel) *ui.Window { } else { // Additional Level tabs (existing level only) config.setupGameRuleFrame(tabframe) + config.setupAdvancedFrame(tabframe) } tabframe.Supervise(config.Supervisor) @@ -561,3 +563,55 @@ func (config AddEditLevel) setupGameRuleFrame(tf *ui.TabFrame) { form.Create(frame, fields) } + +// Creates the Game Rules frame for existing level (set difficulty, etc.) +func (config AddEditLevel) setupAdvancedFrame(tf *ui.TabFrame) { + frame := tf.AddTab("Advanced", ui.NewLabel(ui.Label{ + Text: "Advanced", + Font: balance.TabFont, + })) + + form := magicform.Form{ + Supervisor: config.Supervisor, + Engine: config.Engine, + Vertical: true, + LabelWidth: 120, + PadY: 2, + } + fields := []magicform.Field{ + { + Label: "Level UUID Number", + Font: balance.LabelFont, + }, + { + Label: "Levels are assigned a unique identifier (UUID) for the purpose\n" + + "of saving your high scores for them (in levels which are part of\n" + + "level packs). Your level's UUID is shown below. Click it to\n" + + "re-roll a new UUID number (e.g. in case you save a new copy\n" + + "of a level that you want to be distinct from its original.)", + Font: balance.UIFont, + }, + { + Label: "Level UUID:", + Font: balance.UIFont, + TextVariable: &config.EditLevel.UUID, + Tooltip: ui.Tooltip{ + Text: "Click to re-roll a new UUID value.", + Edge: ui.Top, + }, + OnClick: func() { + modal.Confirm( + "Are you sure you want to re-roll a new UUID value?\n\n" + + "Saving with a new UUID will mark this level as distinct\n" + + "from the previous version - if you place both versions\n" + + "in a levelpack together they will have separate high\n" + + "score values.", + ).WithTitle("Re-roll UUID").Then(func() { + config.EditLevel.UUID = uuid.New().String() + }) + }, + }, + } + + form.Create(frame, fields) +} diff --git a/pkg/windows/levelpack_open.go b/pkg/windows/levelpack_open.go index d079287..37c72e2 100644 --- a/pkg/windows/levelpack_open.go +++ b/pkg/windows/levelpack_open.go @@ -204,7 +204,7 @@ func (config LevelPack) makeIndexScreen(frame *ui.Frame, width, height int, }) numLevels := ui.NewLabel(ui.Label{ - Text: fmt.Sprintf("[completed %d of %d levels]", config.savegame.CountCompleted(lp.Filename), len(lp.Levels)), + Text: fmt.Sprintf("[completed %d of %d levels]", config.savegame.CountCompleted(lp), len(lp.Levels)), Font: balance.MenuFont, }) btnFrame.Pack(numLevels, ui.Pack{ @@ -290,7 +290,7 @@ func (config LevelPack) makeDetailScreen(frame *ui.Frame, width, height int, lp // How many levels completed? var ( - numCompleted = config.savegame.CountCompleted(lp.Filename) + numCompleted = config.savegame.CountCompleted(lp) numUnlocked = lp.FreeLevels + numCompleted ) @@ -363,7 +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) + score := config.savegame.GetLevelScore(lp.Filename, level.Filename, level.UUID) // Make a frame to hold a complex button layout. btnFrame := ui.NewFrame("Frame")