Noah Petherbridge
672ee9641a
* 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.
226 lines
5.1 KiB
Go
226 lines
5.1 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/apps/doodle/assets"
|
|
"git.kirsle.net/apps/doodle/pkg/enum"
|
|
"git.kirsle.net/apps/doodle/pkg/filesystem"
|
|
"git.kirsle.net/apps/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:"-"`
|
|
}
|
|
|
|
// Level holds metadata about the levels in the levelpack.
|
|
type Level struct {
|
|
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 LevelPack{}, err
|
|
}
|
|
filesize = stat.Size()
|
|
|
|
fh, err = os.Open(filename)
|
|
if err != nil {
|
|
return LevelPack{}, err
|
|
}
|
|
}
|
|
|
|
// No luck?
|
|
if fh == nil {
|
|
return LevelPack{}, errors.New("no file found")
|
|
}
|
|
|
|
reader, err := zip.NewReader(fh, filesize)
|
|
if err != nil {
|
|
return LevelPack{}, 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 {
|
|
fmt.Errorf("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)
|
|
}
|
|
|
|
// GetData returns file data from inside the loaded zipfile of a levelpack.
|
|
func (l LevelPack) GetData(filename string) ([]byte, error) {
|
|
if l.Zipfile == nil {
|
|
return []byte{}, errors.New("zipfile not loaded")
|
|
}
|
|
|
|
file, err := l.Zipfile.Open(filename)
|
|
if err != nil {
|
|
return []byte{}, err
|
|
}
|
|
|
|
return ioutil.ReadAll(file)
|
|
}
|
|
|
|
// 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.GetData(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
|
|
}
|