Files
ordoor/internal/menus/menus.go

491 lines
11 KiB
Go

package menus
import (
"fmt"
"image"
"image/color"
"io/ioutil"
"path/filepath"
"strconv"
"strings"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/util/asciiscan"
)
// MenuType tells us what sort of Group we have
type MenuType int
// SubMenuType tells us what sort of Record we have
type SubMenuType int
const (
TypeStatic MenuType = 0
TypeMenu MenuType = 1
TypeDragMenu MenuType = 2 // Only seen in Configure_Vehicle_{Chaos,Ultra}
TypeRadioMenu MenuType = 3 // ???
TypeMainBackground MenuType = 45 // ???
TypeDialogue MenuType = 300
SubTypeSimpleButton SubMenuType = 3
SubTypeDoorHotspot1 SubMenuType = 30 // Like a button I guess? "FONTTYPE is animation speed"
SubTypeDoorHotspot2 SubMenuType = 31 // Seems like a duplicate of the above? What's different?
SubTypeLineKbd SubMenuType = 40
SubTypeLineBriefing SubMenuType = 41
SubTypeThumb SubMenuType = 45 // A "thumb" appears to be a vertical slider
SubTypeInvokeButton SubMenuType = 50
SubTypeDoorHotspot3 SubMenuType = 60 // Maybe? Appears in Arrange.mnu
SubTypeOverlay SubMenuType = 61
SubTypeHypertext SubMenuType = 70
SubTypeCheckbox SubMenuType = 91
SubTypeEditBox SubMenuType = 100
SubTypeInventorySelect SubMenuType = 110
SubTypeRadioButton SubMenuType = 120
SubTypeDropdownButton SubMenuType = 200
SubTypeComboBoxItem SubMenuType = 205
SubTypeAnimationSample SubMenuType = 220
SubTypeAnimationHover SubMenuType = 221 // FONTTYPE is animation speed. Only animate when hovered
SubTypeMainButton SubMenuType = 228
SubTypeSlider SubMenuType = 232
SubTypeStatusBar SubMenuType = 233
SubTypeListBoxUp SubMenuType = 400 // FIXME: these have multiple items in SUBMENUTYPE
SubTypeListBoxDown SubMenuType = 405
)
// FIXME: certain elements - especially overlays - don't have a DESC specified
// in the .mnu file, but display text specified with a number in i18n. The only
// conclusion I can draw is that they're hardcoded in the binary and set from
// outside. So, do that here.
var DescOverrides = map[string]int{
"main:2.6": 50992,
"newgame:2.5": 50993,
"keyboard:3.3": 50995,
"levelply:2.6": 50996,
}
// FIXME: Same idea with text overrides, only these aren't mentioned in the .dta
// file at all!
var TextOverrides = map[string]string{
"main:2.7": "0.1-ordoor",
}
var TypeOverrides = map[string]SubMenuType{
// FIXME: These are put down as simple buttons, but it's a *lot* easier to
// understand them as list box buttons.
"configure_ultequip:7.5": SubTypeListBoxUp,
"configure_ultequip:7.6": SubTypeListBoxDown,
}
type Menu struct {
Name string
// TODO: load these
ObjectFiles []string
FontNames []string
// These are properties set in the menu header. We don't know what they're
// all for.
BackgroundColor color.Color
HypertextColor color.Color
FontType int
// The actual menu records. There are multiple top-level items. Submenus are
// only ever nested one deep.
Groups []*Group
}
// Group represents an element with a MENUTYPE. It is part of a Menu and may
// have children.
type Group struct {
Menu *Menu
Records []*Record
Properties
Type MenuType
}
type Record struct {
Menu *Menu
Group *Group
Properties
Type SubMenuType
}
type Properties struct {
Locator string // Not strictly a property. Set for tracking.
ID int
ObjectIdx int // Can be specified in MENUID, defaults to 0
Accelerator int
Active bool
Desc string
DrawType int
FontType int
Moveable bool
Share int
SoundType int
SpriteId []int
X int
Y int
// From i18n
Text string
Help string
}
func (p *Properties) Point() image.Point {
if p.X > 0 || p.Y > 0 {
return image.Pt(p.X, p.Y)
}
return image.Point{}
}
func LoadMenu(filename string) (*Menu, error) {
name := filepath.Base(filename)
name = strings.TrimSuffix(name, filepath.Ext(name))
name = strings.ToLower(name)
scanner, err := asciiscan.New(filename)
if err != nil {
return nil, err
}
defer scanner.Close()
out := &Menu{
Name: name,
}
if err := loadObjects(out, scanner); err != nil {
return nil, err
}
if err := loadProperties(out, scanner); err != nil {
return nil, err
}
if err := loadFonts(out, scanner); err != nil {
return nil, err
}
if err := loadRecords(filepath.Dir(filename), out, scanner); err != nil {
return nil, err
}
return out, nil
}
func loadObjects(menu *Menu, scanner *asciiscan.Scanner) error {
strs, err := scanner.ConsumeStringList()
if err != nil {
return err
}
menu.ObjectFiles = strs
return nil
}
func loadProperties(menu *Menu, scanner *asciiscan.Scanner) error {
for {
ok, err := scanner.PeekProperty()
if err != nil {
return err
}
if !ok {
break
}
k, v, err := scanner.ConsumeProperty()
if err != nil {
return err
}
vInt, err := strconv.Atoi(v) // All properties have been int
if err != nil {
return err
}
// DeBrief.mnu misspells these
parts := strings.SplitN(strings.ToUpper(k), " ", 3)
if len(parts) > 2 {
k = strings.Join(parts[0:2], " ")
}
switch strings.ToUpper(k) {
case "BACKGROUND COLOR":
menu.BackgroundColor = data.ColorPalette[vInt]
case "HYPERTEXT COLOR":
menu.HypertextColor = data.ColorPalette[vInt]
case "FONT TYPE":
menu.FontType = vInt
default:
return fmt.Errorf("Unhandled menu property in %v: %q=%q", menu.Name, k, v)
}
}
return nil
}
func loadFonts(menu *Menu, scanner *asciiscan.Scanner) error {
// FIXME: Can we just ignore NULL, or does the index matter?
strs, err := scanner.ConsumeStringList("NULL")
if err != nil {
return err
}
menu.FontNames = strs
return nil
}
func loadRecords(baseDir string, menu *Menu, scanner *asciiscan.Scanner) error {
// We build things up line by line in these variables
var group *Group
var record *Record
var properties *Properties
for {
str, err := scanner.ConsumeString()
if err != nil {
return err
}
if strings.HasPrefix(str, "$") {
subScanner, err := asciiscan.New(filepath.Join(baseDir, str[1:]))
if err != nil {
return err
}
err = loadRecords(baseDir, menu, subScanner)
subScanner.Close() // Don't keep this around for all of loadRecords
if err != nil {
return fmt.Errorf("Processing child %q: %v", str, err)
}
continue
}
if str == "*" {
if record != nil {
group.Records = append(group.Records, record)
record = nil
}
if group != nil {
menu.Groups = append(menu.Groups, group)
group = nil
}
continue // New group
}
if str == "~" {
break // THE END
}
k, v := asciiscan.ConsumeProperty(str)
switch strings.ToUpper(k) {
case "MENUID":
if group != nil {
menu.Groups = append(menu.Groups, group)
}
group = newGroup(menu, v)
properties = &group.Properties
case "SUBMENUID":
if record != nil {
group.Records = append(group.Records, record)
}
record = newRecord(group, v)
properties = &record.Properties
case "MENUTYPE":
group.setMenuType(v)
case "SUBMENUTYPE":
record.setSubMenuType(v)
default:
if err := properties.setProperty(k, v); err != nil {
return err
}
}
}
return 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 listOfInts(s string) []int {
vSplit := strings.Split(s, ",")
vSplitInt := make([]int, len(vSplit))
for i, subV := range vSplit {
vSplitInt[i], _ = strconv.Atoi(subV)
}
return vSplitInt
}
func newGroup(menu *Menu, idStr string) *Group {
out := &Group{Menu: menu}
// ObjectIdx can be specified in the MENUID. Only seen for .mni files
ints := listOfInts(idStr)
out.ID = ints[0]
if len(ints) > 1 {
out.ObjectIdx = ints[1]
}
out.Locator = fmt.Sprintf("%v:%v", menu.Name, out.ID)
return out
}
func newRecord(group *Group, idStr string) *Record {
out := &Record{Group: group}
out.ID, _ = strconv.Atoi(idStr) // FIXME: we're ignoring conversion errors here
out.ObjectIdx = group.ObjectIdx // FIXME: we shouldn't *copy* this
out.Locator = fmt.Sprintf("%v.%v", group.Locator, out.ID)
return out
}
func (g *Group) setMenuType(s string) {
v, _ := strconv.Atoi(s) // FIXME: conversion errors
g.Type = MenuType(v)
}
func (r *Record) setSubMenuType(s string) {
// FIXME: Type overrides shouldn't be necessary!
if override, ok := TypeOverrides[r.Locator]; ok {
r.Type = override
return
}
// FIXME: what are the other types here? Related to list boxes?
ints := listOfInts(s)
r.Type = SubMenuType(ints[0])
}
func (p *Properties) setProperty(k, v string) error {
ints := listOfInts(v)
vInt := ints[0]
asBool := (vInt != 0)
switch strings.ToUpper(k) {
case "ACCELERATOR":
p.Accelerator = vInt
case "ACTIVE":
p.Active = asBool
case "DESC":
p.Desc = v // Usually int, occasionally string
case "DRAW TYPE":
p.DrawType = vInt
case "FONTTYPE":
p.FontType = vInt
case "MOVEABLE":
p.Moveable = asBool
case "SOUNDTYPE":
p.SoundType = vInt
case "SPRITEID":
p.SpriteId = ints
case "X-CORD":
p.X = vInt
case "Y-CORD":
p.Y = vInt
case "SHARE":
p.Share = vInt
default:
return fmt.Errorf("Unknown property for %v: %v=%v", p.Locator, k, v)
}
return nil
}
type Replacer interface {
ReplaceText(int, *string)
ReplaceHelp(int, *string)
}
func (r *Record) Internationalize(replacer Replacer) {
if override, ok := TextOverrides[r.Locator]; ok {
r.Text = override
return
}
if override, ok := DescOverrides[r.Locator]; ok {
r.Desc = strconv.Itoa(override)
}
id, err := strconv.Atoi(r.Desc)
if err == nil {
replacer.ReplaceText(id, &r.Text)
replacer.ReplaceHelp(id, &r.Help)
} else {
r.Text = r.Desc // Sometimes it's a string like "EQUIPMENT"
}
}
func (m *Menu) Internationalize(replacer Replacer) {
for _, group := range m.Groups {
for _, record := range group.Records {
record.Internationalize(replacer)
}
}
}
func (g *Group) Props() *Properties {
return &g.Properties
}
func (r *Record) Props() *Properties {
return &r.Properties
}
func (p *Properties) BaseSpriteID() int {
base := p.Share
// SpriteId takes precedence if present
if len(p.SpriteId) > 0 && p.SpriteId[0] >= 0 {
base = p.SpriteId[0]
}
return base
}