First pass at displaying Menu files

This commit is contained in:
2018-12-30 23:23:08 +00:00
parent 6d3a13fcfb
commit b21767fe97
7 changed files with 559 additions and 2 deletions

1
.gitignore vendored
View File

@@ -5,5 +5,6 @@
/view-obj /view-obj
/view-map /view-map
/view-minimap /view-minimap
/view-menu
/view-set /view-set
/wh40k /wh40k

View File

@@ -14,6 +14,9 @@ view-obj: $(srcfiles)
view-map: $(srcfiles) view-map: $(srcfiles)
go build -o view-map ur.gs/ordoor/cmd/view-map go build -o view-map ur.gs/ordoor/cmd/view-map
view-menu: $(srcfiles)
go build -o view-menu ur.gs/ordoor/cmd/view-menu
view-minimap: $(srcfiles) view-minimap: $(srcfiles)
go build -o view-minimap ur.gs/ordoor/cmd/view-minimap go build -o view-minimap ur.gs/ordoor/cmd/view-minimap

View File

@@ -5,25 +5,32 @@ import (
"fmt" "fmt"
"log" "log"
"path/filepath" "path/filepath"
"strings"
"ur.gs/ordoor/internal/data" "ur.gs/ordoor/internal/data"
"ur.gs/ordoor/internal/maps" "ur.gs/ordoor/internal/maps"
"ur.gs/ordoor/internal/menus"
"ur.gs/ordoor/internal/sets" "ur.gs/ordoor/internal/sets"
) )
var ( var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation") gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
skipObj = flag.Bool("skip-obj", true, "Skip loading .obj files")
) )
func main() { func main() {
flag.Parse() flag.Parse()
loadData() loadData()
if !*skipObj {
loadObj() loadObj()
}
loadMapsFrom("Maps") loadMapsFrom("Maps")
loadMapsFrom("MultiMaps") loadMapsFrom("MultiMaps")
loadSets() loadSets()
loadMenus()
} }
func loadData() { func loadData() {
@@ -128,3 +135,33 @@ func loadSets() {
fmt.Printf(" * `%s`: center expected=%d actual=%d\n", key, mapSet.CenterCount, len(mapSet.CenterPalette)) fmt.Printf(" * `%s`: center expected=%d actual=%d\n", key, mapSet.CenterCount, len(mapSet.CenterPalette))
} }
} }
func loadMenus() {
menusPath := filepath.Join(*gamePath, "Menu")
menus, err := menus.LoadMenus(menusPath)
if err != nil {
log.Fatalf("Failed to parse %s/*.mnu as menus: %v", menusPath, err)
}
for _, menu := range menus {
fmt.Printf(" * `%s`: objects=%v fonts=%v\n", menu.Name, menu.ObjectFiles, menu.FontNames)
for _, record := range menu.Records {
displayRecord(record, 2)
}
}
}
func displayRecord(record *menus.Record, depth int) {
content := fmt.Sprintf("id=%v type=%v sprite=%v", record.Id, record.Type, record.SpriteId)
if !record.Active {
content = "(" + content + ")"
}
fmt.Printf("%s* %s\n", strings.Repeat(" ", depth), content)
for _, child := range record.Children {
displayRecord(child, depth+1)
}
}

198
cmd/view-menu/main.go Normal file
View File

@@ -0,0 +1,198 @@
package main
import (
"flag"
"log"
"os"
"path/filepath"
"github.com/faiface/pixel"
"github.com/faiface/pixel/pixelgl"
"golang.org/x/image/colornames"
"ur.gs/ordoor/internal/conv"
"ur.gs/ordoor/internal/data"
"ur.gs/ordoor/internal/menus"
"ur.gs/ordoor/internal/ui"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
menuFile = flag.String("menu", "", "Path to a .mnu file, e.g. ./orig/Menu/MainGame.mnu")
)
type env struct {
menu *menus.Menu
objects []*conv.Object
batch *pixel.Batch
}
type state struct {
env *env
step int
// Redraw the window if these change
winPos pixel.Vec
winBounds pixel.Rect
}
func main() {
flag.Parse()
if *gamePath == "" || *menuFile == "" {
flag.Usage()
os.Exit(1)
}
menu, err := menus.LoadMenu(*menuFile)
if err != nil {
log.Fatalf("Couldn't load menu file %s: %v", *menuFile, err)
}
rawObjs := []*data.Object{}
for _, name := range menu.ObjectFiles {
objFile := filepath.Join(filepath.Dir(*menuFile), name)
obj, err := data.LoadObject(objFile)
if err != nil {
log.Fatalf("Failed to load %s: %v", name, err)
}
obj.Name = name
rawObjs = append(rawObjs, obj)
}
objects, spritesheet := conv.ConvertObjects(rawObjs)
batch := pixel.NewBatch(&pixel.TrianglesData{}, spritesheet)
env := &env{objects: objects, menu: menu, batch: batch}
// The main thread now belongs to pixelgl
pixelgl.Run(env.run)
}
func (e *env) run() {
win, err := ui.NewWindow("View Menu: " + *menuFile)
if err != nil {
log.Fatal("Couldn't create window: %v", err)
}
pWin := win.PixelWindow
state := &state{env: e}
// For now, just try to display the various objects
// left + right to change object, up + down to change frame
win.Run(func() {
oldState := *state
state = state.runStep(pWin)
if oldState != *state || oldState.step == 0 {
state.present(pWin)
}
state.step += 1
})
}
func (s *state) runStep(pWin *pixelgl.Window) *state {
newState := *s
newState.winPos = pWin.GetPos()
newState.winBounds = pWin.Bounds()
newState.handleKeys(pWin)
return &newState
}
const (
origX = 640.0
origY = 480.0
)
func (s *state) present(pWin *pixelgl.Window) {
pWin.Clear(colornames.Black)
s.env.batch.Clear()
// The menus expect to be drawn to a 640x480 screen. We need to scale and
// project that so it fills the window appropriately. This is a combination
// of translate + zoom
winSize := pWin.Bounds().Max
scaleFactor := pixel.Vec{winSize.X / origX, winSize.Y / origY}
cam := pixel.IM
cam = cam.ScaledXY(pixel.ZV, pixel.Vec{1.0, -1.0}) // invert the Y axis
cam = cam.Moved(pixel.Vec{origX / 2, origY / 2})
cam = cam.ScaledXY(pixel.ZV, scaleFactor)
s.env.batch.SetMatrix(cam)
for _, record := range s.env.menu.Records {
s.drawRecord(record, s.env.batch)
}
s.env.batch.Draw(pWin)
}
func (s *state) drawRecord(record *menus.Record, target pixel.Target) {
if !record.Active {
return
}
// Draw this record if it's valid to do so. FIXME: lots to learn
if record.SpriteId >= 0 {
x := float64(record.X)
y := float64(record.Y)
// FIXME: some are set at -1, -1. No idea why
if x < 0.0 {
x = 0.0
}
if y < 0.0 {
y = 0.0
}
log.Printf(
"Drawing id=%v type=%v spriteid=%v x=%v y=%v",
record.Id, record.Type, record.SpriteId, x, y,
)
// FIXME: Need to handle multiple objects
obj := s.env.objects[0]
sprite := obj.Sprites[record.SpriteId]
sprite.Spr.Draw(target, pixel.IM.Moved(pixel.V(x, y)))
}
// Draw all children of this record
for _, child := range record.Children {
s.drawRecord(child, target)
}
}
func (s *state) handleKeys(pWin *pixelgl.Window) {
/*
if pWin.JustPressed(pixelgl.KeyLeft) {
if s.objIdx > 0 {
s.objIdx -= 1
s.spriteIdx = 0
}
}
if pWin.JustPressed(pixelgl.KeyRight) {
if s.objIdx < s.env.set.Count()-1 {
s.objIdx += 1
s.spriteIdx = 0
}
}
if pWin.JustPressed(pixelgl.KeyDown) {
if s.spriteIdx > 0 {
s.spriteIdx -= 1
}
}
if pWin.JustPressed(pixelgl.KeyUp) {
if s.spriteIdx < len(s.curObject().Sprites)-1 {
s.spriteIdx += 1
}
}
// Zoom in and out with the mouse wheel
s.zoom *= math.Pow(1.2, pWin.MouseScroll().Y)
*/
}

View File

@@ -1,6 +1,129 @@
# *.mnu # *.mnu
These files appear to be the UI definitions for Chaos Gate. Some relate to These files appear to be the UI definitions for Chaos Gate. Some relate to
system menus, other to in-game menus. system menus, other to in-game menus. Their names are hardcoded into the
`WH40K.exe` binary. Each has a `.obj` file associated with it.
It's an ASCII-formatted text file with a 12-line header, followed by a number
of descriptor records.
Here's the top of `MainGame.mnu`:
```
MainGame.obj
BACKGROUND COLOR 0..255..-1 trans : 0
HYPERTEXT COLOR 0..255 : 120
FONT TYPE 0..5 : 10
wh40k_12
basfnt12
wh40k_47
wh40k_12_red
wh40k_12_blue
wh40k_12_green
wh40k_12_yellow
NULL
```
The first line of the header is a `.obj` file containing sprites we want to
display on-screen. We see entries further down have a `SPRITEID`, which must
reference entries in that file.
In `SaveGame.mnu`, we can see multiple `.obj` files can be referenced.
There are then 3 lines that seem to be fixed descriptor names with values
that vary. Is this font colour, perhaps? Unsure.
Next is a variable-length list of font names, referencing files in the `Fonts`
directory.
Finally, there's a list of records that specify the menu itself. Truncated
contents of `SaveGame.mnu`:
```
#rem..........Background
MENUID : 1
MENUTYPE : 0
MOVEABLE : 0
ACTIVE : 1
SPRITEID : 0
ACCELERATOR: 0
DRAW TYPE : 0
SHARE : -1
X-CORD : -1
Y-CORD : -1
DESC :
*
#rem..........MAIN BACKGROUND
MENUID : 2
MENUTYPE : 45
MOVEABLE : 0
ACTIVE : 1
SPRITEID : 0
ACCELERATOR: 0
DRAW TYPE : 0
SHARE : -1
X-CORD : -1
Y-CORD : -1
DESC :
#rem.......... MAIN BACKGROUND
SUBMENUID : 1
SUBMENUTYPE: 31
FONTTYPE : 20
ACTIVE : 0
SPRITEID : -1
ACCELERATOR: 0
DRAW TYPE : 0
SHARE : 0
SOUNDTYPE : 0
DESC :
*
#rem..........Chat List Box Menu
MENUID : 21
MENUTYPE : 1
MOVEABLE : 0
ACTIVE : 1
SPRITEID : 764
ACCELERATOR: 0
DRAW TYPE : 0
SHARE : -1
X-CORD : -1
Y-CORD : -1
DESC :
[...]
*
~
```
We start processing these as soon as we see `MENUID`, I suppose. Each toplevel
item is `*`-delimited, and the list is terminated with `~`.
Each menu has a list of parameters:
|---------|----------|---------|
| Name | Examples | Purpose |
|---------|----------|---------|
| `MENUID`| `1`, `2`, `3` | Maybe linking between menus? |
| `MENUTYPE` | `0`, `1`, `2`, `3`, `45`, `300` | ? |
| `MOVEABLE` | `0` | Unimplemented functionality? |
| `ACTIVE` | `0`, `1` | Boolean - whether to show the thing |
| `SPRITEID` | `-1`, `0`, `123` | Select from `.obj` file |
| `ACCELERATOR` | | |
| `DRAW TYPE` | | |
| `SHARE` | | |
| `X-CORD` | | |
| `Y-CORD` | | |
| `FONTTYPE` | | |
| `SOUNDTYPE` | | |
| `DESC` | | |
Submenus also show a couple of unique values:
|------|----------|---------|
| Name | Examples | Purpose |
|------|----------|---------|
| `SUBMENUID` | | |
| `SUBMENUTYPE` | | |

178
internal/menus/menus.go Normal file
View File

@@ -0,0 +1,178 @@
package menus
import (
"fmt"
"io/ioutil"
"path/filepath"
"strconv"
"strings"
"ur.gs/ordoor/internal/util/asciiscan"
)
type Record struct {
Parent *Record
Children []*Record
Id int
Type int
Active bool
SpriteId int
X int
Y int
// FIXME: turn these into first-class data
properties map[string]string
}
type Menu struct {
Name string
// TODO: load these
ObjectFiles []string
FontNames []string
// FIXME: turn these into first-class data
Properties map[string]string
// The actual menu records. There are multiple top-level items. Submenus are
// only ever nested one deep.
Records []*Record
}
func LoadMenu(filename string) (*Menu, error) {
name := filepath.Base(filename)
// FIXME: this needs turning into a real parser sometime
scanner, err := asciiscan.New(filename)
if err != nil {
return nil, err
}
var str string
var record *Record
section := 0
isProp := false
out := &Menu{
Name: name,
Properties: map[string]string{},
}
for {
str, err = scanner.ConsumeString()
if err != nil {
return nil, err
}
// Whether the lines are properties or not alternate with each section,
// except the records use `*` as a separator
if section < 3 && isProp != asciiscan.IsProperty(str) {
section += 1
isProp = !isProp
}
if str == "~" {
break
}
switch section {
case 0: // List of object files
out.ObjectFiles = append(out.ObjectFiles, str)
case 1: // List of properties
k, v := asciiscan.ConsumeProperty(str)
out.Properties[k] = v
case 2: // list of fonts
// FIXME: do we need to do something cleverer here?
if str == "NULL" {
continue
}
out.FontNames = append(out.FontNames, str)
case 3: // Menu records
if str == "*" { // NEXT RECORD
out.Records = append(out.Records, record.Toplevel())
continue
}
k, v := asciiscan.ConsumeProperty(str)
switch k {
case "MENUID":
record = newRecord(nil)
case "SUBMENUID":
record = newRecord(record.Toplevel())
}
setProperty(record, k, v)
}
}
return out, nil
}
func LoadMenus(dir string) (map[string]*Menu, error) {
fis, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
}
out := make(map[string]*Menu, len(fis))
for _, fi := range fis {
relname := fi.Name()
basename := filepath.Base(relname)
extname := filepath.Ext(relname)
// Skip anything that isn't a .mnu file
if !strings.EqualFold(extname, ".mnu") {
continue
}
built, err := LoadMenu(filepath.Join(dir, relname))
if err != nil {
return nil, fmt.Errorf("%s: %v", filepath.Join(dir, relname), err)
}
out[basename] = built
}
return out, nil
}
func newRecord(parent *Record) *Record {
out := &Record{
Parent: parent,
properties: map[string]string{},
}
if parent != nil {
parent.Children = append(parent.Children, out)
}
return out
}
func (r *Record) Toplevel() *Record {
if r.Parent != nil {
return r.Parent.Toplevel()
}
return r
}
func setProperty(r *Record, k, v string) {
vInt, _ := strconv.Atoi(v)
switch k {
case "MENUID", "SUBMENUID":
r.Id = vInt
case "MENUTYPE", "SUBMENUTYPE":
r.Type = vInt
case "ACTIVE":
r.Active = (vInt != 0)
case "SPRITEID":
r.SpriteId = vInt
case "X-CORD":
r.X = vInt
case "Y-CORD":
r.Y = vInt
default:
r.properties[k] = v
}
}

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"strconv" "strconv"
"strings"
) )
var hashComment = []byte("#") var hashComment = []byte("#")
@@ -60,6 +61,22 @@ func (s *Scanner) ConsumeString() (string, error) {
return "", err return "", err
} }
// It's common for properties to be specified as "foo : bar". Parse them out.
func ConsumeProperty(s string) (string, string) {
if !IsProperty(s) {
return "", ""
}
parts := strings.SplitN(s, ":", 2)
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
// Peek ahead in the input stream to see if the next line might be a property
// (contain a colon character).
func IsProperty(s string) bool {
return strings.Contains(s, ":")
}
func (s *Scanner) ConsumeInt() (int, error) { func (s *Scanner) ConsumeInt() (int, error) {
str, err := s.ConsumeString() str, err := s.ConsumeString()
if err != nil { if err != nil {