doodle/pkg/levelpack/levelpack.go
Noah Petherbridge cf1bc81f25 Update savegame format, Allow out-of-bounds camera
Updates the savegame.json file format:

* Levels now have a UUID value assigned at first save.
* The savegame.json will now track level completion/score based on UUID,
making it robust to filename changes in either levels or levelpacks.
* The savegame file is auto-migrated on startup - for any levels not
found or have no UUID, no change is made, it's backwards compatible.
* Level Properties window adds an "Advanced" tab to show/re-roll UUID.

New JavaScript API for doodad scripts:

* `Actors.CameraFollowPlayer()` tells the camera to return focus to the
  player character. Useful for "cutscene" doodads that freeze the player,
  call `Self.CameraFollowMe()` and do a thing before unfreezing and sending the
  camera back to the player. (Or it will follow them at their next directional
  input control).
* `Self.MoveBy(Point(x, y int))` to move the current actor a bit.

New option for the `doodad` command-line tool:

* `doodad resave <.level or .doodad>` will load and re-save a drawing, to
  migrate it to the newest file format versions.

Small tweaks:

* On bounded levels, allow the camera to still follow the player if the player
  finds themselves WELL far out of bounds (40 pixels margin). So on bounded
  levels you can create "interior rooms" out-of-bounds to Warp Door into.
* New wallpaper: "Atmosphere" has a black starscape pattern that fades into a
  solid blue atmosphere.
* Camera strictly follows the player the first 20 ticks, not 60 of level start
* If player is frozen, directional inputs do not take the camera focus back.
2023-03-07 21:55:10 -08:00

295 lines
6.9 KiB
Go

// Package levelpack handles ZIP archives for level packs.
package levelpack
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"runtime"
"sort"
"strings"
"time"
"git.kirsle.net/SketchyMaze/doodle/assets"
"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"
)
// LevelPack describes the contents of a levelpack file.
type LevelPack struct {
Title string `json:"title"`
Description string `json:"description"`
Author string `json:"author"`
Created time.Time `json:"created"`
// Cached metadata about the (displayed) levels.
Levels []Level `json:"levels"`
// Number of levels unlocked by default.
// 0 = all levels unlocked
FreeLevels int `json:"freeLevels"`
// 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:"-"`
// Signature to allow free versions of the game to load embedded
// custom doodads inside this levelpack for its levels.
Signature []byte `json:"signature,omitempty"`
}
// 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"`
}
// LoadFile reads a .levelpack zip file.
func LoadFile(filename string) (*LevelPack, error) {
var (
fh io.ReaderAt
filesize int64
)
// Look in embedded bindata.
if data, err := assets.Asset(filename); err == nil {
filesize = int64(len(data))
fh = bytes.NewReader(data)
}
// Try the filesystem.
if fh == nil {
stat, err := os.Stat(filename)
if err != nil {
return nil, err
}
filesize = stat.Size()
fh, err = os.Open(filename)
if err != nil {
return nil, err
}
}
// No luck?
if fh == nil {
return nil, errors.New("no file found")
}
reader, err := zip.NewReader(fh, filesize)
if err != nil {
return nil, err
}
lp := &LevelPack{
Filename: filename,
Zipfile: reader,
}
// Read the index.json.
lp.GetJSON(lp, "index.json")
return lp, nil
}
// LoadAllAvailable loads every levelpack visible to the game. Returns
// the sorted list of filenames as from ListFiles, plus a deeply loaded
// hash map associating the filenames with their data.
func LoadAllAvailable() ([]string, map[string]*LevelPack, error) {
filenames, err := ListFiles()
if err != nil {
return filenames, nil, err
}
var dictionary = map[string]*LevelPack{}
for _, filename := range filenames {
// Resolve the filename to a definite path on disk.
path, err := filesystem.FindFile(filename)
if err != nil {
log.Error("LoadAllAvailable: FindFile(%s): %s", path, err)
return filenames, nil, err
}
lp, err := LoadFile(path)
if err != nil {
return filenames, nil, fmt.Errorf("LoadAllAvailable: LoadFile(%s): %s", path, err)
}
dictionary[filename] = lp
}
return filenames, dictionary, nil
}
// ListFiles lists all the discoverable levelpack files, starting from
// the game's built-ins all the way to user levelpacks.
func ListFiles() ([]string, error) {
var names []string
// List levelpacks embedded into the binary.
if files, err := assets.AssetDir("assets/levelpacks"); err == nil {
names = append(names, files...)
}
// WASM stops here, no filesystem access.
if runtime.GOOS == "js" {
return names, nil
}
// Read system-level levelpacks.
files, _ := ioutil.ReadDir(filesystem.SystemLevelPacksPath)
for _, file := range files {
name := file.Name()
if strings.HasSuffix(name, enum.LevelPackExt) {
names = append(names, name)
}
}
// Append user levelpacks.
files, _ = ioutil.ReadDir(userdir.LevelPackDirectory)
for _, file := range files {
name := file.Name()
if strings.HasSuffix(name, enum.LevelPackExt) {
names = append(names, name)
}
}
// Deduplicate strings. Can happen e.g. because assets/ is baked
// in to bindata but files also exist there locally.
var (
dedupe []string
seen = map[string]interface{}{}
)
for _, value := range names {
if _, ok := seen[value]; !ok {
seen[value] = nil
dedupe = append(dedupe, value)
}
}
sort.Strings(dedupe)
return dedupe, nil
}
// WriteFile saves the metadata to a .json file on disk.
func (l LevelPack) WriteFile(filename string) error {
out, err := json.Marshal(l)
if err != nil {
return err
}
return ioutil.WriteFile(filename, out, 0655)
}
// WriteZipfile saves a levelpack back into a zip file.
func (l LevelPack) WriteZipfile(filename string) error {
fh, err := os.Create(filename)
if err != nil {
return err
}
defer fh.Close()
// Copy all of the levels and other files from the old zip to new zip.
zf := zip.NewWriter(fh)
defer zf.Close()
// Copy attached doodads and levels.
for _, file := range l.Zipfile.File {
if !strings.HasPrefix(file.Name, "doodads/") &&
!strings.HasPrefix(file.Name, "levels/") {
continue
}
if err := zf.Copy(file); err != nil {
return err
}
}
// Write the index.json metadata.
meta, err := json.Marshal(l)
if err != nil {
return err
}
writer, err := zf.Create("index.json")
if err != nil {
return err
}
_, err = writer.Write(meta)
return err
}
// GetFile returns file data from inside the loaded zipfile of a levelpack.
//
// This also implements the Embeddable interface.
func (l LevelPack) GetFile(filename string) ([]byte, error) {
if l.Zipfile == nil {
return []byte{}, errors.New("zipfile not loaded")
}
// NOTE: levelpacks don't have an "assets/" prefix but the game
// might come looking for "assets/doodads"
if strings.HasPrefix(filename, balance.EmbeddedDoodadsBasePath) {
filename = strings.Replace(filename, balance.EmbeddedDoodadsBasePath, "doodads/", 1)
}
file, err := l.Zipfile.Open(filename)
if err != nil {
return []byte{}, err
}
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)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// ListFiles returns the files in the zipfile that match the prefix given.
func (l LevelPack) ListFiles(prefix string) []string {
var result []string
if l.Zipfile != nil {
for _, file := range l.Zipfile.File {
if strings.HasPrefix(file.Name, prefix) {
result = append(result, file.Name)
}
}
}
return result
}