doodle/pkg/userdir/userdir.go
Noah Petherbridge 672ee9641a 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.
2022-01-02 16:28:43 -08:00

224 lines
5.8 KiB
Go

package userdir
import (
"io/ioutil"
"os"
"path/filepath"
"runtime"
"strings"
"git.kirsle.net/apps/doodle/pkg/wasm"
"github.com/kirsle/configdir"
)
// Profile Directory settings.
var (
ConfigDirectoryName = "doodle"
ProfileDirectory string
LevelDirectory string
LevelPackDirectory string
DoodadDirectory string
CampaignDirectory string
ScreenshotDirectory string
SaveFile string
CacheDirectory string
FontDirectory string
)
// File extensions
const (
extLevel = ".level"
extDoodad = ".doodad"
extLevelPack = ".levelpack"
)
func init() {
// Profile directory contains the user's levels and doodads.
ProfileDirectory = configdir.LocalConfig(ConfigDirectoryName)
LevelDirectory = configdir.LocalConfig(ConfigDirectoryName, "levels")
LevelPackDirectory = configdir.LocalConfig(ConfigDirectoryName, "levelpacks")
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)
FontDirectory = configdir.LocalCache(ConfigDirectoryName, "fonts")
// Ensure all the directories exist.
// WASM: do not make paths in wasm.
if runtime.GOOS != "js" {
configdir.MakePath(LevelDirectory)
configdir.MakePath(LevelPackDirectory)
configdir.MakePath(DoodadDirectory)
configdir.MakePath(CampaignDirectory)
configdir.MakePath(FontDirectory)
configdir.MakePath(ScreenshotDirectory)
}
}
// LevelPath will turn a "simple" filename into an absolute path in the user's
// local levels folder. If the filename already contains slashes, it is returned
// as-is as an absolute or relative path.
func LevelPath(filename string) string {
return resolvePath(LevelDirectory, filename, extLevel)
}
// DoodadPath is like LevelPath but for Doodad files.
func DoodadPath(filename string) string {
return resolvePath(DoodadDirectory, filename, extDoodad)
}
// LevelPackPath returns the user's levelpacks directory.
func LevelPackPath(filename string) string {
return resolvePath(LevelPackDirectory, filename, extLevelPack)
}
// CacheFilename returns a path to a file in the cache folder. Send in path
// components and not literal slashes, like
// CacheFilename("images", "chunks", "id.bmp")
func CacheFilename(filename ...string) string {
paths := append([]string{CacheDirectory}, filename...)
dir := paths[:len(paths)-1]
if runtime.GOOS != "js" {
configdir.MakePath(filepath.Join(dir...))
}
return filepath.Join(paths[0], filepath.Join(paths[1:]...))
}
// ListDoodads returns a listing of all available doodads.
func ListDoodads() ([]string, error) {
var names []string
// WASM: list from localStorage.
if runtime.GOOS == "js" {
return wasm.StorageKeys(DoodadDirectory + "/"), nil
}
files, err := ioutil.ReadDir(DoodadDirectory)
if err != nil {
return names, err
}
for _, file := range files {
name := file.Name()
if strings.HasSuffix(strings.ToLower(name), extDoodad) {
names = append(names, name)
}
}
return names, nil
}
// ListLevels returns a listing of all available levels.
func ListLevels() ([]string, error) {
var names []string
// WASM: list from localStorage.
if runtime.GOOS == "js" {
return wasm.StorageKeys(LevelDirectory + "/"), nil
}
files, err := ioutil.ReadDir(LevelDirectory)
if err != nil {
return names, err
}
for _, file := range files {
name := file.Name()
if strings.HasSuffix(strings.ToLower(name), extLevel) {
names = append(names, name)
}
}
return names, nil
}
// ListCampaigns returns a listing of all available campaigns.
func ListCampaigns() ([]string, error) {
var names []string
// WASM: list from localStorage.
if runtime.GOOS == "js" {
return wasm.StorageKeys(CampaignDirectory + "/"), nil
}
files, err := ioutil.ReadDir(CampaignDirectory)
if err != nil {
return names, err
}
for _, file := range files {
name := file.Name()
if filepath.Ext(name) == ".json" {
names = append(names, name)
}
}
return names, nil
}
// resolvePath is the inner logic for LevelPath and DoodadPath.
func resolvePath(directory, filename, extension string) string {
if strings.Contains(filename, string(filepath.Separator)) {
return filename
}
// Attach the file extension?
if strings.ToLower(filepath.Ext(filename)) != extension {
filename += extension
}
return filepath.Join(directory, filename)
}
// ResolvePath takes an ambiguous simple filename and searches for a Level or
// Doodad that matches. Returns a blank string if no files found.
//
// Pass a true value for `one` if you are intending to create the file. It will
// only test one filepath and return the first one, regardless if the file
// existed. So the filename should have a ".level" or ".doodad" extension and
// then this path will resolve the ProfileDirectory of the file.
func ResolvePath(filename, extension string, one bool) string {
// If the filename exists outright, return it.
if !(runtime.GOOS == "js") {
if _, err := os.Stat(filename); !os.IsNotExist(err) {
return filename
}
}
var paths []string
if extension == extLevel {
paths = append(paths, filepath.Join(LevelDirectory, filename))
} else if extension == extDoodad {
paths = append(paths, filepath.Join(DoodadDirectory, filename))
} else {
paths = append(paths,
filepath.Join(LevelDirectory, filename+".level"),
filepath.Join(DoodadDirectory, filename+".doodad"),
)
}
for _, test := range paths {
// WASM: check the path in localStorage.
if runtime.GOOS == "js" {
if _, ok := wasm.GetSession(test); ok {
return test
}
continue
}
// Desktop: test the filesystem.
if _, err := os.Stat(test); os.IsNotExist(err) {
continue
}
return test
}
return ""
}