Compare commits

...

57 Commits

Author SHA1 Message Date
494fe4eb02 Trim map based on trailer data 2020-06-08 00:53:45 +01:00
30d1786e64 Get SaW maps displaying 2020-06-08 00:48:33 +01:00
65bae80d40 Add a note about SaW trailer 2020-06-08 00:48:19 +01:00
e8e9811b5d More map trailer work 2020-06-08 00:24:57 +01:00
a6fdbaef2b Make some progress decoding map trailer 2020-06-07 01:44:28 +01:00
0bf8233cd1 Fix binary paths in README 2020-06-06 12:45:10 +01:00
c2cbf1d95d Get the initial copyright notice displaying
This is really awful, but it's nice to check it off.
2020-06-06 12:44:08 +01:00
54fe95239e More README niceness 2020-06-05 22:47:06 +01:00
63d3ee0ed6 Update README.md a bit 2020-06-01 01:55:55 +01:00
5c869fc33c Make some Wages of War sprites displayable 2020-06-01 01:43:03 +01:00
4358951e15 Add the Wages of War palette (first guess) 2020-06-01 01:41:45 +01:00
250a6033c8 Fix an error in the palette generator 2020-06-01 01:41:24 +01:00
f64af717b7 Fix format strings 2020-06-01 01:32:03 +01:00
3866ee07a8 Source the palette name from data 2020-06-01 01:24:44 +01:00
c1268e8d57 Start reorganising for multiple games 2020-06-01 01:08:53 +01:00
59baf20c35 Remove unneeded palette investigation 2020-06-01 00:46:02 +01:00
cf58be6a20 Use map rect 2020-05-31 14:58:46 +01:00
14fdab72a0 Update ebiten to v1.11.1
This seems like a significant performance boost. I get 60fps now \o/
2020-05-31 14:50:11 +01:00
c7a2fa80e7 Internalise the map rect 2020-05-20 01:43:44 +01:00
def40a1ee2 Document SaW map format some more 2020-05-20 01:40:46 +01:00
48d098134e One more palette fix 2020-05-20 01:03:40 +01:00
597e346869 Correct the palettes 2020-05-19 22:05:42 +01:00
eea5dea98a Determine the Soldiers At War palette
This commit also takes the first step towards making it configurable;
override `internal/palettes.DefaultPaletteName` at build time to choose
one palette over another. It would be nice to set this at runtime!
2020-05-19 21:33:49 +01:00
04bdf3e352 Make i18n optional, add SoW note 2020-05-19 11:07:10 +01:00
9d0750d134 Scenario viewpoint, Z index management, and arrow controls 2020-04-20 00:16:21 +01:00
1f4bfc771c HAXXX: make the main game UI appear at the bottom 2020-04-19 20:57:45 +01:00
c058f651dc Wire up mission objectives dialogue/menu 2020-04-19 18:49:19 +01:00
9be93b6091 More work for MainGame.mnu 2020-04-19 18:21:08 +01:00
f8828c95bd Add GIF to README 2020-04-18 13:44:00 +01:00
903ddba2ac Selected cursor chrome 2020-04-18 12:23:03 +01:00
b191ba2a94 Simplify bounds clipping a tiny bit 2020-04-18 11:44:05 +01:00
6e70ddcb60 Fix some errors in iso->pix->iso conversions, add debugging
Maps now render a bit more properly, and our mouse position can be
correctly assigned to a cell. Kind of, mostly.
2020-04-18 00:12:15 +01:00
1e141a2fb9 More-efficient scenario draw call 2020-04-17 22:45:02 +01:00
4df6be4fb1 Upgrade to ebiten 1.11.0 2020-04-17 21:59:47 +01:00
4fe9a75d69 White background 2020-04-16 15:39:06 +01:00
2b83ce4f7f Build a simple animation viewer 2020-04-16 15:30:47 +01:00
b690c763bb A few more .idx realisations, and some parsing code 2020-04-16 03:03:51 +01:00
beebfda3ba Decode WarHammer.ani 2020-04-16 01:48:44 +01:00
87c0aae54b More file format musing 2020-04-15 22:18:53 +01:00
32fd9f9aa9 More investigation into animation 2020-04-15 21:11:01 +01:00
80c65f68ca Avoid false positives 2020-04-15 16:44:06 +01:00
acb7882549 Do some more file format spelunking
`WarHammer.ani` turns out to be a regular `obj` file; `WarHammer.idx`
is partially decoded, but I'm struggling to link it to the former in
a reasonable way at the moment.
2020-04-15 00:27:43 +01:00
e2ad8f61c1 Add a couple of FIXMEs 2020-04-14 18:59:57 +01:00
2f65cd312a Wire up inventory select to ship state
Also mixed into this commit:

* Use returning drivers where possible
* Make the credits screen returnable via click
2020-04-14 15:11:25 +01:00
26c976353f Fix inventorySelect 2020-04-14 12:25:25 +01:00
82d3849402 Increase dialogue modality, display keyboard dialogue 2020-04-14 12:12:37 +01:00
786d261f98 Allow dialogues to be hidden or shown
To do this, MENU and SUBMENU are split into two types (at last), and
a Widget type is introduced. This should allow lots of code to be
removed at some point.
2020-04-14 03:14:49 +01:00
dc131939f4 Make include directives work in .mnu files 2020-04-13 21:03:54 +01:00
76bf8438b0 Display MainGame.mnu and map in ordoor simultaneously
It's a complete mess for now - many things are out of place or shown
when they shouldn't be - and we can't move around the game map. But,
it's a good start.
2020-04-11 01:01:05 +01:00
5f8606377a Integrate view-map into ordoor
Now we can view scenario maps from the main game interface. We can't
cancel out of a scenario yet, though.
2020-04-11 00:13:28 +01:00
0025daf8dd Move internal/ordoor/flow to internal/flow 2020-04-10 22:50:01 +01:00
f3fea83173 Reimplement cursor as a query operation 2020-04-10 20:54:58 +01:00
bb3ddc4896 First pass at custom cursor display 2020-04-10 19:55:16 +01:00
d99a5b9ec3 Instantiate a ship on startup 2020-04-10 19:24:03 +01:00
fd73f03aa5 Initial outline of a Ship 2020-04-02 01:23:04 +01:00
df1f116b3d Clean up the options flow handlers a tiny bit 2020-04-02 01:22:53 +01:00
aa43011e8d Set options from defaults on first start
Data/GenericData.dat specifies what the default values for some options
should be. Respect them on startup if the options in config are unset.
2020-04-02 01:21:32 +01:00
86 changed files with 5760 additions and 2862 deletions

18
.gitignore vendored
View File

@@ -1,13 +1,9 @@
/config.toml
/loader
/orig
/palette-idx
/view-font
/view-obj
/view-map
/view-minimap
/view-menu
/view-set
/ordoor
/investigation/Maps
/investigation/Obj
/isos
/CG
/SL
/SaW
/WoW
/WoW-CD
/bin

View File

@@ -2,36 +2,42 @@ srcfiles = Makefile go.mod $(shell find . -iname *.go)
GOBUILD ?= go build -tags ebitengl
all: loader ordoor palette-idx view-font view-obj view-map view-menu view-minimap view-set
all: loader ordoor palette-idx view-ani view-font view-obj view-map view-menu view-minimap view-set
loader: $(srcfiles)
$(GOBUILD) -o loader ./cmd/loader
bin:
mkdir bin
palette-idx: $(srcfiles)
$(GOBUILD) -o palette-idx ./cmd/palette-idx
loader: bin $(srcfiles)
$(GOBUILD) -o bin/loader ./cmd/loader
view-font: $(srcfiles)
$(GOBUILD) -o view-font ./cmd/view-font
palette-idx: bin $(srcfiles)
$(GOBUILD) -o bin/palette-idx ./cmd/palette-idx
view-obj: $(srcfiles)
$(GOBUILD) -o view-obj ./cmd/view-obj
view-ani: bin $(srcfiles)
$(GOBUILD) -o bin/view-ani ./cmd/view-ani
view-map: $(srcfiles)
$(GOBUILD) -o view-map ./cmd/view-map
view-font: bin $(srcfiles)
$(GOBUILD) -o bin/view-font ./cmd/view-font
view-menu: $(srcfiles)
$(GOBUILD) -o view-menu ./cmd/view-menu
view-obj: bin $(srcfiles)
$(GOBUILD) -o bin/view-obj ./cmd/view-obj
view-minimap: $(srcfiles)
$(GOBUILD) -o view-minimap ./cmd/view-minimap
view-map: bin $(srcfiles)
$(GOBUILD) -o bin/view-map ./cmd/view-map
view-set: $(srcfiles)
$(GOBUILD) -o view-set ./cmd/view-set
view-menu: bin $(srcfiles)
$(GOBUILD) -o bin/view-menu ./cmd/view-menu
ordoor: $(srcfiles)
$(GOBUILD) -o ordoor ./cmd/ordoor
view-minimap: bin $(srcfiles)
$(GOBUILD) -o bin/view-minimap ./cmd/view-minimap
view-set: bin $(srcfiles)
$(GOBUILD) -o bin/view-set ./cmd/view-set
ordoor: bin $(srcfiles)
$(GOBUILD) -o bin/ordoor ./cmd/ordoor
clean:
rm -f loader ordoor view-obj view-map view-minimap view-set palette-idx view-font
rm -rf bin
.PHONY: all clean

184
README.md
View File

@@ -1,32 +1,107 @@
# Ordoor
Ordoor is an **unofficial** [game engine recreation](https://en.wikipedia.org/wiki/Game_engine_recreation)
of the classic game from 1998, [Warhammer 40,000: Chaos Gate](https://en.wikipedia.org/wiki/Warhammer_40,000:_Chaos_Gate)
of the Random Games, Inc., [Strategy Engine](https://www.mobygames.com/game-group/game-engine-random-games-1996-2000-strategy-engine),
which was in use from 1996 - 2000.
**You must have a copy of the original game data to use this project**. GOG is
the current publisher of this game; [you can purchase it here](https://www.gog.com/game/warhammer_40000_chaos_gate).
Four games are known to have been published for this engine:
"Warhammer 40,000" is a trademark of Games Workshop, and the game data used by
Ordoor contains Games Workshop intellectual property. I am confident that this
project uses all those things in accordance with the
[Intellectual Property Policy](https://www.games-workshop.com/en-GB/Intellectual-Property-Policy)
and the license granted when purchasing a copy of the game in question. Do let
me know if you see or suspect any violation, and I'll address it immediately.
* [Wages of War: The Business of Battle](https://en.wikipedia.org/wiki/Wages_of_War) (1996)
* [Soldiers At War](https://en.wikipedia.org/wiki/Soldiers_at_War) (1998)
* [Warhammer 40,000: Chaos Gate](https://en.wikipedia.org/wiki/Warhammer_40,000:_Chaos_Gate) (1998) [GOG](https://www.gog.com/game/warhammer_40000_chaos_gate)
* [Avalon Hill's Squad Leader](https://en.wikipedia.org/wiki/Avalon_Hill%27s_Squad_Leader) (2000)
The aim of Ordoor is to be a complete reimplementation that allows all four
of these games to be played on modern hardware. It should also permit new games
of the same style to be built.
For each of the games above, **You must have a copy of the original game data to play**.
Links are provided above if we're aware of an active publisher; otherwise, check
your back catalogue, or perhaps a local charity shop.
Trademarks and intellectual property are the property of their respective
owners, and the games mentioned above (including the game data) are protected by
copyright. As a mere game engine recreation, we're confident that this project
operates legally, and that its goal is a noble one. Do get in touch if you
believe otherwise!
Ordoor is a portmanteau of Order Door, which is, of course, the opposite of a
Chaos Gate.
Chaos Gate. The project began with a Chaos Gate recreation, then more games were
discovered, so scope expanded. A rename and/or rewrite may be on the cards as a
result.
## Current status
### Chaos Gate
Some of the original file formats are either partially or fully decoded. Maps,
menus, and most visual data can be rendered pixel-perfect. Sound can be played
(with a preprocessing step). Some UI tookit work is done. No game mechanics are
implemented yet.
I keep a GIF showcasing interesting progress here:
![](https://ur.gs/img/ordoor-main-menu.gif)
I've just been informed that another game from 1998, [Soldiers At War](https://en.wikipedia.org/wiki/Soldiers_at_War),
seems to use the same engine. Maybe at some point Ordoor will be able to play
both. Will that need a rename? Hmm. Watch this space.
### Soldiers At War
(At least some) objects display. Map support is being worked on in the
`soldiers-at-war` branch, which can more-or-less display them, albeit with many
errors.
### Squad Leader
Squad Leader is the most recent of the games created with this engine. Nothing
has been done with it yet, but a preliminary look at the game data suggests many
changes are afoot. The object files are a different format, at the very least.
### Wages of War
This is the oldest of the four games. The object file format seems to be mostly
the same. the installer only copies some data to the game directory; we may want
to work directly from the CDROM instead, if we can.
Maps are uncompressed, around 243K, and no header is present. They look similar
in principle to the tile data of Soldiers At War or Chaos Gate maps, otherwise.
The menu system seen in Chaos Gate is not present; instead, there is a `BUTTONS`
directory and a lot of `pcx` files under `PIC` that, I suspect, do the job for
this game.
Even with a full installation, Wages of War leaves a lot of data on the CD. It
may be best to run solely from the `WOW` directory on the CD, assuming it's a
strict superset of what gets installed, data-wise.
## Long-term goals
Once full playthrough of the official single-player campaign for all four games
has been achieved, thoughts turn to other things we could do. Here are some
ideas, mostly at random.
Multi-player support.
Graphics enhancements - 3D models instead of sprites, high-resolution tile sets,
32-bit colour, etc. Hopefully we'd be able to drop these in one at a time.
Vastly improved AI.
Mash-ups? How do mercenaries fare against cultists fare against Nazis? Only one
way to find out!
New campaigns with existing assets. Tell new stories, or elaborate on / modify
existing ones.
Completely new fantasy game using the same engine.
## Building from source
I'm writing code in Go at the moment, so you'll need to have a Go runtime
installed on your system:
installed on your system. Dependency management uses `go mod`, so ensure you
have at least Go 1.11.
```
$ go version
@@ -44,28 +119,25 @@ Debian:
You can then run `make all` in the source tree to get the binaries that are
present at the moment.
Place your WH40K: Chaos Gate installation in `./orig` to benefit from automatic
path defaults. Otherwise, point to it with `-game-path`
## Configuring
The `view-map` binary attempts to render a map, and is the current focus of
effort. Once I can render a whole map, including pre-placed characters (cultist
scum), things can start to get more interesting.
Since we support multiple games, a fair bit of configuration is required. Copy
`config.toml.example` to `config.toml` and edit it to your requirements. The
`data_dir` for the engine(s) you want to use is probably the most important bit,
along with the `default_engine`.
Current status: almost pixel-perfect map rendering. Static objects (four per map
coordinate: floor, centre, left, and right) are rendered fine, and each Z level
looks good. There are a few minor artifacts here and there.
The various games all use snapshots of the original engine at different points
in time, and specify a lot in code that we need to specify in data. That should
all go into the config file, so new games will be able to adapt the engine to
their needs.
Characters and animations aren't touched at all yet. Rendering performance is
poor. No gameplay, no campaign logic. Interaction with the play area is minimal
and limited to pan, zoom, and click for basic console output.
Still, I'm proud of myself.
## Running
To run:
```
$ make view-map
$ ./view-map -map Chapter01
$ ./bin/view-map -map Chapter01
```
Looks like this:
@@ -75,27 +147,29 @@ Looks like this:
Use the arrow keys to scroll around the map, the mouse wheel to zoom, and the
`1` - `7` keys to change Z level.
Dependency management uses `go mod`, so ensure you have at least Go 1.11.
There is the **start** of the menu / campaign flow in a `ordoor` binary:
```
$ cp config.toml.example config.toml
$ make ordoor
$ ./ordoor
```
This plays the introductory videos so far, and nothing else.
Menus are in the process of being rendered; you can use the `view-menu` binary
to inspect them:
Menus / UI widgets have fairly good support now; you can use the `view-menu`
binary to inspect them:
```
make view-menu
./view-menu -menu ./orig/Menu/Main.mnu
./bin/view-menu -menu Main
```
This menu *displays* OK, including
This renders the menus found in Chaos Gate and Soldiers At War. The Squad Leader
format seems basically the same, but has some extra files and aren't 8-bit
colour. They don't display at the moment. Wages of War uses a different format
altogether.
For Chaos Gate, there is the **start** of the game in an `ordoor` binary:
```
$ make ordoor
$ ./bin/ordoor
```
The idea is to hook all the different parts together, and to an abstract game
state (which is called `ship` for ordoor), to make the whole thing playable. It
isn't playable *yet*, but it's heading in that direction.
## Sound
@@ -110,31 +184,3 @@ $ ./scripts/convert-wav ./orig/Wav
As with video playback, the ambition is to *eventually* remove this dependency
and operate on the unmodified files instead.
## Miscellany
"Mission Setup" includes information about available squad types
From EquipDef.cpp Dumo: CEquipment we learn the following object types:
0. DELETED
1. WEAPON
2. GRENADE
3. MEDIPACK
4. SCANNER
5. GENESEED
6. CLIP
7. DOOR KEY
8. DOOR KEY
9. DOOR KEY
10. DOOR KEY
And we learn they can be "on"....
0. CHARACTER
1. VEHICLE
2. CANISTER
I'm starting to see some parallels with [this](https://github.com/shlainn/game-file-formats/wiki/)
in the data formats, and the timeline (1997) seems about right. Worth keeping an
eye on!

View File

@@ -3,43 +3,62 @@ package main
import (
"flag"
"fmt"
"image/color"
"log"
"path/filepath"
"strings"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/fonts"
"code.ur.gs/lupine/ordoor/internal/idx"
"code.ur.gs/lupine/ordoor/internal/maps"
"code.ur.gs/lupine/ordoor/internal/menus"
"code.ur.gs/lupine/ordoor/internal/palettes"
"code.ur.gs/lupine/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")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
skipObj = flag.Bool("skip-obj", true, "Skip loading .obj files")
)
// FIXME: all these paths are hardcoded with Chaos Gate in mind
func main() {
flag.Parse()
loadData()
if !*skipObj {
loadObj()
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
engine := cfg.DefaultEngine()
gamePath := engine.DataDir
palette, ok := palettes.Get(engine.Palette)
if !ok {
log.Fatalf("Unknown palette name: %v", engine.Palette)
}
loadMapsFrom("Maps")
loadMapsFrom("MultiMaps")
loadSets()
loadMenus()
loadFonts()
loadData(filepath.Join(gamePath, "Data"))
if !*skipObj {
loadObj(filepath.Join(gamePath, "Obj"))
}
loadMapsFrom(filepath.Join(gamePath, "Maps"))
loadMapsFrom(filepath.Join(gamePath, "MultiMaps"))
loadSets(filepath.Join(gamePath, "Sets"))
loadMenus(filepath.Join(gamePath, "Menu"), palette)
loadFonts(filepath.Join(gamePath, "Fonts"))
loadIdx(filepath.Join(gamePath, "Idx", "WarHammer.idx"))
}
func loadData() {
dataPath := filepath.Join(*gamePath, "Data")
func loadData(dataPath string) {
accountingPath := filepath.Join(dataPath, "Accounting.dat")
genericDataPath := filepath.Join(dataPath, "GenericData.dat")
aniObDefPath := filepath.Join(dataPath, "AniObDef.dat")
genericDataPath := filepath.Join(dataPath, "GenericData.dat")
hasActionPath := filepath.Join(dataPath, "HasAction.dat")
i18nPath := filepath.Join(dataPath, data.I18nFile)
log.Printf("Loading %s...", accountingPath)
@@ -73,11 +92,15 @@ func loadData() {
}
log.Printf("%s: len=%v", i18nPath, i18n.Len())
ha, err := data.LoadHasAction(hasActionPath)
if err != nil {
log.Fatalf("Failed to parse %s: %v", hasActionPath, err)
}
ha.Print()
}
func loadObj() {
objDataPath := filepath.Join(*gamePath, "Obj")
func loadObj(objDataPath string) {
// TODO: Obj/cpiece.rec isn't loaded by this. Do we need it? How do we know?
log.Printf("Loading %s...", objDataPath)
objects, err := data.LoadObjects(objDataPath)
@@ -107,8 +130,7 @@ func loadObj() {
}
}
func loadMapsFrom(part string) {
mapsPath := filepath.Join(*gamePath, part)
func loadMapsFrom(mapsPath string) {
log.Printf("Loading maps from %s", mapsPath)
gameMaps, err := maps.LoadGameMaps(mapsPath)
@@ -118,20 +140,20 @@ func loadMapsFrom(part string) {
log.Printf("Maps in %s:", mapsPath)
for key, gameMap := range gameMaps {
rect := gameMap.Rect()
hdr := gameMap.Header
fmt.Printf(
" * `%s`: IsCampaignMap=%v W=%v:%v L=%v:%v SetName=%s\n",
key,
hdr.IsCampaignMap,
hdr.MinWidth, hdr.MaxWidth,
hdr.MinLength, hdr.MaxLength,
rect.Min.X, rect.Max.X,
rect.Min.Y, rect.Max.Y,
string(hdr.SetName[:]),
)
}
}
func loadSets() {
setsPath := filepath.Join(*gamePath, "Sets")
func loadSets(setsPath string) {
log.Printf("Loading sets from %s", setsPath)
mapSets, err := sets.LoadSets(setsPath)
@@ -147,38 +169,38 @@ func loadSets() {
}
}
func loadMenus() {
menusPath := filepath.Join(*gamePath, "Menu")
func loadMenus(menusPath string, palette color.Palette) {
log.Printf("Loading menus from %s", menusPath)
menus, err := menus.LoadMenus(menusPath)
menus, err := menus.LoadMenus(menusPath, palette)
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)
for _, group := range menu.Groups {
// TODO: display group
for _, record := range group.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)
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)
}
}
func loadFonts() {
fontsPath := filepath.Join(*gamePath, "Fonts")
func loadFonts(fontsPath string) {
log.Printf("Loading fonts from %s", fontsPath)
fonts, err := fonts.LoadFonts(fontsPath)
if err != nil {
@@ -189,3 +211,16 @@ func loadFonts() {
fmt.Printf(" * `%s`: obj=%v entries=%v\n", font.Name, font.ObjectFile, font.Entries())
}
}
func loadIdx(idxPath string) {
log.Printf("Loading idx from %s", idxPath)
idx, err := idx.Load(idxPath)
if err != nil {
log.Fatalf("Failed to parse %s as idx: %v", idxPath, err)
}
for i, group := range idx.Groups {
log.Printf("Group %2d: %4d records, start sprite is %6d", i, len(group.Records), group.Spec.SpriteIdx)
}
}

View File

@@ -5,7 +5,7 @@ import (
"os"
"strconv"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/palettes"
)
func main() {
@@ -13,11 +13,11 @@ func main() {
case "index", "i":
idx, err := strconv.ParseInt(os.Args[2], 16, 64)
if err != nil {
fmt.Println("Usage: palette-idx i <0-255>")
fmt.Println("Usage: palette-idx index <0-255> <name>")
os.Exit(1)
}
fmt.Printf("Palette[%v]: %#v\n", idx, data.ColorPalette[idx])
fmt.Printf("%vPalette[%v]: %#v\n", os.Args[3], idx, palettes.Palettes[os.Args[3]][idx])
case "color", "colour", "c":
fmt.Println("TODO!")
os.Exit(1)

177
cmd/view-ani/main.go Normal file
View File

@@ -0,0 +1,177 @@
package main
import (
"flag"
"image"
"log"
"math"
"os"
"github.com/hajimehoshi/ebiten"
"golang.org/x/image/colornames"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
)
var (
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
groupIdx = flag.Int("group", 1, "Group index to start at")
recIdx = flag.Int("record", 0, "Record index to start at")
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 {
assets *assetstore.AssetStore
ani *assetstore.Animation
step int
state state
lastState state
}
type state struct {
groupIdx int
recIdx int
zoom float64
origin image.Point
}
func main() {
flag.Parse()
if *configFile == "" {
flag.Usage()
os.Exit(1)
}
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatalf("Failed to set up asset store: %v", err)
}
state := state{
zoom: 6.0,
origin: image.Point{-300, -200}, // Show them somewhat centered
groupIdx: *groupIdx,
recIdx: *recIdx,
}
env := &env{
assets: assets,
state: state,
lastState: state,
}
win, err := ui.NewWindow(env, "View Animations", *winX, *winY)
if err != nil {
log.Fatal(err)
}
win.OnKeyUp(ebiten.KeyMinus, env.changeGroup(-1))
win.OnKeyUp(ebiten.KeyEqual, env.changeGroup(+1))
win.OnKeyUp(ebiten.KeyComma, env.changeRec(-1))
win.OnKeyUp(ebiten.KeyPeriod, env.changeRec(+1))
win.OnKeyUp(ebiten.KeyLeft, env.changeOrigin(-16, +0))
win.OnKeyUp(ebiten.KeyRight, env.changeOrigin(+16, +0))
win.OnKeyUp(ebiten.KeyUp, env.changeOrigin(+0, -16))
win.OnKeyUp(ebiten.KeyDown, env.changeOrigin(+0, +16))
win.OnMouseWheel(env.changeZoom)
// The 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 {
ani, err := e.assets.Animation(e.state.groupIdx, e.state.recIdx)
if err != nil {
return err
}
e.ani = ani
log.Printf(
"new state: group=%d rec=%d zoom=%.2f, origin=%+v",
e.state.groupIdx,
e.state.recIdx,
e.state.zoom,
e.state.origin,
)
}
// This should be the final action
e.step += 1
e.lastState = e.state
return nil
}
func (e *env) Draw(screen *ebiten.Image) error {
cam := ebiten.GeoM{}
cam.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
cam.Translate(float64(-e.state.origin.X), float64(-e.state.origin.Y)) // Move to origin
screen.Fill(colornames.White)
if len(e.ani.Frames) > 0 {
sprite := e.ani.Frames[e.step/4%len(e.ani.Frames)]
return screen.DrawImage(sprite.Image, &ebiten.DrawImageOptions{GeoM: cam})
}
return nil
}
func (e *env) changeGroup(by int) func() {
return func() {
e.state.groupIdx += by
if e.state.groupIdx < 0 {
e.state.groupIdx = 0
}
// Reset the record if the group changes
e.state.recIdx = 0
// TODO: upper bounds checking
}
}
func (e *env) changeRec(by int) func() {
return func() {
e.state.recIdx += by
if e.state.recIdx < 0 {
e.state.recIdx = 0
}
// TODO: upper bounds checking
}
}
func (e *env) changeOrigin(byX, byY int) func() {
return func() {
e.state.origin.X += byX
e.state.origin.Y += byY
}
}
func (e *env) changeZoom(_, y float64) {
// Zoom in and out with the mouse wheel
e.state.zoom *= math.Pow(1.2, y)
}

View File

@@ -10,11 +10,14 @@ import (
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
fontName = flag.String("font", "", "Name of a font, e.g., basfont12")
txt = flag.String("text", "Test string", "Text to render")
@@ -37,12 +40,17 @@ type state struct {
func main() {
flag.Parse()
if *gamePath == "" || *fontName == "" {
if *configFile == "" || *fontName == "" {
flag.Usage()
os.Exit(1)
}
assets, err := assetstore.New(*gamePath)
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatal(err)
}

View File

@@ -2,74 +2,57 @@ package main
import (
"flag"
"image"
"log"
"math"
"os"
"sort"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/scenario"
"code.ur.gs/lupine/ordoor/internal/ui"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
gameMap = flag.String("map", "", "Name of a map, e.g., Chapter01")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
gameMap = flag.String("map", "", "Name of a map, e.g., Chapter01")
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 {
assets *assetstore.AssetStore
area *assetstore.Map
step int
state state
lastState state
}
type state struct {
zoom float64
origin image.Point
zIdx int
scenario *scenario.Scenario
}
func main() {
flag.Parse()
if *gamePath == "" || *gameMap == "" {
if *configFile == "" || *gameMap == "" {
flag.Usage()
os.Exit(1)
}
assets, err := assetstore.New(*gamePath)
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatalf("Failed to scan root directory %v: %v", *gamePath, err)
log.Fatalf("Failed to load config: %v", err)
}
area, err := assets.Map(*gameMap)
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatalf("Failed to load map %v: %v", *gameMap, err)
log.Fatalf("Failed to scan root directory: %v", err)
}
// Eager load sprites
if err := area.LoadSprites(); err != nil {
log.Fatal("Eager-loading sprites failed: %v", err)
scenario, err := scenario.NewScenario(assets, *gameMap)
if err != nil {
log.Fatalf("Failed to load scenario %v: %v", *gameMap, err)
}
state := state{
zoom: 1.0,
origin: image.Point{0, 3000}, // FIXME: haxxx
zIdx: 1,
}
env := &env{
area: area,
assets: assets,
state: state,
lastState: state,
scenario: scenario,
}
win, err := ui.NewWindow(env, "View Map "+*gameMap, *winX, *winY)
@@ -77,168 +60,56 @@ func main() {
log.Fatal("Couldn't create window: %v", err)
}
// TODO: click to view cell data
step := 32
win.WhileKeyDown(ebiten.KeyLeft, env.changeOrigin(-step, +0))
win.WhileKeyDown(ebiten.KeyRight, env.changeOrigin(+step, +0))
win.WhileKeyDown(ebiten.KeyUp, env.changeOrigin(+0, -step))
win.WhileKeyDown(ebiten.KeyDown, env.changeOrigin(+0, +step))
win.OnKeyUp(ebiten.KeyLeft, env.changeOrigin(-64, +0))
win.OnKeyUp(ebiten.KeyRight, env.changeOrigin(+64, +0))
win.OnKeyUp(ebiten.KeyUp, env.changeOrigin(+0, -64))
win.OnKeyUp(ebiten.KeyDown, env.changeOrigin(+0, +64))
win.OnMouseWheel(env.changeZoom)
for i := 0; i < 6; i++ {
win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i+1))
for i := 0; i <= 6; i++ {
win.OnKeyUp(ebiten.Key1+ebiten.Key(i), env.setZIdx(i))
}
win.OnMouseClick(env.showCellData)
win.OnMouseWheel(env.changeZoom)
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("zoom=%.2f zIdx=%v camPos=%#v", e.state.zoom, e.state.zIdx, e.state.origin)
}
e.lastState = e.state
e.step += 1
return nil
return e.scenario.Update(screenX, screenY)
}
func (e *env) Draw(screen *ebiten.Image) error {
// Bounds clipping
// http://www.java-gaming.org/index.php?topic=24922.0
// https://stackoverflow.com/questions/892811/drawing-isometric-game-worlds
// https://gamedev.stackexchange.com/questions/25896/how-do-i-find-which-isometric-tiles-are-inside-the-cameras-current-view
sw, sh := screen.Size()
topLeft := pixToCell(e.state.origin)
topLeft.X -= 5 // Ensure we paint to every visible section of the screeen.
topLeft.X -= 5 // FIXME: haxxx
bottomRight := pixToCell(image.Pt(e.state.origin.X+sw, e.state.origin.Y+sh))
bottomRight.X += 5
bottomRight.Y += 5
// X+Y is constant for all tiles in a column
// X-Y is constant for all tiles in a row
// However, the drawing order is odd unless we reorder explicitly.
toDraw := []image.Point{}
for a := topLeft.X + topLeft.Y; a <= bottomRight.X+bottomRight.Y; a++ {
for b := topLeft.X - topLeft.Y; b <= bottomRight.X-bottomRight.Y; b++ {
if b&1 != a&1 {
continue
}
pt := image.Pt((a+b)/2, (a-b)/2)
if !pt.In(e.area.Rect) {
continue
}
toDraw = append(toDraw, pt)
}
}
sort.Slice(toDraw, func(i, j int) bool {
iPix := cellToPix(toDraw[i])
jPix := cellToPix(toDraw[j])
if iPix.Y < jPix.Y {
return true
}
if iPix.Y == jPix.Y {
return iPix.X < jPix.X
}
return false
})
counter := map[string]int{}
for _, pt := range toDraw {
for z := 0; z <= e.state.zIdx; z++ {
if err := e.renderCell(pt.X, pt.Y, z, screen, counter); err != nil {
return err
}
}
}
//log.Printf("%#+v", counter)
return nil
}
func (e *env) renderCell(x, y, z int, screen *ebiten.Image, counter map[string]int) error {
sprites, err := e.area.SpritesForCell(x, y, z)
if err != nil {
return err
}
iso := ebiten.GeoM{}
iso.Translate(-float64(e.state.origin.X), -float64(e.state.origin.Y))
pix := cellToPix(image.Pt(x, y))
iso.Translate(float64(pix.X), float64(pix.Y))
// Taking the Z index away *seems* to draw the object in the correct place.
// FIXME: There are some artifacts, investigate more
iso.Translate(0.0, -float64(z*48.0)) // offset for Z index
// TODO: iso.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
for _, spr := range sprites {
// if _, ok := counter[spr.ID]; !ok {
// counter[spr.ID] = 0
// }
// counter[spr.ID] = counter[spr.ID] + 1
iso.Translate(float64(spr.XOffset), float64(spr.YOffset))
if err := screen.DrawImage(spr.Image, &ebiten.DrawImageOptions{GeoM: iso}); err != nil {
return err
}
iso.Translate(float64(-spr.XOffset), float64(-spr.YOffset))
}
return nil
return e.scenario.Draw(screen)
}
func (e *env) changeOrigin(byX, byY int) func() {
return func() {
e.state.origin.X += byX
e.state.origin.Y += byY
e.scenario.Viewpoint.X += byX
e.scenario.Viewpoint.Y += byY
}
}
func (e *env) changeZoom(_, y float64) {
// Zoom in and out with the mouse wheel
e.state.zoom *= math.Pow(1.2, y)
func (e *env) changeZoom(_, byY float64) {
e.scenario.Zoom *= math.Pow(1.2, byY)
}
func (e *env) setZIdx(to int) func() {
return func() {
e.state.zIdx = to
e.scenario.ZIdx = to
}
}
const (
cellWidth = 64
cellHeight = 64
)
func (e *env) showCellData() {
screenX, screenY := ebiten.CursorPosition()
viewX, viewY := e.scenario.Viewpoint.X+screenX, e.scenario.Viewpoint.Y+screenY
// Doesn't take the camera or Z level into account
func cellToPix(pt image.Point) image.Point {
return image.Pt(
(pt.X-pt.Y)*cellWidth,
(pt.X+pt.Y)*cellHeight/2,
)
}
log.Printf("Click registered at (%d,%d) screen, (%d,%d) virtual", screenX, screenY, viewX, viewY)
// Doesn't take the camera or Z level into account
func pixToCell(pt image.Point) image.Point {
return image.Pt(
pt.Y/cellHeight+pt.X/(cellWidth*2),
pt.Y/cellHeight-pt.X/(cellWidth*2),
)
cell, pos := e.scenario.CellAtCursor()
log.Printf("Viewpoint: %#+v z=%v", e.scenario.Viewpoint, e.scenario.ZIdx)
log.Printf("Cell under cursor: (%.2f,%.2f,%d): %#+v", pos.X, pos.Y, pos.Z, cell)
}

View File

@@ -5,27 +5,43 @@ import (
"log"
"os"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
menuName = flag.String("menu", "", "Name of a menu, e.g. Main")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
)
type dlg struct {
driver *ui.Driver
list []string
pos int
}
func main() {
flag.Parse()
if *gamePath == "" || *menuName == "" {
if *configFile == "" || *menuName == "" {
flag.Usage()
os.Exit(1)
}
assets, err := assetstore.New(*gamePath)
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatal(err)
}
@@ -35,7 +51,7 @@ func main() {
log.Fatalf("Couldn't load menu %s: %v", *menuName, err)
}
driver, err := ui.NewDriver(menu)
driver, err := ui.NewDriver(assets, menu)
if err != nil {
log.Fatalf("Couldn't initialize interface: %v", err)
}
@@ -45,7 +61,38 @@ func main() {
log.Fatal("Couldn't create window: %v", err)
}
// Change the active dialogue
dialogues := driver.Dialogues()
if len(dialogues) > 0 {
dlg := &dlg{
driver: driver,
list: dialogues,
}
win.OnKeyUp(ebiten.KeyLeft, dlg.changeDialogue(-1))
win.OnKeyUp(ebiten.KeyRight, dlg.changeDialogue(+1))
for i, dialogue := range dlg.list {
log.Printf("Dialogue %v: %v", i, dialogue)
}
}
if err := win.Run(); err != nil {
log.Fatal(err)
}
}
func (d *dlg) changeDialogue(by int) func() {
return func() {
newPos := d.pos + by
if newPos < 0 || newPos > len(d.list)-1 {
log.Printf("Hiding dialogue %v: %q", d.pos, d.list[d.pos])
d.driver.HideDialogue()
return
}
locator := d.list[newPos]
log.Printf("Showing dialogue %v: %q", newPos, locator)
d.driver.ShowDialogue(locator)
d.pos = newPos
}
}

View File

@@ -172,18 +172,15 @@ func (e *env) Update(screenX, screenY int) error {
func (e *env) Draw(screen *ebiten.Image) error {
gameMap := e.gameMap
imd, err := ebiten.NewImage(
int(gameMap.MaxWidth),
int(gameMap.MaxLength),
ebiten.FilterDefault,
)
rect := gameMap.Rect()
imd, err := ebiten.NewImage(rect.Dx(), rect.Dy(), ebiten.FilterDefault)
if err != nil {
return err
}
for y := int(gameMap.MinLength); y < int(gameMap.MaxLength); y++ {
for x := int(gameMap.MinWidth); x < int(gameMap.MaxWidth); x++ {
for y := int(rect.Min.Y); y < int(rect.Max.Y); y++ {
for x := int(rect.Min.X); x < int(rect.Max.X); x++ {
cell := gameMap.Cells.At(x, y, int(e.state.zIdx))
imd.Set(x, y, makeColour(&cell, e.state.cellIdx))
}

View File

@@ -10,13 +10,17 @@ import (
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
objFile = flag.String("obj-file", "", "Path of an .obj file, e.g. ./orig/Obj/TZEENTCH.OBJ")
objName = flag.String("obj-name", "", "Name of an .obj file, e.g. TZEENTCH")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
objFile = flag.String("obj-file", "", "Path of an .obj file, e.g. ./orig/Obj/TZEENTCH.OBJ")
objName = flag.String("obj-name", "", "Name of an .obj file, e.g. TZEENTCH")
sprIdx = flag.Int("spr-idx", 0, "Sprite index to start at")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
@@ -41,14 +45,19 @@ type state struct {
func main() {
flag.Parse()
if *gamePath == "" || (*objName == "" && *objFile == "") {
if *configFile == "" || (*objName == "" && *objFile == "") {
flag.Usage()
os.Exit(1)
}
assets, err := assetstore.New(*gamePath)
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatal("Failed to set up asset store: %v", err)
log.Fatalf("Failed to load config: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatalf("Failed to set up asset store: %v", err)
}
var obj *assetstore.Object
@@ -62,8 +71,9 @@ func main() {
}
state := state{
zoom: 6.0,
origin: image.Point{0, 0},
zoom: 6.0,
origin: image.Point{0, 0},
spriteIdx: *sprIdx,
}
env := &env{

View File

@@ -10,12 +10,15 @@ import (
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
)
var (
gamePath = flag.String("game-path", "./orig", "Path to a WH40K: Chaos Gate installation")
setName = flag.String("set", "", "Name of a set, e.g., map01")
configFile = flag.String("config", "config.toml", "Config file")
engine = flag.String("engine", "", "Override engine to use")
setName = flag.String("set", "", "Name of a set, e.g., map01")
winX = flag.Int("win-x", 1280, "Pre-scaled window X dimension")
winY = flag.Int("win-y", 1024, "Pre-scaled window Y dimension")
@@ -39,12 +42,17 @@ type state struct {
func main() {
flag.Parse()
if *gamePath == "" || *setName == "" {
if *configFile == "" || *setName == "" {
flag.Usage()
os.Exit(1)
}
assets, err := assetstore.New(*gamePath)
cfg, err := config.Load(*configFile, *engine)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
log.Fatal(err)
}

View File

@@ -1,6 +1,22 @@
[ordoor]
data_dir = "./orig"
video_player = ["mpv", "--no-config", "--keep-open=no", "--force-window=no", "--no-border", "--no-osc", "--fullscreen", "--no-input-default-bindings"]
video_player = ["mpv", "--no-config", "--keep-open=no", "--force-window=no", "--no-border", "--no-osc", "--fullscreen", "--no-input-default-bindings"]
default_engine = "ordoor"
[engines.geas] # Wages of War -> Gifts of Peace -> Geas
data_dir = "./WoW-CD"
palette = "WagesOfWar"
[engines.ordoor] # Chaos Gate -> Order Door -> Ordoor
data_dir = "./CG"
palette = "ChaosGate"
[engines.baps] # Soldiers At War -> Boys at Play -> Baps
data_dir = "./SaW"
palette = "SoldiersAtWar"
[engines.sl] # Squad Leader -> ??? -> ???
data_dir = "./SL"
palette = "ChaosGate" # may not be relevant?
[options]
play_movies = true

View File

@@ -1,167 +0,0 @@
Hypothesis: Idx/WarHammer.idx points objects into bitmap data in Anim/WarHammer.ani
We can use WH40K_TD.exe and investigate reads of .idx followed by reads of .ani
to test this.
WH40K_TD.exe opens files in this order:
1. Data/USEng.dta
1. WH40K_TD.exe (?)
1. Cursor/Cursors.cur
1. pread64(fd, 23, 0) = 23
1. _llseek(fd, 0, [0], SEEK_CUR) = 0
1. _llseek(fd, 0, [0], SEEK_CUR) = 0
1. _llseek(fd, 40666, [40666], SEEK_SET) = 0
1. _llseek(fd, 0, [0], SEEK_SET) = 0
1. close(fd) = 0
1. read(fd, "\x26\x00\x00\x00\x20\x00\x00\x00\x30\x01\x00\x00\x50\x01\x00\x00\x8a\x9d\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", 32) = 32
1. (...)
1. _llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Cursor/Cursors.cur>, 39868, [39868], SEEK_SET) = 0
1. read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Cursor/Cursors.cur>, "...", 798) = 798
1. (some statting of Idx/WarHammer.idx, no reading that I saw)
1. Anim/WarHammer.ani
1. read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "...", 32) = 32
1. (some clones of child procs, I didn't follow them)
1. Sounds/wh40k.ds
1. pread64(31</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Sounds/wh40k.ds>, "...", 23, 0) = 23
1. read(31</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Sounds/wh40k.ds>, "...", 417792) = 417792
1. read(31</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Sounds/wh40k.ds>, "...", 4096) = 4096
1. Data/Sounds.dat
1. pread64(34</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/Sounds.dat>, "#**********************", 23, 0) = 23
1. ...
1. Pic/wh40k.pcx
1. read(34</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Pic/wh40k.pcx>, "...", 168509) = 168509
1. Sets/*
1. (lots of statting these)
1. Data/Randchar.dat
1. pread64(34</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/RandChar.dat>, "#**********************", 23, 0) = 23
1. read(34</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/RandChar.dat>, "#***************************************************************"..., 4096) = 4096
1. ...
1. Data/WeapDef.dat
1. pread64(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/WeapDef.dat>, "#**********************", 23, 0) = 23
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/WeapDef.dat>, "#***************************************************************"..., 4096) = 4096
1. ...
1. Data/SpellDef.dat
1. pread64(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/SpellDef.dat>, "#**********************", 23, 0) = 23
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/SpellDef.dat>, "#***************************************************************"..., 4096) = 4096
1. ...
1. Data/AniObDef.dat
1. pread64(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/AniObDef.dat>, "# ******** ANIMATED OBJ", 23, 0) = 23
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/AniObDef.dat>, "# ******** ANIMATED OBJECT DEFINITIONS **************\r\n#\t\t0 : **"..., 4096) = 4096
1. ...
1. Data/VehicDef.dat
1. pread64(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/VehicDef.dat>, "# ******** VEHICLE DEFI", 23, 0) = 23
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/VehicDef.dat>, "# ******** VEHICLE DEFINITIONS **************\r\n#\t\t0 : *** VEHICL"..., 4096) = 4096
1. ...
1. Data/StdWeap.dat
1. pread64(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/StdWeap.dat>, "# ******** SQUAD STANDA", 23, 0) = 23
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Data/StdWeap.dat>, "# ******** SQUAD STANDARD WEAPONS **************\r\n#\t\t0 : *** SQU"..., 4096) = 4096
1. ...
1. Data/Ultnames.dat
1. Data/Chanames.dat
1. Data/keymap.dta
1. Filters/wh40k.flt
1. _llseek(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Filters/wh40k.flt>, 0, [0], SEEK_SET) = 0
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Filters/wh40k.flt>, "\x01\x00\x00\x00", 4) = 4
1. _llseek(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Filters/wh40k.flt>, 4, [4], SEEK_SET) = 0
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Filters/wh40k.flt>, "...", 72) = 72
1. _llseek(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Filters/wh40k.flt>, 1444, [1444], SEEK_SET) = 0
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Filters/wh40k.flt>, "...", 327680) = 327680
1. Misc/occlusio.lis
1. pread64(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Misc/occlusio.lis>, "62 # Number of Absol", 23, 0) = 23
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Misc/occlusio.lis>, "62 # Number of Absolute Deltas.\r\n # These Deltas are off"..., 4096) = 982
1. read(35</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Misc/occlusio.lis>, "", 3114) = 0
1. Data/GDestroy.dat
1. (stat Obj/destroy.obj)
1. Data/minimap.dat
1. Misc/occlusio.list
1. Obj/specials.obj
1. Obj/Man_Shadow.obj
1. Sets/map01.set
1. Data/Defs.dat
1. [`Assign/jungtil.asn`](docs/formats/obj.md#assign)
1. [`Obj/jungtil.obj`](docs/formats/obj.md)
1. (more assign + obj pairs)
1. Data/Cycle.cyc
Adding a Librarian to the mission builder performs these seeks and reads:
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509440, [509440], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\fM\266\th\16\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509448, [509448], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "t[\266\t\376\16\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509456, [509456], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "rj\266\tg\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509464, [509464], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\331y\266\t\251\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509472, [509472], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\202\211\266\t\273\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509480, [509480], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "=\231\266\t\10\20\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509488, [509488], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "E\251\266\t\321\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509496, [509496], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\26\271\266\t\1\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509504, [509504], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\27\310\266\t\304\16\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509512, [509512], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\333\326\266\t\343\16\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509520, [509520], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\276\345\266\t\f\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509528, [509528], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\312\364\266\tA\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509536, [509536], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\v\4\267\t\246\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509440, [509440], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\fM\266\th\16\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164448540, [164448540], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\367\0\n\0015\0T\0\0\0\0\0P\16\0\0\324q;\1\0\0\0\0\200\23\207**+*+"..., 3688) = 3688
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509448, [509448], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "t[\266\t\376\16\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164452228, [164452228], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\365\0\10\0017\0W\0\0\0\0\0\346\16\0\0\324q;\1\0\0\0\0\200\25\3*\212+*,"..., 3838) = 3838
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509456, [509456], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "rj\266\tg\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164456066, [164456066], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\364\0\10\0019\0Z\0\0\0\0\0O\17\0\0\324q;\1\0\0\0\0\200\30\201*\5+\200\33"..., 3943) = 3943
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509464, [509464], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\331y\266\t\251\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164460009, [164460009], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\356\0\7\1B\0[\0\0\0\0\0\221\17\0\0\324q;\1\0\0\0\0\200\"\201*\200\37\0\200"..., 4009) = 4009
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509472, [509472], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\202\211\266\t\273\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164464018, [164464018], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\356\0\n\1C\0\\\0\0\0\0\0\243\17\0\0\324q;\1\0\0\0\0\200#\3)\3+\200\32"..., 4027) = 4027
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509480, [509480], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "=\231\266\t\10\20\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164468045, [164468045], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\354\0\t\1C\0Z\0\0\0\0\0\360\17\0\0\324q;\1\0\0\0\0\200$\201*\200\36\0\200"..., 4104) = 4104
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509488, [509488], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "E\251\266\t\321\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164472149, [164472149], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\356\0\t\1?\0V\0\0\0\0\0\271\17\0\0\324q;\1\0\0\0\0\200\35\212&&H)*"..., 4049) = 4049
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509496, [509496], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\26\271\266\t\1\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164476198, [164476198], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\366\0\10\0015\0[\0\0\0\0\0\351\16\0\0\324q;\1\0\0\0\0\200\20\201*\4+\206*"..., 3841) = 3841
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509504, [509504], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\27\310\266\t\304\16\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164480039, [164480039], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\367\0\7\0013\0[\0\0\0\0\0\254\16\0\0\324q;\1\0\0\0\0\200\33\210+,+,,"..., 3780) = 3780
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509512, [509512], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\333\326\266\t\343\16\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164483819, [164483819], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\370\0\7\1A\0_\0\0\0\0\0\313\16\0\0\324q;\1\0\0\0\0\200\34\203,,*\200\""..., 3811) = 3811
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509520, [509520], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\276\345\266\t\f\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164487630, [164487630], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\370\0\7\1H\0`\0\0\0\0\0\364\16\0\0\324q;\1\0\0\0\0\200\35\201,\200*\0\200"..., 3852) = 3852
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509528, [509528], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\312\364\266\tA\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164491482, [164491482], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\373\0\10\1;\0Z\0\0\0\0\0)\17\0\0\324q;\1\0\0\0\0\200\36\202+,\200\33\0"..., 3905) = 3905
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 509536, [509536], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\v\4\267\t\246\17\0\0", 8) = 8
_llseek(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, 164495387, [164495387], SEEK_SET) = 0
read(15</home/lupine/.wine/drive_c/GOG Games/ChaosGate/Anim/WarHammer.ani>, "\366\0\n\0018\0Y\0\0\0\0\0\216\17\0\0\324q;\1\0\0\0\0\200\32\t+\205*\4,"..., 4006) = 4006

402
doc/formats/ani.md Normal file
View File

@@ -0,0 +1,402 @@
# `Anim/WarHammer.ani`
This turns out to simply be an [`obj`](obj.md) file.
The first 1,064 sprites are all of the same Ultramarine, carryng a bolter. There
are eight "facing" orientations:
* North
* Northeast
* East
* Southeast
* South
* Southwest
* West
* Northwest
For each orientation, an action is pictured in a variable number of frames. The
final frame for each action appears to be "stationary".
* Walk (13 frames)
* Run (9 frames)
* Crouch down (8 frames)
* Stand up (8 frames)
* Take aim (standing) (6 frames)
* Fire (standing) (6 frames)
* Relax aim (standing) (6 frames)
* Throw grenade (standing) (18 frames)
* Take aim (crouched) (5 frames)
* Fire (crouched) (5 frames)
* Relax aim (crouched) (5 frames)
* Throw grenade (crouched) (17 frames)
* Draw melee weapon (standing) (10 frames)
* Strike down with melee weapon (standing) (8 frames)
* Stab with melee weapon (standing) (9 frames)
Added together and multiplied by 87, that's 1064.
The next sprite is a walking-north action for an ultramarine with a flamer. The
total number of frames for this character is 1120 - 56 additional frames, or 7
per orientation. Could be an extra action, or an extra frame per action.
Also notable is that while the bolter showed muzzle flash in the animation, the
flamer only showed a tiny hint of fire. I think the animation for spewing flame
is held elsewhere.
I strongly suspect the actions and the number of frames in each action are
configurable. So, what other files are implicated in its interpretation? Here's
a few possibilities:
* `Data/AniObDef.dat`
* `Data/Coordinates.dat`
* `Data/HasAction.dat`
* `Data/VehicDef.dat`
* `Data/WeapDef.dat`
- `Idx/WarHammer.idx`
## `Data/AniObDef.dat`
Including comments, this is 4098 lines, giving approx. 45 lines for each
of the ~188 characters in the `ani`. That doesn't seem many, and there's no
obvious correspondence between the commented-on names (`SMOKE01`?) and the
viewed frames... but then, I've not viewed all the frames.
## `Data/HasAction.dat`
This file seems relevant as it says whether or not particular animations exist
for the different types of character, which maps directly to what is stored in
the .ani file - and so must affect lookups thereof.
Fortunately, it's commented extensively. For each "Character Type", there are
36 different possible animations.
Here's a table representation of the data:
```
Tac Ass Dev Term Apo Tech Chp Lib Cpt CMar CLrd CChp CSrc CTrm Kbz BTh BL FHnd LoC Flm PHr BHr Cult
00 x x x x x x x x x x x x x x x x x x x x x x x
01 x x x x x x x x x x x x x x x x x x x x x x x
02 x x x x x x x x x x x x x x x x x x x x x x x
03
04
05
06 x x x x x x x x x x x x x x x x x x x x
07 x x x x x x x x x x x x x x x x x x x x x x x
08 x x x x x x x x x x x x x x x x
09
10
11
12
13
14 x x x x x x x x x x x x x
15 x x x x x x x x x x x x x
16 x x x x x x x x x x x x x
17 x x x x x x x x x x x x x
18 x x x x x x x x x x x x x
19 x x x x x x x x x x x x x
20 x x x x x x x x x x x x x
21 x x x x x x x x x x x x x
22 x x x x x x x x x x x x x x
23 x x x x x x x x x x x x x
24 x x x x x x x x x x x x x x x x
25 x x x x x x x x x x x x x x x x x x x x x x
26 x x x x x x x x x x x x x x x x x
27 x x x x x x x x x x x x x x x x x x x x x
28 x x x x x x x x x x x x x x
29 x x x
30 x
31 x
32 x x x
33 x x x x x x x x x x x x x x x x x x x x x
34 x x x x x x x x x x x x x x x x x x x x x x x
35 x x x x x x x
```
`WarHammer.ani` doesn't have blank sprites for the unchecked cells, so this must
surely be used to map between set-of-sprites and `AnimAction`. The names map
very well to the descriptions I came up with when observing the sprites.
I think we still need the data in `.idx` for a full picture, though. Things we
still need:
* Mapping of character type to sprite directory index in `WarHammer.ani`
* Number of frames in each AnimAction
Either of these could be hardcoded, or dynamic.
## `Idx/WarHammer.idx`
`WarHammer.idx` (1,880,078 bytes, binary, so around 10KiB per character, in
theory) is more reasonable.
Here's a list of operations on the file when `WH40K_TD.EXE` is instructed to
place a single Librarian:
<details>
```
_llseek(<WarHammer.idx>, 132, [132], SEEK_SET) = 0
read(<WarHammer.idx>, "\x30\x7c\x09\x00\x98\x00\x00\x00\x88\xf8\x00\x00", 12) = 12
_llseek(<WarHammer.idx>, 132, [132], SEEK_SET) = 0
read(<WarHammer.idx>, "\x30\x7c\x09\x00\x98\x00\x00\x00\x88\xf8\x00\x00", 12) = 12
_llseek(<WarHammer.idx>, 621616, [621616], SEEK_SET) = 0
read(<WarHammer.idx>, "\x02\x01\x01\x33\x50\x83\x09\x00\x0d\x00\x00\x00", 12) = 12
_llseek(<WarHammer.idx>, 621628, [621628], SEEK_SET) = 0
read(<WarHammer.idx>, "\x02\x01\x02\x33\xb2\x83\x09\x00\x0d\x00\x00\x00", 12) = 12
_llseek(<WarHammer.idx>, 621640, [621640], SEEK_SET) = 0
read(<WarHammer.idx>, "\x02\x01\x03\x33\x14\x84\x09\x00\x0d\x00\x00\x00", 12) = 12
_llseek(<WarHammer.idx>, 621652, [621652], SEEK_SET) = 0
read(<WarHammer.idx>, "\x02\x01\x04\x33\x76\x84\x09\x00\x0d\x00\x00\x00", 12) = 12
_llseek(<WarHammer.idx>, 621664, [621664], SEEK_SET) = 0
read(<WarHammer.idx>, "\x02\x01\x05\x33\xd8\x84\x09\x00\x0d\x00\x00\x00", 12) = 12
_llseek(<WarHammer.idx>, 623832, [623832], SEEK_SET) = 0
read(<WarHammer.idx>, "\x34\x00\x40\x00\x40\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", 20) = 20
_llseek(<WarHammer.idx>, 0, [623852], SEEK_CUR) = 0
_llseek(<WarHammer.idx>, 623852, [623852], SEEK_SET) = 0
_llseek(<WarHammer.idx>, 623930, [623930], SEEK_SET) = 0
_llseek(<WarHammer.ani>, 509440, [509440], SEEK_SET) = 0
read(<WarHammer.ani>, "\x0c\x4d\xb6\x09\x68\x0e\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509448, [509448], SEEK_SET) = 0
read(<WarHammer.ani>, "\x74\x5b\xb6\x09\xfe\x0e\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509456, [509456], SEEK_SET) = 0
read(<WarHammer.ani>, "\x72\x6a\xb6\x09\x67\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509464, [509464], SEEK_SET) = 0
read(<WarHammer.ani>, "\xd9\x79\xb6\x09\xa9\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509472, [509472], SEEK_SET) = 0
read(<WarHammer.ani>, "\x82\x89\xb6\x09\xbb\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509480, [509480], SEEK_SET) = 0
read(<WarHammer.ani>, "\x3d\x99\xb6\x09\x08\x10\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509488, [509488], SEEK_SET) = 0
read(<WarHammer.ani>, "\x45\xa9\xb6\x09\xd1\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509496, [509496], SEEK_SET) = 0
read(<WarHammer.ani>, "\x16\xb9\xb6\x09\x01\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509504, [509504], SEEK_SET) = 0
read(<WarHammer.ani>, "\x17\xc8\xb6\x09\xc4\x0e\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509512, [509512], SEEK_SET) = 0
read(<WarHammer.ani>, "\xdb\xd6\xb6\x09\xe3\x0e\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509520, [509520], SEEK_SET) = 0
read(<WarHammer.ani>, "\xbe\xe5\xb6\x09\x0c\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509528, [509528], SEEK_SET) = 0
read(<WarHammer.ani>, "\xca\xf4\xb6\x09\x41\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509536, [509536], SEEK_SET) = 0
read(<WarHammer.ani>, "\x0b\x04\xb7\x09\xa6\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 509440, [509440], SEEK_SET) = 0
read(<WarHammer.ani>, "\x0c\x4d\xb6\x09\x68\x0e\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164448540, [164448540], SEEK_SET) = 0
read(<WarHammer.ani>, "\xf7\x00\x0a\x01\x35\x00\x54\x00\x00\x00\x00\x00\x50\x0e\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x13\x87\x2a\x2a\x2b\x2a\x2b"..., 3688) = 3688
_llseek(<WarHammer.ani>, 509448, [509448], SEEK_SET) = 0
read(<WarHammer.ani>, "\x74\x5b\xb6\x09\xfe\x0e\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164452228, [164452228], SEEK_SET) = 0
read(<WarHammer.ani>, "\xf5\x00\x08\x01\x37\x00\x57\x00\x00\x00\x00\x00\xe6\x0e\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x15\x03\x2a\x8a\x2b\x2a\x2c"..., 3838) = 3838
_llseek(<WarHammer.ani>, 509456, [509456], SEEK_SET) = 0
read(<WarHammer.ani>, "\x72\x6a\xb6\x09\x67\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164456066, [164456066], SEEK_SET) = 0
read(<WarHammer.ani>, "\xf4\x00\x08\x01\x39\x00\x5a\x00\x00\x00\x00\x00\x4f\x0f\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x18\x81\x2a\x05\x2b\x80\x1b"..., 3943) = 3943
_llseek(<WarHammer.ani>, 509464, [509464], SEEK_SET) = 0
read(<WarHammer.ani>, "\xd9\x79\xb6\x09\xa9\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164460009, [164460009], SEEK_SET) = 0
read(<WarHammer.ani>, "\xee\x00\x07\x01\x42\x00\x5b\x00\x00\x00\x00\x00\x91\x0f\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x22\x81\x2a\x80\x1f\x00\x80"..., 4009) = 4009
_llseek(<WarHammer.ani>, 509472, [509472], SEEK_SET) = 0
read(<WarHammer.ani>, "\x82\x89\xb6\x09\xbb\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164464018, [164464018], SEEK_SET) = 0
read(<WarHammer.ani>, "\xee\x00\x0a\x01\x43\x00\x5c\x00\x00\x00\x00\x00\xa3\x0f\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x23\x03\x29\x03\x2b\x80\x1a"..., 4027) = 4027
_llseek(<WarHammer.ani>, 509480, [509480], SEEK_SET) = 0
read(<WarHammer.ani>, "\x3d\x99\xb6\x09\x08\x10\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164468045, [164468045], SEEK_SET) = 0
read(<WarHammer.ani>, "\xec\x00\x09\x01\x43\x00\x5a\x00\x00\x00\x00\x00\xf0\x0f\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x24\x81\x2a\x80\x1e\x00\x80"..., 4104) = 4104
_llseek(<WarHammer.ani>, 509488, [509488], SEEK_SET) = 0
read(<WarHammer.ani>, "\x45\xa9\xb6\x09\xd1\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164472149, [164472149], SEEK_SET) = 0
read(<WarHammer.ani>, "\xee\x00\x09\x01\x3f\x00\x56\x00\x00\x00\x00\x00\xb9\x0f\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x1d\x8a\x26\x26\x48\x29\x2a"..., 4049) = 4049
_llseek(<WarHammer.ani>, 509496, [509496], SEEK_SET) = 0
read(<WarHammer.ani>, "\x16\xb9\xb6\x09\x01\x0f\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164476198, [164476198], SEEK_SET) = 0
read(<WarHammer.ani>, "\xf6\x00\x08\x01\x35\x00\x5b\x00\x00\x00\x00\x00\xe9\x0e\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x10\x81\x2a\x04\x2b\x86\x2a"..., 3841) = 3841
_llseek(<WarHammer.ani>, 509504, [509504], SEEK_SET) = 0
read(<WarHammer.ani>, "\x17\xc8\xb6\x09\xc4\x0e\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164480039, [164480039], SEEK_SET) = 0
read(<WarHammer.ani>, "\xf7\x00\x07\x01\x33\x00\x5b\x00\x00\x00\x00\x00\xac\x0e\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x1b\x88\x2b\x2c\x2b\x2c\x2c"..., 3780) = 3780
_llseek(<WarHammer.ani>, 509512, [509512], SEEK_SET) = 0
read(<WarHammer.ani>, "\xdb\xd6\xb6\x09\xe3\x0e\x00\x00", 8) = 8
_llseek(<WarHammer.ani>, 164483819, [164483819], SEEK_SET) = 0
read(<WarHammer.ani>, "\xf8\x00\x07\x01\x41\x00\x5f\x00\x00\x00\x00\x00\xcb\x0e\x00\x00\xd4\x71\x3b\x01\x00\x00\x00\x00\x80\x1c\x83\x2c\x2c\x2a\x80\x22"..., 3811) = 3811
_llseek(<WarHammer.ani>, 509520, [509520], SEEK_SET) = 0
read(<WarHammer.ani>, "\xbe\xe5\xb6\x09\x0c\x0f\x00\x00", 8) = 8
```
</details>
Notable is that we read from `idx` **before** we read from `ani` - so it does
seem like the former should tell us where to pull from the latter.
So what are we doing here? What did we read? Here's what I get:
### Type 1 record
From `0x84`:
```
# xxd -s 0x84 -c 12 -e -l 12 -u orig/Idx/WarHammer.idx
00000084: 00097C30 00000098 0000F888 0|..........
```
The first read contains `0x097C30`. The second (+5) read, at `0x097C30`,
contains `0x0984D8`. We then read 20, followed by 78, bytes, and go on to read
from the `.ani` file.
The whole start of the file looks like a directory of the same kind of records
(call them type 1). The record at offset 0 is empty, as are the last few, but
the rest have always-increasing offsets in the first and third position. The
first appears to be for a "tactical marine", or at least, it is read (similarly
to the librarian) when placing a "tactical squad". That has an offset of
0x1800` in the first position, which gives us space for 512 of these 12-byte
records. We can say they look like:
Is there anything in here that can link us to what we're reading from the `.ani`
file? From it, we read 14 entries from the sprite directory, starting at
byte offset `0x07C600` and direntry offset 63676 (`0xF8BC`). We then load 10
sprites. The first is at byte offset `0x9CD491C`, and is 3688 bytes.
Looking at that sprite in the object viewer, it is the librarian \o/ - facing
south \o/. However, it's not the sprite we see in `WH40K_TD.exe`. That one is,
I think, number 63688 (`0xF8C8`) - 12 sprites on. Nothing matches these numbers.
However, the **first** librarian sprite is at index 63624 (`0xF888`), which
matches the value at offset 8. This, then, must be the link.
If the first sprite is 0, the displayed sprite is 64 (`0x40`)...
We still need to know how to go from "librarian" to "index 11", though. The
`CTYPE_LIBRARIAN` value in `HasAction.ani` gives librarians an 8...
| Offset | Size | Meaning |
| ------ | ---- | ------- |
| 0 | 4 | Offset of type 2 records |
| 4 | 4 | Number of type 2 records |
| 8 | 4 | First sprite in `WarHammer.ani` for this record |
### Type 2 record(s)
From `0x097C30`:
```
# xxd -s 0x00097C30 -g 1 -c 12 -l 60 -u orig/Idx/WarHammer.idx
00097c30: 02 01 01 33 50 83 09 00 0D 00 00 00 ...3P.......
00097c3c: 02 01 02 33 B2 83 09 00 0D 00 00 00 ...3........
00097c48: 02 01 03 33 14 84 09 00 0D 00 00 00 ...3........
00097c54: 02 01 04 33 76 84 09 00 0D 00 00 00 ...3v.......
00097c60: 02 01 05 33 D8 84 09 00 0D 00 00 00 ...3........
```
Next, we read 5x 12-byte records - 60 bytes total - from that offset in the type
1 record. The address of the next read is embedded in the fifth, which is where
the reads of type 2 records stop - so we were searching for it.
In the first 12-byte record, we have a close offset: `0x098350`. So we have
1,824 bytes available in this block of type 2 records - enough for 152 of them,
which is the number specified in the second position of the type 1 header.
What is the significance of the fifth 12-byte read? Why do we move onto type 3
records when we reach it? When we place the librarian, he is **facing** south,
and that facing is the fifth one in the listing (N, NE, E, SE, S). It's all I
can come up with.
Perhaps this is the fifth facing of the first action? Looking ahead in the file,
we can see that the third byte counts from 1 to 8 and falls again, so this is a
tempting idea.
If so, since we know the librarian has 23 actions, we'd expect room for 23 * 8
type 2 records in this block. That would need 2208 bytes, and we only have 1824
- enough for 19 animations, which is quite close.
Looking at the librarian in the `ani` file, we see they have 1055 sprites in
total, but I haven't counted the actions yet.
| Offset | Size | Meaning |
| ------ | ---- | ------- |
| 0 | 2? | ActionID? Static per each group of 8 type-2 records? |
| 2 | 1? | Counts up from `01` to `08` in each group of 8 type-2 records? |
| 3 | 1? | Is `0x33` for all but the last 4 groups of 8 type-2 records? |
| 4 | 4 | Position of type 3 record |
| 8 | 4? | ??? - small values though. Count of frames? |
### Type 3 record
From `0x0984D8`:
```
# xxd -s 0x984D8 -g 1 -c 12 -l 20 -u orig/Idx/WarHammer.idx
000984d8: 34 00 40 00 40 00 01 00 00 00 00 00 4.@.@.......
000984e4: 00 00 00 00 00 00 00 00
# xxd -s 0x984EC -g 1 -c 12 -l 78 -u orig/Idx/WarHammer.idx
000984ec: 00 00 06 00 04 00 00 00 06 00 04 00 ............
000984f8: 00 00 06 00 04 00 00 00 05 00 04 00 ............
00098504: 00 00 05 00 04 00 00 00 03 00 04 00 ............
00098510: 00 00 04 00 FC FF 00 00 05 00 FC FF ............
0009851c: 00 00 06 00 FC FF 00 00 05 00 FC FF ............
00098528: 00 00 06 00 FC FF 00 00 06 00 FC FF ............
00098534: 00 00 00 00 00 00
```
Here, in the first read, we see `34 00` and `40 00`. These are the **relative**
offsets of the frames we load.
| Offset | Size | Meaning |
| ------ | ---- | ------- |
| 0 | 2 | First sprite in animation (relative offset) |
| 2 | 2 | Last sprite in animation (relative offset)? |
| 4 | 2? | Could also be last sprite in animation? |
| 6 | 2? | ??? |
| 8 | 12? | ??? - unset in this case |
The remaining 78-byte chunk is impenetrable so far, but we should now have the
information we need to display all the animated sequences in `WarHammer.ani`!
How do we know it needs to be 78 bytes? One option is multiplying the final
field of the type 2 record by 6. Maybe we have 6 bytes of description per frame,
or maybe it's unrelated to frames?

View File

@@ -6,29 +6,27 @@ remake.
## Filesystem layout
* `Anim/`
* `WarHammer.ani` # Doesn't seem to be a RIFF file. 398M so very important.
* There's a pcx image header at `dd ... bs=1 skip=213` but it seems to be a false alert
* Hits for "AmigaOS bitmap font"... probably a false positive
* Lots of 8-byte reads when loading stuff in the mission editor
* Some ~4K reads, havent found one corresponding to a known format yet
* [✓] [`Anim/`](obj.md#WarHammer.ani)
* [`WarHammer.ani`](obj.md#WarHammer.ani)
* [`Assign/`](obj.md#assign)
* `*.asn` # Unknown, seems to be related to .obj files
* `*.asn` # Specify properties for frames in .obj files
* `Cursor/`
* `*.ani` # RIFF data
* `*.cur` # Presumably standard windows-format non-animated cursors
* `*.ani` # RIFF data, standard ANI format \o/
* [`Cursors.cur`](obj.md) # `obj` file containing pointers and drag elements
* `Data/`
* `*.dat` # plaintext files defining properties of objects. No single format
* **PARSED**
* `Accounting.dat` # key = value => internal/data/accounting.go
* `AniObjDef.dat` # animated object definitions
* `GenericData.dat` # Generic Game Settings
* [`HasAction.dat`](ani.md) # "Are there animation for each of the character" - list of booleans
* **TODO**
* `ChaNames.dat` # list of character names
* `Coordinates.dat` # Weapon Firing Coordinates
* `Credits.dat` # list of credits
* `Defs.dat` # defines properties for objects and tiles, each of which seems to have an id
* `GDestroy.dat` # table of what destroys what?
* `HasAction.dat` # "Are there animation for each of the character" - list of booleans
* `MiniMap.dat` # lots of seemingly random numbers. IDs?
* `MissionBriefing.dat` # Contains all Campaign Mission Briefing Text. Sections: "CAMPAIGN MISSION X ... END CAMPAIGN MISSION X"
* `PWeight.dat` # Personality weights for the individual character types
@@ -42,7 +40,6 @@ remake.
* `WeapDef.dat` # Weapon definitions
* **PROBABLY NOT NEEDED**
* `BugHunt.dat` # Contains SMF text for Bug hunt random missions
* `Credits.dat` # list of credits
* `GenArm.dat` # "Random campaign armory levels - space marine"
* `HeroArm.dat` # "Random campaign armory levels - Veteran"
* `MHeroArm.dat` # "Random campaign armory levels - Veteran"
@@ -52,35 +49,34 @@ remake.
* `SpArm.dat` # "RANDOM CAMPAIGN ARMORY LEVELS - Veteran"
* `VetArm.dat` # "RANDOM CAMPAIGN ARMORY LEVELS - Veteran"
* `*.chk` # checksums? Mentions all the .dat files
* `*.cyc` # ColorCycle DataFile.
* `*.dta` # localized strings and things
* `Cycle.cyc` # ColorCycle DataFile.
* `Encyclopedia.dta` # encyclopedia entries
* `KeyMap.dta` # unknown
* `Keymap.dta` # unknown
* `keymap.dta` # unknown
* `USEng.dta` # Localized strings
* `EquipmentMenuData` # gzip-compressed, presumably to do with (initial?) squad configuration
* `Filters/`
* `wh40k.flt` # Audio filter(s?)
* [✓] [`Fonts/`](fonts.md)
* `cboxfont` # ???
* `*.fnt`
* `*.spr`
* `Idx/`
* `WarHammer.idx` # unknown, 1.8M
* [`*.fnt`](fonts.md)
* [`*.spr`](obj.md) # `obj` file
* [ ] [`Idx/`](ani.md)
* [`WarHammer.idx`](ani.md) # unknown, 1.8M
* [`Maps/`](maps.md)
* `*.MAP`
* `*.TXT`
* [`*.MAP`](maps.md)
* [`*.TXT`](maps.md)
* [`Menu/`](mnu.md) - UI element definitions
* `*.mni`
* `*.mnu`
* [`*.mni`](mnu.md) # Menu include file
* [`*.mnu`](mnu.md)
* [`*.obj`](obj.md)
* `Misc/`
* `occlusio.lis` # plain text, presumably occlusion mappings?
* [`MultiMaps/`](maps.md#multimaps)
* `*.MAP`
* `*.TXT`
* [✓] [`Obj/`](obj.md)
* `*.obj`
* [`*.MAP`](maps.md)
* [`*.TXT`](maps.md)
* [`Obj/`](obj.md)
* [ ] `cpiece.rec` # "Rects for various cursor piece types..."
* [✓] [`*.obj`](obj.md)
* [✓] `Pic/`
* `*.pcx` # Standard .pcx format
* `RandomMaps/`
@@ -91,12 +87,12 @@ remake.
* `*.txt` # Seems to be a copy of one of Maps/*.txt
* [✓] [`Sets/`](sets.md)
* `Data.chk`
* `*.set`
* [`*.set`](sets.md)
* [✓] `SMK/`
* `*.smk` # Videos: RAD Game Tools Smacker Multimedia version 2
* [ ] `Sounds/`
* [ ] [`Sounds/`](sound.md)
* [`wh40k.ds`](sound.md)
* [ ] `Wav/`
* [ ] [`Wav/`](sound.md)
* [`*.wav`](sound.md)
Phew.
@@ -107,7 +103,7 @@ Phew.
* *Almost everything* seems to be in a data file somewhere. Helpful!
* `make loader` creates a `load` binary that will try to load various bits
of data from `investigation/` and `orig/`. I use it to investigate file
formats and the parsers I'm writing. Ensure you're in a `GOPATH`!
formats and the parsers I'm writing.
## Cross-links / associations

View File

@@ -392,8 +392,11 @@ well-aligned amount.
Investigation has so far suggested the following:
* `Cell[0]` seems related to doors and canisters. Observed:
* Nothing special: 0x38
* ???: 0x39
* Imperial crate: 0x28
* Door: 0xB8
* `Cell[1]` seems related to special placeables (but not triggers). Bitfield. Observed:
* 0x01: Reactor
* 0x20: Door or door lock?
@@ -408,12 +411,12 @@ Investigation has so far suggested the following:
* `Cell[7]` Object 2 (Right) Area (Sets/*.set lookup)
* `Cell[6]` Object 2 (Right) Sprite + active flag
* `Cell[9]` Object 3 (Center) Area (Sets/*.set lookup)
* `Cell[10]` Object 3 (Right) Sprite + active flag
* `Cell[11]` all 255?
* `Cell[10]` Object 3 (Center) Sprite + active flag
* `Cell[11]` all 255? Vehicle?
* `Cell[12]` all 0?
* `Cell[13]` all 0?
* `Cell[14]` all 0?
* `Cell[15]` shows squad positions, MP start positions, etc, as 0x04
* `Cell[15]` shows squad positions, MP start positions, etc, as 0x04. Bitfield?
Mapping the altar in Chapter01 to the map01 set suggests it's a palette entry
lookup, 0-indexed. `U` debug in WH40K_TD.exe says the cell's `Object 3-Center`
@@ -515,5 +518,210 @@ Around 001841A0: mission objectives!
00184240 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
```
Since all the files are exactly the same length uncompressed, I'm going to
assume these are all a fixed number of fixed-size records when looking into it.
Relative offsets from the start of the trailer, we have:
| Offset | Text |
| -------- | ---- |
| `0xEE` | Mania |
| `0x78A` | Dagon |
| `0xE26` | Nihasa |
| `0x14C2` | Samnu |
| `0x1b5e` | Bael |
| `0x2896` | Gigamen |
| `0x2f32` | Valefor |
| `0x35ce` | Baalberith |
| `0x3c6a` | Fenriz |
| `0x4306` | #Character |
| `0x49a2` | Apollyon |
So there are 1692 bytes between each name (the names probably don't come at the
start of each block, but it's still a useful stride). Presumably `#Character` is
a space for one of the player characters, while the others specify an NPC placed
on the map.
There's 56 of these records between the first and last name we see - `Ahpuch`.
Then there are a number of other strings that seem related to triggers / events,
including lots that say `NO FILE`. The first two are 96 bytes apart; from then
on they seem to be placed variably apart from each other; I've seen 96, 256, and
352 byte offsets.
At 0x20916 the mission objective is readable.
At 0x2092a the mission description is readable.
Generating another map with just 5 characters on it, things look different:
* Trailer size is 13543 bytes
* There are only 5 names
* There are none of the trigger/event strings
* Mission title is found at 0x2b93
* Mission briefing is found at 0x2c92
Since the trailer is a variable size, there must be a header that tells us how
many of each type of record to read. Peeking at the differences in `vbindiff`:
```
Chapter01.MAP.Trailer
0000 0000: 38 00 00 68 00 00 00 50 00 00 00 1A 00 00 00 14 8..h...P ........
0000 0010: 00 00 00 3A 00 00 00 00 38 25 00 04 00 00 00 00 ...:.... 8%......
0000 0020: 00 00 00 1A 00 00 00 00 00 00 00 00 00 00 00 00 ........ ........
0000 0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ........ ........
0000 0040: 00 00 00 00 00 00 00 00 00 00 00 02 00 00 00 00 ........ ........
0000 0050: 00 00 00 32 00 00 00 00 00 00 00 00 00 00 00 00 ...2.... ........
TINYSQUAD.MAP.Trailer
0000 0000: 38 00 00 4B 00 00 00 3C 00 00 00 37 00 00 00 28 8..K...< ...7...(
0000 0010: 00 00 00 05 00 00 00 00 2B 3A 00 04 00 00 00 05 ........ +:......
0000 0020: 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 ........ ........
0000 0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ........ ........
0000 0040: 00 00 00 00 00 00 00 00 00 00 00 02 00 00 00 00 ........ ........
0000 0050: 00 00 00 32 00 00 00 00 00 00 00 00 00 00 00 00 ...2.... ........
```
The size of the trailer for Chapter01 is 139,483 bytes, assuming it starts at
`0x163890`. However, things may be a lot more sensible if we drop 3 bytes off
the start of that to get the fields into little-endian alignment. Have I made a
maths error above somewhere? Is it some sort of alignment thing? Do those 3
bytes actually have meaning?
Ignoring them for now, here's a first guess at a header:
| Offset | Size | Meaning |
| ------ | ---- | ------- |
| 0 | 4 | Map maximum X + 1 |
| 4 | 4 | Map maximum Y + 1 |
| 8 | 4 | Map minimum X |
| 12 | 4 | Map minimum Y |
| 16 | 4 | Number of character records |
| 20 | 4 | Padding? - invariant `00 00 00 00` |
| 24 | 2 | ??? - varies. Seems related to character/squad position? |
| 26 | 2 | ??? - invariant `00 04` |
| 28 | 4 | ??? - varies (0 vs 5) |
| 32 | 4 | Number of thingies (26 vs 1) |
| 36 | 20 | Padding? |
56 bytes of data is interesting because the value of that first, ignored byte is
0x38 - perhaps it's a skip value + 2 bytes of padding? It's just weird. Keep
ignoring it for now.
0x4b contains the next non-null byte; is the gap between the the number of
thingies, and it, padding? Minus a bit? 0x50 is another non-null byte. Then
it's all zeroes until one byte before the first name at 0xee.
It's hard to say where the alignment should be at this point. We need to compare
more characters with each other. Other notes...
Characters are organised into Squads somehow.
Individual cells seem to have a flag to say "We have a character in us", but not
the number for the character themselves, so the coordinates must be in the
per-character records also. There are several candidates for this.
Placing a single character at (64,49) causes those bytes to show up at four
offsets - 0x18 (!), 0x1F4, 0x1F8, and 0x6C8.
Generating a map with no characters at all, the trailer is 2,447 bytes, and the
mission title starts at 0x3B (59). So we can say we have 20 bytes of padding as
a first approximation?
The "trailer trailer", for want of a better term, seems to be organised as:
| Offset | Size | Meaning |
| ----- | ---- | ------- |
| 0 | 255 | Title |
| 255 | 2048 | Briefing |
| 2304 | 85 | ??? - each byte is 1 or 0. Spaced so it may be partly uint32 |
## Soldiers At War
All the above applies to Chaos Gate maps. Maps for Soldiers At War seem to have
a lot of similarities, but also some differences. For a start, the maps are a
variable size!
Starting with the header, given a tiny 26x20 generated map, the first 256 bytes
look like this:
```
00000000: 1500414d 425f4d41 50005041 52495300 ..AMB_MAP.PARIS.
00000010: 00000000 00000000 00000000 00000000 ................
00000020: 00000000 00000000 00000000 00000000 ................
00000030: 00000000 00000000 00000000 00000000 ................
00000040: 00000000 00000000 00000000 00000000 ................
00000050: 00000000 00000000 00000000 00000000 ................
00000060: 00000000 00000000 00000000 00000000 ................
00000070: 00000000 00000000 00000000 00000000 ................
00000080: 00000000 00000000 00001e00 45000100 ............E...
00000090: 1f004600 10010000 52000000 00001b00 ..F.....R.......
000000a0: 38000100 00000500 0a000001 00f0f9ff 8...............
000000b0: ffb60500 00000100 ff370a00 64006400 .........7..d.d.
000000c0: 08008501 00000000 00ff0000 1f008082 ................
000000d0: 01000000 0000ff00 001f0080 84010000 ................
000000e0: 000000ff 00001f00 00810100 00000000 ................
000000f0: ff00001f 00808301 00000000 00ff0000 ................
```
Almost everything we knew is out of the window, but a few things look familiar.
First, the header seems simplified down to just two recognisable-at-first-glance
fields: Magic bytes (now `\x15\x00AMV_MAP\x00`) and the set name, coming
immediately after.
Like Chaos Gate, all map files are the same size once uncompressed, but they are
smaller - at 1,214,559 bytes, they are 76% the size. This is quite significant.
We now have 13.3 bytes per voxel, rather than the 17.5 bytes per voxel that was
available to Chaos Gate. This means that the number of bytes *per cell* must be
reduced, in addition to the header (and trailer?) values.
Looking at data from 0x110, it seems to group naturally into 13-byte records:
```
$ xxd -s 0x110 -c 13 -l 65 -g 1 TINYMAP.MAP
00000110: 80 01 00 00 00 00 00 ff 00 00 1f 00 00 .............
0000011d: 85 01 00 00 00 00 00 ff 00 00 1f 00 00 .............
0000012a: 82 01 00 00 00 00 00 ff 00 00 1f 00 80 .............
00000137: 82 01 00 00 00 00 00 ff 00 00 1f 00 00 .............
00000144: 82 01 00 00 00 00 00 ff 00 00 1f 00 80 .............
```
It's a strange number. Chaos Gate cells group nicely on 16 bytes:
```
$ xxd -s 0x110 -c 16 -l 64 -g 1 Chapter01.MAP
00000110: 3f 00 00 00 83 01 00 00 00 00 00 ff 00 00 00 00 ?...............
00000120: 38 00 00 00 85 01 00 00 00 00 00 ff 00 00 00 00 8...............
00000130: 38 00 00 00 84 01 00 00 00 00 00 ff 00 00 00 00 8...............
00000140: 38 00 00 00 8a 01 00 00 00 00 00 ff 00 00 00 00 8...............
00000150: 38 00 00 00 83 01 00 00 00 00 00 ff 00 00 00 00 8...............
```
That grouping is very enticing, though. I feel strongly that it's the right
number.
Now we need to ask about start offset. Where is byte 0 of the per-cell data, and
do the 13 bytes it has line up neatly to the functions of some of the 16 bytes
seen in Chaos Gate?
I generated a `BIGGESTMAP` (130x100) to investigate. It's just grass, nothing
but grass, and 0xC0 is the first offset where it starts to look nicely grouped:
```
xxd -s 0xc0 -c 13 -l 260 -g 13 BIGGESTMAP.MAP
000000c0: 08 80 81 01 00 00 00 00 00 ff 00 00 1f .............
000000cd: 00 80 81 01 00 00 00 00 00 ff 00 00 1f .............
000000da: 00 00 81 01 00 00 00 00 00 ff 00 00 1f .............
000000e7: 00 00 85 01 00 00 00 00 00 ff 00 00 1f .............
# ...
```
This can be interpreted more or less the same way as the Chaos Gate maps now,
and the `soldiers-at-war` branch contains a hacked-up implementation that kind
of works \o/.
Does the same trailer apply? Seemingly not. Looking at `PARIS.MAP`, there's no
similarity at first glance.
However, I did manage to track down 4 32-bit ints inside the trailer, starting
at `0x121ad1`, which specify dimensions of the map, at least. Perhaps the
position has moved, but some of the data is the same? It's 3320 bytes into the
trailer.

View File

@@ -134,7 +134,27 @@ $GenLoad.mni
It looks like we just interpolate the named file into the text when we come
across one of these lines.
## (Sub)menu types
The `MENUID` in `GenDialog` and `GenLoad` is a 2-element list, like `1000,1`
or `2000,2`. The second number corresponds to the offset in the list of object
files.
## `MENUTYPE`
Here's the full list of values for `MENUTYPE`:
| Value | Meaning |
| ----- | ------------ |
| 0 | `Background` |
| 1 | `Menu` |
| 2 | `DragMenu` |
| 3 | `RadioMenu` ??? - only seen in `LevelPly` and `LoadGame` around select-one items |
| 45 | `MainBackground` ??? - only seen in `MainGame` and `MainGameChaos` |
| 300 | `Dialogue` |
The `MENUTYPE` acts as a logical grouping of a set of objects onscreen, and
gives strong hints about how to handle their children.
## `SUBMENUTYPE`
The types seem to refer to different types of UI widget. Here's a list of unique
values:
@@ -142,47 +162,49 @@ values:
| Value | Meaning |
|-------|---------|
| 0 | Background |
| 1 | Logical menu grouping? |
| 2 | ? |
| 3 | Standard button? |
| 30 | Equipment? |
| 31 | "Character helmet" / "Slot" |
| 40 | "X Line Y" |
| 41 | "X Line Y" |
| 45 | ? |
| 45,10,11,9 | ? |
| 45,11,12,10 | ? |
| 45,14,15,13 | ? |
| 45,17,18,16 | ? |
| 45,3,4,2 | ? |
| 45,5,6,4 | ? |
| 45,6,7,5 | ? |
| 45,7,8,6 | ? |
| 45,8,9,7 | ? |
| 45,9,10,8 | ? |
| 50 | ? |
| 60 | Other text to display? (`UltEquip.mnu`) |
| 61 | Text to display |
| 70 | Hypertext to display |
| 91 | ? |
| 100 | ? |
| 110 | ? |
| 120 | ? |
| 200 | Drop-down button? |
| 205 | Single list box item? |
| 220 | Psyker power? |
| 221 | Page? |
| 228 | Big buttons in `Main.mnu` |
| 232 | ? |
| 233 | ? |
| 300 | Pop-up dialog box |
| 400,0,0,{8, 16} | ? |
| 400,22,22,{2, 4, 5, 6, 7, 8, 9, 9, 10, 13, 16} | ? |
| 400,30,-1,5 | ? |
| 405,0,0,{8, 16} | ? |
| 405,22,22,{2, 4, 5, 6, 7, 8, 9, 10, 13, 16} | ? |
| 405,30,-1,5 | ? |
| 3 | `Button` |
| 30 | `DoorHotspot1` |
| 31 | `DoorHotspot2` |
| 40 | `LineKbd` |
| 41 | `LineBriefing` |
| 45 | `Thumb` |
| 50 | `InvokeButton` |
| 60 | `DoorHotspot3` |
| 61 | `Overlay` |
| 70 | `Hypertext` |
| 91 | `Checkbox` |
| 100 | `EditBox` |
| 110 | `InventorySelect` |
| 120 | `RadioButton` |
| 200 | `DropdownButton` |
| 205 | `ComboBoxItem` |
| 220 | `AnimationSample` |
| 221 | `AnimationHover` |
| 228 | `MainButton` |
| 232 | `Slider` |
| 233 | `StatusBar` |
| 400 | `ListBoxUp` |
| 405 | `ListBoxDown` |
`400`, `405`, and `45`, can all accept 4 values for `SUBMENUTYPE` in a
comma-separated list. These records combine to form a `TListBox` control, with a
number of visible slots that act as a viewport. There is a draggable vertical
slider (the "thumb") to show where in the full list the viewport is, and up +
down buttons to move the position of the thumb by one, so it's feasible that
these values tell us about the available steps.
Here are the values in `Briefing.mnu`:
```
#rem..........List Box Menu
MENUTYPE : 1 # List Box Menu
SUBMENUTYPE: 400,22,22,13 # Scroll Up
SUBMENUTYPE: 405,22,22,13 # Scroll Down
SUBMENUTYPE: 45, 14,15,13 # Thumb
```
There are 13 elements in this listbox, which sorts out the fourth number (but
what is it used for?). The other two need more investigation.
## Positioning
@@ -195,6 +217,9 @@ successfully, for instance:
![](img/Options.mnu.png)
However, it's *not* sufficient to put all the items for `MainGame.mnu` in the
right place.
## Animation
This seems to be done by choosing a different sprite to draw every N ticks. They
@@ -238,28 +263,6 @@ attributes plucked from `Main.mnu`:
The buttons, menu title and version hotspot are submenus of the start menu.
### `MENUTYPE`
This is the only menu where we see a type of 228. ~750 other unique values are
observed, suggesting structure. For instance, we have `24`, `240`, `241` and
`2410`, but not `2411` or `2409`. Sometimes we have a comma-separated list,
e.g.: `400,30,-1,5`.
A listing of currently-known values:
| Value | Type |
| ----- | ---------------- |
| 0 | Static image |
| 1 | Menu |
| 3 | Button |
| 50 | Invoke? Button? |
| 61 | "Overlay" |
| 70 | "Hypertext" |
| 91 | Checkbox |
| 220 | Animation sample |
| 228 | Main menu button |
| 232 | Slider |
### `ACTIVE`
There are only 4 values seen across all menus: `0`, `1`, `1,0`, `102` and `1,1`.

View File

@@ -6,80 +6,6 @@ be placed on it - a `SURFACE`, `LEFT`, `RIGHT`, and `CENTER`. Maps reference
objects in a space-efficient way via sets, which seem to be a kind of object
palette.
## Assign
The `Assign/` directory contains a matching `.asn` file for each `Obj/*.obj`.
It's a plain-text format which seems to assign properties to frames, and has
references to a `<name>.flc` file which does not exist in the tree.
Theory: .obj files were originally generated from `.flc` files. This is an
AutoDesk format for visual data, so this suggests the .obj files contain pixels
\o/
`blank.asn` references 6 frames (0-5):
```
# single pixel tile
# transpix.obj/.asn
#/--> transpix.flc
#
0-5:DEF 1;
0-5:TYPE 13;
END OF FILE
```
`jungtil.asn` references 18 frames (0-17):
```
# jungle floor
# jungtil.obj/.asn
# /--> d:\warflics\missions\jungtil.flc
#
0:DEF 2;
1-11:DEF 454;
#damaged frames!!!!
12:DEF 2;
13-16:DEF 454;
17:DEF 454;
0:TYPE 2;
1-11:TYPE 0;
12:TYPE 2;
13-16:TYPE 0;
17:TYPE 0;
0:DESTROY 12;
1-3:DESTROY 13;
4-6:DESTROY 14;
7-9:DESTROY 15;
10-11:DESTROY 16;
17:DESTROY 15;
1-11:Dmg1Lnk 17;
END OF FILE
```
So it seems this visual data can have quite complicated attributes. At a minimum
we see:
* `TYPE`
* `DEF`
* `DESTROY`
* `Dmg1Lnk`
The `type` field **may** tell us what format each sprite is in.
## OBJ container structure
`.obj` files represent visual data. They contain a number of sprites, which are
@@ -206,8 +132,8 @@ in the CENTER position. Interesting.
| 0x0004 | x,y size (16 bits each) |
| 0x0008 | ? (blank in all cases so far)
| 0x000c | Size of remaining pixeldata |
| 0x0010 | Padding? |
| 0x0014 | Padding? |
| 0x0010 | Set in `WarHammer.ani` |
| 0x0014 | ? (blank in all cases so far) |
The volume represented by a cell is a little odd. We see three faces of a fake
3D volume of size 64x64x32(ish). This is presented in an isomorphic fashion, so
@@ -512,3 +438,144 @@ break *0x41DD10
This lets me focus very narrowly on what happens when loading sprites, and
might give clues.
## Assign
The `Assign/` directory contains a matching `.asn` file for each `Obj/*.obj`.
It's a plain-text format which seems to assign properties to frames, and has
references to a `<name>.flc` file which does not exist in the tree.
Theory: .obj files were originally generated from `.flc` files. This is an
AutoDesk format for visual data, so this suggests the .obj files contain pixels
\o/
`blank.asn` references 6 frames (0-5):
```
# single pixel tile
# transpix.obj/.asn
#/--> transpix.flc
#
0-5:DEF 1;
0-5:TYPE 13;
END OF FILE
```
`jungtil.asn` references 18 frames (0-17):
```
# jungle floor
# jungtil.obj/.asn
# /--> d:\warflics\missions\jungtil.flc
#
0:DEF 2;
1-11:DEF 454;
#damaged frames!!!!
12:DEF 2;
13-16:DEF 454;
17:DEF 454;
0:TYPE 2;
1-11:TYPE 0;
12:TYPE 2;
13-16:TYPE 0;
17:TYPE 0;
0:DESTROY 12;
1-3:DESTROY 13;
4-6:DESTROY 14;
7-9:DESTROY 15;
10-11:DESTROY 16;
17:DESTROY 15;
1-11:Dmg1Lnk 17;
END OF FILE
```
So it seems this visual data can have quite complicated attributes. We see:
* `DEF`
* `DESTROY`
* `Dmg1Lnk`
* `MacroLnk`
* `TYPE`
Each command takes the form:
```
<sprite-spec>:<command> <args>;
```
The sprite-spec can either be a single number, or a range of n-m.
### `DESTROY`
### `DEF`
I wonder if this `DEF`ines sprites an object, represented by an integer. In
`Data/Defs.dat`, we see sections that `EDIT <integer>`. Perhaps the two point to
the same thing?
`Assign/cabbage.asn` has `DEF 17` and `DEF 20`, covering two ranges of sprites.
`Data/Defs.dat` has `EDIT 17 # TREE LEAVES` and `EDIT 20 # NON-CENTERED CENTER
BUSHES`.
`Assign/TZEENTCH.asn` has `DEF 24` and `DEF 25`. `Data/Defs.dat` has `EDIT 24 #
SWITCHES & HOSES AS CENTER OBJS...RIGHT` and `EDIT 25 # SWITCHES & HOSES AS
CENTER OBJS...LEFT`.
`Assign/altar.asn` has `DEF 232`, and `Data/Defs.data` has `EDIT 232 # MRS
BUILDING PIECES - POOL TABLE`.
These all seem really close.
Then we have `EDIT 19 #big rocks` and these instances of `DEF 19;`:
```
Assign/grayroks.asn:5:0-7:DEF 19;
Assign/grayroks.asn:9:8-15:DEF 19;
Assign/sn_roks.asn:6:0-7:DEF 19;
Assign/sn_roks.asn:10:8-15:DEF 19;
```
The sprites for `grayroks` depicts some grey rocks, while `sn_roks` depicts
the same, but with snow on them. So this seems correct to me.
Does each object ID represent a unique thing? Or is it an index for a set of
characteristics that may be shared across multiple things? A set could quite
easily specify both `grayroks` and `sn_roks` for a map...
### `Dmg1Lnk`
This command is fairly easy. It takes a sprite-spec and says "if damage has been
sustained and you would normally display these sprites, display this sprite
instead". This is particularly obvious for the `cabbage` pair.
### `MacroLnk`
### `TYPE`
## WarHammer.ani
This 400MiB file appears to be a standard object file, it's just very large.
The directory contains 188,286 sprites!
The field at 0x10 in each sprite header is set in `WarHammer.ani`, but not in
the other object files encountered so far. However, it seems to be
set statically to the bytes `[212 113 59 1]` for all of them.
Assuming ~1000 sprites per character, `WarHammer.ani` contains 188 characters.
Two other files have been implicated in animation - `Data/AniObDefs.dat` and
`Idx/WarHammer.idx`. More on those in [ani.md](ani.md)

11
go.mod
View File

@@ -1,16 +1,19 @@
module code.ur.gs/lupine/ordoor
go 1.12
go 1.14
require (
github.com/BurntSushi/toml v0.3.1
github.com/hajimehoshi/ebiten v1.11.0-alpha.2.0.20200101150127-38815ba801a5
github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065
github.com/hajimehoshi/ebiten v1.11.1
github.com/jfreymuth/oggvorbis v1.0.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/samuel/go-pcx v0.0.0-20180426214139-d9c017170db4
github.com/stretchr/testify v1.5.1
golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5 // indirect
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 // indirect
golang.org/x/image v0.0.0-20200119044424-58c23975cae1
golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007 // indirect
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d // indirect
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
)

22
go.sum
View File

@@ -3,6 +3,10 @@ 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065 h1:7QVNyw2v9R1qOvbe9vfeVJWWKCSnd2Ap+8l8/CtG9LM=
github.com/emef/bitfield v0.0.0-20170503144143-7d3f8f823065/go.mod h1:uN4GbWHfit2ByfOKQ4K6fuLy1/Os2eLynsIrDvjiDgM=
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=
@@ -13,6 +17,10 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw
github.com/hajimehoshi/bitmapfont v1.2.0/go.mod h1:h9QrPk6Ktb2neObTlAbma6Ini1xgMjbJ3w7ysmD7IOU=
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/ebiten v1.11.0 h1:+pIxfzfVgRbHGM7wBAJtgzPiWiZopA7lyIKNQqc9amk=
github.com/hajimehoshi/ebiten v1.11.0/go.mod h1:aDEhx0K9gSpXw3Cxf2hCXDxPSoF8vgjNqKxrZa/B4Dg=
github.com/hajimehoshi/ebiten v1.11.1 h1:7gy2bHBDNtfTh3GlcUAilk3lNWW9fTLaP7iZAodS9F8=
github.com/hajimehoshi/ebiten v1.11.1/go.mod h1:aDEhx0K9gSpXw3Cxf2hCXDxPSoF8vgjNqKxrZa/B4Dg=
github.com/hajimehoshi/go-mp3 v0.2.1 h1:DH4ns3cPv39n3cs8MPcAlWqPeAwLCK8iNgqvg0QBWI8=
github.com/hajimehoshi/go-mp3 v0.2.1/go.mod h1:Rr+2P46iH6PwTPVgSsEwBkon0CK5DxCAeX/Rp65DCTE=
github.com/hajimehoshi/oto v0.3.4/go.mod h1:PgjqsBJff0efqL2nlMJidJgVJywLn6M4y8PI4TfeWfA=
@@ -35,6 +43,13 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/samuel/go-pcx v0.0.0-20180426214139-d9c017170db4 h1:Y/KOCu+ZLB730PudefxfsKVjtI0m0RhvFk9a0l4O1+c=
github.com/samuel/go-pcx v0.0.0-20180426214139-d9c017170db4/go.mod h1:qxuIawynlRhuaHowuXvd1xjyFWx87Ro4gkZlKRXtHnQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -55,6 +70,7 @@ golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCc
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20191025110607-73ccc5ba0426 h1:8RjY2wWN6kjy6JvJjDPT51tx4ht4+ldy/a5Yw0AyEr4=
golang.org/x/mobile v0.0.0-20191025110607-73ccc5ba0426/go.mod h1:p895TfNkDgPEmEQrNiOtIl3j98d/tGU95djDj7NfyjQ=
golang.org/x/mobile v0.0.0-20200222142934-3c8601c510d0/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007 h1:JxsyO7zPDWn1rBZW8FV5RFwCKqYeXnyaS/VQPLpXu6I=
golang.org/x/mobile v0.0.0-20200329125638-4c31acba0007/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
@@ -74,16 +90,22 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190909214602-067311248421/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191026034945-b2104f82a97d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117220505-0cba7a3a9ee9/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -0,0 +1,78 @@
package assetstore
import (
"code.ur.gs/lupine/ordoor/internal/idx"
)
type Animation struct {
Frames []*Sprite
}
func (a *AssetStore) AnimationsIndex() (*idx.Idx, error) {
if a.idx != nil {
return a.idx, nil
}
filename, err := a.lookup("WarHammer", "idx", "Idx")
if err != nil {
return nil, err
}
idx, err := idx.Load(filename)
if err != nil {
return nil, err
}
a.idx = idx
return idx, nil
}
func (a *AssetStore) AnimationsObject() (*Object, error) {
if a.aniObj != nil {
return a.aniObj, nil
}
filename, err := a.lookup("WarHammer", "ani", "Anim")
if err != nil {
return nil, err
}
obj, err := a.ObjectByPath(filename)
if err != nil {
return nil, err
}
a.aniObj = obj
return obj, nil
}
func (a *AssetStore) Animation(groupIdx, recIdx int) (*Animation, error) {
idx, err := a.AnimationsIndex()
if err != nil {
return nil, err
}
obj, err := a.AnimationsObject()
if err != nil {
return nil, err
}
group := idx.Groups[groupIdx]
if group.Spec.Count == 0 {
return &Animation{}, nil
}
// rec := group.Records[recIdx]
det := group.Details[recIdx]
first := int(group.Spec.SpriteIdx) + int(det.FirstSprite)
last := int(group.Spec.SpriteIdx) + int(det.LastSprite)
count := last - first + 1
sprites, err := obj.Sprites(first, count)
if err != nil {
return nil, err
}
return &Animation{Frames: sprites}, nil
}

View File

@@ -2,23 +2,25 @@ package assetstore
import (
"fmt"
"image/color"
"io/ioutil"
"os"
"path/filepath"
"strings"
"code.ur.gs/lupine/ordoor/internal/data"
)
"github.com/hajimehoshi/ebiten"
const (
RootDir = "" // Used in the entryMap for entries pertaining to the root dir
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/idx"
"code.ur.gs/lupine/ordoor/internal/palettes"
)
type entryMap map[string]map[string]string
// type AssetStore is responsible for lazily loading game data when it is
// required. Applications shouldn't need to do anything except set one of these
// up, pointing at the game dir root, to access all assets.
// up, pointing at the game dir root, to access all assets for that game.
//
// Assets should be loaded on-demand to keep memory costs as low as possible.
// Cross-platform differences such as filename case sensitivity are also dealt
@@ -26,27 +28,47 @@ type entryMap map[string]map[string]string
//
// We assume the directory is read-only. You can run Refresh() if you make a
// change.
//
// To mix assets from different games, either construct a synthetic directory
// or instantiate two separate asset stores.
type AssetStore struct {
RootDir string
Palette color.Palette
// Case-insensitive file lookup.
// {"":{"anim":"Anim", "obj":"Obj", ...}, "anim":{ "warhammer.ani":"WarHammer.ani" }, ...}
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
sets map[string]*Set
sounds map[string]*Sound
strings *data.I18n
aniObj *Object
cursorObj *Object
cursors map[CursorName]*Cursor
fonts map[string]*Font
generic *data.Generic
idx *idx.Idx
images map[string]*ebiten.Image
maps map[string]*Map
menus map[string]*Menu
objs map[string]*Object
sets map[string]*Set
sounds map[string]*Sound
strings *data.I18n
}
// New returns a new AssetStore
func New(dir string) (*AssetStore, error) {
func New(engine *config.Engine) (*AssetStore, error) {
if engine == nil {
return nil, fmt.Errorf("Unconfigured engine passed to assetstore")
}
palette, ok := palettes.Get(engine.Palette)
if !ok {
return nil, fmt.Errorf("Couldn't find palette %q for engine", engine.Palette)
}
store := &AssetStore{
RootDir: dir,
RootDir: engine.DataDir,
Palette: palette,
}
// fill entryMap
@@ -64,7 +86,7 @@ func (a *AssetStore) Refresh() error {
}
newEntryMap := make(entryMap, len(rootEntries))
newEntryMap[RootDir] = rootEntries
newEntryMap[""] = rootEntries
for lower, natural := range rootEntries {
path := filepath.Join(a.RootDir, natural)
@@ -84,8 +106,13 @@ func (a *AssetStore) Refresh() error {
}
// Refresh
a.aniObj = nil
a.cursorObj = nil
a.cursors = make(map[CursorName]*Cursor)
a.entries = newEntryMap
a.fonts = make(map[string]*Font)
a.idx = nil
a.images = make(map[string]*ebiten.Image)
a.maps = make(map[string]*Map)
a.menus = make(map[string]*Menu)
a.objs = make(map[string]*Object)
@@ -108,13 +135,13 @@ func (a *AssetStore) lookup(name, ext string, dirs ...string) (string, error) {
dir = canonical(dir)
if base, ok := a.entries[dir]; ok {
if file, ok := base[filename]; ok {
actualDir := a.entries[RootDir][dir]
actualDir := a.entries[""][dir]
return filepath.Join(a.RootDir, actualDir, file), nil
}
}
}
return "", os.ErrNotExist
return "", fmt.Errorf("file %q does not exist", filename)
}
func canonical(s string) string {

View File

@@ -0,0 +1,100 @@
package assetstore
import (
"image"
"github.com/hajimehoshi/ebiten"
)
// These are just offsets into the Cursors.cur file
type CursorName int
type Cursor struct {
Hotspot image.Point
Image *ebiten.Image
}
const (
UltPointer CursorName = 0
ChaosPointer CursorName = 1
UltWaiter CursorName = 2
ChaosWaiter CursorName = 3
// I think these cursors are used in drag + drop
ChaosMarine1 CursorName = 4
ChaosMarine2 CursorName = 5
ChaosMarine3 CursorName = 6
UltMarine1 CursorName = 7
UltMarine2 CursorName = 8
UltMarine3 CursorName = 9
UltMarine4 CursorName = 10
UltMarine5 CursorName = 11
ChaosHeavy1 CursorName = 12
ChaosHeavy2 CursorName = 13
UltHeavy1 CursorName = 14
UltHeavy2 CursorName = 15
UltHeavy3 CursorName = 16
UltHeavy4 CursorName = 17
UltHeavy5 CursorName = 18
UltHeavy6 CursorName = 19
ChaosTerminator1 CursorName = 20
ChaosTerminator2 CursorName = 21
UltTerminator1 CursorName = 22
UltTerminator2 CursorName = 23
UltTerminator3 CursorName = 24
UltTerminator4 CursorName = 25
UltTerminator5 CursorName = 26
Deny CursorName = 27 // Red X
UltLogo CursorName = 28
UltSquadMarine CursorName = 29
UltSquadHeavy CursorName = 30
UltSquadAssault CursorName = 31
UltCaptain CursorName = 32
UltChaplain CursorName = 33 // (maybe?)
UltApothecary CursorName = 34
UltTechmarine CursorName = 35
UltLibrarian CursorName = 36
DenyAgain CursorName = 37 // Identical to Deny as far as I can see *shrug*
)
func (a *AssetStore) Cursor(name CursorName) (*Cursor, error) {
if cur, ok := a.cursors[name]; ok {
return cur, nil
}
if a.cursorObj == nil {
filename, err := a.lookup("Cursors.cur", "", "Cursor")
if err != nil {
return nil, err
}
obj, err := a.ObjectByPath(filename)
if err != nil {
return nil, err
}
a.cursorObj = obj
}
spr, err := a.cursorObj.Sprite(int(name))
if err != nil {
return nil, err
}
// TODO: hotspot info. We're using Cursor.cur because it's object format,
// but we do also have .ani files that might contain hotspots.
cur := &Cursor{Image: spr.Image}
a.cursors[name] = cur
return cur, nil
}

View File

@@ -0,0 +1,71 @@
package assetstore
import (
"path/filepath"
"strings"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/data"
)
// Generic returns a struct containing a grab-bag of otherwise-unrelated data
// TODO: it would be nice if this could be cleaner
func (a *AssetStore) Generic() (*data.Generic, error) {
if a.generic != nil {
return a.generic, nil
}
filename, err := a.lookup("GenericData", "dat", "Data")
if err != nil {
return nil, err
}
generic, err := data.LoadGeneric(filename)
if err != nil {
return nil, err
}
// These changes are made so data in generic plays nicer with assetstore
for i, filename := range generic.CampaignMaps {
generic.CampaignMaps[i] =
strings.TrimSuffix(filename, filepath.Ext(filename))
}
a.generic = generic
return generic, nil
}
func (a *AssetStore) DefaultOptions() (*config.Options, error) {
cfg := &config.Options{}
g, err := a.Generic()
if err != nil {
return nil, err
}
cfg.PlayMovies = intToBool(g.Options[data.OptionMovies])
cfg.PlayMusic = intToBool(g.Options[data.OptionMusic])
cfg.CombatVoices = intToBool(g.Options[data.OptionCombatVoices])
cfg.ShowGrid = intToBool(g.Options[data.OptionGrid])
cfg.ShowPaths = intToBool(g.Options[data.OptionShowPaths])
cfg.PointSaving = intToBool(g.Options[data.OptionPointSave])
cfg.AutoCutLevel = intToBool(g.Options[data.OptionAutoCutLevel])
cfg.Animations = intToBool(g.Options[data.OptionShowUnitAnimations])
// These are overrides of data.OptionCombatResolution. *This* default from
// 1998 is no good at all!
cfg.XRes = 1280
cfg.YRes = 1024
cfg.MusicVolume = g.Options[data.OptionMusicVolume]
cfg.SFXVolume = g.Options[data.OptionSoundEffectsVolume]
cfg.UnitSpeed = g.Options[data.OptionUnitAnimationSpeed]
cfg.AnimSpeed = g.Options[data.OptionEffectAnimationSpeed]
return cfg, nil
}
func intToBool(i int) bool {
return i > 0
}

View File

@@ -0,0 +1,42 @@
package assetstore
import (
"image"
"os"
"github.com/hajimehoshi/ebiten"
_ "github.com/samuel/go-pcx/pcx" // PCX support
)
func (a *AssetStore) Image(name string) (*ebiten.Image, error) {
name = canonical(name)
if img, ok := a.images[name]; ok {
return img, nil
}
// baps, ordoor, geas store .pcx files in Pic
// TODO: SL stores .bmp files in Res
filename, err := a.lookup(name, "pcx", "Pic")
if err != nil {
return nil, err
}
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
rawImg, _, err := image.Decode(f)
if err != nil {
return nil, err
}
img, err := ebiten.NewImageFromImage(rawImg, ebiten.FilterDefault)
if err != nil {
return nil, err
}
a.images[name] = img
return img, nil
}

View File

@@ -1,6 +1,7 @@
package assetstore
import (
"fmt"
"image"
"log"
@@ -46,12 +47,7 @@ func (a *AssetStore) Map(name string) (*Map, error) {
}
m := &Map{
Rect: image.Rect(
int(raw.MinWidth),
int(raw.MinLength),
int(raw.MaxWidth),
int(raw.MaxLength),
),
Rect: raw.Rect(),
assets: a,
raw: raw,
set: set,
@@ -64,8 +60,8 @@ func (a *AssetStore) Map(name string) (*Map, error) {
func (m *Map) LoadSprites() error {
// Eager load the sprites we use
for x := m.Rect.Min.X; x <= m.Rect.Max.X; x++ {
for y := m.Rect.Min.Y; y <= m.Rect.Max.Y; y++ {
for x := m.Rect.Min.X; x < m.Rect.Max.X; x++ {
for y := m.Rect.Min.Y; y < m.Rect.Max.Y; y++ {
for z := 0; z < maps.MaxHeight; z++ {
if _, err := m.SpritesForCell(x, y, z); err != nil {
return err
@@ -77,6 +73,11 @@ func (m *Map) LoadSprites() error {
return nil
}
// FIXME: get rid of this
func (m *Map) Cell(x, y, z int) maps.Cell {
return m.raw.Cells.At(x, y, z)
}
// SpritesForCell returns the sprites needed to correctly render this cell.
// They should be rendered from first to last to get the correct ordering
func (m *Map) SpritesForCell(x, y, z int) ([]*Sprite, error) {
@@ -90,7 +91,7 @@ func (m *Map) SpritesForCell(x, y, z int) ([]*Sprite, error) {
obj, err := m.set.Object(ref.Index())
if err != nil {
return nil, err
return nil, fmt.Errorf("Failed to get object for %#+v: %v", ref, err)
}
sprite, err := obj.Sprite(ref.Sprite())

View File

@@ -1,23 +1,25 @@
package assetstore
import (
"log"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/menus"
)
type Menu struct {
assets *AssetStore
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
assets *AssetStore
fonts []*Font // TODO: place the fonts directly into the relevant records
objects []*Object // TODO: place the objects directly into the relevant records
raw *menus.Menu // TODO: remove raw
Name string
}
// FIXME: don't expose this
func (m *Menu) Records() []*menus.Record {
return m.raw.Records
func (m *Menu) Groups() []*menus.Group {
return m.raw.Groups
}
// FIXME: don't expose this
@@ -25,10 +27,10 @@ func (m *Menu) Font(idx int) *Font {
return m.fonts[idx]
}
func (m *Menu) Images(start, count int) ([]*ebiten.Image, error) {
func (m *Menu) Images(objIdx, start, count int) ([]*ebiten.Image, error) {
out := make([]*ebiten.Image, count)
sprites, err := m.Sprites(start, count)
sprites, err := m.Sprites(objIdx, start, count)
if err != nil {
return nil, err
}
@@ -40,23 +42,12 @@ func (m *Menu) Images(start, count int) ([]*ebiten.Image, error) {
return out, nil
}
func (m *Menu) Sprites(start, count int) ([]*Sprite, error) {
out := make([]*Sprite, count)
for i := start; i < start+count; i++ {
sprite, err := m.Sprite(i)
if err != nil {
return nil, err
}
out[i-start] = sprite
}
return out, nil
func (m *Menu) Sprites(objIdx, start, count int) ([]*Sprite, error) {
return m.objects[objIdx].Sprites(start, count)
}
func (m *Menu) Sprite(idx int) (*Sprite, error) {
return m.obj.Sprite(idx)
func (m *Menu) Sprite(objIdx, idx int) (*Sprite, error) {
return m.objects[objIdx].Sprite(idx)
}
func (a *AssetStore) Menu(name string) (*Menu, error) {
@@ -71,7 +62,7 @@ func (a *AssetStore) Menu(name string) (*Menu, error) {
return nil, err
}
raw, err := menus.LoadMenu(filename)
raw, err := menus.LoadMenu(filename, a.Palette)
if err != nil {
return nil, err
}
@@ -88,34 +79,45 @@ func (a *AssetStore) Menu(name string) (*Menu, error) {
i18n, err := a.i18n()
if err != nil {
return nil, err
log.Printf("Failed to load i18n data, skipping internationalisatoin: %s", err)
} else {
raw.Internationalize(i18n)
}
raw.Internationalize(i18n)
obj, err := a.loadMenuObject(raw) // TODO: multiple objects
// FIXME: we should parse the menu into a list of elements like "ListBox",
// "Dialogue", etc, and present those with objects already selected
objects, err := a.loadMenuObjects(raw)
if err != nil {
return nil, err
}
menu := &Menu{
assets: a,
fonts: fonts,
obj: obj,
raw: raw,
Name: name,
assets: a,
fonts: fonts,
objects: objects,
raw: raw,
Name: name,
}
a.menus[name] = menu
return menu, nil
}
func (a *AssetStore) loadMenuObject(menu *menus.Menu) (*Object, error) {
filename := menu.ObjectFiles[0]
filename, err := a.lookup(filename, "", "Menu") // Extension already present
if err != nil {
return nil, err
func (a *AssetStore) loadMenuObjects(menu *menus.Menu) ([]*Object, error) {
out := make([]*Object, len(menu.ObjectFiles))
for i, name := range menu.ObjectFiles {
filename, err := a.lookup(name, "", "Menu") // Extension already present
if err != nil {
return nil, err
}
obj, err := a.ObjectByPath(filename)
if err != nil {
return nil, err
}
out[i] = obj
}
return a.ObjectByPath(filename)
return out, nil
}

View File

@@ -42,7 +42,7 @@ func (a *AssetStore) Object(name string) (*Object, error) {
}
log.Printf("Loading object %v", name)
filename, err := a.lookup(name, "obj", "Obj")
filename, err := a.lookup(name, "obj", "Obj", "spr")
if err != nil {
return nil, err
}
@@ -90,6 +90,21 @@ func (o *Object) LoadSprites() error {
return nil
}
func (o *Object) Sprites(start, count int) ([]*Sprite, error) {
out := make([]*Sprite, count)
for i := start; i < start+count; i++ {
sprite, err := o.Sprite(i)
if err != nil {
return nil, err
}
out[i-start] = sprite
}
return out, nil
}
func (o *Object) Sprite(idx int) (*Sprite, error) {
if sprite := o.sprites[idx]; sprite != nil {
return sprite, nil
@@ -102,7 +117,7 @@ func (o *Object) Sprite(idx int) (*Sprite, error) {
}
raw := o.raw.Sprites[idx]
img, err := ebiten.NewImageFromImage(raw.ToImage(), ebiten.FilterDefault)
img, err := ebiten.NewImageFromImage(raw.ToImage(o.assets.Palette), ebiten.FilterDefault)
if err != nil {
return nil, err
}

View File

@@ -1,15 +1,17 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
type Ordoor struct {
DataDir string `toml:"data_dir"`
VideoPlayer []string `toml:"video_player"`
type Engine struct {
DataDir string `toml:"data_dir"`
Palette string `toml:"palette"`
}
// Things set in the options hash
@@ -37,11 +39,40 @@ type Options struct {
type Config struct {
filename string `toml:"-"`
Ordoor `toml:"ordoor"`
Options `toml:"options"`
VideoPlayer []string `toml:"video_player"`
Engines map[string]Engine `toml:"engines"`
DefaultEngineName string `toml:"default_engine"`
// FIXME: options may well end up being per-engine too
Defaults *Options `toml:"-"`
Options `toml:"options"`
}
func Load(filename string) (*Config, error) {
func (c *Config) Engine(name string) *Engine {
engine, ok := c.Engines[name]
if !ok {
return nil
}
return &engine
}
func (c *Config) DefaultEngine() *Engine {
return c.Engine(c.DefaultEngineName)
}
// TODO: case-insensitive lookup
func (c *Config) DataFile(engine string, path string) string {
cfg, ok := c.Engines[engine]
if !ok {
return ""
}
return filepath.Join(cfg.DataDir, path)
}
func Load(filename string, overrideDefaultEngine string) (*Config, error) {
var out Config
_, err := toml.DecodeFile(filename, &out)
@@ -51,7 +82,21 @@ func Load(filename string) (*Config, error) {
out.filename = filename
return &out, err
if overrideDefaultEngine != "" {
out.DefaultEngineName = overrideDefaultEngine
}
if out.DefaultEngine() == nil {
return nil, fmt.Errorf("Default engine %q not configured", out.DefaultEngineName)
}
return &out, nil
}
func (c *Config) HasUnsetOptions() bool {
var empty Options
return c.Options == empty
}
func (c *Config) Save() error {
@@ -64,9 +109,14 @@ func (c *Config) Save() error {
return toml.NewEncoder(f).Encode(c)
}
// TODO: case-insensitive lookup
func (c *Config) DataFile(path string) string {
return filepath.Join(c.DataDir, path)
func (c *Config) ResetDefaults() error {
if c.Defaults == nil {
return errors.New("Defaults not available")
}
c.Options = *c.Defaults
return c.Save()
}
func (o *Options) ResolutionIndex() int {

178
internal/data/has_action.go Normal file
View File

@@ -0,0 +1,178 @@
package data
import (
"fmt"
"github.com/emef/bitfield"
"code.ur.gs/lupine/ordoor/internal/util/asciiscan"
)
// AnimAction represents an animation that is stored in WarHammer.ani
type AnimAction int
// CharacterType represents one of the different types of character in the game.
//
// TODO: can we load the list of character types anywhere or is it hardcoded in
// the original too?
type CharacterType int
const (
AnimActionNone AnimAction = 0
AnimActionAnim AnimAction = 1
AnimActionWalk AnimAction = 2
AnimActionExplosion AnimAction = 3
AnimActionProjectile AnimAction = 4
AnimActionSmoke AnimAction = 5
AnimActionStandingShoot AnimAction = 6
AnimActionStandingDeath AnimAction = 7
AnimActionPain AnimAction = 8
AnimActionSpellFx1 AnimAction = 9
AnimActionSpellFx2 AnimAction = 10
AnimActionSpellFx3 AnimAction = 11
AnimActionSpellFx4 AnimAction = 12
AnimActionSpellFx5 AnimAction = 13
AnimActionRun AnimAction = 14
AnimActionCrouch AnimAction = 15
AnimActionStand AnimAction = 16
AnimActionStandingRead AnimAction = 17
AnimActionStandingUnready AnimAction = 18
AnimActionCrouchingReady AnimAction = 19
AnimActionCrouchingUnready AnimAction = 20
AnimActionCrouchingShoot AnimAction = 21
AnimActionStandingGrenade AnimAction = 22
AnimActionCrouchingGrenade AnimAction = 23
AnimActionDrawMelee AnimAction = 24
AnimActionSlash AnimAction = 25
AnimActionStab AnimAction = 26
AnimActionBlown AnimAction = 27
AnimActionCrouchingDeath AnimAction = 28
AnimActionJump AnimAction = 29
AnimActionHeal AnimAction = 30
AnimActionTechWork AnimAction = 31
AnimActionCast AnimAction = 32
AnimActionShoot AnimAction = 33
AnimActionDeath AnimAction = 34
AnimActionFromWarp AnimAction = 35
AnimActionStart = AnimActionNone
AnimActionEnd = AnimActionFromWarp
AnimActionCount = AnimActionEnd - AnimActionStart + 1
// FIXME: indexed from 1, very annoying
CharacterTypeTactical CharacterType = 1
CharacterTypeAssault CharacterType = 2
CharacterTypeDevastator CharacterType = 3
CharacterTypeTerminator CharacterType = 4
CharacterTypeApothecary CharacterType = 5
CharacterTypeTechmarine CharacterType = 6
CharacterTypeChaplain CharacterType = 7
CharacterTypeLibrarian CharacterType = 8
CharacterTypeCaptain CharacterType = 9
CharacterTypeChaosMarine CharacterType = 10
CharacterTypeChaosLord CharacterType = 11
CharacterTypeChaosChaplain CharacterType = 12
CharacterTypeChaosSorcerer CharacterType = 13
CharacterTypeChaosTerminator CharacterType = 14
CharacterTypeKhorneBerserker CharacterType = 15
CharacterTypeBloodThirster CharacterType = 16
CharacterTypeBloodLetter CharacterType = 17
CharacterTypeFleshHound CharacterType = 18
CharacterTypeLordOfChange CharacterType = 19
CharacterTypeFlamer CharacterType = 20
CharacterTypePinkHorror CharacterType = 21
CharacterTypeBlueHorror CharacterType = 22
CharacterTypeChaosCultist CharacterType = 23
CharacterTypeStart = CharacterTypeTactical
CharacterTypeEnd = CharacterTypeChaosCultist
CharacterTypeCount = CharacterTypeEnd - CharacterTypeStart + 1
)
// HasAction tells us whether a character has an animation or not.
type HasAction struct {
bits bitfield.BitField
}
func LoadHasAction(filename string) (*HasAction, error) {
scanner, err := asciiscan.New(filename)
if err != nil {
return nil, err
}
defer scanner.Close()
out := &HasAction{
bits: bitfield.New(int(CharacterTypeCount) * int(AnimActionCount)),
}
// Reuse this for every loop
var actions [AnimActionCount]bool
ptrs := make([]*bool, len(actions))
for i, _ := range actions {
ptrs[i] = &actions[i]
}
for c := CharacterTypeStart; c <= CharacterTypeEnd; c++ {
if err := scanner.ConsumeBoolPtrs(ptrs...); err != nil {
return nil, err
}
for j, value := range actions {
a := AnimActionStart + AnimAction(j)
out.set(c, a, value)
}
}
return out, nil
}
func (h *HasAction) Check(c CharacterType, a AnimAction) bool {
return h.bits.Test(h.offset(c, a))
}
func (h *HasAction) offset(c CharacterType, a AnimAction) uint32 {
// Best to view this as a 2D array with CharacterTypeCount * AnimActionCount elements
i := uint32(c - CharacterTypeStart)
j := uint32(a - AnimActionStart)
return (i * uint32(AnimActionCount)) + j
}
func (h *HasAction) set(c CharacterType, a AnimAction, value bool) {
if value {
h.bits.Set(h.offset(c, a))
} else {
h.bits.Clear(h.offset(c, a))
}
}
// Actions returns the list of animations that a character type has
func (h *HasAction) Actions(c CharacterType) []AnimAction {
var out []AnimAction
for j := AnimActionStart; j < AnimActionCount; j++ {
if h.Check(c, j) {
out = append(out, j)
}
}
return out
}
func (h *HasAction) Print() {
fmt.Println(" Tac Ass Dev Term Apo Tech Chp Lib Cpt CMar CLrd CChp CSrc CTrm Kbz BTh BL FHnd LoC Flm PHr BHr Cult")
for a := AnimActionStart; a <= AnimActionEnd; a++ {
fmt.Printf("%.2d", int(a))
for c := CharacterTypeStart; c <= CharacterTypeEnd; c++ {
if h.Check(c, a) {
fmt.Print(" x ")
} else {
fmt.Print(" ")
}
}
fmt.Println("")
}
}

View File

@@ -4,8 +4,10 @@ import (
"encoding/binary"
"fmt"
"image"
"image/color"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
@@ -19,13 +21,26 @@ type SpriteHeader struct {
Width uint16
Height uint16
Padding1 uint32 // I don't think this is used. Could be wrong.
PixelSize uint32 // Size of PixelData, excluding this sprite header
Padding2 uint64 // I don't think this is used either. Could be wrong.
PixelSize uint32
Unknown1 [4]byte // ??? Only observed in `WarHammer.ani` so far
Padding2 uint32 // I don't think this is used either. Could be wrong.
}
func (s SpriteHeader) Check(expectedSize uint32) error {
if s.Padding1 != 0 || s.Padding2 != 0 {
return fmt.Errorf("Sprite header padding contains unknown values: %d %d", s.Padding1, s.Padding2)
if s.Padding1 == 271 && s.Padding2 == 0 {
log.Printf("Sprite header padding matches FIXME value")
} else {
return fmt.Errorf("Sprite header padding contains unknown values: %d %d", s.Padding1, s.Padding2)
}
}
// TODO: WarHammer.ani sets Unknown1 to this for all 188,286 sprites. I am
// very interested in seeing if there are any others
if s.Unknown1[0]|s.Unknown1[1]|s.Unknown1[2]|s.Unknown1[3] > 0 {
if s.Unknown1[0] != 212 || s.Unknown1[1] != 113 || s.Unknown1[2] != 59 || s.Unknown1[3] != 1 {
log.Printf("Value of Unknown1 field: %v", s.Unknown1)
}
}
// Remove 24 bytes from passed-in size to account for the header
@@ -42,12 +57,12 @@ type Sprite struct {
Data []byte
}
func (s *Sprite) ToImage() *image.Paletted {
func (s *Sprite) ToImage(palette color.Palette) *image.Paletted {
return &image.Paletted{
Pix: s.Data,
Stride: int(s.Width),
Rect: image.Rect(0, 0, int(s.Width), int(s.Height)),
Palette: ColorPalette,
Palette: palette,
}
}

38
internal/flow/bridge.go Normal file
View File

@@ -0,0 +1,38 @@
package flow
func (f *Flow) linkBridge() {
// FIXME: sometimes these doors are frozen, depending on ship state, but we
// don't implement that yet.
f.onClick(bridge, "2.1", f.setReturningDriver(bridge, briefing)) // Mission briefing clickable
f.onClick(bridge, "2.2", f.setReturningDriver(bridge, choices)) // Options door hotspot
f.onClick(bridge, "2.4", f.playNextScenario(bridge)) // Enter combat door hotspot
f.setFreeze(bridge, "2.6", true) // TODO: Vehicle configure door hotspot
// FIXME: setReturningDriver would leave behind junk
f.onClick(bridge, "2.8", f.setDriver(arrange)) // Squads configure door hotspot.
// link children
f.linkBriefing()
f.linkChoices()
f.linkMainGame()
f.linkArrange()
}
func (f *Flow) linkBriefing() {
f.onClick(briefing, "3.1", f.setDriver(bridge))
}
func (f *Flow) linkArrange() {
// FIXME: we should be operating on game data in here
f.onClick(arrange, "8.1", f.setDriver(bridge)) // Return to bridge ("cathedral")
f.onClick(arrange, "8.3", f.setDriver(configureUltEquip)) // Configure squads
f.linkConfigureUltEquip()
}
func (f *Flow) linkConfigureUltEquip() {
// FIXME: we should be modifying loadouts of selected squad members here
f.onClick(configureUltEquip, "8.1", f.setDriver(bridge)) // Return to bridge
}

20
internal/flow/choices.go Normal file
View File

@@ -0,0 +1,20 @@
package flow
func (f *Flow) linkChoices() {
f.onClick(choices, "2.1", f.setReturningDriver(choices, loadGame)) // Load another game button
f.onClick(choices, "2.2", f.setReturningDriver(choices, saveGame)) // Save this game button
f.onClick(choices, "2.3", f.setReturningDriver(choices, options)) // More options button
f.onClick(choices, "2.4", func() { // New Game button. FIXME: should ask about the emperor
f.ship.Reset() // Throws away in-progress game
f.reset()
})
f.onClick(choices, "2.5", f.setReturningDriver(choices, credits)) // Credits button
f.onClick(choices, "2.6", f.setExit) // Quit button. FIXME: should ask about the emperor
f.onClick(choices, "2.7", f.returnToLastDriver(choices)) // Back button
// loadGame is linked by main
f.linkSaveGame()
// options is linked by main
f.linkCredits()
}

8
internal/flow/credits.go Normal file
View File

@@ -0,0 +1,8 @@
package flow
func (f *Flow) linkCredits() {
// Clicking anywhere in credits should return us
f.onClick(credits, "1", f.returnToLastDriver(credits))
// TODO: lots of text
}

145
internal/flow/drivers.go Normal file
View File

@@ -0,0 +1,145 @@
package flow
import (
"fmt"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/ui"
)
type driverName string
const (
// Names of all the drivers
main driverName = "Main"
levelPly driverName = "LevelPly"
singles driverName = "Singles"
randomMap driverName = "RandomMap"
newGame driverName = "NewGame"
loadGame driverName = "LoadGame"
options driverName = "Options"
kbd driverName = "Keyboard"
bridge driverName = "Bridge"
briefing driverName = "Briefing"
choices driverName = "Choices"
saveGame driverName = "SaveGame"
credits driverName = "Credits"
arrange driverName = "Arrange"
configureUltEquip driverName = "Configure_UltEquip"
configureVehiclesUltra driverName = "Configure_Vehicles_Ultra"
mainGame driverName = "MainGame"
)
var (
driverNames = []driverName{
main, levelPly, singles, randomMap, newGame, loadGame, options, kbd,
bridge, briefing, choices, saveGame, credits, arrange,
configureUltEquip, configureVehiclesUltra,
mainGame,
}
menuTransforms = map[driverName]func(*assetstore.Menu){
mainGame: offsetMainGame,
}
)
// FIXME: HURK: MainGame elements need changes to show up in the right place
func offsetMainGame(menu *assetstore.Menu) {
for _, group := range menu.Groups() {
id := group.ID
// Bottom-aligned, not top-aligned
if id == 1 || id == 2 || id == 3 || id == 4 || id == 5 || id == 6 ||
id == 7 || id == 8 || id == 9 || id == 10 || id == 15 || id == 16 {
group.Y = 320 // Down by 320px
// FIXME: in reality, this appears to be a property of the group only
for _, rec := range group.Records {
rec.Y = 320
}
}
// Right-aligned, not left-aligned
// FIXME: this presents problems as there are two sizes and both need to
// be right-aligned, so a static offset won't quite work
// if id == 14 {
// group.X = 400 (or so)
// }
// Left-aligned, not centered
// FIXME: we're re-using the X-CORD and Y-CORD elements here. How do we
// signal a negative number?
// if id == 18 {
// group.X = 0
// }
}
}
func buildDriver(assets *assetstore.AssetStore, name driverName) (*ui.Driver, error) {
menu, err := assets.Menu(string(name))
if err != nil {
return nil, err
}
if tf, ok := menuTransforms[name]; ok {
tf(menu)
}
driver, err := ui.NewDriver(assets, menu)
if err != nil {
return nil, err
}
return driver, nil
}
func (f *Flow) returnToLastDriver(from driverName) func() {
return func() {
to, ok := f.returns[from]
if !ok {
f.exit = fmt.Errorf("Couldn't work out where to return to from %v", from)
return
}
delete(f.returns, from)
f.setDriverNow(to)
}
}
// from is the child menu, to is the parent
func (f *Flow) returnToLastDriverNow(from driverName) error {
to, ok := f.returns[from]
if !ok {
return fmt.Errorf("Couldn't work out where to return to from %v", from)
}
delete(f.returns, from)
f.setDriverNow(to)
return nil
}
func (f *Flow) setDriver(name driverName) func() {
return func() {
f.setDriverNow(name)
}
}
func (f *Flow) setDriverNow(name driverName) {
f.current = f.drivers[name]
}
// from is the parent menu, to is the child
func (f *Flow) setReturningDriver(from, to driverName) func() {
return func() {
f.setReturningDriverNow(from, to)
}
}
func (f *Flow) setReturningDriverNow(from, to driverName) {
f.returns[to] = from
f.setDriverNow(to)
}

316
internal/flow/flow.go Normal file
View File

@@ -0,0 +1,316 @@
package flow
import (
"errors"
"fmt"
"log"
"strings"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/data"
"code.ur.gs/lupine/ordoor/internal/scenario"
"code.ur.gs/lupine/ordoor/internal/ship"
"code.ur.gs/lupine/ordoor/internal/ui"
)
// type Flow is responsible for wiring up UI elements to each other and ensuring
// they behave as expected. This includes forward / back buttons to switch
// between screens, loading and saving options, launching a scenario, etc
type Flow struct {
assets *assetstore.AssetStore
config *config.Config
current *ui.Driver
drivers map[driverName]*ui.Driver
generic *data.Generic
// Some screens can be returned to from more than one place. Where this is
// the case, instead of hardcoding it, we'll store an entry in here so we
// know where we're going back to
//
// FIXME: this really suggests wiring everything up at the start is wrong.
returns map[driverName]driverName
// If we're currently playing a scenario, it it placed here
scenario *scenario.Scenario
ship *ship.Ship
exit error
}
var (
ErrExit = errors.New("exiting gracefully")
// Constants used for sliders
h3Slider = map[int]int{1: 8, 2: 56, 3: 110, 4: 120}
v10Slider = map[int]int{
0: 0,
10: 9, 20: 18, 30: 27, 40: 36, 50: 45,
60: 54, 70: 63, 80: 72, 90: 81, 100: 90,
}
h9Slider = map[int]int{
0: 0,
10: 10, 20: 20, 30: 30, 40: 40,
50: 50, 60: 60, 70: 70, 80: 80,
}
)
func New(assets *assetstore.AssetStore, config *config.Config, ship *ship.Ship) (*Flow, error) {
generic, err := assets.Generic()
if err != nil {
return nil, fmt.Errorf("Failed to read generic data: %v", err)
}
out := &Flow{
assets: assets,
config: config,
generic: generic,
drivers: make(map[driverName]*ui.Driver, len(driverNames)),
returns: make(map[driverName]driverName),
ship: ship,
}
// Load all the drivers upfront
for _, name := range driverNames {
driver, err := buildDriver(assets, name)
if err != nil {
return nil, err
}
out.drivers[name] = driver
}
out.linkDrivers()
out.reset()
return out, out.exit
}
func (f *Flow) Update(screenX, screenY int) error {
if f.exit != nil {
return f.exit
}
// Keybindings for map control
// FIXME: this needs a big rethink
if f.current != nil && f.scenario != nil && !f.current.IsInDialogue() {
step := 32
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
f.scenario.Viewpoint.X -= step
}
if ebiten.IsKeyPressed(ebiten.KeyRight) {
f.scenario.Viewpoint.X += step
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
f.scenario.Viewpoint.Y -= step
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
f.scenario.Viewpoint.Y += step
}
}
if f.scenario != nil {
if err := f.scenario.Update(screenX, screenY); err != nil {
return err
}
}
if f.current != nil {
if err := f.current.Update(screenX, screenY); err != nil {
return err
}
}
return nil
}
func (f *Flow) Draw(screen *ebiten.Image) error {
if f.exit != nil {
return f.exit
}
if f.scenario != nil {
if err := f.scenario.Draw(screen); err != nil {
return err
}
}
if f.current != nil {
if err := f.current.Draw(screen); err != nil {
return err
}
}
return nil
}
func (f *Flow) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) {
if f.current != nil {
return f.current.Cursor()
}
// FIXME: we should get a cursor from current all the time.
return nil, nil, nil
}
func (f *Flow) linkDrivers() {
// linkMain
f.onClick(main, "2.1", f.setReturningDriver(main, newGame)) // New game
f.onClick(main, "2.2", f.setReturningDriver(main, loadGame)) // Load game
f.setFreeze(main, "2.3", true) // Multiplayer - disable for now
f.onClick(main, "2.4", f.setReturningDriver(main, options)) // Options
f.onClick(main, "2.5", f.setExit) // Quit
// Now link immediate children. They will link their children, and so on
f.linkNewGame()
f.linkLoadGame()
// TODO: link multiplayer
f.linkOptions()
}
func maybeErr(driver driverName, err error) error {
if err != nil {
return fmt.Errorf("%v: %v", driver, err)
}
return nil
}
func (f *Flow) configureSlider(driver driverName, id string, steps map[int]int) {
if f.exit != nil {
return
}
f.exit = f.drivers[driver].ConfigureSlider(id, steps)
}
func (f *Flow) onClick(driver driverName, id string, fn func()) {
if f.exit != nil {
return
}
f.exit = maybeErr(driver, f.drivers[driver].OnClick(id, fn))
}
func (f *Flow) setFreeze(driver driverName, id string, value bool) {
if f.exit != nil {
return
}
f.exit = maybeErr(driver, f.drivers[driver].SetFreeze(id, value))
}
func (f *Flow) setValueBool(driver driverName, id string, value bool) {
if f.exit != nil {
return
}
f.exit = f.drivers[driver].SetValueBool(id, value)
}
func (f *Flow) valueBool(driver driverName, id string) bool {
if f.exit != nil {
return false
}
var value bool
f.exit = f.drivers[driver].ValueBool(id, &value)
return value
}
func (f *Flow) playNextScenario(from driverName) func() {
return func() {
log.Printf("Loading scenario: %v", f.ship.NextScenario)
// TODO: we *could* load scenario assets in a separate assetstore to
// make it easier to chuck them away at the end?
scenario, err := scenario.NewScenario(f.assets, f.ship.NextScenario)
if err != nil {
f.exit = err
return
}
f.setReturningDriverNow(from, mainGame)
f.scenario = scenario
}
}
func (f *Flow) setActive(driver driverName, id string, value bool) func() {
return func() {
if f.exit != nil {
return
}
f.exit = maybeErr(driver, f.setActiveNow(driver, id, value))
}
}
func (f *Flow) setActiveNow(driver driverName, id string, value bool) error {
return f.drivers[driver].SetActive(locator(driver, id), value)
}
func (f *Flow) toggleActive(driver driverName, id string) func() {
return func() {
if f.exit != nil {
return
}
f.exit = maybeErr(driver, f.drivers[driver].ToggleActive(locator(driver, id)))
}
}
func (f *Flow) showDialogue(driver driverName, id string) func() {
return func() {
if f.exit != nil {
return
}
f.exit = maybeErr(driver, f.drivers[driver].ShowDialogue(locator(driver, id)))
}
}
func (f *Flow) hideDialogue(driver driverName) func() {
return f.drivers[driver].HideDialogue
}
func (f *Flow) withScenario(then func()) func() {
return func() {
if f.scenario != nil {
then()
}
}
}
func (f *Flow) reset() {
if f.exit != nil {
return
}
f.setDriverNow(main) // Back to the main interface
// Wipe out any returns that may exist
f.returns = make(map[driverName]driverName)
// FIXME: these should really happen via data binding.
f.resetLevelPlyInventorySelect()
f.exit = f.configIntoOptions()
}
func (f *Flow) setExit() {
f.exit = ErrExit
}
// TODO: convert all to locators
func locator(driver driverName, id string) string {
return fmt.Sprintf("%v:%v", strings.ToLower(string(driver)), id)
}

25
internal/flow/keyboard.go Normal file
View File

@@ -0,0 +1,25 @@
package flow
import (
"fmt"
"log"
)
func (f *Flow) linkKeyboard() {
// Keyboard settings
// TODO: implement keybindings save/load behaviour
f.onClick(kbd, "3.1", f.returnToLastDriver(kbd)) // Done button
f.onClick(kbd, "3.2", f.returnToLastDriver(kbd)) // Cancel button
f.onClick(kbd, "3.4", func() {}) // TODO: Reset to defaults button
for i := 1; i <= 13; i++ {
f.onClick(kbd, fmt.Sprintf("2.%v", i), f.captureKeybinding(i))
}
}
func (f *Flow) captureKeybinding(forLine int) func() {
return func() {
log.Printf("HELLO %v", forLine)
f.showDialogue(kbd, "4")()
}
}

View File

@@ -0,0 +1,6 @@
package flow
func (f *Flow) linkLoadGame() {
// Load game
f.onClick(loadGame, "3.3", f.returnToLastDriver(loadGame)) // Cancel button
}

156
internal/flow/main_game.go Normal file
View File

@@ -0,0 +1,156 @@
package flow
// TODO: There are Chaos and Ultramarine versions of MainGame. Do we really want
// to duplicate everything for both?
func (f *Flow) linkMainGame() {
f.linkMainGameActionMenu()
f.linkMainGameInterfaceOptionsMenu()
// 5: Holding menu
f.linkMainGameViewMenu()
// 7: General character menu
f.onClick(mainGame, "7.4", func() { // More button
f.setActiveNow(mainGame, "7", false)
f.setActiveNow(mainGame, "8", true)
})
// 8: Character stats
f.onClick(mainGame, "8.21", func() { // Stat more buttont
f.setActiveNow(mainGame, "7", true)
f.setActiveNow(mainGame, "8", false)
})
// 9: Visible enemy menu
// 10: Friendly squad menu
// 11: Psyker spell dialogue
// 12: Inventory dialogue
f.onClick(mainGame, "12.21", f.hideDialogue(mainGame)) // Exit
// 13: exchange menu
// 14: Map
// 14.1: MAP_SPRITE
// 14.2: Multiplier button (2x)
f.onClick(mainGame, "14.3", f.setActive(mainGame, "14", false))
// 14.4: Area
// FIXME: the display of left and right interface buttons is hidden by these
// sprites, because we draw in strict numeric order. Just hide them for now.
//
// FIXME: The child element is already set to hidden, while the menu itself
// is set to active, so maybe this is a hint that menus shouldn't be drawn?
//
// FIXME: the approach taken by the original binary in resolutions greater
// than 640x480 is to draw the menu elements *unscaled*. They are centered,
// and the dead space is filled by the "interface wing" sprites in the
// background. Should we replicate this, or keep with the current scaling
// behaviour? Which is better?
f.exit = maybeErr(mainGame, f.setActiveNow(mainGame, "15", false)) // Interface wing left
f.exit = maybeErr(mainGame, f.setActiveNow(mainGame, "16", false)) // Interface wing right
// 17: Grenade dialogue
f.onClick(mainGame, "18.12", f.setActive(mainGame, "18", false)) // Info "dialogue"
// 19: Turn start dialogue
// 20: Chat menu
// Chat list menu box - active by default, hide it
f.exit = maybeErr(mainGame, f.setActiveNow(mainGame, "21", false))
}
func (f *Flow) linkMainGameActionMenu() {
// 3: Action menu. These are mostly predicated on selected character state
// 3.1: Aimed shot
// 3.2: Shooting
// 3.3: Walk
// 3.4: Run
// 3.5: Crouch/Stand
// 3.6: Hand to hand (commented out)
// 3.7: Retrieve
// 3.8: Door
// 3.9: Switch
// 3.10: Overwatch
// 3.11: Rally/Formation
// 3.12: Board/Disembark
// FIXME: for now, this is "end scenario", for convenience
f.onClick(mainGame, "3.13", func() { // End turn button.
f.scenario = nil
f.returnToLastDriverNow(mainGame)
})
// 3.14: Special action heal
// 3.15: Special action techmarine
// 3.16: Special action jump pack
// 3.17: Special action spell
}
func (f *Flow) linkMainGameInterfaceOptionsMenu() {
// 4: Interface options menu
f.onClick(mainGame, "4.1", f.setReturningDriver(mainGame, options)) // Options button
// FIXME: map should be shown top-right, not top-left. We need to support 2x
// mode as well.
f.onClick(mainGame, "4.2", f.toggleActive(mainGame, "14")) // Map button
// FIXME: mission objectives should be shown top-left, not centered
f.onClick(mainGame, "4.3", f.toggleActive(mainGame, "18")) // Mission objectives
f.onClick(mainGame, "4.4", f.showDialogue(mainGame, "12")) // Inventory
// 4.5: Next man
// 4.6: Next enemy
// 4.7: Total enemy text
}
func (f *Flow) linkMainGameViewMenu() {
// FIXME: all these buttons should show current state as well as have an
// effect
f.onClick(mainGame, "6.1", f.withScenario(func() { // View 100%
f.scenario.Zoom = 1.0
}))
f.onClick(mainGame, "6.2", f.withScenario(func() { // View 50%
f.scenario.Zoom = 0.5
}))
f.onClick(mainGame, "6.3", f.withScenario(func() { // View 25%
f.scenario.Zoom = 0.25
}))
f.onClick(mainGame, "6.4", f.withScenario(func() { // Z index up
f.scenario.ChangeZIdx(+1)
}))
f.onClick(mainGame, "6.5", f.withScenario(func() { // Z index down
f.scenario.ChangeZIdx(-1)
}))
f.onClick(mainGame, "6.6", f.withScenario(func() { // Z index 1
f.scenario.ZIdx = 0
}))
f.onClick(mainGame, "6.7", f.withScenario(func() { // Z index 2
f.scenario.ZIdx = 1
}))
f.onClick(mainGame, "6.8", f.withScenario(func() { // Z index 3
f.scenario.ZIdx = 2
}))
f.onClick(mainGame, "6.9", f.withScenario(func() { // Z index 4
f.scenario.ZIdx = 3
}))
f.onClick(mainGame, "6.10", f.withScenario(func() { // Z index 5
f.scenario.ZIdx = 4
}))
f.onClick(mainGame, "6.11", f.withScenario(func() { // Z index 6
f.scenario.ZIdx = 5
}))
f.onClick(mainGame, "6.12", f.withScenario(func() { // Z index 7
f.scenario.ZIdx = 6
}))
}

79
internal/flow/new_game.go Normal file
View File

@@ -0,0 +1,79 @@
package flow
import (
"code.ur.gs/lupine/ordoor/internal/ship"
)
func (f *Flow) linkNewGame() {
// New game
f.onClick(newGame, "2.1", f.setReturningDriver(newGame, levelPly)) // New campaign button
f.onClick(newGame, "2.2", f.setReturningDriver(newGame, singles)) // Single scenario button
f.onClick(newGame, "2.3", f.setReturningDriver(newGame, randomMap)) // Random scenario button
f.onClick(newGame, "2.4", f.returnToLastDriver(newGame)) // Back button
f.linkLevelPly()
f.linkSingles()
f.linkRandomMap()
}
func (f *Flow) resetLevelPlyInventorySelect() {
// FIXME: Make the radio button respect changes via setValue
for _, v := range []string{"2.1", "2.2", "2.3", "2.4"} {
f.setValueBool(levelPly, v, false)
}
switch f.ship.Difficulty {
case ship.DifficultyLevelMarine:
f.setValueBool(levelPly, "2.1", true)
case ship.DifficultyLevelVeteran:
f.setValueBool(levelPly, "2.2", true)
case ship.DifficultyLevelHero:
f.setValueBool(levelPly, "2.3", true)
case ship.DifficultyLevelMighty:
f.setValueBool(levelPly, "2.4", true)
}
}
func (f *Flow) linkLevelPly() {
f.onClick(levelPly, "2.5", func() { // Back button
f.resetLevelPlyInventorySelect() // FIXME: should use data binding
f.returnToLastDriverNow(levelPly)
})
// FIXME: we should be able to read the difficulty level from the group
f.onClick(levelPly, "2.7", func() { // Select button
if f.valueBool(levelPly, "2.1") {
f.ship.Difficulty = ship.DifficultyLevelMarine
}
if f.valueBool(levelPly, "2.2") {
f.ship.Difficulty = ship.DifficultyLevelVeteran
}
if f.valueBool(levelPly, "2.3") {
f.ship.Difficulty = ship.DifficultyLevelHero
}
if f.valueBool(levelPly, "2.4") {
// FIXME: we should select a savegame. Mighty Hero disables manual saves.
f.ship.Difficulty = ship.DifficultyLevelMighty
}
f.ship.NextScenario = f.generic.CampaignMaps[0]
// FIXME: we should show a movie here. Need an internal SMK player first
f.setDriverNow(bridge)
})
// Link children
f.linkBridge()
}
func (f *Flow) linkSingles() {
f.onClick(singles, "4.11", f.returnToLastDriver(singles)) // Back button
}
func (f *Flow) linkRandomMap() {
f.onClick(randomMap, "2.19", f.returnToLastDriver(randomMap)) // Back button
}

View File

@@ -5,47 +5,39 @@ import (
)
func (f *Flow) linkOptions() {
f.onClick(options, "2.8", f.setDriver(kbd)) // Keyboard settings button
f.onClick(options, "2.8", f.setReturningDriver(options, kbd)) // Keyboard settings button
f.configureSlider(options, "2.9", h3Slider) // Resolution slider
f.configureSlider(options, "2.10", v10Slider) // Music volume slider
f.configureSlider(options, "2.11", v10Slider) // SFX volume slider
f.onClick(options, "2.12", f.acceptOptions()) // OK button
f.onClick(options, "2.24", f.cancelOptions()) // Cancel button
f.onClick(options, "2.12", f.acceptOptions) // OK button
f.onClick(options, "2.24", f.cancelOptions) // Cancel button
f.configureSlider(options, "2.26", h9Slider) // Unit speed slider
f.configureSlider(options, "2.27", h9Slider) // Animation speed slider
// Keyboard settings
// TODO: implement keybindings save/load behaviour
f.onClick(kbd, "3.1", f.setDriver(options)) // Done button
f.onClick(kbd, "3.2", f.setDriver(options)) // Cancel button
f.onClick(kbd, "3.4", func() {}) // Reset to defaults button
f.linkKeyboard()
}
// FIXME: exiting is a bit OTT. Perhaps display "save failed"?
func (f *Flow) acceptOptions() func() {
return func() {
if err := f.optionsIntoConfig(); err != nil {
log.Printf("Saving options to config failed: %v", err)
f.exit = err
} else {
f.setDriverNow(main)
}
func (f *Flow) acceptOptions() {
if err := f.optionsIntoConfig(); err != nil {
log.Printf("Saving options to config failed: %v", err)
f.exit = err
} else {
f.returnToLastDriverNow(options)
}
}
// FIXME: again, exiting is OTT. We're just resetting the state of
// the interface to the values in config.
func (f *Flow) cancelOptions() func() {
return func() {
if err := f.configIntoOptions(); err != nil {
log.Printf("Saving options to config failed: %v", err)
f.exit = err
} else {
f.exit = f.returnToLastDriverNow(options)
}
func (f *Flow) cancelOptions() {
if err := f.configIntoOptions(); err != nil {
log.Printf("Saving options to config failed: %v", err)
f.exit = err
} else {
f.exit = f.returnToLastDriverNow(options)
}
}

View File

@@ -0,0 +1,10 @@
package flow
func (f *Flow) linkSaveGame() {
// Save game button is disabled unless a listbox item is selected
// 3.2 is a hypertext that should be displayed when 3.1 is disabled... but
// it has no DESC.
f.setFreeze(saveGame, "3.1", true)
f.onClick(saveGame, "3.1", func() {}) // TODO: Save Game button
f.onClick(saveGame, "3.3", f.returnToLastDriver(saveGame)) // Back button
}

109
internal/idx/idx.go Normal file
View File

@@ -0,0 +1,109 @@
// package idx parses the Idx/WarHammer.idx file. It groups the sprites in
// Anim/WarHammer.ani into playable animations.
package idx
import (
"encoding/binary"
"fmt"
//"log"
"os"
//"strings"
)
const (
NumGroups = 512 // Experimentally determined
)
type Idx struct {
Filename string
Groups []Group
}
type Group struct {
Spec Spec
Records []Record
Details []Detail // Records and details are correlated by index
}
// type Spec links a set of animations to a starting sprite in WarHammer.ani
type Spec struct {
Offset uint32 // Where the Records for this Spec are to be found
Count uint32 // Number of Records for this Spec
SpriteIdx uint32 // Index of the first sprite in
}
type Record struct {
// A guess, but each group of 8 records with increasing compass points share
// this value.
ActionID uint16
Compass byte // It's odd to only have one byte. Maybe Unknown1 belongs to this too?
Unknown1 byte // ??? Only see values 0x33 and 0x00 for librarian.
Offset uint32 // Where the Detail for this Record is to be found.
NumFrames uint32 // A guess, but seems to fit. Number of frames for this action + compass.
}
type Detail struct {
FirstSprite uint16 // Relative offset from the group's SpriteIdx
LastSprite uint16 // Relative offset from the group's SpriteIdx
Unknown1 uint16 // Could also be LastSprite? Something else? AtRestSprite?
Unknown2 uint16 // Number of resting sprites, if we're AtRestSprite?
Padding [12]byte // Set to zero in the cases I've looked at so far.
// Remainder []byte // FIXME: no idea what this is yet, but we seem to have NumFrames*6 of them
}
func Load(filename string) (*Idx, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
out := &Idx{
Filename: filename,
Groups: make([]Group, NumGroups),
}
var specs [NumGroups]Spec
if err := binary.Read(f, binary.LittleEndian, &specs); err != nil {
return nil, fmt.Errorf("reading specs: %v", err)
}
for i, spec := range specs {
group := &out.Groups[i]
group.Spec = spec
group.Records = make([]Record, spec.Count)
group.Details = make([]Detail, spec.Count)
if _, err := f.Seek(int64(spec.Offset), 0); err != nil {
return nil, fmt.Errorf("spec %v: seeking: %v", i, err)
}
// We can read all records at once
if err := binary.Read(f, binary.LittleEndian, &group.Records); err != nil {
return nil, fmt.Errorf("spec %v: reading records: %v", i, err)
}
// But we need to step through the records to learn where to read details
for j, rec := range group.Records {
// group.Details[j].Remainder = make([]byte, rec.NumFrames*6)
if _, err := f.Seek(int64(rec.Offset), 0); err != nil {
return nil, fmt.Errorf("spec %v, record %v: seeking to detail: %v", i, j, err)
}
if err := binary.Read(f, binary.LittleEndian, &group.Details[j]); err != nil {
return nil, fmt.Errorf("spec %v, record %v: reading detail: %v", i, j, err)
}
}
out.Groups[i] = *group
}
return out, nil
}

View File

@@ -5,6 +5,7 @@ import (
"compress/gzip"
"encoding/binary"
"fmt"
"image"
"io"
"io/ioutil"
"log"
@@ -14,8 +15,8 @@ import (
)
var (
expectedMagic = []byte("\x08\x00WHMAP\x00")
expectedSetNameOffset = uint32(0x34)
expectedMagic = []byte("\x15\x00AMB_MAP\x00")
expectedSetNameOffset = uint32(0x10)
notImplemented = fmt.Errorf("Not implemented")
)
@@ -24,41 +25,53 @@ const (
MaxLength = 100 // Y coordinate
MaxWidth = 130 // X coordinate
CellSize = 16 // seems to be
CellSize = 13 // seems to be
cellDataOffset = 0x110 // tentatively
cellDataOffset = 0xc0
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`
Magic [10]byte // "\x15\x00AMB_MAP\x00"
SetName [8]byte // Links to a filename in `/Sets/*.set`
// Need to investigate the rest of the header too
IsCampaignMap byte
}
func (h Header) Width() int {
return int(h.MaxWidth - h.MinWidth)
type TrailerHeader struct {
Discard1 [3]byte // No idea what this lot is
MaxWidth uint32
MaxLength uint32
MinWidth uint32
MinLength uint32
NumCharacters uint32
Unknown1 uint32
Unknown2 uint16
Unknown3 uint16
Unknown4 uint32
NumThingies uint32
Padding1 [20]byte
}
func (h Header) Length() int {
return int(h.MaxLength - h.MinLength)
type TrailerTrailer struct {
Title [255]byte
Briefing [2048]byte
Unknown1 [85]uint8 // Maybe? each contains either 0 or 1? Hard to say
}
func (h Header) Height() int {
return MaxHeight
type Character struct {
Unknown1 uint32
}
type Characters []Character
// TODO. These are triggers/reactors/etc.
type Thingy struct {}
type Thingies []Thingy
func (h Header) MapSetName() string {
idx := bytes.IndexByte(h.SetName[:], 0)
if idx < 0 {
@@ -79,7 +92,7 @@ type ObjRef struct {
// The index into a set palette to retrieve the object
func (o ObjRef) Index() int {
return int(o.AreaByte)
return int(o.AreaByte & 0x7f)
}
func (o ObjRef) Sprite() int {
@@ -90,12 +103,13 @@ func (o ObjRef) Sprite() int {
// The top bit seems to say whether we should draw or not.
func (o ObjRef) IsActive() bool {
return (o.SpriteAndFlagByte & 0x80) == 0x80
}
} // PARIS is 78 x 60 x 7
// 4E 3C 7
/*
type Cell struct {
DoorAndCanisterRelated byte
DoorLockAndReactorRelated byte
Unknown2 byte
// DoorLockAndReactorRelated byte
// Unknown2 byte
Surface ObjRef
Left ObjRef
Right ObjRef
@@ -104,43 +118,60 @@ type Cell struct {
Unknown12 byte
Unknown13 byte
Unknown14 byte
SquadRelated byte
// SquadRelated byte
}*/
type Cell struct {
Unknown1 byte
Surface ObjRef
Left ObjRef
Right ObjRef
Center ObjRef
Unknown2 [4]byte
/*
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
return c.Unknown1
case 1:
return c.DoorLockAndReactorRelated
case 2:
return c.Unknown2
case 3:
return c.Surface.AreaByte
case 4:
case 2:
return c.Surface.SpriteAndFlagByte
case 5:
case 3:
return c.Left.AreaByte
case 6:
case 4:
return c.Left.SpriteAndFlagByte
case 7:
case 5:
return c.Right.AreaByte
case 8:
case 6:
return c.Right.SpriteAndFlagByte
case 9:
case 7:
return c.Center.AreaByte
case 10:
case 8:
return c.Center.SpriteAndFlagByte
case 9:
return c.Unknown2[0]
case 10:
return c.Unknown2[1]
case 11:
return c.Unknown11
return c.Unknown2[2]
case 12:
return c.Unknown12
case 13:
return c.Unknown13
case 14:
return c.Unknown14
case 15:
return c.SquadRelated
return c.Unknown2[3]
}
return 0
@@ -149,15 +180,23 @@ func (c *Cell) At(n int) byte {
// Cells is always a fixed size; use At to get a cell according to x,y,z
type Cells []Cell
// 6 Possibilities for being laid out in memory. Most likely:
// XXYYZZ
// OR
// XYZXYZ
func (c Cells) At(x, y, z int) Cell {
return c[(z*MaxLength*MaxWidth)+(y*MaxWidth)+x]
// log.Printf("At (%v,%v,%v)=%v", x, y, z, x*y*z)
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 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))
@@ -169,10 +208,26 @@ func (h Header) Check() []error {
type GameMap struct {
Header
Cells
// TODO: parse this into sections
TrailerHeader
Characters
Thingies
TrailerTrailer
// TODO: parse this into sections. This is the text that comes from the
// .TXT file. Maybe we don't need it at all since it should be duplicated in
// the trailer.
Text string
}
func (m *GameMap) Rect() image.Rectangle {
return image.Rect(
int(m.TrailerHeader.MinWidth),
int(m.TrailerHeader.MinLength),
int(m.TrailerHeader.MaxWidth-1),
int(m.TrailerHeader.MaxLength-1),
)
}
// 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) {
@@ -276,5 +331,42 @@ func loadMapFile(filename string) (*GameMap, error) {
return nil, fmt.Errorf("Error parsing cells for %s: %v", filename, err)
}
// no gzip.SeekReader, so discard unread trailer bytes for now
if _, err := io.CopyN(ioutil.Discard, zr, int64(3320-2)); err != nil { // observed
return nil, err
}
if err := binary.Read(zr, binary.LittleEndian, &out.TrailerHeader); err != nil {
return nil, fmt.Errorf("Error parsing trailer header for %s: %v", filename, err)
}
log.Printf("Trailer Header: %#+v", out.TrailerHeader)
/*
// TODO: until we know how large each character record should be, we can't read this lot
out.Characters = make(Characters, int(out.TrailerHeader.NumCharacters))
if err := binary.Read(zr, binary.LittleEndian, &out.Characters); err != nil {
return nil, fmt.Errorf("Error parsing characters for %s: %v", filename, err)
}
out.Thingies = make(Thingies, int(out.TrailerHeader.NumThingies))
if err := binary.Read(zr, binary.LittleEndian, &out.Thingies); err != nil {
return nil, fmt.Errorf("Error parsing thingies for %s: %v", filename, err)
}
if err := binary.Read(zr, binary.LittleEndian, &out.TrailerTrailer); err != nil {
return nil, fmt.Errorf("Error parsing trailer trailer for %s: %v", filename, err)
}
log.Printf("Trailer Trailer: %s", out.TrailerTrailer.String())
*/
return &out, nil
}
func (t *TrailerTrailer) String() string {
return fmt.Sprintf(
"title=%q briefing=%q rest=%#+v",
strings.TrimRight(string(t.Title[:]), "\x00"),
strings.TrimRight(string(t.Briefing[:]), "\x00"),
t.Unknown1,
)
}

View File

@@ -2,48 +2,54 @@ package menus
import (
"fmt"
"image"
"image/color"
"io/ioutil"
"log"
"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
const (
TypeStatic MenuType = 0
TypeMenu MenuType = 1
TypeDragMenu MenuType = 2 // Only seen in Configure_Vehicle_{Chaos,Ultra}
TypeSimpleButton MenuType = 3
TypeDoorHotspot MenuType = 30 // Like a button I guess? "FONTTYPE is animation speed"
TypeDoorHotspot2 MenuType = 31 // Seems like a duplicate of the above? What's different?
TypeLineKbd MenuType = 40
TypeThumb MenuType = 45 // A "thumb" appears to be a vertical slider
TypeLineBriefing MenuType = 41
TypeInvokeButton MenuType = 50
TypeDoorHotspot3 MenuType = 60 // Maybe? Appears in Arrange.mnu
TypeOverlay MenuType = 61
TypeHypertext MenuType = 70
TypeCheckbox MenuType = 91
TypeEditBox MenuType = 100
TypeInventorySelect MenuType = 110
TypeRadioButton MenuType = 120
TypeDropdownButton MenuType = 200
TypeComboBoxItem MenuType = 205
TypeAnimationSample MenuType = 220
TypeAnimationHover MenuType = 221 // FONTTYPE is animation speed. Only animate when hovered
TypeMainButton MenuType = 228
TypeSlider MenuType = 232
TypeStatusBar MenuType = 233
TypeDialogue MenuType = 300
// SubMenuType tells us what sort of Record we have
type SubMenuType int
TypeListBoxUp MenuType = 400 // FIXME: these have multiple items in MENUTYPE
TypeListBoxDown MenuType = 405
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
@@ -63,39 +69,11 @@ var TextOverrides = map[string]string{
"main:2.7": "0.1-ordoor",
}
// FIXME: The menu is specified as type 2 (button) in these cases, which is
// weird. Make it a menu for now.
var TypeOverrides = map[string]MenuType{
"levelply:2": TypeMenu,
"savegame:2": TypeMenu,
"loadgame:2": TypeMenu,
// ???
"configure_ultequip:7.5": TypeListBoxUp,
"configure_ultequip:7.6": TypeListBoxDown,
}
type Record struct {
Menu *Menu
Parent *Record
Children []*Record
Id int
Type MenuType
DrawType int
FontType int
Active bool
SpriteId []int
Share int
X int
Y int
// From i18n
Text string
Help string
// FIXME: turn these into first-class data
properties map[string]string
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 {
@@ -104,23 +82,71 @@ type Menu struct {
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
// FIXME: turn these into first-class data
Properties map[string]string
FontType int
// The actual menu records. There are multiple top-level items. Submenus are
// only ever nested one deep.
Records []*Record
Groups []*Group
}
func LoadMenu(filename string) (*Menu, error) {
// 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, palette color.Palette) (*Menu, error) {
name := filepath.Base(filename)
name = strings.Replace(name, filepath.Ext(name), "", -1)
name = strings.TrimSuffix(name, filepath.Ext(name))
name = strings.ToLower(name)
// FIXME: this needs turning into a real parser sometime
scanner, err := asciiscan.New(filename)
if err != nil {
return nil, err
@@ -128,84 +154,171 @@ func LoadMenu(filename string) (*Menu, error) {
defer scanner.Close()
var str string
var record *Record
section := 0
isProp := false
out := &Menu{
Name: name,
Properties: map[string]string{},
Name: name,
}
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)
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" {
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(out, nil)
case "SUBMENUID":
record = newRecord(out, record.Toplevel())
}
setProperty(record, k, v)
}
if err := loadObjects(out, scanner); err != nil {
return nil, err
}
log.Printf("Menu properties: %#+v", out.Properties)
if err := loadProperties(out, scanner, palette); 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 LoadMenus(dir string) (map[string]*Menu, error) {
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, palette color.Palette) 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 = palette[vInt]
case "HYPERTEXT COLOR":
menu.HypertextColor = palette[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, palette color.Palette) (map[string]*Menu, error) {
fis, err := ioutil.ReadDir(dir)
if err != nil {
return nil, err
@@ -223,7 +336,7 @@ func LoadMenus(dir string) (map[string]*Menu, error) {
continue
}
built, err := LoadMenu(filepath.Join(dir, relname))
built, err := LoadMenu(filepath.Join(dir, relname), palette)
if err != nil {
return nil, fmt.Errorf("%s: %v", filepath.Join(dir, relname), err)
}
@@ -234,68 +347,94 @@ func LoadMenus(dir string) (map[string]*Menu, error) {
return out, nil
}
func newRecord(menu *Menu, parent *Record) *Record {
out := &Record{
Menu: menu,
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) {
vSplit := strings.Split(v, ",")
vInt, _ := strconv.Atoi(v)
func listOfInts(s string) []int {
vSplit := strings.Split(s, ",")
vSplitInt := make([]int, len(vSplit))
for i, subV := range vSplit {
vSplitInt[i], _ = strconv.Atoi(subV)
}
switch k {
case "MENUID", "SUBMENUID":
r.Id = vInt
case "MENUTYPE", "SUBMENUTYPE":
if strings.Contains(v, ",") {
r.Type = MenuType(vSplitInt[0]) // FIXME: what are the other values in this case?
} else {
r.Type = MenuType(vInt)
}
return vSplitInt
}
// FIXME: Type override. Note that MENUID is specified first, so this works
if override, ok := TypeOverrides[r.Locator()]; ok {
r.Type = override
}
case "ACTIVE":
r.Active = (vInt != 0)
case "SPRITEID":
r.SpriteId = vSplitInt
case "X-CORD":
r.X = vInt
case "Y-CORD":
r.Y = vInt
case "FONTTYPE":
r.FontType = vInt
case "DRAW TYPE":
r.DrawType = vInt
case "SHARE":
r.Share = vInt
default:
r.properties[k] = v
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 {
@@ -304,43 +443,47 @@ type Replacer interface {
}
func (r *Record) Internationalize(replacer Replacer) {
if override, ok := TextOverrides[r.Locator()]; ok {
delete(r.properties, "DESC")
if override, ok := TextOverrides[r.Locator]; ok {
r.Text = override
return
}
if override, ok := DescOverrides[r.Locator()]; ok {
r.properties["DESC"] = strconv.Itoa(override)
if override, ok := DescOverrides[r.Locator]; ok {
r.Desc = strconv.Itoa(override)
}
id, err := strconv.Atoi(r.properties["DESC"])
id, err := strconv.Atoi(r.Desc)
if err == nil {
delete(r.properties, "DESC")
replacer.ReplaceText(id, &r.Text)
replacer.ReplaceHelp(id, &r.Help)
}
for _, child := range r.Children {
child.Internationalize(replacer)
} else {
r.Text = r.Desc // Sometimes it's a string like "EQUIPMENT"
}
}
func (m *Menu) Internationalize(replacer Replacer) {
for _, record := range m.Records {
record.Internationalize(replacer)
for _, group := range m.Groups {
for _, record := range group.Records {
record.Internationalize(replacer)
}
}
}
func (r *Record) Path() string {
var path []string
func (g *Group) Props() *Properties {
return &g.Properties
}
for rec := r; rec != nil; rec = rec.Parent {
path = append([]string{strconv.Itoa(rec.Id)}, path...)
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 strings.Join(path, ".")
}
func (r *Record) Locator() string {
return fmt.Sprintf("%v:%v", r.Menu.Name, r.Path())
return base
}

View File

@@ -0,0 +1,20 @@
package ordoor
import (
"time"
)
func (o *Ordoor) DisplayImageFor(d time.Duration, name string) error {
img, err := o.assets.Image(name)
if err != nil {
return err
}
o.pic = img
go func() {
<-time.After(d)
o.pic = nil // FIXME: this is a race condition and a half
}()
return nil
}

View File

@@ -1,47 +0,0 @@
package flow
func (f *Flow) linkBridge() {
// FIXME: sometimes these doors are frozen, depending on game state
f.onClick(bridge, "2.1", f.setDriver(briefing)) // TODO: Mission briefing clickable
f.onClick(bridge, "2.2", f.setDriver(choices)) // Options door hotspot
f.setFreeze(bridge, "2.4", false) // FIXME: Enter combat door hotspot (!!!)
f.setFreeze(bridge, "2.6", false) // FIXME: Vehicle configure door hotspot
f.onClick(bridge, "2.8", f.setDriver(arrange)) // Squads configure door hotspot
// link children
f.linkBriefing()
f.linkChoices()
f.linkArrange()
}
func (f *Flow) linkBriefing() {
f.onClick(briefing, "3.1", f.setDriver(bridge))
}
func (f *Flow) linkChoices() {
f.onClick(choices, "2.1", f.setDriver(loadGame)) // Load another game button
f.onClick(choices, "2.2", f.setDriver(saveGame)) // Save this game button
f.onClick(choices, "2.3", f.setReturningDriver(choices, options)) // More options button
// FIXME: wipe out game state when this goes through
f.onClick(choices, "2.4", f.setDriver(main)) // Restart button
f.onClick(choices, "2.5", f.setDriver(credits)) // Credits button
f.onClick(choices, "2.6", f.setExit) // Quit button
f.onClick(choices, "2.7", f.setDriver(bridge)) // Back button
}
func (f *Flow) linkArrange() {
// FIXME: we should be operating on game data in here
f.onClick(arrange, "8.1", f.setDriver(bridge)) // Return to bridge ("cathedral")
f.onClick(arrange, "8.3", f.setDriver(configureUltEquip)) // Configure squads
f.linkConfigureUltEquip()
}
func (f *Flow) linkConfigureUltEquip() {
// FIXME: we should be modifying loadouts of selected squad members here
f.onClick(configureUltEquip, "8.1", f.setDriver(bridge)) // Return to bridge
}

View File

@@ -1,229 +0,0 @@
package flow
import (
"errors"
"fmt"
"github.com/hajimehoshi/ebiten"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ui"
)
// type Flow is responsible for wiring up UI elements to each other and ensuring
// they behave as expected. This includes forward / back buttons to switch
// between screens, loading and saving options, launching a scenario, etc
type Flow struct {
assets *assetstore.AssetStore
config *config.Config
current *ui.Driver
drivers map[driverName]*ui.Driver
// Some screens can be returned to from more than one place. Where this is
// the case, instead of hardcoding it, we'll store an entry in here so we
// know where we're going back to
//
// FIXME: this really suggests wiring everything up at the start is wrong.
returns map[driverName]driverName
exit error
}
type driverName string
const (
// Names of all the drivers
main driverName = "Main"
levelPly driverName = "LevelPly"
singles driverName = "Singles"
randomMap driverName = "RandomMap"
newGame driverName = "NewGame"
loadGame driverName = "LoadGame"
options driverName = "Options"
kbd driverName = "Keyboard"
bridge driverName = "Bridge"
briefing driverName = "Briefing"
choices driverName = "Choices"
saveGame driverName = "SaveGame"
credits driverName = "Credits"
arrange driverName = "Arrange"
configureUltEquip driverName = "Configure_UltEquip"
configureVehiclesUltra driverName = "Configure_Vehicles_Ultra"
)
var (
ErrExit = errors.New("exiting gracefully")
driverNames = []driverName{
main, levelPly, singles, randomMap, newGame, loadGame, options, kbd,
bridge, briefing, choices, saveGame, credits, arrange,
configureUltEquip, configureVehiclesUltra,
}
// Constants used for sliders
h3Slider = map[int]int{1: 8, 2: 56, 3: 110, 4: 120}
v10Slider = map[int]int{
0: 0,
10: 9, 20: 18, 30: 27, 40: 36, 50: 45,
60: 54, 70: 63, 80: 72, 90: 81, 100: 90,
}
h9Slider = map[int]int{
0: 0,
10: 10, 20: 20, 30: 30, 40: 40,
50: 50, 60: 60, 70: 70, 80: 80,
}
)
func New(assets *assetstore.AssetStore, config *config.Config) (*Flow, error) {
out := &Flow{
assets: assets,
config: config,
drivers: make(map[driverName]*ui.Driver, len(driverNames)),
returns: make(map[driverName]driverName),
}
// Load all the drivers upfront
for _, name := range driverNames {
driver, err := buildDriver(assets, name)
if err != nil {
return nil, err
}
out.drivers[name] = driver
}
// Initial load of the config into the options UI
if err := out.configIntoOptions(); err != nil {
return nil, err
}
out.linkDrivers()
out.setDriverNow(main)
return out, nil
}
func buildDriver(assets *assetstore.AssetStore, name driverName) (*ui.Driver, error) {
menu, err := assets.Menu(string(name))
if err != nil {
return nil, err
}
driver, err := ui.NewDriver(menu)
if err != nil {
return nil, err
}
return driver, nil
}
func (f *Flow) Update(screenX, screenY int) error {
if f.exit != nil {
return f.exit
}
return f.current.Update(screenX, screenY)
}
func (f *Flow) Draw(screen *ebiten.Image) error {
if f.exit != nil {
return f.exit
}
return f.current.Draw(screen)
}
func (f *Flow) linkDrivers() {
// linkMain
f.onClick(main, "2.1", f.setDriver(newGame)) // New game
f.onClick(main, "2.2", f.setDriver(loadGame)) // Load game
f.setFreeze(main, "2.3", true) // Multiplayer - disable for now
f.onClick(main, "2.4", f.setReturningDriver(main, options)) // Options
f.onClick(main, "2.5", f.setExit) // Quit
// Now link immediate children. They will link their children, and so on
f.linkNewGame()
f.linkLoadGame()
// TODO: link multiplayer
f.linkOptions()
}
func maybeErr(driver driverName, err error) error {
if err != nil {
return fmt.Errorf("%v: %v", driver, err)
}
return nil
}
func (f *Flow) configureSlider(driver driverName, id string, steps map[int]int) {
if f.exit != nil {
return
}
f.exit = f.drivers[driver].ConfigureSlider(id, steps)
}
func (f *Flow) onClick(driver driverName, id string, fn func()) {
if f.exit != nil {
return
}
f.exit = maybeErr(driver, f.drivers[driver].OnClick(id, fn))
}
func (f *Flow) setFreeze(driver driverName, id string, value bool) {
if f.exit != nil {
return
}
f.exit = maybeErr(driver, f.drivers[driver].SetFreeze(id, value))
}
func (f *Flow) setValueBool(driver driverName, id string, value bool) {
if f.exit != nil {
return
}
f.exit = f.drivers[driver].SetValueBool(id, value)
}
func (f *Flow) setDriver(name driverName) func() {
return func() {
f.setDriverNow(name)
}
}
func (f *Flow) setDriverNow(name driverName) {
f.current = f.drivers[name]
}
// from is the parent menu, to is the child
func (f *Flow) setReturningDriver(from, to driverName) func() {
return func() {
f.returns[to] = from
f.setDriverNow(to)
}
}
// from is the child menu, to is the parent
func (f *Flow) returnToLastDriverNow(from driverName) error {
to, ok := f.returns[from]
if !ok {
return fmt.Errorf("Couldn't work out where to return to from %v", from)
}
delete(f.returns, from)
f.setDriverNow(to)
return nil
}
func (f *Flow) setExit() {
f.exit = ErrExit
}

View File

@@ -1,6 +0,0 @@
package flow
func (f *Flow) linkLoadGame() {
// Load game
f.onClick(loadGame, "3.3", f.setDriver(main)) // Cancel button
}

View File

@@ -1,44 +0,0 @@
package flow
func (f *Flow) linkNewGame() {
// New game
f.onClick(newGame, "2.1", f.setDriver(levelPly)) // New campaign button
f.onClick(newGame, "2.2", f.setDriver(singles)) // Single scenario button
f.onClick(newGame, "2.3", f.setDriver(randomMap)) // Random scenario button
f.onClick(newGame, "2.4", f.setDriver(main)) // Back button
f.linkLevelPly()
f.linkSingles()
f.linkRandomMap()
}
func (f *Flow) linkLevelPly() {
// We want the default difficulty level to be Veteran, not Hero.
// FIXME: Make the radio button respect changes via setValue
resetLevel := func() {
f.setValueBool(levelPly, "2.1", false)
f.setValueBool(levelPly, "2.2", true)
}
resetLevel()
f.onClick(levelPly, "2.5", func() { // Back button
resetLevel()
f.setDriverNow(newGame)
})
// FIXME: we should select a savegame if Mighty Hero is selected here
// FIXME: we should show a movie here. Need an internal SMK player first
// FIXME: we should set up new game state here!
f.onClick(levelPly, "2.7", f.setDriver(bridge)) // Select button
// Link children
f.linkBridge()
}
func (f *Flow) linkSingles() {
f.onClick(singles, "4.11", f.setDriver(newGame)) // Back button
}
func (f *Flow) linkRandomMap() {
f.onClick(randomMap, "2.19", f.setDriver(newGame)) // Back button
}

View File

@@ -5,63 +5,70 @@
package ordoor
import (
"errors"
"fmt"
"log"
"time"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/audio"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/config"
"code.ur.gs/lupine/ordoor/internal/ordoor/flow"
"code.ur.gs/lupine/ordoor/internal/flow"
"code.ur.gs/lupine/ordoor/internal/ship"
"code.ur.gs/lupine/ordoor/internal/ui"
)
type gameState int
const (
StateInterface gameState = 1
StateExit gameState = 666
)
var (
errExit = errors.New("User-requested exit action")
)
type Ordoor struct {
assets *assetstore.AssetStore
config *config.Config
music *audio.Player
win *ui.Window
state gameState
nextState gameState
// Relevant to interface state
flow *flow.Flow
// FIXME: should be put inside flow
// If this is set, we display it instead of flow
pic *ebiten.Image
// Relevant to campaign state
ship *ship.Ship
}
func Run(configFile string, overrideX, overrideY int) error {
cfg, err := config.Load(configFile)
cfg, err := config.Load(configFile, "ordoor")
if err != nil {
return fmt.Errorf("Couldn't load config file: %v", err)
}
assets, err := assetstore.New(cfg.Ordoor.DataDir)
assets, err := assetstore.New(cfg.DefaultEngine())
if err != nil {
return fmt.Errorf("Failed to initialize asset store: %v", err)
}
defaults, err := assets.DefaultOptions()
if err != nil {
return fmt.Errorf("Failed to read option defaults: %v", err)
}
cfg.Defaults = defaults
if cfg.HasUnsetOptions() {
if err := cfg.ResetDefaults(); err != nil {
return fmt.Errorf("Failed to set options on first-start: %v", err)
}
}
if _, err := audio.NewContext(48000); err != nil {
return fmt.Errorf("Failed to set up audio context: %v", err)
}
ordoor := &Ordoor{
assets: assets,
config: cfg,
state: StateInterface,
nextState: StateInterface,
assets: assets,
config: cfg,
ship: ship.New(),
}
x, y := cfg.Options.XRes, cfg.Options.YRes
@@ -79,24 +86,24 @@ func Run(configFile string, overrideX, overrideY int) error {
ordoor.win = win
if err := ordoor.setupFlow(); err != nil {
return fmt.Errorf("failed to setup UI flow: %v", err)
}
if err := ordoor.Run(); err != nil {
return fmt.Errorf("Run returned %v", err)
return fmt.Errorf("Run finished with error: %v", err)
}
return nil
}
func (o *Ordoor) Run() error {
// FIXME: we're missing a screen about SSI here
// FIXME: these should be displayed *after*, not *before*, the copyright
if o.config.Options.PlayMovies {
o.PlayUnskippableVideo("LOGOS")
o.PlaySkippableVideo("LOGOS")
o.PlaySkippableVideo("movie1")
}
if err := o.DisplayImageFor(time.Second, "copyright"); err != nil {
log.Printf("Failed to display copyright image: %v", err)
}
err := o.win.Run()
if err == flow.ErrExit {
log.Printf("Exit requested")
@@ -108,6 +115,8 @@ func (o *Ordoor) Run() error {
// Only one music track can play at a time. This is handled at the toplevel.
// FIXME: should take references from Sounds.dat
// FIXME: music probably properly belongs to flow. This package can just do
// initialization and wire the flow to the ship?
func (o *Ordoor) PlayMusic(name string) error {
if o.music != nil {
if err := o.music.Close(); err != nil {
@@ -136,7 +145,7 @@ func (o *Ordoor) PlayMusic(name string) error {
func (o *Ordoor) setupFlow() error {
o.PlayMusic("music_interface")
flow, err := flow.New(o.assets, o.config)
flow, err := flow.New(o.assets, o.config, o.ship)
if err != nil {
return err
}
@@ -147,21 +156,15 @@ func (o *Ordoor) setupFlow() error {
}
func (o *Ordoor) Update(screenX, screenY int) error {
// Perform state transitions
if o.state != o.nextState {
log.Printf("State transition: %v -> %v", o.state, o.nextState)
switch o.nextState {
case StateExit:
{
return errExit
}
default:
return fmt.Errorf("Unknown state transition: %v -> %v", o.state, o.nextState)
}
if pic := o.pic; pic != nil {
return nil // Ignore flow until we don't have a pic any more
}
// State transition is finished, hooray
o.state = o.nextState
if o.flow == nil {
if err := o.setupFlow(); err != nil {
return fmt.Errorf("failed to setup UI flow: %v", err)
}
}
// Ensure music is doing the right thing
if o.music != nil && o.music.IsPlaying() != o.config.Options.PlayMusic {
@@ -173,19 +176,28 @@ func (o *Ordoor) Update(screenX, screenY int) error {
}
}
switch o.state {
case StateInterface:
return o.flow.Update(screenX, screenY)
default:
return fmt.Errorf("Unknown state: %v", o.state)
}
return o.flow.Update(screenX, screenY)
}
func (o *Ordoor) Draw(screen *ebiten.Image) error {
switch o.state {
case StateInterface:
return o.flow.Draw(screen)
default:
return fmt.Errorf("Unknown state: %v", o.state)
if pic := o.pic; pic != nil {
// Scale the picture to the screen and draw it
scaleX := float64(screen.Bounds().Dx()) / float64(pic.Bounds().Dx())
scaleY := float64(screen.Bounds().Dy()) / float64(pic.Bounds().Dy())
do := &ebiten.DrawImageOptions{}
do.GeoM.Scale(scaleX, scaleY)
return screen.DrawImage(pic, do)
}
return o.flow.Draw(screen)
}
func (o *Ordoor) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) {
if o.flow != nil {
return o.flow.Cursor()
}
return nil, nil, nil
}

View File

@@ -6,7 +6,7 @@ import (
)
func (o *Ordoor) PlayVideo(name string, skippable bool) {
filename := o.config.DataFile("SMK/" + name + ".smk")
filename := o.config.DataFile("ordoor", "SMK/"+name+".smk")
if len(o.config.VideoPlayer) == 0 {
log.Printf("Video player not configured, skipping video %v", filename)

View File

@@ -1,12 +1,11 @@
package data
package palettes
import "image/color"
var (
Transparent = color.RGBA{R: 0, G: 0, B: 0, A: 0}
ColorPalette = color.Palette{
ChaosGatePalette = color.Palette{
Transparent,
color.RGBA{R: 128, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 128, B: 0, A: 255},
color.RGBA{R: 128, G: 128, B: 0, A: 255},
@@ -264,3 +263,7 @@ var (
color.RGBA{R: 255, G: 255, B: 255, A: 255},
}
)
func init() {
Palettes["ChaosGate"] = ChaosGatePalette
}

View File

@@ -0,0 +1,20 @@
package palettes
import (
"image/color"
"sync"
)
var (
Transparent = color.RGBA{R: 0, G: 0, B: 0, A: 0}
Palettes = map[string]color.Palette{}
initPalettes = sync.Once{}
)
func Get(name string) (color.Palette, bool) {
p, ok := Palettes[name]
return p, ok
}

View File

@@ -0,0 +1,269 @@
package palettes
import "image/color"
var (
SoldiersAtWarPalette = color.Palette{
Transparent,
color.RGBA{R: 128, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 128, B: 0, A: 255},
color.RGBA{R: 128, G: 128, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 128, A: 255},
color.RGBA{R: 128, G: 0, B: 128, A: 255},
color.RGBA{R: 0, G: 128, B: 128, A: 255},
color.RGBA{R: 192, G: 192, B: 192, A: 255},
color.RGBA{R: 192, G: 220, B: 192, A: 255},
color.RGBA{R: 166, G: 202, B: 240, A: 255},
color.RGBA{R: 0, G: 136, B: 65, A: 255},
color.RGBA{R: 24, G: 153, B: 68, A: 255},
color.RGBA{R: 48, G: 170, B: 71, A: 255},
color.RGBA{R: 72, G: 187, B: 73, A: 255},
color.RGBA{R: 96, G: 204, B: 76, A: 255},
color.RGBA{R: 120, G: 218, B: 122, A: 255},
color.RGBA{R: 24, G: 12, B: 0, A: 255},
color.RGBA{R: 31, G: 14, B: 7, A: 255},
color.RGBA{R: 43, G: 19, B: 5, A: 255},
color.RGBA{R: 48, G: 21, B: 3, A: 255},
color.RGBA{R: 60, G: 26, B: 2, A: 255},
color.RGBA{R: 79, G: 43, B: 18, A: 255},
color.RGBA{R: 99, G: 60, B: 35, A: 255},
color.RGBA{R: 118, G: 77, B: 52, A: 255},
color.RGBA{R: 137, G: 94, B: 69, A: 255},
color.RGBA{R: 157, G: 111, B: 86, A: 255},
color.RGBA{R: 176, G: 127, B: 103, A: 255},
color.RGBA{R: 195, G: 144, B: 120, A: 255},
color.RGBA{R: 214, G: 161, B: 137, A: 255},
color.RGBA{R: 234, G: 178, B: 154, A: 255},
color.RGBA{R: 253, G: 197, B: 173, A: 255},
color.RGBA{R: 11, G: 11, B: 230, A: 255},
color.RGBA{R: 41, G: 50, B: 255, A: 255},
color.RGBA{R: 62, G: 79, B: 255, A: 255},
color.RGBA{R: 83, G: 109, B: 255, A: 255},
color.RGBA{R: 103, G: 138, B: 255, A: 255},
color.RGBA{R: 124, G: 168, B: 255, A: 255},
color.RGBA{R: 145, G: 197, B: 255, A: 255},
color.RGBA{R: 166, G: 227, B: 255, A: 255},
color.RGBA{R: 0, G: 16, B: 0, A: 255},
color.RGBA{R: 1, G: 27, B: 0, A: 255},
color.RGBA{R: 2, G: 37, B: 0, A: 255},
color.RGBA{R: 3, G: 48, B: 0, A: 255},
color.RGBA{R: 4, G: 59, B: 0, A: 255},
color.RGBA{R: 5, G: 70, B: 0, A: 255},
color.RGBA{R: 6, G: 80, B: 0, A: 255},
color.RGBA{R: 7, G: 91, B: 0, A: 255},
color.RGBA{R: 48, G: 4, B: 0, A: 255},
color.RGBA{R: 64, G: 4, B: 0, A: 255},
color.RGBA{R: 93, G: 8, B: 0, A: 255},
color.RGBA{R: 121, G: 24, B: 0, A: 255},
color.RGBA{R: 145, G: 40, B: 0, A: 255},
color.RGBA{R: 174, G: 64, B: 0, A: 255},
color.RGBA{R: 198, G: 89, B: 0, A: 255},
color.RGBA{R: 226, G: 121, B: 0, A: 255},
color.RGBA{R: 238, G: 155, B: 0, A: 255},
color.RGBA{R: 255, G: 182, B: 28, A: 255},
color.RGBA{R: 255, G: 202, B: 60, A: 255},
color.RGBA{R: 255, G: 218, B: 97, A: 255},
color.RGBA{R: 255, G: 234, B: 129, A: 255},
color.RGBA{R: 255, G: 242, B: 165, A: 255},
color.RGBA{R: 255, G: 250, B: 198, A: 255},
color.RGBA{R: 255, G: 238, B: 238, A: 255},
color.RGBA{R: 0, G: 32, B: 16, A: 255},
color.RGBA{R: 0, G: 44, B: 24, A: 255},
color.RGBA{R: 4, G: 60, B: 36, A: 255},
color.RGBA{R: 8, G: 76, B: 52, A: 255},
color.RGBA{R: 16, G: 89, B: 64, A: 255},
color.RGBA{R: 24, G: 105, B: 80, A: 255},
color.RGBA{R: 36, G: 121, B: 97, A: 255},
color.RGBA{R: 4, G: 56, B: 0, A: 255},
color.RGBA{R: 11, G: 68, B: 9, A: 255},
color.RGBA{R: 19, G: 80, B: 18, A: 255},
color.RGBA{R: 26, G: 92, B: 27, A: 255},
color.RGBA{R: 34, G: 105, B: 37, A: 255},
color.RGBA{R: 41, G: 117, B: 46, A: 255},
color.RGBA{R: 49, G: 129, B: 55, A: 255},
color.RGBA{R: 56, G: 141, B: 64, A: 255},
color.RGBA{R: 40, G: 0, B: 0, A: 255},
color.RGBA{R: 56, G: 0, B: 0, A: 255},
color.RGBA{R: 76, G: 0, B: 0, A: 255},
color.RGBA{R: 97, G: 0, B: 0, A: 255},
color.RGBA{R: 113, G: 0, B: 0, A: 255},
color.RGBA{R: 133, G: 4, B: 4, A: 255},
color.RGBA{R: 153, G: 4, B: 4, A: 255},
color.RGBA{R: 170, G: 8, B: 8, A: 255},
color.RGBA{R: 190, G: 12, B: 12, A: 255},
color.RGBA{R: 210, G: 16, B: 16, A: 255},
color.RGBA{R: 214, G: 48, B: 48, A: 255},
color.RGBA{R: 222, G: 80, B: 80, A: 255},
color.RGBA{R: 230, G: 117, B: 117, A: 255},
color.RGBA{R: 238, G: 153, B: 153, A: 255},
color.RGBA{R: 246, G: 194, B: 194, A: 255},
color.RGBA{R: 255, G: 212, B: 227, A: 255},
color.RGBA{R: 10, G: 10, B: 10, A: 255},
color.RGBA{R: 25, G: 25, B: 25, A: 255},
color.RGBA{R: 41, G: 41, B: 41, A: 255},
color.RGBA{R: 56, G: 56, B: 56, A: 255},
color.RGBA{R: 71, G: 71, B: 71, A: 255},
color.RGBA{R: 87, G: 87, B: 87, A: 255},
color.RGBA{R: 102, G: 102, B: 102, A: 255},
color.RGBA{R: 117, G: 117, B: 117, A: 255},
color.RGBA{R: 133, G: 133, B: 133, A: 255},
color.RGBA{R: 148, G: 148, B: 148, A: 255},
color.RGBA{R: 163, G: 163, B: 163, A: 255},
color.RGBA{R: 179, G: 179, B: 179, A: 255},
color.RGBA{R: 194, G: 194, B: 194, A: 255},
color.RGBA{R: 209, G: 209, B: 209, A: 255},
color.RGBA{R: 225, G: 225, B: 225, A: 255},
color.RGBA{R: 240, G: 240, B: 240, A: 255},
color.RGBA{R: 7, G: 7, B: 19, A: 255},
color.RGBA{R: 12, G: 12, B: 24, A: 255},
color.RGBA{R: 20, G: 20, B: 32, A: 255},
color.RGBA{R: 32, G: 32, B: 48, A: 255},
color.RGBA{R: 44, G: 44, B: 64, A: 255},
color.RGBA{R: 60, G: 60, B: 80, A: 255},
color.RGBA{R: 72, G: 72, B: 97, A: 255},
color.RGBA{R: 85, G: 85, B: 109, A: 255},
color.RGBA{R: 101, G: 101, B: 125, A: 255},
color.RGBA{R: 117, G: 117, B: 128, A: 255},
color.RGBA{R: 133, G: 133, B: 157, A: 255},
color.RGBA{R: 149, G: 149, B: 170, A: 255},
color.RGBA{R: 165, G: 165, B: 186, A: 255},
color.RGBA{R: 182, G: 182, B: 202, A: 255},
color.RGBA{R: 202, G: 202, B: 218, A: 255},
color.RGBA{R: 222, G: 222, B: 234, A: 255},
color.RGBA{R: 158, G: 158, B: 158, A: 255},
color.RGBA{R: 146, G: 146, B: 146, A: 255},
color.RGBA{R: 75, G: 65, B: 55, A: 255},
color.RGBA{R: 71, G: 55, B: 46, A: 255},
color.RGBA{R: 64, G: 36, B: 22, A: 255},
color.RGBA{R: 60, G: 26, B: 1, A: 255},
color.RGBA{R: 66, G: 44, B: 38, A: 255},
color.RGBA{R: 64, G: 63, B: 54, A: 255},
color.RGBA{R: 88, G: 84, B: 82, A: 255},
color.RGBA{R: 81, G: 95, B: 95, A: 255},
color.RGBA{R: 101, G: 102, B: 102, A: 255},
color.RGBA{R: 116, G: 117, B: 117, A: 255},
color.RGBA{R: 132, G: 133, B: 133, A: 255},
color.RGBA{R: 63, G: 62, B: 53, A: 255},
color.RGBA{R: 87, G: 83, B: 81, A: 255},
color.RGBA{R: 80, G: 94, B: 94, A: 255},
color.RGBA{R: 195, G: 80, B: 0, A: 255},
color.RGBA{R: 235, G: 150, B: 0, A: 255},
color.RGBA{R: 250, G: 200, B: 60, A: 255},
color.RGBA{R: 95, G: 95, B: 95, A: 255},
color.RGBA{R: 110, G: 110, B: 110, A: 255},
color.RGBA{R: 170, G: 170, B: 170, A: 255},
color.RGBA{R: 85, G: 52, B: 12, A: 255},
color.RGBA{R: 68, G: 40, B: 4, A: 255},
color.RGBA{R: 52, G: 32, B: 0, A: 255},
color.RGBA{R: 36, G: 20, B: 0, A: 255},
color.RGBA{R: 68, G: 42, B: 22, A: 255},
color.RGBA{R: 77, G: 48, B: 25, A: 255},
color.RGBA{R: 86, G: 55, B: 29, A: 255},
color.RGBA{R: 93, G: 61, B: 32, A: 255},
color.RGBA{R: 106, G: 72, B: 38, A: 255},
color.RGBA{R: 114, G: 80, B: 42, A: 255},
color.RGBA{R: 122, G: 87, B: 46, A: 255},
color.RGBA{R: 130, G: 96, B: 56, A: 255},
color.RGBA{R: 136, G: 103, B: 64, A: 255},
color.RGBA{R: 141, G: 110, B: 73, A: 255},
color.RGBA{R: 145, G: 115, B: 80, A: 255},
color.RGBA{R: 173, G: 142, B: 102, A: 255},
color.RGBA{R: 200, G: 168, B: 123, A: 255},
color.RGBA{R: 228, G: 195, B: 145, A: 255},
color.RGBA{R: 0, G: 0, B: 79, A: 255},
color.RGBA{R: 25, G: 25, B: 100, A: 255},
color.RGBA{R: 51, G: 51, B: 121, A: 255},
color.RGBA{R: 76, G: 76, B: 141, A: 255},
color.RGBA{R: 101, G: 101, B: 162, A: 255},
color.RGBA{R: 126, G: 126, B: 183, A: 255},
color.RGBA{R: 152, G: 152, B: 204, A: 255},
color.RGBA{R: 80, G: 100, B: 143, A: 255},
color.RGBA{R: 20, G: 27, B: 49, A: 255},
color.RGBA{R: 30, G: 42, B: 64, A: 255},
color.RGBA{R: 13, G: 33, B: 69, A: 255},
color.RGBA{R: 24, G: 45, B: 82, A: 255},
color.RGBA{R: 35, G: 55, B: 94, A: 255},
color.RGBA{R: 46, G: 66, B: 106, A: 255},
color.RGBA{R: 57, G: 77, B: 118, A: 255},
color.RGBA{R: 69, G: 89, B: 130, A: 255},
color.RGBA{R: 40, G: 48, B: 0, A: 255},
color.RGBA{R: 58, G: 66, B: 12, A: 255},
color.RGBA{R: 77, G: 84, B: 24, A: 255},
color.RGBA{R: 95, G: 102, B: 36, A: 255},
color.RGBA{R: 114, G: 120, B: 48, A: 255},
color.RGBA{R: 132, G: 138, B: 61, A: 255},
color.RGBA{R: 151, G: 156, B: 73, A: 255},
color.RGBA{R: 169, G: 174, B: 85, A: 255},
color.RGBA{R: 188, G: 192, B: 97, A: 255},
color.RGBA{R: 206, G: 210, B: 109, A: 255},
color.RGBA{R: 56, G: 56, B: 40, A: 255},
color.RGBA{R: 76, G: 76, B: 56, A: 255},
color.RGBA{R: 101, G: 101, B: 76, A: 255},
color.RGBA{R: 125, G: 125, B: 101, A: 255},
color.RGBA{R: 145, G: 145, B: 121, A: 255},
color.RGBA{R: 170, G: 170, B: 145, A: 255},
color.RGBA{R: 41, G: 46, B: 30, A: 255},
color.RGBA{R: 48, G: 53, B: 33, A: 255},
color.RGBA{R: 55, G: 60, B: 37, A: 255},
color.RGBA{R: 63, G: 67, B: 40, A: 255},
color.RGBA{R: 70, G: 74, B: 43, A: 255},
color.RGBA{R: 77, G: 81, B: 47, A: 255},
color.RGBA{R: 84, G: 89, B: 50, A: 255},
color.RGBA{R: 91, G: 96, B: 54, A: 255},
color.RGBA{R: 98, G: 103, B: 57, A: 255},
color.RGBA{R: 106, G: 110, B: 60, A: 255},
color.RGBA{R: 113, G: 117, B: 64, A: 255},
color.RGBA{R: 120, G: 124, B: 67, A: 255},
color.RGBA{R: 230, G: 230, B: 230, A: 255},
color.RGBA{R: 218, G: 218, B: 218, A: 255},
color.RGBA{R: 206, G: 206, B: 206, A: 255},
color.RGBA{R: 182, G: 182, B: 182, A: 255},
color.RGBA{R: 100, G: 59, B: 34, A: 255},
color.RGBA{R: 125, G: 84, B: 59, A: 255},
color.RGBA{R: 150, G: 109, B: 84, A: 255},
color.RGBA{R: 174, G: 134, B: 108, A: 255},
color.RGBA{R: 199, G: 159, B: 133, A: 255},
color.RGBA{R: 97, G: 64, B: 16, A: 255},
color.RGBA{R: 113, G: 76, B: 28, A: 255},
color.RGBA{R: 129, G: 93, B: 36, A: 255},
color.RGBA{R: 141, G: 105, B: 48, A: 255},
color.RGBA{R: 157, G: 117, B: 60, A: 255},
color.RGBA{R: 174, G: 133, B: 76, A: 255},
color.RGBA{R: 190, G: 149, B: 93, A: 255},
color.RGBA{R: 202, G: 165, B: 109, A: 255},
color.RGBA{R: 218, G: 182, B: 129, A: 255},
color.RGBA{R: 234, G: 198, B: 145, A: 255},
color.RGBA{R: 250, G: 218, B: 170, A: 255},
color.RGBA{R: 157, G: 76, B: 36, A: 255},
color.RGBA{R: 161, G: 93, B: 60, A: 255},
color.RGBA{R: 174, G: 113, B: 72, A: 255},
color.RGBA{R: 202, G: 139, B: 104, A: 255},
color.RGBA{R: 178, G: 127, B: 100, A: 255},
color.RGBA{R: 28, G: 36, B: 0, A: 255},
color.RGBA{R: 41, G: 49, B: 10, A: 255},
color.RGBA{R: 55, G: 62, B: 20, A: 255},
color.RGBA{R: 68, G: 75, B: 30, A: 255},
color.RGBA{R: 81, G: 88, B: 40, A: 255},
color.RGBA{R: 95, G: 100, B: 50, A: 255},
color.RGBA{R: 121, G: 126, B: 70, A: 255},
color.RGBA{R: 135, G: 139, B: 80, A: 255},
color.RGBA{R: 148, G: 152, B: 90, A: 255},
color.RGBA{R: 162, G: 165, B: 100, A: 255},
color.RGBA{R: 175, G: 178, B: 110, A: 255},
color.RGBA{R: 255, G: 251, B: 240, A: 255},
color.RGBA{R: 160, G: 160, B: 164, A: 255},
color.RGBA{R: 128, G: 128, B: 128, A: 255},
color.RGBA{R: 255, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 255, B: 0, A: 255},
color.RGBA{R: 255, G: 255, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 255, A: 255},
color.RGBA{R: 255, G: 0, B: 255, A: 255},
color.RGBA{R: 0, G: 255, B: 255, A: 255},
color.RGBA{R: 255, G: 255, B: 255, A: 255},
}
)
func init() {
Palettes["SoldiersAtWar"] = SoldiersAtWarPalette
}

View File

@@ -0,0 +1,269 @@
package palettes
import "image/color"
var (
WagesOfWarPalette = color.Palette{
Transparent,
color.RGBA{R: 1, G: 1, B: 1, A: 255},
color.RGBA{R: 254, G: 254, B: 254, A: 255},
color.RGBA{R: 187, G: 4, B: 4, A: 255},
color.RGBA{R: 181, G: 195, B: 2, A: 255},
color.RGBA{R: 94, G: 79, B: 148, A: 255},
color.RGBA{R: 233, G: 91, B: 4, A: 255},
color.RGBA{R: 60, G: 60, B: 60, A: 255},
color.RGBA{R: 102, G: 102, B: 102, A: 255},
color.RGBA{R: 170, G: 170, B: 170, A: 255},
color.RGBA{R: 197, G: 195, B: 193, A: 255},
color.RGBA{R: 234, G: 234, B: 235, A: 255},
color.RGBA{R: 218, G: 218, B: 240, A: 255},
color.RGBA{R: 202, G: 202, B: 245, A: 255},
color.RGBA{R: 186, G: 186, B: 250, A: 255},
color.RGBA{R: 170, G: 170, B: 255, A: 255},
color.RGBA{R: 238, G: 238, B: 238, A: 255},
color.RGBA{R: 222, G: 222, B: 222, A: 255},
color.RGBA{R: 206, G: 206, B: 206, A: 255},
color.RGBA{R: 190, G: 190, B: 190, A: 255},
color.RGBA{R: 174, G: 174, B: 174, A: 255},
color.RGBA{R: 157, G: 157, B: 157, A: 255},
color.RGBA{R: 141, G: 141, B: 141, A: 255},
color.RGBA{R: 129, G: 129, B: 129, A: 255},
color.RGBA{R: 113, G: 113, B: 113, A: 255},
color.RGBA{R: 97, G: 97, B: 97, A: 255},
color.RGBA{R: 80, G: 80, B: 80, A: 255},
color.RGBA{R: 64, G: 64, B: 64, A: 255},
color.RGBA{R: 48, G: 48, B: 48, A: 255},
color.RGBA{R: 32, G: 32, B: 32, A: 255},
color.RGBA{R: 16, G: 16, B: 16, A: 255},
color.RGBA{R: 4, G: 4, B: 4, A: 255},
color.RGBA{R: 255, G: 238, B: 238, A: 255},
color.RGBA{R: 246, G: 194, B: 194, A: 255},
color.RGBA{R: 238, G: 153, B: 153, A: 255},
color.RGBA{R: 230, G: 117, B: 117, A: 255},
color.RGBA{R: 222, G: 80, B: 80, A: 255},
color.RGBA{R: 214, G: 48, B: 48, A: 255},
color.RGBA{R: 210, G: 16, B: 16, A: 255},
color.RGBA{R: 190, G: 12, B: 12, A: 255},
color.RGBA{R: 170, G: 8, B: 8, A: 255},
color.RGBA{R: 153, G: 4, B: 4, A: 255},
color.RGBA{R: 133, G: 4, B: 4, A: 255},
color.RGBA{R: 113, G: 0, B: 0, A: 255},
color.RGBA{R: 97, G: 0, B: 0, A: 255},
color.RGBA{R: 76, G: 0, B: 0, A: 255},
color.RGBA{R: 56, G: 0, B: 0, A: 255},
color.RGBA{R: 40, G: 0, B: 0, A: 255},
color.RGBA{R: 255, G: 255, B: 250, A: 255},
color.RGBA{R: 255, G: 255, B: 165, A: 255},
color.RGBA{R: 255, G: 255, B: 80, A: 255},
color.RGBA{R: 255, G: 255, B: 0, A: 255},
color.RGBA{R: 234, G: 222, B: 12, A: 255},
color.RGBA{R: 214, G: 194, B: 28, A: 255},
color.RGBA{R: 194, G: 170, B: 40, A: 255},
color.RGBA{R: 174, G: 149, B: 52, A: 255},
color.RGBA{R: 255, G: 202, B: 153, A: 255},
color.RGBA{R: 255, G: 170, B: 101, A: 255},
color.RGBA{R: 255, G: 133, B: 48, A: 255},
color.RGBA{R: 255, G: 93, B: 0, A: 255},
color.RGBA{R: 222, G: 89, B: 12, A: 255},
color.RGBA{R: 190, G: 85, B: 28, A: 255},
color.RGBA{R: 157, G: 76, B: 36, A: 255},
color.RGBA{R: 129, G: 68, B: 40, A: 255},
color.RGBA{R: 198, G: 255, B: 206, A: 255},
color.RGBA{R: 170, G: 238, B: 182, A: 255},
color.RGBA{R: 149, G: 222, B: 157, A: 255},
color.RGBA{R: 125, G: 206, B: 137, A: 255},
color.RGBA{R: 105, G: 190, B: 113, A: 255},
color.RGBA{R: 89, G: 174, B: 97, A: 255},
color.RGBA{R: 72, G: 157, B: 76, A: 255},
color.RGBA{R: 56, G: 141, B: 64, A: 255},
color.RGBA{R: 40, G: 125, B: 48, A: 255},
color.RGBA{R: 32, G: 109, B: 36, A: 255},
color.RGBA{R: 20, G: 93, B: 24, A: 255},
color.RGBA{R: 12, G: 76, B: 16, A: 255},
color.RGBA{R: 4, G: 60, B: 8, A: 255},
color.RGBA{R: 0, G: 44, B: 4, A: 255},
color.RGBA{R: 0, G: 32, B: 0, A: 255},
color.RGBA{R: 0, G: 16, B: 0, A: 255},
color.RGBA{R: 210, G: 255, B: 255, A: 255},
color.RGBA{R: 182, G: 238, B: 234, A: 255},
color.RGBA{R: 157, G: 222, B: 218, A: 255},
color.RGBA{R: 137, G: 210, B: 202, A: 255},
color.RGBA{R: 113, G: 194, B: 186, A: 255},
color.RGBA{R: 97, G: 178, B: 165, A: 255},
color.RGBA{R: 76, G: 165, B: 149, A: 255},
color.RGBA{R: 64, G: 149, B: 129, A: 255},
color.RGBA{R: 48, G: 133, B: 113, A: 255},
color.RGBA{R: 36, G: 121, B: 97, A: 255},
color.RGBA{R: 24, G: 105, B: 80, A: 255},
color.RGBA{R: 16, G: 89, B: 64, A: 255},
color.RGBA{R: 8, G: 76, B: 52, A: 255},
color.RGBA{R: 4, G: 60, B: 36, A: 255},
color.RGBA{R: 0, G: 44, B: 24, A: 255},
color.RGBA{R: 0, G: 32, B: 16, A: 255},
color.RGBA{R: 255, G: 255, B: 170, A: 255},
color.RGBA{R: 238, G: 238, B: 149, A: 255},
color.RGBA{R: 222, G: 222, B: 129, A: 255},
color.RGBA{R: 206, G: 210, B: 109, A: 255},
color.RGBA{R: 190, G: 194, B: 93, A: 255},
color.RGBA{R: 174, G: 182, B: 76, A: 255},
color.RGBA{R: 157, G: 165, B: 64, A: 255},
color.RGBA{R: 141, G: 149, B: 52, A: 255},
color.RGBA{R: 125, G: 137, B: 40, A: 255},
color.RGBA{R: 109, G: 121, B: 28, A: 255},
color.RGBA{R: 97, G: 109, B: 20, A: 255},
color.RGBA{R: 80, G: 93, B: 12, A: 255},
color.RGBA{R: 64, G: 76, B: 4, A: 255},
color.RGBA{R: 52, G: 64, B: 4, A: 255},
color.RGBA{R: 40, G: 48, B: 0, A: 255},
color.RGBA{R: 28, G: 36, B: 0, A: 255},
color.RGBA{R: 214, G: 255, B: 214, A: 255},
color.RGBA{R: 157, G: 255, B: 157, A: 255},
color.RGBA{R: 105, G: 255, B: 105, A: 255},
color.RGBA{R: 48, G: 255, B: 48, A: 255},
color.RGBA{R: 0, G: 255, B: 0, A: 255},
color.RGBA{R: 24, G: 210, B: 28, A: 255},
color.RGBA{R: 44, G: 165, B: 48, A: 255},
color.RGBA{R: 52, G: 125, B: 56, A: 255},
color.RGBA{R: 226, G: 121, B: 0, A: 255},
color.RGBA{R: 198, G: 89, B: 0, A: 255},
color.RGBA{R: 174, G: 64, B: 0, A: 255},
color.RGBA{R: 145, G: 40, B: 0, A: 255},
color.RGBA{R: 121, G: 24, B: 0, A: 255},
color.RGBA{R: 93, G: 8, B: 0, A: 255},
color.RGBA{R: 64, G: 4, B: 0, A: 255},
color.RGBA{R: 40, G: 0, B: 0, A: 255},
color.RGBA{R: 194, G: 255, B: 255, A: 255},
color.RGBA{R: 165, G: 255, B: 255, A: 255},
color.RGBA{R: 109, G: 255, B: 255, A: 255},
color.RGBA{R: 0, G: 255, B: 255, A: 255},
color.RGBA{R: 4, G: 222, B: 222, A: 255},
color.RGBA{R: 16, G: 194, B: 194, A: 255},
color.RGBA{R: 20, G: 161, B: 161, A: 255},
color.RGBA{R: 24, G: 133, B: 133, A: 255},
color.RGBA{R: 137, G: 157, B: 255, A: 255},
color.RGBA{R: 101, G: 121, B: 255, A: 255},
color.RGBA{R: 64, G: 80, B: 255, A: 255},
color.RGBA{R: 28, G: 40, B: 255, A: 255},
color.RGBA{R: 0, G: 0, B: 255, A: 255},
color.RGBA{R: 12, G: 12, B: 206, A: 255},
color.RGBA{R: 20, G: 20, B: 157, A: 255},
color.RGBA{R: 24, G: 24, B: 109, A: 255},
color.RGBA{R: 234, G: 234, B: 255, A: 255},
color.RGBA{R: 202, G: 202, B: 238, A: 255},
color.RGBA{R: 178, G: 178, B: 222, A: 255},
color.RGBA{R: 153, G: 153, B: 206, A: 255},
color.RGBA{R: 129, G: 129, B: 194, A: 255},
color.RGBA{R: 105, G: 105, B: 178, A: 255},
color.RGBA{R: 89, G: 89, B: 161, A: 255},
color.RGBA{R: 68, G: 68, B: 145, A: 255},
color.RGBA{R: 52, G: 52, B: 133, A: 255},
color.RGBA{R: 40, G: 40, B: 117, A: 255},
color.RGBA{R: 28, G: 28, B: 101, A: 255},
color.RGBA{R: 16, G: 16, B: 89, A: 255},
color.RGBA{R: 8, G: 8, B: 72, A: 255},
color.RGBA{R: 4, G: 4, B: 56, A: 255},
color.RGBA{R: 0, G: 0, B: 40, A: 255},
color.RGBA{R: 0, G: 0, B: 28, A: 255},
color.RGBA{R: 250, G: 218, B: 170, A: 255},
color.RGBA{R: 234, G: 198, B: 145, A: 255},
color.RGBA{R: 218, G: 182, B: 129, A: 255},
color.RGBA{R: 202, G: 165, B: 109, A: 255},
color.RGBA{R: 190, G: 149, B: 93, A: 255},
color.RGBA{R: 174, G: 133, B: 76, A: 255},
color.RGBA{R: 157, G: 117, B: 60, A: 255},
color.RGBA{R: 141, G: 105, B: 48, A: 255},
color.RGBA{R: 129, G: 93, B: 36, A: 255},
color.RGBA{R: 113, G: 76, B: 28, A: 255},
color.RGBA{R: 97, G: 64, B: 16, A: 255},
color.RGBA{R: 85, G: 52, B: 12, A: 255},
color.RGBA{R: 68, G: 40, B: 4, A: 255},
color.RGBA{R: 52, G: 32, B: 0, A: 255},
color.RGBA{R: 36, G: 20, B: 0, A: 255},
color.RGBA{R: 24, G: 12, B: 0, A: 255},
color.RGBA{R: 255, G: 230, B: 186, A: 255},
color.RGBA{R: 238, G: 210, B: 161, A: 255},
color.RGBA{R: 226, G: 190, B: 141, A: 255},
color.RGBA{R: 214, G: 170, B: 121, A: 255},
color.RGBA{R: 202, G: 149, B: 105, A: 255},
color.RGBA{R: 186, G: 133, B: 89, A: 255},
color.RGBA{R: 174, G: 113, B: 72, A: 255},
color.RGBA{R: 161, G: 93, B: 60, A: 255},
color.RGBA{R: 145, G: 76, B: 48, A: 255},
color.RGBA{R: 133, G: 60, B: 36, A: 255},
color.RGBA{R: 121, G: 44, B: 24, A: 255},
color.RGBA{R: 109, G: 32, B: 16, A: 255},
color.RGBA{R: 93, G: 20, B: 8, A: 255},
color.RGBA{R: 80, G: 8, B: 4, A: 255},
color.RGBA{R: 68, G: 4, B: 0, A: 255},
color.RGBA{R: 56, G: 0, B: 0, A: 255},
color.RGBA{R: 218, G: 218, B: 198, A: 255},
color.RGBA{R: 194, G: 194, B: 170, A: 255},
color.RGBA{R: 170, G: 170, B: 145, A: 255},
color.RGBA{R: 145, G: 145, B: 121, A: 255},
color.RGBA{R: 125, G: 125, B: 101, A: 255},
color.RGBA{R: 101, G: 101, B: 76, A: 255},
color.RGBA{R: 76, G: 76, B: 56, A: 255},
color.RGBA{R: 56, G: 56, B: 40, A: 255},
color.RGBA{R: 246, G: 222, B: 206, A: 255},
color.RGBA{R: 234, G: 206, B: 190, A: 255},
color.RGBA{R: 226, G: 194, B: 170, A: 255},
color.RGBA{R: 214, G: 178, B: 153, A: 255},
color.RGBA{R: 206, G: 165, B: 141, A: 255},
color.RGBA{R: 194, G: 153, B: 125, A: 255},
color.RGBA{R: 186, G: 141, B: 113, A: 255},
color.RGBA{R: 174, G: 129, B: 101, A: 255},
color.RGBA{R: 165, G: 117, B: 89, A: 255},
color.RGBA{R: 153, G: 109, B: 76, A: 255},
color.RGBA{R: 145, G: 97, B: 64, A: 255},
color.RGBA{R: 133, G: 89, B: 56, A: 255},
color.RGBA{R: 125, G: 76, B: 48, A: 255},
color.RGBA{R: 113, G: 68, B: 36, A: 255},
color.RGBA{R: 105, G: 60, B: 32, A: 255},
color.RGBA{R: 93, G: 52, B: 24, A: 255},
color.RGBA{R: 85, G: 44, B: 16, A: 255},
color.RGBA{R: 72, G: 36, B: 12, A: 255},
color.RGBA{R: 64, G: 32, B: 8, A: 255},
color.RGBA{R: 56, G: 24, B: 4, A: 255},
color.RGBA{R: 44, G: 16, B: 0, A: 255},
color.RGBA{R: 36, G: 12, B: 0, A: 255},
color.RGBA{R: 24, G: 8, B: 0, A: 255},
color.RGBA{R: 16, G: 4, B: 0, A: 255},
color.RGBA{R: 255, G: 255, B: 234, A: 255},
color.RGBA{R: 255, G: 250, B: 198, A: 255},
color.RGBA{R: 255, G: 242, B: 165, A: 255},
color.RGBA{R: 255, G: 234, B: 129, A: 255},
color.RGBA{R: 255, G: 218, B: 97, A: 255},
color.RGBA{R: 255, G: 202, B: 60, A: 255},
color.RGBA{R: 255, G: 182, B: 28, A: 255},
color.RGBA{R: 255, G: 157, B: 0, A: 255},
color.RGBA{R: 222, G: 222, B: 234, A: 255},
color.RGBA{R: 202, G: 202, B: 218, A: 255},
color.RGBA{R: 182, G: 182, B: 202, A: 255},
color.RGBA{R: 165, G: 165, B: 186, A: 255},
color.RGBA{R: 149, G: 149, B: 170, A: 255},
color.RGBA{R: 133, G: 133, B: 157, A: 255},
color.RGBA{R: 117, G: 117, B: 141, A: 255},
color.RGBA{R: 101, G: 101, B: 125, A: 255},
color.RGBA{R: 85, G: 85, B: 109, A: 255},
color.RGBA{R: 72, G: 72, B: 97, A: 255},
color.RGBA{R: 60, G: 60, B: 80, A: 255},
color.RGBA{R: 44, G: 44, B: 64, A: 255},
color.RGBA{R: 32, G: 32, B: 48, A: 255},
color.RGBA{R: 20, G: 20, B: 32, A: 255},
color.RGBA{R: 12, G: 12, B: 24, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
color.RGBA{R: 0, G: 0, B: 0, A: 255},
}
)
func init() {
Palettes["WagesOfWar"] = WagesOfWarPalette
}

221
internal/scenario/draw.go Normal file
View File

@@ -0,0 +1,221 @@
package scenario
import (
"fmt"
"image"
"sort"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/ebitenutil"
)
type CartPt struct {
X float64
Y float64
}
type IsoPt struct {
X float64
Y float64
}
func (s *Scenario) Update(screenX, screenY int) error {
s.tick += 1
geo := s.geoForCam()
geo.Translate(cellWidthHalf, 0)
geo.Scale(s.Zoom, s.Zoom)
geo.Invert()
cX, cY := ebiten.CursorPosition()
x, y := geo.Apply(float64(cX), float64(cY))
screenPos := CartPt{
X: x,
Y: y,
}
// FIXME: adjust for Z level
s.selectedCell = screenPos.ToISO()
return nil
}
func (s *Scenario) Draw(screen *ebiten.Image) error {
// Bounds clipping
// http://www.java-gaming.org/index.php?topic=24922.0
// https://stackoverflow.com/questions/892811/drawing-isometric-game-worlds
// https://gamedev.stackexchange.com/questions/25896/how-do-i-find-which-isometric-tiles-are-inside-the-cameras-current-view
// FIXME: we don't cope with zoom very neatly here
sw, sh := screen.Size()
topLeft := CartPt{
X: float64(s.Viewpoint.X) - (2*cellWidth/s.Zoom), // Ensure all visible cells are rendered
Y: float64(s.Viewpoint.Y) - (2*cellHeight/s.Zoom),
}.ToISO()
bottomRight := CartPt{
X: float64(s.Viewpoint.X) + (float64(sw)/s.Zoom) + (2*cellHeight/s.Zoom),
Y: float64(s.Viewpoint.Y) + (float64(sh)/s.Zoom) + (5*cellHeight/s.Zoom), // Z dimension requires it
}.ToISO()
// X+Y is constant for all tiles in a column
// X-Y is constant for all tiles in a row
// However, the drawing order is odd unless we reorder explicitly.
toDraw := []IsoPt{}
for a := int(topLeft.X + topLeft.Y); a <= int(bottomRight.X+bottomRight.Y); a++ {
for b := int(topLeft.X - topLeft.Y); b <= int(bottomRight.X-bottomRight.Y); b++ {
if b&1 != a&1 {
continue
}
pt := IsoPt{X: float64((a + b) / 2), Y: float64((a - b) / 2)}
ipt := image.Pt(int(pt.X), int(pt.Y))
if !ipt.In(s.area.Rect) {
continue
}
toDraw = append(toDraw, pt)
}
}
sort.Slice(toDraw, func(i, j int) bool {
iPix := toDraw[i].ToCart()
jPix := toDraw[j].ToCart()
if iPix.Y < jPix.Y {
return true
}
if iPix.Y == jPix.Y {
return iPix.X < jPix.X
}
return false
})
counter := map[string]int{}
for _, pt := range toDraw {
for z := 0; z <= s.ZIdx; z++ {
if err := s.renderCell(int(pt.X), int(pt.Y), z, screen, counter); err != nil {
return err
}
}
}
//log.Printf("%#+v", counter)
// Finally, draw cursor chrome
// FIXME: it looks like we might need to do this in normal painting order...
spr, err := s.specials.Sprite(0)
if err != nil {
return err
}
op := ebiten.DrawImageOptions{}
geo := s.geoForCoords(int(s.selectedCell.X), int(s.selectedCell.Y), 0)
op.GeoM = geo
op.GeoM.Translate(-209, -332)
op.GeoM.Translate(float64(spr.Rect.Min.X), float64(spr.Rect.Min.Y))
op.GeoM.Scale(s.Zoom, s.Zoom)
if err := screen.DrawImage(spr.Image, &op); err != nil {
return err
}
x1, y1 := geo.Apply(0, 0)
ebitenutil.DebugPrintAt(
screen,
fmt.Sprintf("(%d,%d)", int(s.selectedCell.X), int(s.selectedCell.Y)),
int(x1),
int(y1),
)
/*
// debug: draw a square around the selected cell
x2, y2 := geo.Apply(cellWidth, cellHeight)
ebitenutil.DrawLine(screen, x1, y1, x2, y1, colornames.Green) // top line
ebitenutil.DrawLine(screen, x1, y1, x1, y2, colornames.Green) // left line
ebitenutil.DrawLine(screen, x2, y1, x2, y2, colornames.Green) // right line
ebitenutil.DrawLine(screen, x1, y2, x2, y2, colornames.Green) // bottom line
*/
return nil
}
func (s *Scenario) geoForCam() ebiten.GeoM {
geo := ebiten.GeoM{}
geo.Translate(-float64(s.Viewpoint.X), -float64(s.Viewpoint.Y))
return geo
}
func (s *Scenario) geoForCoords(x, y, z int) ebiten.GeoM {
geo := s.geoForCam()
pix := IsoPt{X: float64(x), Y: float64(y)}.ToCart()
geo.Translate(pix.X, pix.Y)
// Taking the Z index away *seems* to draw the object in the correct place.
// FIXME: There are some artifacts, investigate more
geo.Translate(0.0, -float64(z*48.0)) // offset for Z index
return geo
}
func (s *Scenario) renderCell(x, y, z int, screen *ebiten.Image, counter map[string]int) error {
sprites, err := s.area.SpritesForCell(x, y, z)
if err != nil {
return err
}
// TODO: iso.Scale(e.state.zoom, e.state.zoom) // apply current zoom factor
iso := s.geoForCoords(x, y, z)
// FIXME: this fixed offset is found in jungtil.obj. Drawing with it
// means we put everywhere where the iso->pix conversion expects, but
// it's a bit nasty. Is there a better way?
iso.Translate(-209, -332)
for _, spr := range sprites {
// if _, ok := counter[spr.ID]; !ok {
// counter[spr.ID] = 0
// }
// counter[spr.ID] = counter[spr.ID] + 1
op := ebiten.DrawImageOptions{GeoM: iso}
op.GeoM.Translate(float64(spr.Rect.Min.X), float64(spr.Rect.Min.Y))
// Zoom has to come last
op.GeoM.Scale(s.Zoom, s.Zoom)
if err := screen.DrawImage(spr.Image, &op); err != nil {
return err
}
}
return nil
}
const (
cellWidth = 128.0
cellHeight = 63.0
cellWidthHalf = cellWidth / 2.0
cellHeightHalf = cellHeight / 2.0
)
func (p CartPt) ToISO() IsoPt {
return IsoPt{
X: (p.Y / cellHeight) + (p.X / cellWidth),
Y: (p.Y / cellHeight) - (p.X / cellWidth),
}
}
func (p IsoPt) ToCart() CartPt {
return CartPt{
X: (p.X - p.Y) * cellWidthHalf,
Y: (p.X + p.Y) * cellHeightHalf,
}
}

View File

@@ -0,0 +1,21 @@
package scenario
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestCARTtoISOtoCART(t *testing.T) {
for i := 0; i < 120; i++ {
for j := 0; j < 120; j++ {
orig := IsoPt{X: float64(i), Y: float64(j)}
asPix := orig.ToCart()
andBack := asPix.ToISO()
t.Logf("%v,%v: asPix = %v", i, j, asPix)
require.Equal(t, orig, andBack)
}
}
}

View File

@@ -0,0 +1,28 @@
package scenario
import (
"code.ur.gs/lupine/ordoor/internal/maps"
)
type CellPoint struct {
IsoPt
Z int
}
func (s *Scenario) CellAtCursor() (maps.Cell, CellPoint) {
cell := s.area.Cell(int(s.selectedCell.X), int(s.selectedCell.Y), 0)
return cell, CellPoint{IsoPt: s.selectedCell, Z: 0}
}
func (s *Scenario) ChangeZIdx(by int) {
newZ := s.ZIdx + by
if newZ < 0 {
newZ = 0
}
if newZ > 6 {
newZ = 6
}
s.ZIdx = newZ
}

View File

@@ -0,0 +1,51 @@
// package play takes a map and turns it into a playable scenario
package scenario
import (
"fmt"
"image"
"code.ur.gs/lupine/ordoor/internal/assetstore"
)
type Scenario struct {
area *assetstore.Map
specials *assetstore.Object
tick int
turn int
selectedCell IsoPt
// All these must be modified by user actions somehow.
// TODO: extract into the idea of a viewport passed to Update / Draw somehow?
// Or have a separater Drawer for the Scenario?
Viewpoint image.Point // Top-left of the screen
ZIdx int // Currently-viewed Z index
Zoom float64 // Zoom level to set
}
func NewScenario(assets *assetstore.AssetStore, name string) (*Scenario, error) {
area, err := assets.Map(name)
if err != nil {
return nil, err
}
specials, err := assets.Object("specials") // FIXME: should this be hardcoded?
if err != nil {
return nil, err
}
// Eager load sprites. TODO: do we really want to do this?
if err := area.LoadSprites(); err != nil {
return nil, fmt.Errorf("Eager-loading sprites failed: %v", err)
}
out := &Scenario{
area: area,
specials: specials,
Viewpoint: image.Pt(0, 3000), // FIXME: haxxx
Zoom: 1.0,
}
return out, nil
}

93
internal/ship/ship.go Normal file
View File

@@ -0,0 +1,93 @@
package ship
type DifficultyLevel int
const (
DifficultyLevelMarine DifficultyLevel = 0
DifficultyLevelVeteran DifficultyLevel = 1
DifficultyLevelHero DifficultyLevel = 2
DifficultyLevelMighty DifficultyLevel = 3
)
// Ship encapsulates campaign state, including current location in the campaign,
// marines and their stats, supplies, etc.
type Ship struct {
Difficulty DifficultyLevel
NextScenario string
Squads []*Squad
Captain *Character
Chaplain *Character
Apothecary *Character
Techmarines [2]*Character
Librarians [4]*Character
}
type SquadType int
type CharacterType int
const (
SquadTypeTactical SquadType = 0
SquadTypeTerminator SquadType = 1
SquadTypeAssault SquadType = 2
SquadTypeDevastator SquadType = 3
CharTypeMarine CharacterType = 0
CharTypeCaptain CharacterType = 1
CharTypeChaplain CharacterType = 2
CharTypeApothecary CharacterType = 3
CharTypeTechmarine CharacterType = 4
CharTypeLibrarian CharacterType = 5
)
type Squad struct {
Type SquadType
Characters []*Character
}
type Character struct {
Name string
Type CharacterType
Stats
Honours
}
type Stats struct {
ActionPoints int
Health int
Armour int
BallisticSkill int
WeaponSkill int
Strength int
Toughness int
Initiative int
Attacks int
Leadership int
MissionCount int
KillCount int
Experience int
}
type Honours struct {
Marksman bool
CruxTerminatus bool
PuritySeal bool
ImperialLaurel bool
}
func New() *Ship {
s := &Ship{}
s.Reset()
return s
}
func (s *Ship) Reset() {
*s = Ship{
Difficulty: DifficultyLevelVeteran, // Default difficulty level
}
}

View File

@@ -7,20 +7,13 @@ import (
"code.ur.gs/lupine/ordoor/internal/menus"
)
func init() {
registerBuilder(menus.TypeSimpleButton, noChildren(registerSimpleButton))
registerBuilder(menus.TypeInvokeButton, noChildren(registerInvokeButton))
registerBuilder(menus.TypeMainButton, noChildren(registerMainButton))
registerBuilder(menus.TypeDoorHotspot, noChildren(registerDoorHotspot))
registerBuilder(menus.TypeDoorHotspot2, noChildren(registerDoorHotspot))
registerBuilder(menus.TypeDoorHotspot3, noChildren(registerDoorHotspot))
}
// A button without hover animation
// FIXME: Keyboard.mnu has TypeSimpleButton instances that seem to include a
// hover in the SpriteId field
type button struct {
path string
locator string
rect image.Rectangle
baseSpr *assetstore.Sprite
clickSpr *assetstore.Sprite
@@ -38,99 +31,102 @@ type mainButton struct {
button
}
func registerSimpleButton(d *Driver, r *menus.Record) error {
_, err := registerButton(d, r, r.SpriteId[0])
return err
}
func registerInvokeButton(d *Driver, r *menus.Record) error {
_, err := registerButton(d, r, r.Share)
return err
}
func registerMainButton(d *Driver, r *menus.Record) error {
sprites, err := d.menu.Sprites(r.Share, 3) // base, pressed, disabled
func (d *Driver) buildButton(p *menus.Properties) (*button, *Widget, error) {
sprites, err := d.menu.Sprites(p.ObjectIdx, p.BaseSpriteID(), 3) // base, pressed, disabled
if err != nil {
return err
return nil, nil, err
}
hovers, err := d.menu.Images(r.SpriteId[0], r.DrawType)
btn := &button{
locator: p.Locator,
rect: sprites[0].Rect.Add(p.Point()),
baseSpr: sprites[0],
clickSpr: sprites[1],
frozenSpr: sprites[2],
hoverImpl: hoverImpl{text: p.Text},
}
widget := &Widget{
Locator: p.Locator,
Active: p.Active,
ownClickables: []clickable{btn},
ownFreezables: []freezable{btn},
ownHoverables: []hoverable{btn},
ownPaintables: []paintable{btn},
}
return btn, widget, nil
}
func (d *Driver) buildMainButton(p *menus.Properties) (*mainButton, *Widget, error) {
sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 3) // base, pressed, disabled
if err != nil {
return err
return nil, nil, err
}
hovers, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0], p.DrawType)
if err != nil {
return nil, nil, err
}
btn := &mainButton{
hoverAnim: animation(hovers),
button: button{
path: r.Path(),
locator: p.Locator,
rect: sprites[0].Rect.Add(p.Point()),
baseSpr: sprites[0],
clickSpr: sprites[1],
frozenSpr: sprites[2],
hoverImpl: hoverImpl{text: r.Text},
hoverImpl: hoverImpl{text: p.Text},
},
}
d.clickables = append(d.clickables, btn)
d.freezables = append(d.freezables, btn)
d.hoverables = append(d.hoverables, btn)
d.paintables = append(d.paintables, btn)
widget := &Widget{
Locator: p.Locator,
Active: p.Active,
ownClickables: []clickable{btn},
ownFreezables: []freezable{btn},
ownHoverables: []hoverable{btn},
ownPaintables: []paintable{btn},
}
return nil
return btn, widget, nil
}
func registerDoorHotspot(d *Driver, r *menus.Record) error {
sprites, err := d.menu.Sprites(r.Share, 2) // base, pressed
func (d *Driver) buildDoorHotspot(p *menus.Properties) (*button, *Widget, error) {
sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 2) // base, pressed
if err != nil {
return err
return nil, nil, err
}
btn := &button{
path: r.Path(),
locator: p.Locator,
rect: sprites[0].Rect.Add(p.Point()),
baseSpr: sprites[0],
clickSpr: sprites[1],
frozenSpr: sprites[0], // No disabled sprite
hoverImpl: hoverImpl{text: r.Text},
hoverImpl: hoverImpl{text: p.Text},
}
d.clickables = append(d.clickables, btn)
d.freezables = append(d.freezables, btn)
d.hoverables = append(d.hoverables, btn)
d.paintables = append(d.paintables, btn)
return nil
}
func registerButton(d *Driver, r *menus.Record, spriteId int) (*button, error) {
sprites, err := d.menu.Sprites(spriteId, 3) // base, pressed, disabled
if err != nil {
return nil, err
widget := &Widget{
Locator: p.Locator,
Active: p.Active,
ownClickables: []clickable{btn},
ownFreezables: []freezable{btn},
ownHoverables: []hoverable{btn},
ownPaintables: []paintable{btn},
}
btn := &button{
path: r.Path(),
baseSpr: sprites[0],
clickSpr: sprites[1],
frozenSpr: sprites[2],
hoverImpl: hoverImpl{text: r.Text},
}
return btn, widget, nil
d.clickables = append(d.clickables, btn)
d.freezables = append(d.freezables, btn)
d.hoverables = append(d.hoverables, btn)
d.paintables = append(d.paintables, btn)
return btn, nil
}
func (b *button) id() string {
return b.path
return b.locator
}
func (b *button) bounds() image.Rectangle {
return b.baseSpr.Rect
return b.rect
}
func (b *button) mouseDownState() bool {
@@ -160,6 +156,7 @@ func (b *button) regions(tick int) []region {
}
func (m *mainButton) regions(tick int) []region {
// FIXME: main button should complete its animation when we mouse away
if !m.isFrozen() && !m.mouseDownState() && m.hoverState() {
return oneRegion(m.bounds().Min, m.hoverAnim.image(tick))
}

41
internal/ui/dialogues.go Normal file
View File

@@ -0,0 +1,41 @@
package ui
import (
"fmt"
)
func (d *Driver) Dialogues() []string {
out := make([]string, len(d.dialogues))
for i, dialogue := range d.dialogues {
out[i] = dialogue.Locator
}
return out
}
func (d *Driver) IsInDialogue() bool {
return d.activeDialogue != nil
}
func (d *Driver) ShowDialogue(locator string) error {
for _, dialogue := range d.dialogues {
if dialogue.Locator == locator {
// FIXME: we should unhover and mouseup the non-dialogue elements
dialogue.Active = true
d.activeDialogue = dialogue
return nil
}
}
return fmt.Errorf("Couldn't find dialogue %v", locator)
}
func (d *Driver) HideDialogue() {
if d.activeDialogue != nil {
d.activeDialogue.Active = false
}
d.activeDialogue = nil
}

View File

@@ -3,102 +3,42 @@ package ui
import (
"fmt"
"image"
"log"
"runtime/debug"
"strconv"
"github.com/hajimehoshi/ebiten"
"github.com/hajimehoshi/ebiten/ebitenutil"
"code.ur.gs/lupine/ordoor/internal/assetstore"
"code.ur.gs/lupine/ordoor/internal/menus"
)
func init() {
// FIXME: these need implementing
// Needed for Keyboard.mnu (main -> options -> keyboard)
registerBuilder(menus.TypeDialogue, registerDebug("Unimplemented Dialogue", nil))
// Needed for ChaEquip.mnu
// Needed for MainGameChaos.mnu
registerBuilder(menus.TypeStatusBar, registerDebug("Unimplemented StatusBar", nil))
// Needed for Multiplayer_Choose.mnu
registerBuilder(menus.TypeComboBoxItem, registerDebug("Unimplemented ComboBoxItem", nil))
registerBuilder(menus.TypeDropdownButton, registerDebug("Unimplemented DropdownButton", nil))
// Needed for Multiplayer_Configure.mnu
registerBuilder(menus.TypeEditBox, registerDebug("Unimplemented EditBox", nil))
// Needed for Multiplayer_Connect.mnu
registerBuilder(menus.TypeRadioButton, registerDebug("Unimplemented RadioButton", nil))
}
const (
OriginalX = 640.0
OriginalY = 480.0
)
var (
// Widgets register their builder here
widgetBuilders = map[menus.MenuType]builderFunc{}
)
// Used to add widgets to a driver
type builderFunc func(d *Driver, r *menus.Record) (children []*menus.Record, err error)
func registerDebug(reason string, onward builderFunc) builderFunc {
return func(d *Driver, r *menus.Record) ([]*menus.Record, error) {
log.Printf("%v: %#+v", reason, r)
if onward == nil {
return r.Children, nil
}
return onward(d, r)
}
}
func noChildren(f func(d *Driver, r *menus.Record) error) builderFunc {
return func(d *Driver, r *menus.Record) ([]*menus.Record, error) {
if len(r.Children) > 0 {
return nil, fmt.Errorf("Children in record %v:%v (%#+v)", r.Menu.Name, r.Path(), r)
}
return nil, f(d, r)
}
}
func ownedByMenu(d *Driver, r *menus.Record) ([]*menus.Record, error) {
return nil, fmt.Errorf("This record should be handled by a menu: %v:%v (%#+v)", r.Menu.Name, r.Path(), r)
}
func registerBuilder(t menus.MenuType, f builderFunc) {
if _, ok := widgetBuilders[t]; ok {
panic(fmt.Sprintf("A builder for menu type %v already exists", t))
}
widgetBuilders[t] = f
}
// Driver acts as an interface between the main loop and the widgets specified
// in a menu.
//
// Menu assets assume a 640x480 screen; Driver is responsible for scaling to the
// actual screen size when drawing.
//
// TODO: move scaling responsibilities to Window?
type Driver struct {
Name string
menu *assetstore.Menu
assets *assetstore.AssetStore
menu *assetstore.Menu
// UI elements we need to drive
clickables []clickable
freezables []freezable
hoverables []hoverable
mouseables []mouseable
paintables []paintable
valueables []valueable
// UI elements we need to drive. Note that widgets are hierarchical - these
// are just the toplevel. Dialogues are separated out. We only want to show
// one dialogue at a time, and if a dialogue is active, the main widgets are
// unusable (i.e., dialogues are modal)
dialogues []*Widget
widgets []*Widget
activeDialogue *Widget
cursor assetstore.CursorName
// The cursor in two different coordinate spaces: original, and screen-scaled
cursorOrig image.Point
@@ -112,14 +52,16 @@ type Driver struct {
tooltip string
}
func NewDriver(menu *assetstore.Menu) (*Driver, error) {
func NewDriver(assets *assetstore.AssetStore, menu *assetstore.Menu) (*Driver, error) {
driver := &Driver{
Name: menu.Name,
menu: menu,
assets: assets,
menu: menu,
}
for _, record := range menu.Records() {
if err := driver.addRecord(record); err != nil {
for _, group := range menu.Groups() {
if err := driver.registerGroup(group); err != nil {
return nil, err
}
}
@@ -127,104 +69,6 @@ func NewDriver(menu *assetstore.Menu) (*Driver, error) {
return driver, nil
}
func (d *Driver) Value(id string, into *string) error {
for _, valueable := range d.valueables {
if valueable.id() == id {
*into = valueable.value()
return nil
}
}
return fmt.Errorf("Couldn't find valueable widget %v:%v", d.menu.Name, id)
}
func (d *Driver) SetValue(id, value string) error {
for _, valueable := range d.valueables {
if valueable.id() == id {
valueable.setValue(value)
return nil
}
}
return fmt.Errorf("Couldn't find valueable widget %v:%v", d.menu.Name, id)
}
func (d *Driver) ValueBool(id string, into *bool) error {
var vStr string
if err := d.Value(id, &vStr); err != nil {
return err
}
*into = vStr == "1"
return nil
}
func (d *Driver) SetValueBool(id string, value bool) error {
vStr := "0"
if value {
vStr = "1"
}
return d.SetValue(id, vStr)
}
func (d *Driver) SetFreeze(id string, value bool) error {
for _, freezable := range d.freezables {
if freezable.id() == id {
freezable.setFreezeState(value)
return nil
}
}
return fmt.Errorf("Couldn't find clickable widget %v:%v", d.menu.Name, id)
}
func (d *Driver) OnClick(id string, f func()) error {
for _, clickable := range d.clickables {
if clickable.id() == id {
clickable.onClick(f)
return nil
}
}
return fmt.Errorf("Couldn't find clickable widget %v:%v", d.menu.Name, id)
}
// FIXME: HURK. Surely I'm missing something? steps is value:offset
func (d *Driver) ConfigureSlider(id string, steps map[int]int) error {
for _, clickable := range d.clickables {
if slider, ok := clickable.(*slider); ok && slider.id() == id {
slider.steps = steps
return nil
}
}
return fmt.Errorf("Couldn't find slider %v:%v", d.menu.Name, id)
}
func (d *Driver) ValueInt(id string, into *int) error {
var vStr string
if err := d.Value(id, &vStr); err != nil {
return err
}
value, err := strconv.Atoi(vStr)
if err != nil {
return err
}
*into = value
return nil
}
func (d *Driver) SetValueInt(id string, value int) error {
vStr := strconv.Itoa(value)
return d.SetValue(id, vStr)
}
func (d *Driver) Update(screenX, screenY int) error {
if d == nil {
debug.PrintStack()
@@ -250,7 +94,7 @@ func (d *Driver) Update(screenX, screenY int) error {
d.cursorOrig = image.Pt(int(mnX), int(mnY))
// Dispatch notifications to our widgets
for _, hoverable := range d.hoverables {
for _, hoverable := range d.activeHoverables() {
inBounds := d.cursorOrig.In(hoverable.bounds())
d.hoverStartEvent(hoverable, inBounds)
@@ -262,7 +106,7 @@ func (d *Driver) Update(screenX, screenY int) error {
}
mouseIsDown := ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft)
for _, clickable := range d.clickables {
for _, clickable := range d.activeClickables() {
inBounds := d.cursorOrig.In(clickable.bounds())
mouseWasDown := clickable.mouseDownState()
@@ -271,7 +115,7 @@ func (d *Driver) Update(screenX, screenY int) error {
d.mouseUpEvent(clickable, inBounds, mouseWasDown, mouseIsDown)
}
for _, mouseable := range d.mouseables {
for _, mouseable := range d.activeMouseables() {
mouseable.registerMousePosition(d.cursorOrig)
}
@@ -286,7 +130,7 @@ func (d *Driver) Draw(screen *ebiten.Image) error {
var do ebiten.DrawImageOptions
for _, paint := range d.paintables {
for _, paint := range d.activePaintables() {
for _, region := range paint.regions(d.ticks) {
x, y := d.orig2native.Apply(float64(region.offset.X), float64(region.offset.Y))
@@ -307,71 +151,121 @@ func (d *Driver) Draw(screen *ebiten.Image) error {
return nil
}
func (d *Driver) addRecord(record *menus.Record) error {
//log.Printf("Adding record: %#+v", record)
children := record.Children
handler, ok := widgetBuilders[record.Type]
if !ok {
return fmt.Errorf("UI driver encountered unknown menu record: %#+v", record)
func (d *Driver) Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error) {
cursor, err := d.assets.Cursor(d.cursor)
if err != nil {
return nil, nil, err
}
if handler != nil {
var err error
children, err = handler(d, record)
if err != nil {
return err
}
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(d.cursorOrig.X), float64(d.cursorOrig.Y))
op.GeoM.Concat(d.orig2native)
op.GeoM.Translate(float64(-cursor.Hotspot.X), float64(-cursor.Hotspot.Y))
return cursor.Image, op, nil
}
func (d *Driver) allClickables() []clickable {
var out []clickable
for _, widget := range d.widgets {
out = append(out, widget.allClickables()...)
}
// Recursively add all remaining children of this record
for _, record := range children {
if err := d.addRecord(record); err != nil {
return err
for _, widget := range d.dialogues {
out = append(out, widget.allClickables()...)
}
return out
}
func (d *Driver) allFreezables() []freezable {
var out []freezable
for _, widget := range d.widgets {
out = append(out, widget.allFreezables()...)
}
for _, widget := range d.dialogues {
out = append(out, widget.allFreezables()...)
}
return out
}
func (d *Driver) allValueables() []valueable {
var out []valueable
for _, widget := range d.widgets {
out = append(out, widget.allValueables()...)
}
for _, widget := range d.dialogues {
out = append(out, widget.allValueables()...)
}
return out
}
func (d *Driver) activeClickables() []clickable {
if d.activeDialogue != nil {
return d.activeDialogue.activeClickables()
}
var out []clickable
for _, widget := range d.widgets {
out = append(out, widget.activeClickables()...)
}
return out
}
func (d *Driver) activeHoverables() []hoverable {
if d.activeDialogue != nil {
return d.activeDialogue.activeHoverables()
}
var out []hoverable
for _, widget := range d.widgets {
out = append(out, widget.activeHoverables()...)
}
return out
}
func (d *Driver) activeMouseables() []mouseable {
if d.activeDialogue != nil {
return d.activeDialogue.activeMouseables()
}
var out []mouseable
for _, widget := range d.widgets {
out = append(out, widget.activeMouseables()...)
}
return out
}
func (d *Driver) activePaintables() []paintable {
var out []paintable
for _, widget := range d.widgets {
out = append(out, widget.activePaintables()...)
}
if d.activeDialogue != nil {
out = append(out, d.activeDialogue.activePaintables()...)
}
return out
}
func (d *Driver) findWidget(locator string) *Widget {
toplevels := append(d.widgets, d.dialogues...)
for _, widget := range toplevels {
if w := widget.findWidget(locator); w != nil {
return w
}
}
return nil
}
func (d *Driver) hoverStartEvent(h hoverable, inBounds bool) {
if inBounds && !h.hoverState() {
//log.Printf("hoverable false -> true")
h.setHoverState(true)
}
}
func (d *Driver) hoverEndEvent(h hoverable, inBounds bool) {
if !inBounds && h.hoverState() {
//log.Printf("hoverable true -> false")
h.setHoverState(false)
}
}
func (d *Driver) mouseDownEvent(c clickable, inBounds, wasDown, isDown bool) {
if inBounds && !wasDown && isDown {
//log.Printf("mouse down false -> true")
c.setMouseDownState(true)
}
}
func (d *Driver) mouseClickEvent(c clickable, inBounds, wasDown, isDown bool) {
if inBounds && wasDown && !isDown {
//log.Printf("mouse click")
c.registerMouseClick()
}
}
func (d *Driver) mouseUpEvent(c clickable, inBounds, wasDown, isDown bool) {
if inBounds {
if wasDown && !isDown {
//log.Printf("mouse down true -> false")
c.setMouseDownState(false)
}
} else {
if wasDown {
//log.Printf("mouse down true -> false")
c.setMouseDownState(false)
}
}
}

43
internal/ui/events.go Normal file
View File

@@ -0,0 +1,43 @@
package ui
func (d *Driver) hoverStartEvent(h hoverable, inBounds bool) {
if inBounds && !h.hoverState() {
//log.Printf("hoverable false -> true")
h.setHoverState(true)
}
}
func (d *Driver) hoverEndEvent(h hoverable, inBounds bool) {
if !inBounds && h.hoverState() {
//log.Printf("hoverable true -> false")
h.setHoverState(false)
}
}
func (d *Driver) mouseDownEvent(c clickable, inBounds, wasDown, isDown bool) {
if inBounds && !wasDown && isDown {
//log.Printf("mouse down false -> true")
c.setMouseDownState(true)
}
}
func (d *Driver) mouseClickEvent(c clickable, inBounds, wasDown, isDown bool) {
if inBounds && wasDown && !isDown {
//log.Printf("mouse click")
c.registerMouseClick()
}
}
func (d *Driver) mouseUpEvent(c clickable, inBounds, wasDown, isDown bool) {
if inBounds {
if wasDown && !isDown {
//log.Printf("mouse down true -> false")
c.setMouseDownState(false)
}
} else {
if wasDown {
//log.Printf("mouse down true -> false")
c.setMouseDownState(false)
}
}
}

200
internal/ui/group.go Normal file
View File

@@ -0,0 +1,200 @@
package ui
import (
"code.ur.gs/lupine/ordoor/internal/menus"
"fmt"
"log"
)
func (d *Driver) registerGroup(group *menus.Group) error {
// log.Printf("Adding group %v: %#+v", group.Locator, group)
var dialogue bool
switch group.Type {
case menus.TypeStatic, menus.TypeMainBackground, menus.TypeMenu, menus.TypeDragMenu, menus.TypeRadioMenu:
case menus.TypeDialogue:
dialogue = true
default:
return fmt.Errorf("Unknown group type: %v", group.Type)
}
var groupWidget *Widget
// Groups have a background sprite (FIXME: always?)
if group.BaseSpriteID() >= 0 {
var err error
_, groupWidget, err = d.buildStatic(group.Props())
if err != nil {
return err
}
} else {
groupWidget = &Widget{
Locator: group.Locator,
Active: group.Active,
}
}
if dialogue {
d.dialogues = append(d.dialogues, groupWidget)
} else {
d.widgets = append(d.widgets, groupWidget)
}
// TRadioGroup is best handled like this
records, widget, err := d.maybeBuildInventorySelect(group, group.Records)
if err != nil {
return err
}
if widget != nil {
groupWidget.Children = append(groupWidget.Children, widget)
}
records, widget, err = d.maybeBuildListBox(group, records)
if err != nil {
return err
}
if widget != nil {
groupWidget.Children = append(groupWidget.Children, widget)
}
for _, record := range records {
child, err := d.buildRecord(record)
if err != nil {
return err
}
if child != nil {
groupWidget.Children = append(groupWidget.Children, child)
}
}
return nil
}
func (d *Driver) buildRecord(r *menus.Record) (*Widget, error) {
var widget *Widget
var err error
switch r.Type {
case menus.SubTypeSimpleButton, menus.SubTypeInvokeButton:
_, widget, err = d.buildButton(r.Props())
case menus.SubTypeDoorHotspot1, menus.SubTypeDoorHotspot2, menus.SubTypeDoorHotspot3:
_, widget, err = d.buildDoorHotspot(r.Props())
case menus.SubTypeOverlay:
_, widget, err = d.buildOverlay(r.Props())
case menus.SubTypeHypertext:
_, widget, err = d.buildHypertext(r.Props())
case menus.SubTypeCheckbox:
_, widget, err = d.buildCheckbox(r.Props())
case menus.SubTypeEditBox:
log.Printf("Unimplemented: SubTypeEditBox: %v", r.Locator) // TODO
case menus.SubTypeRadioButton:
log.Printf("Unimplemented: SubTypeRadioButton: %v", r.Locator) // TODO
case menus.SubTypeDropdownButton:
log.Printf("Unimplemented: SubTypeDropdownButton: %v", r.Locator) // TODO
case menus.SubTypeComboBoxItem:
log.Printf("Unimplemented: SubTypeComboBoxItem: %v", r.Locator) // TODO
case menus.SubTypeAnimationSample:
_, widget, err = d.buildAnimationSample(r.Props())
case menus.SubTypeAnimationHover:
_, widget, err = d.buildAnimationHover(r.Props())
case menus.SubTypeMainButton:
_, widget, err = d.buildMainButton(r.Props())
case menus.SubTypeSlider:
_, widget, err = d.buildSlider(r.Props()) // TODO: take sliders at an earlier point?
case menus.SubTypeStatusBar:
log.Printf("Unimplemented: SubTypeStatusBar: %v", r.Locator) // TODO
default:
return nil, fmt.Errorf("Unknown record type for %v: %v", r.Locator, r.Type)
}
return widget, err
}
func (d *Driver) maybeBuildInventorySelect(group *menus.Group, records []*menus.Record) ([]*menus.Record, *Widget, error) {
var untouched []*menus.Record
var touched []*menus.Record
for _, record := range records {
if record.Type == menus.SubTypeInventorySelect {
touched = append(touched, record)
} else {
untouched = append(untouched, record)
}
}
if len(touched) == 0 {
return untouched, nil, nil
}
elements := make([]*inventorySelect, len(touched))
widget := &Widget{
Locator: group.Locator,
Active: group.Active,
}
for i, record := range touched {
element, childWidget, err := d.buildInventorySelect(record.Props())
if err != nil {
return nil, nil, err
}
elements[i] = element
widget.Children = append(widget.Children, childWidget)
}
elements[0].setValue("1")
for _, element := range elements {
element.others = elements
}
return untouched, widget, nil
}
func (d *Driver) maybeBuildListBox(group *menus.Group, records []*menus.Record) ([]*menus.Record, *Widget, error) {
// Unless up, down, thumb, and items, are all present, it's not a listbox
var up *menus.Record
var down *menus.Record
var thumb *menus.Record
var items []*menus.Record
var untouched []*menus.Record
for _, rec := range records {
switch rec.Type {
case menus.SubTypeListBoxUp:
if up != nil {
return nil, nil, fmt.Errorf("Duplicate up buttons in menu %v", group.Locator)
}
up = rec
case menus.SubTypeListBoxDown:
if down != nil {
return nil, nil, fmt.Errorf("Duplicate down buttons in menu %v", group.Locator)
}
down = rec
case menus.SubTypeLineKbd, menus.SubTypeLineBriefing:
items = append(items, rec)
case menus.SubTypeThumb:
if thumb != nil {
return nil, nil, fmt.Errorf("Duplicate thumbs in menu %v", group.Locator)
}
thumb = rec
default:
// e.g. maingame:18.12 includes a button that is not part of the box
untouched = append(untouched, rec)
}
}
// Since not all the elements are present, this isn't a listbox
if len(items) == 0 || thumb == nil || up == nil || down == nil {
return untouched, nil, nil
}
_, widget, err := d.buildListBox(group, up, down, thumb, items...)
if err != nil {
return nil, nil, err
}
return untouched, widget, nil
}

View File

@@ -4,10 +4,6 @@ import (
"code.ur.gs/lupine/ordoor/internal/menus"
)
func init() {
registerBuilder(menus.TypeInventorySelect, ownedByMenu)
}
// An inventory select is a sort of radio button. If 2 share the same menu,
// selecting one deselects the other. Otherwise, they act like checkboxes.
//
@@ -20,33 +16,26 @@ type inventorySelect struct {
}
// Called from the menu, which fills "others" for us
func registerInventorySelect(d *Driver, r *menus.Record) (*inventorySelect, error) {
sprites, err := d.menu.Sprites(r.Share, 3) // unchecked, checked, disabled
func (d *Driver) buildInventorySelect(p *menus.Properties) (*inventorySelect, *Widget, error) {
c, _, err := d.buildCheckbox(p)
if err != nil {
return nil, err
return nil, nil, err
}
element := &inventorySelect{
checkbox: checkbox{
button: button{
path: r.Path(),
baseSpr: sprites[0], // unchecked
clickSpr: sprites[1], // checked
frozenSpr: sprites[2], // disabled
hoverImpl: hoverImpl{text: r.Text},
},
// In an inventorySelect, the frozen and click sprites are reversed
c.clickSpr, c.frozenSpr = c.frozenSpr, c.clickSpr
valueImpl: valueImpl{str: "0"},
},
element := &inventorySelect{checkbox: *c}
widget := &Widget{
Active: p.Active,
ownClickables: []clickable{element},
ownFreezables: []freezable{element},
ownHoverables: []hoverable{element},
ownPaintables: []paintable{element},
ownValueables: []valueable{element},
}
d.clickables = append(d.clickables, element)
d.freezables = append(d.freezables, element)
d.hoverables = append(d.hoverables, element)
d.paintables = append(d.paintables, element)
d.valueables = append(d.valueables, element)
return element, nil
return element, widget, nil
}
func (i *inventorySelect) registerMouseClick() {

View File

@@ -8,21 +8,14 @@ import (
"code.ur.gs/lupine/ordoor/internal/menus"
)
func init() {
registerBuilder(menus.TypeLineKbd, ownedByMenu)
registerBuilder(menus.TypeLineBriefing, ownedByMenu)
registerBuilder(menus.TypeThumb, ownedByMenu)
registerBuilder(menus.TypeListBoxUp, ownedByMenu)
registerBuilder(menus.TypeListBoxDown, ownedByMenu)
}
// listBox is a TListBox in VCL terms. It has a number of lines of text, one of
// which may be selected, and a slider with up and down buttons to scroll if the
// options in the box exceed its viewing capacity.
//
// TODO: multi-select functionality? Is it needed?
type listBox struct {
locator string
upBtn *button
downBtn *button
@@ -30,7 +23,6 @@ type listBox struct {
thumbBase *assetstore.Sprite // Bounds are given by this
thumbImg *assetstore.Sprite // This is displayed at offset * (height / steps)
base *noninteractive // The menu itself has a sprite to display
lines []*noninteractive // We display to these
// The list box acts as a window onto these
@@ -40,71 +32,32 @@ type listBox struct {
offset int
}
func registerListBox(d *Driver, menu *menus.Record) ([]*menus.Record, error) {
var upBtn *menus.Record
var downBtn *menus.Record
var thumb *menus.Record
var items []*menus.Record
for _, rec := range menu.Children {
switch rec.Type {
case menus.TypeListBoxUp:
if upBtn != nil {
return nil, fmt.Errorf("Duplicate up buttons in menu %v", menu.Locator())
}
upBtn = rec
case menus.TypeListBoxDown:
if downBtn != nil {
return nil, fmt.Errorf("Duplicate down buttons in menu %v", menu.Locator())
}
downBtn = rec
case menus.TypeLineKbd, menus.TypeLineBriefing:
items = append(items, rec)
case menus.TypeThumb:
if thumb != nil {
return nil, fmt.Errorf("Duplicate thumbs in menu %v", menu.Locator())
}
thumb = rec
default:
return nil, fmt.Errorf("Unrecognised child in listbox menu: %v", rec.Locator())
}
}
if len(items) == 0 || thumb == nil || upBtn == nil || downBtn == nil {
return nil, fmt.Errorf("Missing items in menu %v", menu.Locator())
}
// Now build the wonderful thing
baseElem, err := registerNoninteractive(d, menu)
func (d *Driver) buildListBox(group *menus.Group, up, down, thumb *menus.Record, items ...*menus.Record) (*listBox, *Widget, error) {
upElem, upWidget, err := d.buildButton(up.Props())
if err != nil {
return nil, err
return nil, nil, err
}
elemUp, err := registerButton(d, upBtn, upBtn.SpriteId[0])
downElem, downWidget, err := d.buildButton(down.Props())
if err != nil {
return nil, err
return nil, nil, err
}
elemDown, err := registerButton(d, downBtn, downBtn.SpriteId[0])
thumbBaseSpr, err := d.menu.Sprite(thumb.ObjectIdx, thumb.Share)
if err != nil {
return nil, err
return nil, nil, err
}
thumbBaseSpr, err := d.menu.Sprite(thumb.Share)
thumbImgSpr, err := d.menu.Sprite(thumb.ObjectIdx, thumb.BaseSpriteID())
if err != nil {
return nil, err
}
thumbImgSpr, err := d.menu.Sprite(thumb.SpriteId[0])
if err != nil {
return nil, err
return nil, nil, err
}
element := &listBox{
base: baseElem,
locator: group.Locator,
// TODO: upBtn needs to be frozen when offset == 0; downBtn when offset == max
upBtn: elemUp,
downBtn: elemDown,
upBtn: upElem,
downBtn: downElem,
// TODO: need to be able to drag the thumb
thumbBase: thumbBaseSpr,
@@ -112,8 +65,8 @@ func registerListBox(d *Driver, menu *menus.Record) ([]*menus.Record, error) {
}
// Internal wiring-up
elemUp.onClick(element.up)
elemDown.onClick(element.down)
upElem.onClick(element.up)
downElem.onClick(element.down)
// FIXME: Test data for now
for i := 0; i < 50; i++ {
@@ -122,30 +75,48 @@ func registerListBox(d *Driver, menu *menus.Record) ([]*menus.Record, error) {
// Register everything. Since we're a composite of other controls, they are
// mostly self-registered at the moment.
d.paintables = append(d.paintables, element)
widget := &Widget{
Children: []*Widget{upWidget, downWidget},
Active: group.Active, // FIXME: children have their own active state
ownPaintables: []paintable{element},
ownValueables: []valueable{element},
}
// FIXME: we should be able to freeze/unfreeze as a group.
// HURK: These need to be registered after the other elements so they are
// drawn in the correct order to be visible
for _, rec := range items {
ni, err := registerNoninteractive(d, rec)
ni, niWidget, err := d.buildStatic(rec.Props())
if err != nil {
return nil, err
return nil, nil, err
}
niWidget.ownClickables = append(niWidget.ownClickables, ni)
// TODO: pick the correct font
ni.label = &label{
align: AlignModeLeft,
font: d.menu.Font(0),
rect: ni.rect,
}
element.lines = append(element.lines, ni)
widget.Children = append(widget.Children, niWidget)
}
element.refresh()
return nil, nil
return element, widget, nil
}
func (l *listBox) id() string {
return l.locator
}
func (l *listBox) value() string {
return ""
}
func (l *listBox) setValue(s string) {
}
func (l *listBox) SetStrings(to []string) {

View File

@@ -1,72 +0,0 @@
package ui
import (
"code.ur.gs/lupine/ordoor/internal/menus"
)
func init() {
// These menu types don't need driving, so we can ignore them
registerBuilder(menus.TypeMenu, registerMenu)
registerBuilder(menus.TypeDragMenu, nil) // Menus are just containers
}
func registerMenu(d *Driver, r *menus.Record) ([]*menus.Record, error) {
childrenLeft, err := listBoxFromMenu(d, r, r.Children)
if err != nil {
return nil, err
}
childrenLeft, err = inventorySelectFromMenu(d, r, childrenLeft)
if err != nil {
return nil, err
}
// Return all the unhandled children to be processed further
return childrenLeft, nil
}
func listBoxFromMenu(d *Driver, menu *menus.Record, children []*menus.Record) ([]*menus.Record, error) {
ok := false
for _, rec := range children {
if rec.Type == menus.TypeThumb { // FIXME: we're using this to indicate a listbox
ok = true
break
}
}
if !ok {
return children, nil
}
return registerListBox(d, menu)
}
// Group all inventory selects that share a menu together
func inventorySelectFromMenu(d *Driver, menu *menus.Record, children []*menus.Record) ([]*menus.Record, error) {
var childrenLeft []*menus.Record
var inventorySelects []*inventorySelect
for _, child := range children {
switch child.Type {
case menus.TypeInventorySelect:
is, err := registerInventorySelect(d, child)
if err != nil {
return nil, err
}
inventorySelects = append(inventorySelects, is)
default:
childrenLeft = append(childrenLeft, child)
}
}
if len(inventorySelects) > 0 {
inventorySelects[0].setValue("1") // Always start with one selected
for _, is := range inventorySelects {
is.others = inventorySelects
}
}
return childrenLeft, nil
}

View File

@@ -1,6 +1,7 @@
package ui
import (
"fmt"
"image"
"log"
@@ -15,22 +16,14 @@ const (
AlignModeLeft AlignMode = 1
)
func init() {
registerBuilder(menus.TypeStatic, noChildren(registerStatic))
registerBuilder(menus.TypeHypertext, noChildren(registerHypertext))
registerBuilder(menus.TypeOverlay, noChildren(registerOverlay))
registerBuilder(menus.TypeAnimationSample, noChildren(registerAnimation))
registerBuilder(menus.TypeAnimationHover, noChildren(registerAnimationHover))
}
// A non-interactive element is not a widget; it merely displays some pixels and
// may optionally have a tooltip for display within bounds.
//
// For non-animated non-interactive elements, just give them a single frame.
type noninteractive struct {
path string
frames animation
rect image.Rectangle
locator string
frames animation
rect image.Rectangle
// Some non-interactives, e.g., overlays, are an image + text to be shown
label *label
@@ -58,145 +51,159 @@ type animationHover struct {
closing bool
}
func registerStatic(d *Driver, r *menus.Record) error {
_, err := registerNoninteractive(d, r)
return err
}
func registerNoninteractive(d *Driver, r *menus.Record) (*noninteractive, error) {
// FIXME: SpriteID takes precedence over SHARE if present, but is that right?
spriteId := r.Share
if len(r.SpriteId) > 0 && r.SpriteId[0] != -1 {
spriteId = r.SpriteId[0]
func (d *Driver) buildNoninteractive(p *menus.Properties) (*noninteractive, error) {
// FIXME: SpriteID takes precedence over SHARE if present, but is that
// always right?
spriteId := p.BaseSpriteID()
if spriteId < 0 {
return nil, fmt.Errorf("No base sprite for %v", p.Locator)
}
sprite, err := d.menu.Sprite(spriteId)
sprite, err := d.menu.Sprite(p.ObjectIdx, spriteId)
if err != nil {
return nil, err
}
ni := &noninteractive{
path: r.Path(),
frames: animation{sprite.Image},
hoverImpl: hoverImpl{text: r.Text},
rect: sprite.Rect,
locator: p.Locator,
frames: animation{sprite.Image},
rect: sprite.Rect.Add(p.Point()),
}
d.hoverables = append(d.hoverables, ni)
d.paintables = append(d.paintables, ni)
return ni, nil
}
func registerHypertext(d *Driver, r *menus.Record) error {
sprite, err := d.menu.Sprite(r.Share)
func (d *Driver) buildStatic(p *menus.Properties) (*noninteractive, *Widget, error) {
ni, err := d.buildNoninteractive(p)
if err != nil {
return err
return nil, nil, err
}
ni := &noninteractive{
path: r.Path(),
hoverImpl: hoverImpl{text: r.Text},
rect: sprite.Rect,
ni.hoverImpl.text = p.Text
widget := &Widget{
Locator: ni.locator,
Active: p.Active,
ownClickables: []clickable{ni}, // FIXME: credits background needs to be clickable
ownHoverables: []hoverable{ni},
ownPaintables: []paintable{ni},
}
d.clickables = append(d.clickables, ni)
d.hoverables = append(d.hoverables, ni)
return ni, widget, nil
}
return nil
func (d *Driver) buildHypertext(p *menus.Properties) (*noninteractive, *Widget, error) {
ni, err := d.buildNoninteractive(p)
if err != nil {
return nil, nil, err
}
// FIXME: check if this is still needed on the bridge -> briefing transition
widget := &Widget{
Locator: ni.locator,
Active: p.Active,
ownClickables: []clickable{ni},
ownHoverables: []hoverable{ni},
}
return ni, widget, nil
}
// An overlay is a static image + some text that needs to be rendered
func registerOverlay(d *Driver, r *menus.Record) error {
sprite, err := d.menu.Sprite(r.Share)
func (d *Driver) buildOverlay(p *menus.Properties) (*noninteractive, *Widget, error) {
ni, err := d.buildNoninteractive(p)
if err != nil {
return err
return nil, nil, err
}
ni := &noninteractive{
path: r.Path(),
frames: animation{sprite.Image},
rect: sprite.Rect,
widget := &Widget{
Locator: ni.locator,
Active: p.Active,
ownPaintables: []paintable{ni},
}
d.paintables = append(d.paintables, ni)
if r.Text != "" {
if p.Text != "" {
// FIXME: is this always right? Seems to make sense for Main.mnu
fnt := d.menu.Font(r.FontType/10 - 1)
fnt := d.menu.Font(p.FontType/10 - 1)
ni.label = &label{
font: fnt,
rect: ni.rect, // We will be centered by default
text: r.Text,
text: p.Text,
}
} else {
log.Printf("Overlay without text detected: %#+v", r)
log.Printf("Overlay without text detected in %v", p.Locator)
}
return nil
return ni, widget, nil
}
// An animation is a non-interactive element that displays something in a loop
func registerAnimation(d *Driver, r *menus.Record) error {
sprite, err := d.menu.Sprite(r.SpriteId[0])
func (d *Driver) buildAnimationSample(p *menus.Properties) (*noninteractive, *Widget, error) {
sprite, err := d.menu.Sprite(p.ObjectIdx, p.SpriteId[0])
if err != nil {
return err
return nil, nil, err
}
frames, err := d.menu.Images(r.SpriteId[0], r.DrawType)
frames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0], p.DrawType)
if err != nil {
return err
return nil, nil, err
}
ani := &noninteractive{
path: r.Path(),
locator: p.Locator,
frames: animation(frames),
hoverImpl: hoverImpl{text: r.Text},
rect: sprite.Rect,
hoverImpl: hoverImpl{text: p.Text},
rect: sprite.Rect.Add(p.Point()),
}
d.hoverables = append(d.hoverables, ani)
d.paintables = append(d.paintables, ani)
widget := &Widget{
Active: p.Active,
ownHoverables: []hoverable{ani},
ownPaintables: []paintable{ani},
}
return nil
return ani, widget, nil
}
func registerAnimationHover(d *Driver, r *menus.Record) error {
sprite, err := d.menu.Sprite(r.SpriteId[0])
func (d *Driver) buildAnimationHover(p *menus.Properties) (*animationHover, *Widget, error) {
sprite, err := d.menu.Sprite(p.ObjectIdx, p.SpriteId[0])
if err != nil {
return err
return nil, nil, err
}
enterFrames, err := d.menu.Images(r.SpriteId[0], r.DrawType)
enterFrames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0], p.DrawType)
if err != nil {
return err
return nil, nil, err
}
exitFrames, err := d.menu.Images(r.SpriteId[0]+r.DrawType, r.DrawType)
exitFrames, err := d.menu.Images(p.ObjectIdx, p.SpriteId[0]+p.DrawType, p.DrawType)
if err != nil {
return err
return nil, nil, err
}
ani := &animationHover{
noninteractive: noninteractive{
path: r.Path(),
locator: p.Locator,
frames: animation(enterFrames),
hoverImpl: hoverImpl{text: r.Text},
rect: sprite.Rect,
hoverImpl: hoverImpl{text: p.Text},
rect: sprite.Rect.Add(p.Point()),
},
exitFrames: animation(exitFrames),
}
d.hoverables = append(d.hoverables, ani)
d.paintables = append(d.paintables, ani)
widget := &Widget{
Active: p.Active,
ownHoverables: []hoverable{ani},
ownPaintables: []paintable{ani},
}
return nil
return ani, widget, nil
}
func (n *noninteractive) id() string {
return n.path
return n.locator
}
func (n *noninteractive) bounds() image.Rectangle {

View File

@@ -9,11 +9,6 @@ import (
"code.ur.gs/lupine/ordoor/internal/menus"
)
func init() {
registerBuilder(menus.TypeCheckbox, noChildren(registerCheckbox))
registerBuilder(menus.TypeSlider, noChildren(registerSlider))
}
// A checkbox can be a fancy button
type checkbox struct {
button
@@ -23,7 +18,9 @@ type checkbox struct {
// A slider is harder. Two separate elements to render
type slider struct {
path string
locator string
rect image.Rectangle
baseSpr *assetstore.Sprite
clickSpr *assetstore.Sprite
@@ -38,52 +35,62 @@ type slider struct {
}
// A checkbox has 3 sprites, and 3 states: unchecked, checked, disabled.
func registerCheckbox(d *Driver, r *menus.Record) error {
sprites, err := d.menu.Sprites(r.Share, 3) // unchecked, disabled, checked
func (d *Driver) buildCheckbox(p *menus.Properties) (*checkbox, *Widget, error) {
sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 3) // unchecked, disabled, checked
if err != nil {
return err
return nil, nil, err
}
checkbox := &checkbox{
button: button{
path: r.Path(),
locator: p.Locator,
rect: sprites[0].Rect.Add(p.Point()),
baseSpr: sprites[0], // unchecked
clickSpr: sprites[2], // checked
frozenSpr: sprites[1],
hoverImpl: hoverImpl{text: r.Text},
frozenSpr: sprites[1], // disabled
hoverImpl: hoverImpl{text: p.Text},
},
valueImpl: valueImpl{str: "0"},
}
d.clickables = append(d.clickables, checkbox)
d.freezables = append(d.freezables, checkbox)
d.hoverables = append(d.hoverables, checkbox)
d.paintables = append(d.paintables, checkbox)
d.valueables = append(d.valueables, checkbox)
widget := &Widget{
Locator: p.Locator,
Active: p.Active,
ownClickables: []clickable{checkbox},
ownFreezables: []freezable{checkbox},
ownHoverables: []hoverable{checkbox},
ownPaintables: []paintable{checkbox},
ownValueables: []valueable{checkbox},
}
return nil
return checkbox, widget, nil
}
func registerSlider(d *Driver, r *menus.Record) error {
sprites, err := d.menu.Sprites(r.Share, 3) // base, clicked, slider element
func (d *Driver) buildSlider(p *menus.Properties) (*slider, *Widget, error) {
sprites, err := d.menu.Sprites(p.ObjectIdx, p.Share, 3) // base, clicked, slider element
if err != nil {
return err
return nil, nil, err
}
slider := &slider{
path: r.Path(),
locator: p.Locator,
rect: sprites[0].Rect.Add(p.Point()),
baseSpr: sprites[0],
clickSpr: sprites[1],
sliderSpr: sprites[2],
hv: sprites[0].Rect.Dy() > sprites[0].Rect.Dx(), // A best guess
}
d.clickables = append(d.clickables, slider)
d.mouseables = append(d.mouseables, slider)
d.paintables = append(d.paintables, slider)
d.valueables = append(d.valueables, slider)
widget := &Widget{
Locator: p.Locator,
Active: p.Active,
ownClickables: []clickable{slider},
ownMouseables: []mouseable{slider},
ownPaintables: []paintable{slider},
ownValueables: []valueable{slider},
}
return nil
return slider, widget, nil
}
func (c *checkbox) registerMouseClick() {
@@ -107,12 +114,12 @@ func (c *checkbox) regions(tick int) []region {
}
func (s *slider) id() string {
return s.path
return s.locator
}
// The bounds of the slider are the whole thing
func (s *slider) bounds() image.Rectangle {
return s.baseSpr.Rect
return s.rect
}
func (s *slider) registerMouseClick() {

128
internal/ui/value.go Normal file
View File

@@ -0,0 +1,128 @@
package ui
import (
"fmt"
"strconv"
)
func (d *Driver) realId(id string) string {
return fmt.Sprintf("%v:%v", d.menu.Name, id)
}
func (d *Driver) Value(id string, into *string) error {
for _, valueable := range d.allValueables() {
if valueable.id() == d.realId(id) {
*into = valueable.value()
return nil
}
}
return fmt.Errorf("Couldn't find valueable widget %v:%v", d.menu.Name, id)
}
func (d *Driver) SetValue(id, value string) error {
for _, valueable := range d.allValueables() {
if valueable.id() == d.realId(id) {
valueable.setValue(value)
return nil
}
}
return fmt.Errorf("Couldn't find valueable widget %v:%v", d.menu.Name, id)
}
func (d *Driver) ValueBool(id string, into *bool) error {
var vStr string
if err := d.Value(id, &vStr); err != nil {
return err
}
*into = vStr == "1"
return nil
}
func (d *Driver) SetValueBool(id string, value bool) error {
vStr := "0"
if value {
vStr = "1"
}
return d.SetValue(id, vStr)
}
func (d *Driver) SetFreeze(id string, value bool) error {
for _, freezable := range d.allFreezables() {
if freezable.id() == d.realId(id) {
freezable.setFreezeState(value)
return nil
}
}
return fmt.Errorf("Couldn't find clickable widget %v:%v", d.menu.Name, id)
}
func (d *Driver) ToggleActive(locator string) error {
if widget := d.findWidget(locator); widget != nil {
widget.Active = !widget.Active
return nil
}
return fmt.Errorf("Couldn't find activatable widget %v to toggle", locator)
}
func (d *Driver) SetActive(locator string, value bool) error {
if widget := d.findWidget(locator); widget != nil {
widget.Active = value
return nil
}
return fmt.Errorf("Couldn't find activeatable widget %v to set to %v", locator, value)
}
func (d *Driver) OnClick(id string, f func()) error {
for _, clickable := range d.allClickables() {
if clickable.id() == d.realId(id) {
clickable.onClick(f)
return nil
}
}
return fmt.Errorf("Couldn't find clickable widget %v:%v", d.menu.Name, id)
}
// FIXME: HURK. Surely I'm missing something? steps is value:offset
func (d *Driver) ConfigureSlider(id string, steps map[int]int) error {
for _, clickable := range d.activeClickables() {
if slider, ok := clickable.(*slider); ok && slider.id() == d.realId(id) {
slider.steps = steps
return nil
}
}
return fmt.Errorf("Couldn't find slider %v:%v", d.menu.Name, id)
}
func (d *Driver) ValueInt(id string, into *int) error {
var vStr string
if err := d.Value(id, &vStr); err != nil {
return err
}
value, err := strconv.Atoi(vStr)
if err != nil {
return err
}
*into = value
return nil
}
func (d *Driver) SetValueInt(id string, value int) error {
vStr := strconv.Itoa(value)
return d.SetValue(id, vStr)
}

142
internal/ui/widget.go Normal file
View File

@@ -0,0 +1,142 @@
package ui
type Widget struct {
Locator string
Children []*Widget
Active bool
ownClickables []clickable
ownFreezables []freezable
ownHoverables []hoverable
ownMouseables []mouseable
ownPaintables []paintable
ownValueables []valueable
}
func (w *Widget) allClickables() []clickable {
out := w.ownClickables
for _, widget := range w.Children {
out = append(out, widget.allClickables()...)
}
return out
}
func (w *Widget) allFreezables() []freezable {
out := w.ownFreezables
for _, widget := range w.Children {
out = append(out, widget.allFreezables()...)
}
return out
}
func (w *Widget) allValueables() []valueable {
out := w.ownValueables
for _, widget := range w.Children {
out = append(out, widget.allValueables()...)
}
return out
}
func (w *Widget) activeClickables() []clickable {
if !w.Active {
return nil
}
out := w.ownClickables
for _, widget := range w.Children {
out = append(out, widget.activeClickables()...)
}
return out
}
func (w *Widget) activeFreezables() []freezable {
if !w.Active {
return nil
}
out := w.ownFreezables
for _, widget := range w.Children {
out = append(out, widget.activeFreezables()...)
}
return out
}
func (w *Widget) activeHoverables() []hoverable {
if !w.Active {
return nil
}
out := w.ownHoverables
for _, widget := range w.Children {
out = append(out, widget.activeHoverables()...)
}
return out
}
func (w *Widget) activeMouseables() []mouseable {
if !w.Active {
return nil
}
out := w.ownMouseables
for _, widget := range w.Children {
out = append(out, widget.activeMouseables()...)
}
return out
}
func (w *Widget) activePaintables() []paintable {
if !w.Active {
return nil
}
out := w.ownPaintables
for _, widget := range w.Children {
out = append(out, widget.activePaintables()...)
}
return out
}
func (w *Widget) activeValueables() []valueable {
if !w.Active {
return nil
}
out := w.ownValueables
for _, widget := range w.Children {
out = append(out, widget.activeValueables()...)
}
return out
}
func (w *Widget) findWidget(locator string) *Widget {
if w.Locator == locator {
return w
}
for _, child := range w.Children {
if found := child.findWidget(locator); found != nil {
return found
}
}
return nil
}

View File

@@ -18,15 +18,26 @@ type Game interface {
Draw(*ebiten.Image) error
}
type CustomCursor interface {
// The cursor draw operation
Cursor() (*ebiten.Image, *ebiten.DrawImageOptions, error)
}
var (
screenScale = flag.Float64("screen-scale", 1.0, "Scale the window by this factor")
cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
)
// TODO: move all scaling into Window, so drivers only need to cope with one
// coordinate space. This will allow us to draw custom mouse cursors in the
// window, rather than in the driver.
type Window struct {
Title string
KeyUpHandlers map[ebiten.Key]func()
MouseWheelHandler func(float64, float64)
MouseClickHandler func()
WhileKeyDownHandlers map[ebiten.Key]func()
// Allow the "game" to be switched out at any time
game Game
@@ -45,13 +56,16 @@ func NewWindow(game Game, title string, xRes int, yRes int) (*Window, error) {
ebiten.SetRunnableInBackground(true)
return &Window{
Title: title,
Title: title,
debug: true,
firstRun: true,
game: game,
xRes: xRes,
yRes: yRes,
WhileKeyDownHandlers: make(map[ebiten.Key]func()),
KeyUpHandlers: make(map[ebiten.Key]func()),
debug: true,
firstRun: true,
game: game,
xRes: xRes,
yRes: yRes,
}, nil
}
@@ -60,14 +74,44 @@ func (w *Window) OnKeyUp(key ebiten.Key, f func()) {
w.KeyUpHandlers[key] = f
}
func (w *Window) WhileKeyDown(key ebiten.Key, f func()) {
w.WhileKeyDownHandlers[key] = f
}
func (w *Window) OnMouseWheel(f func(x, y float64)) {
w.MouseWheelHandler = f
}
func (w *Window) OnMouseClick(f func()) {
w.MouseClickHandler = f
}
func (w *Window) Layout(_, _ int) (int, int) {
return w.xRes, w.yRes
}
func (w *Window) drawCursor(screen *ebiten.Image) error {
cIface, ok := w.game.(CustomCursor)
if !ok {
return nil
}
cursor, op, err := cIface.Cursor()
if err != nil {
return err
}
// Hide the system cursor if we have a custom one
if cursor == nil {
ebiten.SetCursorMode(ebiten.CursorModeVisible)
return nil
}
ebiten.SetCursorMode(ebiten.CursorModeHidden)
return screen.DrawImage(cursor, op)
}
func (w *Window) Update(screen *ebiten.Image) (outErr error) {
// Ebiten does not like it if we panic inside its main loop
defer func() {
@@ -84,7 +128,8 @@ func (w *Window) Update(screen *ebiten.Image) (outErr error) {
return err
}
// Process keys
// Process keys.
// FIXME: : should this happen before or after update?
// TODO: efficient set operations
for key, cb := range w.KeyUpHandlers {
@@ -93,6 +138,12 @@ func (w *Window) Update(screen *ebiten.Image) (outErr error) {
}
}
for key, cb := range w.WhileKeyDownHandlers {
if ebiten.IsKeyPressed(key) {
cb()
}
}
if w.MouseWheelHandler != nil {
x, y := ebiten.Wheel()
if x != 0 || y != 0 {
@@ -100,6 +151,12 @@ func (w *Window) Update(screen *ebiten.Image) (outErr error) {
}
}
if w.MouseClickHandler != nil {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
w.MouseClickHandler()
}
}
if ebiten.IsDrawingSkipped() {
return nil
}
@@ -114,7 +171,8 @@ func (w *Window) Update(screen *ebiten.Image) (outErr error) {
ebitenutil.DebugPrint(screen, msg)
}
return nil
// Draw the cursor last
return w.drawCursor(screen)
}
// TODO: a stop or other cancellation mechanism

View File

@@ -4,6 +4,7 @@ package asciiscan
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strconv"
@@ -15,6 +16,9 @@ var hashComment = []byte("#")
type Scanner struct {
bufio *bufio.Scanner
closer io.Closer
// If we've peeked, there will be items here
buffered []string
}
func New(filename string) (*Scanner, error) {
@@ -38,6 +42,13 @@ func (s *Scanner) Close() error {
}
func (s *Scanner) ConsumeString() (string, error) {
if len(s.buffered) > 0 {
out, buffered := s.buffered[0], s.buffered[1:]
s.buffered = buffered
return out, nil
}
for s.bufio.Scan() {
line := s.bufio.Bytes()
@@ -68,15 +79,41 @@ func ConsumeProperty(s string) (string, string) {
}
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).
// Check to see if the line looks like a property (contains a colon character).
func IsProperty(s string) bool {
return strings.Contains(s, ":")
}
// Checks if the next line might be a property, without reading it
func (s *Scanner) PeekProperty() (bool, error) {
str, err := s.ConsumeString()
if err != nil {
return false, err
}
s.buffered = append(s.buffered, str)
return IsProperty(str), nil
}
func (s *Scanner) ConsumeProperty() (string, string, error) {
str, err := s.ConsumeString()
if err != nil {
return "", "", err
}
if !IsProperty(str) {
return "", "", fmt.Errorf("Not a property: %q", str)
}
k, v := ConsumeProperty(str)
return k, v, nil
}
func (s *Scanner) ConsumeInt() (int, error) {
str, err := s.ConsumeString()
if err != nil {
@@ -86,6 +123,48 @@ func (s *Scanner) ConsumeInt() (int, error) {
return strconv.Atoi(str)
}
func (s *Scanner) ConsumeBool() (bool, error) {
integer, err := s.ConsumeInt()
if err != nil {
return false, err
}
return (integer > 0), nil
}
// Reads a list of non-property lines, skipping any that match the given strings
func (s *Scanner) ConsumeStringList(skip ...string) ([]string, error) {
skipper := make(map[string]bool, len(skip))
for _, str := range skip {
skipper[str] = true
}
var out []string
for {
isProp, err := s.PeekProperty()
if err != nil {
return nil, err
}
// The object list is terminated by the first property
if isProp {
break
}
str, err := s.ConsumeString()
if err != nil {
return nil, err
}
if !skipper[str] {
out = append(out, str)
}
}
return out, nil
}
func (s *Scanner) ConsumeIntPtr(to *int) error {
val, err := s.ConsumeInt()
if err != nil {
@@ -96,6 +175,16 @@ func (s *Scanner) ConsumeIntPtr(to *int) error {
return nil
}
func (s *Scanner) ConsumeBoolPtr(to *bool) error {
val, err := s.ConsumeBool()
if err != nil {
return err
}
*to = val
return nil
}
func (s *Scanner) ConsumeIntPtrs(ptrs ...*int) error {
for _, ptr := range ptrs {
if err := s.ConsumeIntPtr(ptr); err != nil {
@@ -105,3 +194,13 @@ func (s *Scanner) ConsumeIntPtrs(ptrs ...*int) error {
return nil
}
func (s *Scanner) ConsumeBoolPtrs(ptrs ...*bool) error {
for _, ptr := range ptrs {
if err := s.ConsumeBoolPtr(ptr); err != nil {
return err
}
}
return nil
}

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env ruby
lines = File.read("palette.ppm").split("\n")
lines = lines.select { |l| l[0] != "#" }
raise "Not an ASCII ppm" if lines.shift != "P3"
raise "Incorrect dimensions" unless lines.shift == "1 256"
raise "Incorrect maxval" unless lines.shift == "255"
raise "Too many lines left" unless lines.size == 768
puts <<EOF
package data
import "image/color"
var (
Transparent = color.RGBA{R: 0, G: 0, B: 0, A: 0}
ColorPalette = color.Palette{
Transparent,
EOF
lines.shift(3) # Ignore idx 0
255.times do
r, g, b = lines.shift(3).map(&:to_i)
puts "\t\tcolor.RGBA{R: #{r}, G: #{g}, B: #{b}, A: 255},"
end
puts <<EOF
}
)
EOF

View File

@@ -1,12 +0,0 @@
# jungle floor
# jungtil.obj/.asn
# /--> d:\warflics\missions\jungtil.flc
#
0:DEF 2;
0:TYPE 2;
END OF FILE

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,772 +0,0 @@
P3
# CREATOR: GIMP PNM Filter Version 1.1
1 256
255
255
255
255
128
0
0
0
128
0
128
128
0
0
0
128
128
0
128
0
128
128
192
192
192
192
220
192
166
202
240
255
255
255
240
240
240
221
221
221
203
203
203
187
187
187
178
178
178
168
168
168
157
157
157
147
147
147
137
137
137
127
127
127
117
117
117
106
106
106
96
96
96
86
86
86
76
76
76
61
61
61
49
49
49
36
36
36
24
24
24
12
12
12
0
0
0
134
134
255
113
113
241
93
93
228
72
72
214
63
63
200
55
55
186
46
46
172
38
38
158
29
29
144
20
20
131
12
12
117
3
3
103
3
3
91
3
3
79
3
3
68
3
3
56
255
145
145
242
123
123
230
101
101
217
79
79
205
70
70
193
61
61
181
53
53
169
44
44
157
35
35
144
26
26
132
18
18
120
9
9
108
8
8
94
7
7
79
7
7
65
6
6
147
142
185
132
126
172
117
109
159
102
93
146
95
86
133
88
78
123
82
73
115
77
67
107
72
61
100
67
55
92
61
50
84
56
44
76
51
38
68
46
32
60
40
27
52
35
21
44
200
150
137
187
130
115
175
110
94
164
95
77
154
79
61
143
64
44
137
60
42
132
55
38
125
50
33
120
48
29
111
44
26
103
39
24
94
35
21
83
30
18
72
25
14
61
20
10
121
107
34
109
94
29
96
82
25
84
69
20
77
62
17
70
55
14
63
47
12
56
40
9
93
120
53
80
103
42
66
86
31
53
69
20
49
60
16
45
52
12
43
44
10
43
38
8
136
145
44
118
128
37
101
111
30
83
94
23
70
79
17
56
65
11
42
50
6
28
36
0
57
134
64
48
118
54
38
101
43
29
85
33
22
71
25
15
58
17
7
45
8
0
32
0
143
87
56
126
75
45
110
64
35
93
52
24
85
44
16
72
36
12
64
32
8
56
24
4
127
96
54
115
85
46
102
75
39
90
64
31
82
57
25
75
51
20
68
44
15
61
38
10
141
86
56
126
75
46
110
65
36
95
54
26
88
51
25
73
40
18
57
29
10
42
18
3
172
199
199
138
173
173
104
148
148
71
122
122
37
97
97
3
71
71
4
56
56
4
41
41
217
209
200
202
194
184
188
178
167
173
163
151
158
147
134
148
136
123
137
125
112
126
114
101
116
104
91
105
93
80
94
82
69
84
71
58
73
60
47
62
50
36
52
39
25
41
28
14
231
232
207
219
217
180
208
201
152
196
186
125
184
171
98
173
155
70
161
140
43
150
129
39
139
119
37
127
109
33
117
99
29
105
89
25
90
76
21
75
62
18
60
49
14
45
35
10
128
99
127
113
73
112
97
47
97
82
21
82
75
2
74
68
2
67
52
3
50
35
4
33
247
178
102
229
152
75
212
127
48
194
101
21
179
87
16
161
73
12
142
59
9
124
45
5
255
0
0
194
3
3
161
2
2
255
227
11
209
185
8
169
150
6
103
190
255
2
130
232
4
4
209
0
255
0
7
180
7
3
132
3
255
114
230
255
17
205
203
6
156
246
164
73
221
123
16
177
95
4
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
132
18
18
113
13
13
94
7
7
107
8
8
120
9
9
193
62
62
181
54
54
168
45
45
157
36
36
144
27
27
62
50
36
57
45
31
52
39
25
46
34
20
41
28
14
255
251
240
160
160
164
128
128
128
255
0
0
0
255
0
255
255
0
0
0
255
255
0
255
0
255
255
255
255
255

View File

@@ -1,13 +0,0 @@
To build this palette, I did the following:
* Created the .asn + .obj files by hand
* Referenced them in a .set file and asked WH40K_TD.exe to render the sprites
* Screenshotted the output and used GIMP to create a 1x255 image with them all
* Exported that image as an ASCII .ppm file
* Used the `generate_palette` script to output Go code \o/
Note that palette index 0 is ignored, and hardcoded to be transparent black.
This is because 0x00 seems to be used as a record separator in .obj files, so
can't be used as a palette index anyway.

Binary file not shown.

45
scripts/palette-from-pcx Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env ruby
if ARGV.size != 2
puts "Usage: $0 <filename.pcx> <Palette name>"
exit 1
end
FILENAME = ARGV[0]
PALETTENAME = ARGV[1]
f = File.open(FILENAME)
# In a 256-colour PCX file, the palette is stored in the final 768 bytes as RGB
# triplets, and the byte before that should be 0x0C: https://en.wikipedia.org/wiki/PCX#Color_palette
f.seek(-769, IO::SEEK_END)
raise "Check byte is wrong" unless f.read(1) == "\x0C"
puts <<EOF
package palettes
import "image/color"
var (
#{PALETTENAME}Palette = color.Palette{
Transparent,
EOF
f.read(3) # Ignore idx 0 so we can make it transparent
255.times do
r, g, b = f.read(3).bytes
puts "\t\tcolor.RGBA{R: #{r}, G: #{g}, B: #{b}, A: 255},"
end
puts <<EOF
}
)
func init() {
Palettes["#{PALETTENAME}"] = #{PALETTENAME}Palette
}
EOF

View File

@@ -19,7 +19,7 @@ module Obj
def self.parse(data)
hdr = new(*data[0..SIZE - 1].unpack("VVVVV"))
pp hdr
# pp hdr
hdr.validate!(data.bytes.size)
hdr
end
@@ -96,7 +96,7 @@ module Obj
DirEntry.parse(rel_data.byteslice(rel_offset, DirEntry::SIZE))
end
pp entries
# pp entries
new(entries)
end
@@ -380,6 +380,18 @@ def correlate(filenames)
pp results
end
def directory(filename, num)
data = File.read(filename).force_encoding("BINARY")
hdr = Obj::Header.parse(data)
dir = Obj::SpriteDir.parse(data[hdr.dir_range])
entry = dir.entries[num]
puts "Sprite directory starts at 0x#{hdr.dir_offset.to_s(16)}"
puts "Directory entry for sprite #{num} is at 0x#{(hdr.dir_offset + (Obj::DirEntry::SIZE * num)).to_s(16)}"
puts "Sprite #{num} is at 0x#{(hdr.data_offset + entry.rel_offset).to_s(16)} and is #{entry.sprite_size} bytes"
end
def sprites(filename)
obj = load_obj(filename)
@@ -499,7 +511,25 @@ def build(filename)
File.open(filename, "w") { |f| f.write(built.to_data) }
end
def unknown16(filenames)
objs = filenames.map { |f| load_obj(f) }
results = Set.new
objs.each do |obj|
obj.sprites.each do |spr|
results << spr.header.unknown16
end
end
puts "Unique widths for u16,4"
pp results
end
case command = ARGV.shift
when "directory" then
directory(ARGV[0], ARGV[1].to_i)
when "unknown16" then
unknown16(ARGV)
when "sprites" then
ARGV.each { |filename| sprites(filename) }
when "sprite" then