diff --git a/.gitignore b/.gitignore index 64aa390..7ad2426 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ /view-obj /view-map /view-minimap +/view-menu /view-set /wh40k diff --git a/Makefile b/Makefile index 9097cc6..59c7fa1 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,9 @@ view-obj: $(srcfiles) view-map: $(srcfiles) 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) go build -o view-minimap ur.gs/ordoor/cmd/view-minimap diff --git a/cmd/loader/main.go b/cmd/loader/main.go index a73aa48..06c3bc5 100644 --- a/cmd/loader/main.go +++ b/cmd/loader/main.go @@ -5,25 +5,32 @@ import ( "fmt" "log" "path/filepath" + "strings" "ur.gs/ordoor/internal/data" "ur.gs/ordoor/internal/maps" + "ur.gs/ordoor/internal/menus" "ur.gs/ordoor/internal/sets" ) var ( 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() { flag.Parse() loadData() - loadObj() + + if !*skipObj { + loadObj() + } loadMapsFrom("Maps") loadMapsFrom("MultiMaps") loadSets() + loadMenus() } func loadData() { @@ -128,3 +135,33 @@ func loadSets() { 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) + } +} diff --git a/cmd/view-menu/main.go b/cmd/view-menu/main.go new file mode 100644 index 0000000..9e35f57 --- /dev/null +++ b/cmd/view-menu/main.go @@ -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) + */ +} diff --git a/doc/formats/mnu.md b/doc/formats/mnu.md index 7d96e59..182a0fa 100644 --- a/doc/formats/mnu.md +++ b/doc/formats/mnu.md @@ -1,6 +1,129 @@ # *.mnu 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` | | | + diff --git a/internal/menus/menus.go b/internal/menus/menus.go new file mode 100644 index 0000000..35a0919 --- /dev/null +++ b/internal/menus/menus.go @@ -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 + } +} diff --git a/internal/util/asciiscan/asciiscan.go b/internal/util/asciiscan/asciiscan.go index 0a17bb1..b09fda1 100644 --- a/internal/util/asciiscan/asciiscan.go +++ b/internal/util/asciiscan/asciiscan.go @@ -7,6 +7,7 @@ import ( "io" "os" "strconv" + "strings" ) var hashComment = []byte("#") @@ -60,6 +61,22 @@ func (s *Scanner) ConsumeString() (string, error) { 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) { str, err := s.ConsumeString() if err != nil {