This cuts memory use significantly, since many sprites in an object are never used. We can get savings over time by evicting sprites when they go out of scope, but that's, well, out of scope. To achieve this, I introduce an assetstore package that is in charge of loading things from the filesystem. This also allows some lingering case-sensitivity issues to be handled cleanly. I'd hoped that creating fewer ebiten.Image instances would help CPU usage, but that doesn't seem to be the case.
281 lines
5.9 KiB
Go
281 lines
5.9 KiB
Go
package maps
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
expectedMagic = []byte("\x08\x00WHMAP\x00")
|
|
expectedSetNameOffset = uint32(0x34)
|
|
notImplemented = fmt.Errorf("Not implemented")
|
|
)
|
|
|
|
const (
|
|
MaxHeight = 7 // Z coordinate
|
|
MaxLength = 100 // Y coordinate
|
|
MaxWidth = 130 // X coordinate
|
|
|
|
CellSize = 16 // seems to be
|
|
|
|
cellDataOffset = 0x110 // tentatively
|
|
cellCount = MaxHeight * MaxLength * MaxWidth
|
|
)
|
|
|
|
type Header struct {
|
|
IsCampaignMap uint32 // Tentatively: 0 = no, 1 = yes
|
|
MinWidth uint32
|
|
MinLength uint32
|
|
MaxWidth uint32
|
|
MaxLength uint32
|
|
Unknown1 uint32
|
|
Unknown2 uint32
|
|
Unknown3 uint32
|
|
Unknown4 uint32
|
|
Magic [8]byte // "\x08\x00WHMAP\x00"
|
|
Unknown5 uint32
|
|
Unknown6 uint32
|
|
SetName [8]byte // Links to a filename in `/Sets/*.set`
|
|
// Need to investigate the rest of the header too
|
|
}
|
|
|
|
func (h Header) Width() int {
|
|
return int(h.MaxWidth - h.MinWidth)
|
|
}
|
|
|
|
func (h Header) Length() int {
|
|
return int(h.MaxLength - h.MinLength)
|
|
}
|
|
|
|
func (h Header) Height() int {
|
|
return MaxHeight
|
|
}
|
|
|
|
func (h Header) MapSetName() string {
|
|
idx := bytes.IndexByte(h.SetName[:], 0)
|
|
if idx < 0 {
|
|
idx = 8 // all 8 bytes are used
|
|
}
|
|
|
|
return string(h.SetName[0:idx:idx])
|
|
}
|
|
|
|
func (h Header) MapSetFilename() string {
|
|
return h.MapSetName() + ".set"
|
|
}
|
|
|
|
type ObjRef struct {
|
|
AreaByte byte
|
|
SpriteAndFlagByte byte
|
|
}
|
|
|
|
// The index into a set palette to retrieve the object
|
|
func (o ObjRef) Index() int {
|
|
return int(o.AreaByte)
|
|
}
|
|
|
|
func (o ObjRef) Sprite() int {
|
|
// The top bit seems to be a flag of some kind
|
|
return int(o.SpriteAndFlagByte & 0x7f)
|
|
}
|
|
|
|
// The top bit seems to say whether we should draw or not.
|
|
func (o ObjRef) IsActive() bool {
|
|
return (o.SpriteAndFlagByte & 0x80) == 0x80
|
|
}
|
|
|
|
type Cell struct {
|
|
DoorAndCanisterRelated byte
|
|
DoorLockAndReactorRelated byte
|
|
Unknown2 byte
|
|
Surface ObjRef
|
|
Left ObjRef
|
|
Right ObjRef
|
|
Center ObjRef
|
|
Unknown11 byte
|
|
Unknown12 byte
|
|
Unknown13 byte
|
|
Unknown14 byte
|
|
SquadRelated byte
|
|
}
|
|
|
|
func (c *Cell) At(n int) byte {
|
|
switch n {
|
|
case 0:
|
|
return c.DoorAndCanisterRelated
|
|
case 1:
|
|
return c.DoorLockAndReactorRelated
|
|
case 2:
|
|
return c.Unknown2
|
|
case 3:
|
|
return c.Surface.AreaByte
|
|
case 4:
|
|
return c.Surface.SpriteAndFlagByte
|
|
case 5:
|
|
return c.Left.AreaByte
|
|
case 6:
|
|
return c.Left.SpriteAndFlagByte
|
|
case 7:
|
|
return c.Right.AreaByte
|
|
case 8:
|
|
return c.Right.SpriteAndFlagByte
|
|
case 9:
|
|
return c.Center.AreaByte
|
|
case 10:
|
|
return c.Center.SpriteAndFlagByte
|
|
case 11:
|
|
return c.Unknown11
|
|
case 12:
|
|
return c.Unknown12
|
|
case 13:
|
|
return c.Unknown13
|
|
case 14:
|
|
return c.Unknown14
|
|
case 15:
|
|
return c.SquadRelated
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// Cells is always a fixed size; use At to get a cell according to x,y,z
|
|
type Cells []Cell
|
|
|
|
func (c Cells) At(x, y, z int) Cell {
|
|
return c[(z*MaxLength*MaxWidth)+(y*MaxWidth)+x]
|
|
}
|
|
|
|
func (h Header) Check() []error {
|
|
var out []error
|
|
if h.IsCampaignMap > 1 {
|
|
out = append(out, fmt.Errorf("Expected 0 or 1 for IsCampaignMap, got %v", h.IsCampaignMap))
|
|
}
|
|
|
|
if bytes.Compare(expectedMagic, h.Magic[:]) != 0 {
|
|
out = append(out, fmt.Errorf("Unexpected magic value: %v", h.Magic))
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
type GameMap struct {
|
|
Header
|
|
Cells
|
|
// TODO: parse this into sections
|
|
Text string
|
|
}
|
|
|
|
// A game map contains a .txt and a .map. If they're in the same directory,
|
|
// just pass the directory + basename to load both
|
|
func LoadGameMap(prefix string) (*GameMap, error) {
|
|
for _, txtExt := range []string{".TXT", ".txt"} {
|
|
for _, mapExt := range []string{".MAP", ".map"} {
|
|
out, err := LoadGameMapByFiles(prefix+mapExt, prefix+txtExt)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
|
|
if err == nil {
|
|
return out, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("Couldn't find %s.{map,txt}, even ignoring case", prefix)
|
|
}
|
|
|
|
// A game map is composed of two files: .map and .txt
|
|
func LoadGameMapByFiles(mapFile, txtFile string) (*GameMap, error) {
|
|
out, err := loadMapFile(mapFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// TODO: load text and parse into sections
|
|
txt, err := ioutil.ReadFile(txtFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.Text = string(txt)
|
|
|
|
for _, err := range out.Check() {
|
|
log.Printf("%s: %v", mapFile, err)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func LoadGameMaps(dir string) (map[string]*GameMap, error) {
|
|
fis, err := ioutil.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make(map[string]*GameMap)
|
|
|
|
for _, fi := range fis {
|
|
filename := filepath.Join(dir, fi.Name())
|
|
basename := filepath.Base(filename)
|
|
extname := filepath.Ext(filename)
|
|
|
|
// Only pay attention to .MAP files. Assume they will have a .txt too
|
|
if !strings.EqualFold(extname, ".MAP") {
|
|
continue
|
|
}
|
|
|
|
prefix := filename[0 : len(filename)-4]
|
|
|
|
gameMap, err := LoadGameMap(prefix)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Error processing %v: %s", filename, err)
|
|
}
|
|
|
|
out[basename] = gameMap
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func loadMapFile(filename string) (*GameMap, error) {
|
|
var out GameMap
|
|
|
|
mf, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer mf.Close()
|
|
|
|
zr, err := gzip.NewReader(mf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer zr.Close()
|
|
|
|
if err := binary.Read(zr, binary.LittleEndian, &out.Header); err != nil {
|
|
return nil, fmt.Errorf("Error parsing %s: %v", filename, err)
|
|
}
|
|
|
|
// no gzip.SeekReader, so discard unread header bytes for now
|
|
discardSize := int64(cellDataOffset - binary.Size(&out.Header))
|
|
if _, err := io.CopyN(ioutil.Discard, zr, discardSize); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out.Cells = make(Cells, cellCount)
|
|
if err := binary.Read(zr, binary.LittleEndian, &out.Cells); err != nil {
|
|
return nil, fmt.Errorf("Error parsing cells for %s: %v", filename, err)
|
|
}
|
|
|
|
return &out, nil
|
|
}
|