2018-06-17 17:29:57 +00:00
|
|
|
package level
|
|
|
|
|
|
|
|
import (
|
2022-04-30 03:34:59 +00:00
|
|
|
"archive/zip"
|
2018-06-17 17:29:57 +00:00
|
|
|
"bytes"
|
2021-07-14 02:23:09 +00:00
|
|
|
"compress/gzip"
|
2018-06-17 17:29:57 +00:00
|
|
|
"encoding/json"
|
2018-08-11 00:19:47 +00:00
|
|
|
"fmt"
|
2018-10-16 16:20:25 +00:00
|
|
|
"io/ioutil"
|
2022-04-30 03:34:59 +00:00
|
|
|
"net/http"
|
2019-05-05 21:03:20 +00:00
|
|
|
|
2022-09-24 22:17:25 +00:00
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/balance"
|
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-08 05:55:10 +00:00
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/branding"
|
2022-09-24 22:17:25 +00:00
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
|
|
|
|
"git.kirsle.net/SketchyMaze/doodle/pkg/usercfg"
|
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-08 05:55:10 +00:00
|
|
|
"github.com/google/uuid"
|
2018-06-17 17:29:57 +00:00
|
|
|
)
|
|
|
|
|
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-08 05:55:10 +00:00
|
|
|
/*
|
|
|
|
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.
|
|
|
|
*/
|
2019-06-27 22:07:34 +00:00
|
|
|
func FromJSON(filename string, data []byte) (*Level, error) {
|
2019-07-03 23:22:30 +00:00
|
|
|
var m = New()
|
2021-07-14 02:23:09 +00:00
|
|
|
|
|
|
|
// Inspect if this file is JSON or gzip compressed.
|
|
|
|
if len(data) > 0 && data[0] == '{' {
|
|
|
|
// Looks standard JSON.
|
|
|
|
err := json.Unmarshal(data, m)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
} else if len(data) > 1 && data[0] == 0x1f && data[1] == 0x8b {
|
|
|
|
// Gzip compressed. `1F8B` is gzip magic number.
|
|
|
|
log.Debug("Decompress level %s", filename)
|
|
|
|
if gzmap, err := FromGzip(data); err != nil {
|
|
|
|
return nil, err
|
|
|
|
} else {
|
|
|
|
m = gzmap
|
|
|
|
}
|
2022-04-30 03:34:59 +00:00
|
|
|
} else if http.DetectContentType(data) == "application/zip" {
|
|
|
|
if zipmap, err := FromZipfile(data); err != nil {
|
|
|
|
return nil, err
|
|
|
|
} else {
|
|
|
|
m = zipmap
|
|
|
|
}
|
2021-07-14 02:23:09 +00:00
|
|
|
} else {
|
2022-04-30 03:34:59 +00:00
|
|
|
return nil, fmt.Errorf("invalid file format")
|
2021-07-14 02:23:09 +00:00
|
|
|
}
|
2019-06-27 22:07:34 +00:00
|
|
|
|
|
|
|
// Fill in defaults.
|
|
|
|
if m.Wallpaper == "" {
|
|
|
|
m.Wallpaper = DefaultWallpaper
|
|
|
|
}
|
|
|
|
|
|
|
|
// Inflate the chunk metadata to map the pixels to their palette indexes.
|
2021-09-12 04:18:22 +00:00
|
|
|
m.Inflate()
|
2019-06-27 22:07:34 +00:00
|
|
|
|
2021-07-14 02:23:09 +00:00
|
|
|
return m, nil
|
2019-06-27 22:07:34 +00:00
|
|
|
}
|
|
|
|
|
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-08 05:55:10 +00:00
|
|
|
/*
|
|
|
|
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.
|
|
|
|
*/
|
2018-06-17 17:29:57 +00:00
|
|
|
func (m *Level) ToJSON() ([]byte, error) {
|
2021-07-14 02:23:09 +00:00
|
|
|
// Gzip compressing?
|
2022-04-30 03:34:59 +00:00
|
|
|
if balance.DrawingFormat == balance.FormatGZip {
|
2021-07-14 02:23:09 +00:00
|
|
|
return m.ToGzip()
|
|
|
|
}
|
|
|
|
|
2022-04-30 03:34:59 +00:00
|
|
|
// Zipfile?
|
|
|
|
if balance.DrawingFormat == balance.FormatZipfile {
|
|
|
|
return m.ToZipfile()
|
|
|
|
}
|
|
|
|
|
|
|
|
return m.AsJSON()
|
|
|
|
}
|
|
|
|
|
|
|
|
// AsJSON returns it just as JSON without any fancy gzip/zip magic.
|
|
|
|
func (m *Level) AsJSON() ([]byte, error) {
|
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-08 05:55:10 +00:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
|
2018-06-17 17:29:57 +00:00
|
|
|
out := bytes.NewBuffer([]byte{})
|
|
|
|
encoder := json.NewEncoder(out)
|
2021-06-20 05:14:41 +00:00
|
|
|
if usercfg.Current.JSONIndent {
|
2019-05-05 21:03:20 +00:00
|
|
|
encoder.SetIndent("", "\t")
|
|
|
|
}
|
2018-06-17 17:29:57 +00:00
|
|
|
err := encoder.Encode(m)
|
|
|
|
return out.Bytes(), err
|
|
|
|
}
|
|
|
|
|
2021-07-14 02:23:09 +00:00
|
|
|
// ToGzip serializes the level as gzip compressed JSON.
|
|
|
|
func (m *Level) ToGzip() ([]byte, error) {
|
|
|
|
var (
|
|
|
|
handle = bytes.NewBuffer([]byte{})
|
|
|
|
zipper = gzip.NewWriter(handle)
|
|
|
|
encoder = json.NewEncoder(zipper)
|
|
|
|
)
|
|
|
|
if err := encoder.Encode(m); err != nil {
|
2018-08-11 00:19:47 +00:00
|
|
|
return nil, err
|
2018-06-17 17:29:57 +00:00
|
|
|
}
|
|
|
|
|
2021-07-14 02:23:09 +00:00
|
|
|
err := zipper.Close()
|
|
|
|
return handle.Bytes(), err
|
|
|
|
}
|
|
|
|
|
2022-04-30 03:34:59 +00:00
|
|
|
// ToZipfile serializes the level as a ZIP archive and also migrates
|
|
|
|
// data loaded from an older save into the new zip format.
|
|
|
|
func (m *Level) ToZipfile() ([]byte, error) {
|
|
|
|
// If we do not have a Zipfile yet, migrate legacy data into one.
|
|
|
|
// if m.Zipfile == nil {
|
|
|
|
fh := bytes.NewBuffer([]byte{})
|
|
|
|
zipper := zip.NewWriter(fh)
|
|
|
|
defer zipper.Close()
|
|
|
|
|
|
|
|
// Migrate any legacy Chunker data into external files in the zip.
|
|
|
|
if err := m.Chunker.MigrateZipfile(zipper); err != nil {
|
|
|
|
return nil, fmt.Errorf("MigrateZipfile: %s", err)
|
|
|
|
}
|
|
|
|
|
2022-04-30 19:47:35 +00:00
|
|
|
// Migrate attached files to ZIP.
|
|
|
|
if err := m.Files.MigrateZipfile(zipper); err != nil {
|
|
|
|
return nil, fmt.Errorf("FileSystem.MigrateZipfile: %s", err)
|
|
|
|
}
|
|
|
|
|
2022-04-30 03:34:59 +00:00
|
|
|
// Write the header json.
|
|
|
|
{
|
|
|
|
header, err := m.AsJSON()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
writer, err := zipper.Create("level.json")
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("zipping index.js: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if n, err := writer.Write(header); err != nil {
|
|
|
|
return nil, err
|
|
|
|
} else {
|
2023-12-02 20:33:14 +00:00
|
|
|
log.Debug("Written level.json to zipfile: %d bytes", n)
|
2022-04-30 03:34:59 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
zipper.Close()
|
|
|
|
|
|
|
|
// Refresh our Zipfile reader from the zipper we just wrote.
|
|
|
|
bin := fh.Bytes()
|
|
|
|
if err := m.ReloadZipfile(bin); err != nil {
|
|
|
|
log.Error("ReloadZipfile: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return fh.Bytes(), nil
|
|
|
|
}
|
|
|
|
|
2021-07-14 02:23:09 +00:00
|
|
|
// FromGzip deserializes a gzip compressed level JSON.
|
|
|
|
func FromGzip(data []byte) (*Level, error) {
|
|
|
|
// This function works, do not touch.
|
|
|
|
var (
|
|
|
|
level = New()
|
|
|
|
buf = bytes.NewBuffer(data)
|
|
|
|
reader *gzip.Reader
|
|
|
|
decoder *json.Decoder
|
|
|
|
)
|
|
|
|
|
|
|
|
reader, err := gzip.NewReader(buf)
|
2018-08-11 00:19:47 +00:00
|
|
|
if err != nil {
|
2021-07-14 02:23:09 +00:00
|
|
|
return nil, err
|
2018-08-11 00:19:47 +00:00
|
|
|
}
|
|
|
|
|
2021-07-14 02:23:09 +00:00
|
|
|
decoder = json.NewDecoder(reader)
|
|
|
|
decoder.Decode(level)
|
2018-10-28 05:22:13 +00:00
|
|
|
|
2021-07-14 02:23:09 +00:00
|
|
|
return level, nil
|
|
|
|
}
|
2018-09-23 22:20:45 +00:00
|
|
|
|
2022-04-30 03:34:59 +00:00
|
|
|
// FromZipfile reads a level in zipfile format.
|
|
|
|
func FromZipfile(data []byte) (*Level, error) {
|
|
|
|
var (
|
|
|
|
level = New()
|
|
|
|
err = level.populateFromZipfile(data)
|
|
|
|
)
|
|
|
|
return level, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// ReloadZipfile re-reads the level's zipfile after a write.
|
|
|
|
func (m *Level) ReloadZipfile(data []byte) error {
|
|
|
|
return m.populateFromZipfile(data)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Common function between FromZipfile and ReloadZipFile.
|
|
|
|
func (m *Level) populateFromZipfile(data []byte) error {
|
|
|
|
var (
|
|
|
|
buf = bytes.NewReader(data)
|
|
|
|
zf *zip.Reader
|
|
|
|
decoder *json.Decoder
|
|
|
|
)
|
|
|
|
|
|
|
|
zf, err := zip.NewReader(buf, buf.Size())
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Read the level.json.
|
|
|
|
file, err := zf.Open("level.json")
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
decoder = json.NewDecoder(file)
|
|
|
|
err = decoder.Decode(m)
|
|
|
|
|
|
|
|
// Keep the zipfile reader handy.
|
|
|
|
m.Zipfile = zf
|
|
|
|
m.Chunker.Zipfile = zf
|
2022-04-30 19:47:35 +00:00
|
|
|
m.Files.Zipfile = zf
|
|
|
|
|
|
|
|
// Re-inflate the level: ensures Actor instances get their IDs
|
|
|
|
// and everything is reloaded after saving the level.
|
|
|
|
m.Inflate()
|
2022-04-30 03:34:59 +00:00
|
|
|
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-07-14 02:23:09 +00:00
|
|
|
// LoadJSON loads a map from JSON file (gzip supported).
|
|
|
|
func LoadJSON(filename string) (*Level, error) {
|
|
|
|
data, err := ioutil.ReadFile(filename)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return FromJSON(filename, data)
|
2018-06-17 17:29:57 +00:00
|
|
|
}
|
2019-05-05 21:03:20 +00:00
|
|
|
|
|
|
|
// WriteJSON writes a level to JSON on disk.
|
|
|
|
func (m *Level) WriteJSON(filename string) error {
|
|
|
|
json, err := m.ToJSON()
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("Level.WriteJSON: JSON encode error: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = ioutil.WriteFile(filename, json, 0755)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("Level.WriteJSON: WriteFile error: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2022-04-30 03:34:59 +00:00
|
|
|
|
|
|
|
// Loop may be called each loop to allow the level to maintain its
|
|
|
|
// memory usage, e.g., for chunks not requested recently from a zipfile
|
|
|
|
// level to free those from RAM.
|
|
|
|
func (m *Level) Loop() error {
|
|
|
|
m.Chunker.FreeCaches()
|
|
|
|
return nil
|
|
|
|
}
|