diff --git a/.gitignore b/.gitignore index 8a61620..e8a9b50 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /loader /orig /palette-idx +/view-font /view-obj /view-map /view-minimap diff --git a/Makefile b/Makefile index 9658889..75b8131 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ srcfiles = Makefile go.mod $(shell find . -iname *.go) GOBUILD ?= go build -tags ebitengl -all: loader ordoor palette-idx view-obj view-map view-menu view-minimap view-set +all: loader ordoor palette-idx view-font view-obj view-map view-menu view-minimap view-set loader: $(srcfiles) $(GOBUILD) -o loader ./cmd/loader @@ -10,6 +10,9 @@ loader: $(srcfiles) palette-idx: $(srcfiles) $(GOBUILD) -o palette-idx ./cmd/palette-idx +view-font: $(srcfiles) + $(GOBUILD) -o view-font ./cmd/view-font + view-obj: $(srcfiles) $(GOBUILD) -o view-obj ./cmd/view-obj @@ -29,6 +32,6 @@ ordoor: $(srcfiles) $(GOBUILD) -o ordoor ./cmd/ordoor clean: - rm -f loader ordoor view-obj view-map view-minimap view-set palette-idx + rm -f loader ordoor view-obj view-map view-minimap view-set palette-idx view-font .PHONY: all clean diff --git a/cmd/view-font/main.go b/cmd/view-font/main.go new file mode 100644 index 0000000..7d01462 --- /dev/null +++ b/cmd/view-font/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "flag" + "image" + "log" + "math" + "os" + + "github.com/hajimehoshi/ebiten" + + "code.ur.gs/lupine/ordoor/internal/assetstore" + "code.ur.gs/lupine/ordoor/internal/ui" +) + +var ( + gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation") + fontName = flag.String("font", "", "Name of a font, e.g., basfont12") + txt = flag.String("text", "Test string", "Text to render") + + winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension") + winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension") +) + +type env struct { + font *assetstore.Font + step int + state state + lastState state +} + +type state struct { + zoom float64 + origin image.Point +} + +func main() { + flag.Parse() + + if *gamePath == "" || *fontName == "" { + flag.Usage() + os.Exit(1) + } + + assets, err := assetstore.New(*gamePath) + if err != nil { + log.Fatal(err) + } + + font, err := assets.Font(*fontName) + if err != nil { + log.Fatalf("Couldn't load font %s: %v", *fontName, err) + } + + state := state{zoom: 8.0} + env := &env{ + font: font, + state: state, + lastState: state, + } + + win, err := ui.NewWindow(env, "View Font: "+*fontName, *winX, *winY) + if err != nil { + log.Fatal("Couldn't create window: %v", err) + } + + win.OnMouseWheel(env.changeZoom) + + // Main thread now belongs to ebiten + if err := win.Run(); err != nil { + log.Fatal(err) + } +} + +func (e *env) Update(screenX, screenY int) error { + if e.step == 0 || e.lastState != e.state { + log.Printf("new state: zoom=%.2f", e.state.zoom) + } + + e.step += 1 + e.lastState = e.state + + return nil +} + +func (e *env) Draw(screen *ebiten.Image) error { + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor + + img, err := e.font.DrawLine(*txt) + if err != nil { + return err + } + + return screen.DrawImage(img, op) +} + +func (e *env) changeZoom(_, y float64) { + // Zoom in and out with the mouse wheel + e.state.zoom *= math.Pow(1.2, y) +} diff --git a/go.sum b/go.sum index e83ca24..e3c10fe 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72 h1:b+9H1GAsx5RsjvDFLoS5zkNBzIQMuVKUYQDmxU3N5XE= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= @@ -12,9 +10,6 @@ github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/hajimehoshi/bitmapfont v1.2.0/go.mod h1:h9QrPk6Ktb2neObTlAbma6Ini1xgMjbJ3w7ysmD7IOU= -github.com/hajimehoshi/ebiten v1.10.2 h1:PiJBY4Q4udip675T+Zqvb3NKMp1eyLWBelp660ZMrkQ= -github.com/hajimehoshi/ebiten v1.10.2/go.mod h1:i9dIEUf5/MuPtbK1/wHR0PB7ZtqhjOxxg+U1xfxapcY= -github.com/hajimehoshi/ebiten v1.10.5 h1:hVb3GJP4IDqOETifRmPg4xmURRgbIJoB9gQk+Jqe8Uk= github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5 h1:hke9UdXY1YPfqjXG1bCSZnoVnfVBw9SzvmlrRn3dL3w= github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5/go.mod h1:0SLvfr8iI2NxzpNB/olBM+dLN9Ur5a9szG13wOgQ0nQ= github.com/hajimehoshi/go-mp3 v0.2.1 h1:DH4ns3cPv39n3cs8MPcAlWqPeAwLCK8iNgqvg0QBWI8= @@ -39,10 +34,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= -golang.org/x/exp v0.0.0-20200228211341-fcea875c7e85 h1:jqhIzSw5SQNkbu5hOGpgMHhkfXxrbsLJdkIRcX19gCY= -golang.org/x/exp v0.0.0-20200228211341-fcea875c7e85/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= -golang.org/x/exp v0.0.0-20200319221330-857350248e3d h1:1kJNg12kVM6Xid7xoFkhq/YJVU4NMTv5b3hJCfQnwjc= -golang.org/x/exp v0.0.0-20200319221330-857350248e3d/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= golang.org/x/exp v0.0.0-20200320212757-167ffe94c325 h1:iPGJw87eUJvke9YLYKX0jIwLHiIrY/kXcFSgOpjav28= golang.org/x/exp v0.0.0-20200320212757-167ffe94c325/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= @@ -74,8 +65,6 @@ golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d h1:62ap6LNOjDU6uGmKXHJbSfciMoV+FeI1sRXx/pLDL44= -golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200321134203-328b4cd54aae h1:3tcmuaB7wwSZtelmiv479UjUB+vviwABz7a133ZwOKQ= golang.org/x/sys v0.0.0-20200321134203-328b4cd54aae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/assetstore/assetstore.go b/internal/assetstore/assetstore.go index 775d8ca..55167f4 100644 --- a/internal/assetstore/assetstore.go +++ b/internal/assetstore/assetstore.go @@ -34,6 +34,7 @@ type AssetStore struct { entries entryMap // These members are used to store things we've already loaded + fonts map[string]*Font maps map[string]*Map menus map[string]*Menu objs map[string]*Object @@ -84,6 +85,7 @@ func (a *AssetStore) Refresh() error { // Refresh a.entries = newEntryMap + a.fonts = make(map[string]*Font) a.maps = make(map[string]*Map) a.menus = make(map[string]*Menu) a.objs = make(map[string]*Object) diff --git a/internal/assetstore/font.go b/internal/assetstore/font.go new file mode 100644 index 0000000..1f7ffc6 --- /dev/null +++ b/internal/assetstore/font.go @@ -0,0 +1,116 @@ +package assetstore + +import ( + "fmt" + "log" + + "github.com/hajimehoshi/ebiten" + + "code.ur.gs/lupine/ordoor/internal/fonts" +) + +type Font struct { + Name string + mapping map[rune]*Sprite +} + +func (a *AssetStore) Font(name string) (*Font, error) { + name = canonical(name) + + // FIXME: these fonts don't exist. For now, point at one that does. + switch name { + case "imfnt13", "imfnt14": + name = "wh40k_12" + } + + if font, ok := a.fonts[name]; ok { + return font, nil + } + log.Printf("Loading font %v", name) + + filename, err := a.lookup(name, "fnt", "Fonts") + if err != nil { + return nil, err + } + + raw, err := fonts.LoadFont(filename) + if err != nil { + return nil, err + } + + objFile, err := a.lookup(raw.ObjectFile, "", "Fonts") + if err != nil { + return nil, err + } + + obj, err := a.ObjectByPath(objFile) + if err != nil { + return nil, err + } + + out := &Font{ + Name: name, + mapping: make(map[rune]*Sprite, len(raw.Mapping)), + } + + for r, offset := range raw.Mapping { + spr, err := obj.Sprite(offset) + if err != nil { + return nil, err + } + + out.mapping[r] = spr + } + + a.fonts[name] = out + + return out, nil +} + +// FIXME: this violates the ebiten rules for fast drawing. We may need to do the +// draw ourselves, with image.Paletted for each glyph to a single ebiten.Image +// +// FIXME: it'd be great if we didn't have to implement this all by ourselves; +// golang.org/x/image/font and github.com/hajimehoshi/ebiten/text are *almost* +// sufficient, but don't seem to like it when the glyphs are literal colours +// instead of a mask. +// +// TODO: draw text in a bounding box, multiple lines, etc +func (f *Font) DrawLine(text string) (*ebiten.Image, error) { + sprites := make([]*Sprite, 0, len(text)) + width := 0 + height := 0 + + for _, r := range text { + spr, ok := f.mapping[r] + if !ok { + return nil, fmt.Errorf("Font %v does not specify rune %v", f.Name, r) + } + + width += spr.Rect.Dx() + if y := spr.Rect.Dy(); y > height { + height = y + } + + sprites = append(sprites, spr) + } + + img, err := ebiten.NewImage(width, height, ebiten.FilterDefault) + if err != nil { + return nil, err + } + + xOff := 0 + for _, spr := range sprites { + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(float64(xOff), 0) + + xOff += spr.Rect.Dx() + + if err := img.DrawImage(spr.Image, op); err != nil { + return nil, err + } + } + + return img, nil +} diff --git a/internal/assetstore/menu.go b/internal/assetstore/menu.go index 75e685f..8f38653 100644 --- a/internal/assetstore/menu.go +++ b/internal/assetstore/menu.go @@ -8,8 +8,9 @@ import ( type Menu struct { assets *AssetStore - obj *Object // TODO: handle multiple objects in the menu - raw *menus.Menu + fonts []*Font // TODO: place the fonts directly into the relevant records + obj *Object // TODO: handle multiple objects in the menu + raw *menus.Menu // TODO: remove raw Name string } @@ -19,6 +20,11 @@ func (m *Menu) Records() []*menus.Record { return m.raw.Records } +// FIXME: don't expose this +func (m *Menu) Font(idx int) *Font { + return m.fonts[idx] +} + func (m *Menu) Images(start, count int) ([]*ebiten.Image, error) { out := make([]*ebiten.Image, count) @@ -70,6 +76,16 @@ func (a *AssetStore) Menu(name string) (*Menu, error) { return nil, err } + var fonts []*Font + for _, fontName := range raw.FontNames { + fnt, err := a.Font(fontName) + if err != nil { + return nil, err + } + + fonts = append(fonts, fnt) + } + i18n, err := a.i18n() if err != nil { return nil, err @@ -84,6 +100,7 @@ func (a *AssetStore) Menu(name string) (*Menu, error) { menu := &Menu{ assets: a, + fonts: fonts, obj: obj, raw: raw, Name: name, diff --git a/internal/fonts/fonts.go b/internal/fonts/fonts.go index 4d59133..9a2a8d7 100644 --- a/internal/fonts/fonts.go +++ b/internal/fonts/fonts.go @@ -10,17 +10,28 @@ import ( "code.ur.gs/lupine/ordoor/internal/util/asciiscan" ) +type Range struct { + Min Point + Max Point +} + +type Point struct { + Rune rune + Sprite int +} + type Font struct { Name string - // Contains the sprite data for the font. FIXME: load this? + // Contains the sprite data for the font ObjectFile string // Maps ASCII bytes to a sprite offset in the ObjectFile - mapping map[int]int + Ranges []Range + Mapping map[rune]int } func (f *Font) Entries() int { - return len(f.mapping) + return len(f.Mapping) } // Returns the offsets required to display a given string, returning an error if @@ -30,7 +41,7 @@ func (f *Font) Indices(s string) ([]int, error) { for i, b := range []byte(s) { - offset, ok := f.mapping[int(b)] + offset, ok := f.Mapping[rune(b)] if !ok { return nil, fmt.Errorf("Unknown codepoint %v at offset %v in string %s", b, i, s) } @@ -58,7 +69,7 @@ func LoadFont(filename string) (*Font, error) { out := &Font{ Name: filepath.Base(filename), ObjectFile: objFile, - mapping: make(map[int]int), + Mapping: make(map[rune]int), } for { @@ -82,15 +93,32 @@ func LoadFont(filename string) (*Font, error) { cpEnd, _ := strconv.Atoi(fields[2]) idxStart, _ := strconv.Atoi(fields[3]) idxEnd, _ := strconv.Atoi(fields[4]) - size := idxEnd - idxStart + cpSize := cpEnd - cpStart + idxSize := idxEnd - idxStart // FIXME: I'd love this to be an error but several .fnt files do it - if cpEnd-cpStart != size { - fmt.Printf("WARNING: %v has mismatched codepoints and indices: %q\n", filename, str) + // Take the smallest range + if cpSize != idxSize { + fmt.Printf("WARNING: %v has mismatched codepoints (sz=%v) and indices (sz=%v): %q\n", filename, cpSize, idxSize, str) + if cpSize < idxSize { + idxEnd = idxStart + cpSize + idxSize = cpSize + } else { + cpEnd = cpStart + idxSize + cpSize = idxSize + } + } - for offset := 0; offset < size; offset++ { - out.mapping[cpStart+offset] = idxStart + offset + r := Range{ + Min: Point{Rune: rune(cpStart), Sprite: idxStart}, + Max: Point{Rune: rune(cpEnd), Sprite: idxEnd}, + } + + out.Ranges = append(out.Ranges, r) + + for offset := 0; offset <= cpSize; offset++ { + out.Mapping[rune(cpStart+offset)] = idxStart + offset } case "v": // A single codepoint, 4 fields if len(fields) < 3 { @@ -99,8 +127,11 @@ func LoadFont(filename string) (*Font, error) { cp, _ := strconv.Atoi(fields[1]) idx, _ := strconv.Atoi(fields[2]) + pt := Point{Rune: rune(cp), Sprite: idx} - out.mapping[cp] = idx + out.Ranges = append(out.Ranges, Range{Min: pt, Max: pt}) + + out.Mapping[rune(cp)] = idx default: return nil, parseErr } diff --git a/internal/menus/menus.go b/internal/menus/menus.go index 5f46987..e625e40 100644 --- a/internal/menus/menus.go +++ b/internal/menus/menus.go @@ -2,11 +2,14 @@ package menus import ( "fmt" + "image/color" "io/ioutil" + "log" "path/filepath" "strconv" "strings" + "code.ur.gs/lupine/ordoor/internal/data" "code.ur.gs/lupine/ordoor/internal/util/asciiscan" ) @@ -93,6 +96,9 @@ type Menu struct { ObjectFiles []string FontNames []string + BackgroundColor color.Color + HypertextColor color.Color + // FIXME: turn these into first-class data Properties map[string]string @@ -146,7 +152,23 @@ func LoadMenu(filename string) (*Menu, error) { out.ObjectFiles = append(out.ObjectFiles, str) case 1: // List of properties k, v := asciiscan.ConsumeProperty(str) - out.Properties[k] = v + vInt, err := strconv.Atoi(v) // FIXME: + switch k { + case "BACKGROUND COLOR 0..255..-1 trans": + if err != nil { + return nil, err + } + + out.BackgroundColor = data.ColorPalette[vInt] + case "HYPERTEXT COLOR 0..255": + if err != nil { + return nil, err + } + + out.HypertextColor = data.ColorPalette[vInt] + default: + out.Properties[k] = v + } case 2: // list of fonts // FIXME: do we need to do something cleverer here? if str == "NULL" { @@ -170,6 +192,8 @@ func LoadMenu(filename string) (*Menu, error) { } } + log.Printf("Menu properties: %#+v", out.Properties) + return out, nil } diff --git a/internal/ui/noninteractive.go b/internal/ui/noninteractive.go index 20caa53..2ba66a7 100644 --- a/internal/ui/noninteractive.go +++ b/internal/ui/noninteractive.go @@ -5,7 +5,6 @@ import ( "log" "github.com/hajimehoshi/ebiten" - "github.com/hajimehoshi/ebiten/ebitenutil" "code.ur.gs/lupine/ordoor/internal/menus" ) @@ -28,7 +27,8 @@ type noninteractive struct { rect image.Rectangle // Some non-interactives, e.g., overlays, are an image + text to be shown - textImg *ebiten.Image + textImg *ebiten.Image + textOffset image.Point clickImpl // Alright, alright, it turns out the bridge mission briefing is clickable hoverImpl @@ -102,14 +102,27 @@ func registerOverlay(d *Driver, r *menus.Record) error { } if r.Text != "" { - // FIXME: we should be rendering the text much more nicely than this - textImg, err := ebiten.NewImage(sprite.Rect.Dx(), sprite.Rect.Dy(), ebiten.FilterDefault) + // FIXME: is this always right? Seems to make sense for Main.mnu + fnt := d.menu.Font(r.FontType/10 - 1) + + textImg, err := fnt.DrawLine(r.Text) if err != nil { return err } - ebitenutil.DebugPrint(textImg, r.Text) ni.textImg = textImg + + // Centre the image + xSlack := ni.rect.Dx() - textImg.Bounds().Dx() + if xSlack > 0 { + ni.textOffset.X = xSlack / 2 + } + + ySlack := ni.rect.Dy() - textImg.Bounds().Dy() + if ySlack > 0 { + ni.textOffset.Y = ySlack / 2 + } + } else { log.Printf("Overlay without text detected: %#+v", r) } @@ -189,12 +202,16 @@ func (n *noninteractive) regions(tick int) []region { out := oneRegion(n.bounds().Min, n.frames.image(tick)) if n.textImg != nil { - out = append(out, oneRegion(n.bounds().Min, n.textImg)...) + out = append(out, oneRegion(n.textPos(), n.textImg)...) } return out } +func (n *noninteractive) textPos() image.Point { + return image.Pt(n.rect.Min.X+n.textOffset.X, n.rect.Min.Y+n.textOffset.Y) +} + func (a *animationHover) regions(tick int) []region { if a.opening || a.closing { var anim animation